👋 关于作者
你好,这里是一条 泥憨鱼 | 专注于 后端开发 、AI 前沿技术与计算机基础 。

🏠 个人主页 :一条泥憨鱼
通过技术博客记录技术成长,分享学习心得与经验。期待与您共同交流!
📚精选技术专栏:
|-------------------------------------------------------------------------------------------|--------------------------------------------------------------------------------------------|
| ❄️后端开发 | ❄️Redis| 黑马点评 |
| ❄️从0开始学习计算机网络 | ❄️数据结构与算法 |
| ❄️苍穹外卖 | ❄️探秘AI与agent |
"消息发出去了,消费者说没收到",这类问题多半不是某个 ACK 开关没打开。这篇沿着生产者、Broker、消费者三段链路拆开,看每个确认点确认的是什么、失败后各自丢什么,最后说明为什么三段都做对了,系统仍然只能给你至少一次。
ACK 出现在链路的哪几段
一条消息从业务代码到被消费,中间至少经过两次网络传输和一次持久化。每一次跨越边界,都存在消息已经离开上一方、还没被下一方接住的窗口。ACK 的作用就是在这些窗口上建立确认:下一方明确告诉上一方"我接住了",上一方才敢认为这段传输完成。
整条链路可以切成三段确认:
生产者到 Broker 的确认,解决的是"消息有没有成功进入 Broker"。没有这段确认,生产者在网络抖动时不知道该不该重发,消息可能停留在发送缓冲区里就丢了。
Broker 内部的落盘与副本同步,解决的是"消息进了 Broker 之后会不会因为宕机丢失"。消息被 Broker 接收,不等于落在磁盘上,更不等于其他副本也拿到了。这段没有确认,Broker 主节点挂掉,刚接收的消息就随内存一起消失。
消费者到 Broker 的消费确认,解决的是"消息被处理完之后,能不能安全地从队列里移除"。没有这段确认,消费者拿到消息后崩溃,消息就永久丢失;确认得太早,处理失败也没法重来。

这三段是串联关系,任何一段的确认语义不够强,整条链路的可靠性就被拉到那一段的水平。下面逐段看。
生产者侧:确认模式与重试的代价
Kafka 的 acks 参数直接对应上面第一段确认的强度:
-
acks=0:生产者把消息写进 socket 缓冲区就返回成功,不等 Broker 任何回应。吞吐最高,但网络一断、Broker 一挂,消息就没了,生产者完全不知情。
-
acks=1:Leader 副本写入本地日志后返回确认。这里的关键词是 Leader,如果这个 Leader 在把消息同步给 Follower 之前宕机,新选出的 Leader 上没有这条消息,消息丢失。
-
acks=all(等价于 -1):要求 ISR 中所有副本都写入后才确认。这是最强的确认语义,但它的含义依赖 ISR 的构成,下一节展开。
RabbitMQ 的对应机制是 publisher confirm。开启 confirm 模式后,Broker 会异步回一个 basic.ack 或 basic.nack,生产者据此判断消息是否被 Broker 接收。注意它确认的只是"Broker 接收并处理了",对于持久化消息,confirm 的时机和是否刷盘有关,但对普通消息,confirm 并不等于已经落盘。这和 Kafka 的 acks=all 在语义层级上并不完全对等,跨中间件迁移时容易想当然。
java
// Kafka 生产者:开启幂等 + 最强确认,是"不丢 + 不重复写"的常见组合
Properties props = new Properties();
props.put("bootstrap.servers", "broker1:9092,broker2:9092");
props.put("acks", "all"); // 等 ISR 全部写入
props.put("enable.idempotence", "true"); // 幂等生产者,见下文
props.put("retries", Integer.MAX_VALUE); // 配合幂等,重试不再担心重复
props.put("max.in.flight.requests.per.connection", 5); // 幂等开启时 <=5 可保序
acks=all 并不等于"不丢"。它只保证消息被当前 ISR 全部写入,如果 ISR 因为 Follower 落后而收缩到只剩 Leader 一个,acks=all 实际退化成了 acks=1。也就是说,确认强度是由 acks=all 和 ISR 大小共同决定的,单看参数会误判。
重试是另一面。生产者没收到确认就会重发,而"没收到确认"和"消息没写进去"是两回事------可能 Broker 已经写成功,只是响应在网络上丢了。这时重发就产生了重复消息。Kafka 的幂等生产者通过给每条消息分配 PID 和序列号,让 Broker 侧去重,能在单个生产者会话、单个分区内消除这种重复。但它的边界很明确:生产者重启后 PID 变化,跨会话的去重不成立;跨分区也不做去重。所以幂等生产者解决的是"重试导致的重复",不是业务层面的重复。
Broker 侧:落盘、副本与 ISR
消息被 Broker 接收,首先进入的是操作系统的页缓存,而不是磁盘。Kafka 依赖操作系统的刷盘策略,消息什么时候真正落到磁盘由 OS 决定。这意味着 Broker 进程崩溃通常不丢数据(页缓存还在),但机器断电就可能丢。这是 Kafka 用顺序写加页缓存换吞吐的设计取舍,不是缺陷。
副本机制补上了这一层。每个分区有多个副本,Leader 负责读写,Follower 主动拉取同步。acks=all 等待的是 ISR 中所有副本都写入,ISR 就是"跟得上 Leader 的副本集合"。配合参数 min.insync.replicas(比如设为 2),可以约束写入时必须至少有这么多副本确认,否则直接抛 NotEnoughReplicasException,让生产者感知失败。
这两个参数是配套使用的:
java
# 常见配置:3 副本,至少 2 个同步副本才算写入成功
replication.factor=3
min.insync.replicas=2
acks=all
这个组合的含义是:只要还有 2 个副本存活并同步,写入就能成功且不会因单点宕机丢失;一旦存活同步副本少于 2,写入直接失败,宁可不可用也不接受可能丢失的写入。这是可用性和一致性的显式取舍,不是配置错误。
风险在于 ISR 收缩。当 Follower 同步落后超过 replica.lag.time.max.ms,它会被踢出 ISR。如果集群持续不稳定,ISR 可能一路缩到只剩 Leader,此时 acks=all 的保证名存实亡。生产环境要监控 ISR 大小和 UnderReplicatedPartitions,而不是只盯着 acks 配置。另一个坑是 unclean.leader.election.enable,开启后允许非 ISR 副本当选 Leader,能提升可用性,但会丢消息------这个开关默认关闭,别为了"看起来更稳"随手打开。
消费者侧:ACK 时机才是真正的分水岭
前两段确认保证了消息安全到达 Broker 并被持久化,但消息最终要落到业务逻辑里。消费者侧的 ACK 决定的是"这条消息什么时候可以从队列中移除",而 ACK 的时机直接决定了失败时会发生什么。
Kafka 消费者的位点提交有两种方式。自动提交由 enable.auto.commit=true 控制,按 auto.commit.interval.ms 周期性地把当前消费位点提交上去。问题在于提交时机和业务处理时机完全脱钩:可能在消息刚拉取、还没处理完时就提交了位点,此时消费者崩溃,这条消息就再也不会被投递,等于丢失。
手动提交把控制权交回业务:
java
// 先处理业务,成功后再提交位点,崩溃时消息会被重新投递
while (true) {
ConsumerRecords<String, String> records = consumer.poll(Duration.ofMillis(500));
for (ConsumerRecord<String, String> record : records) {
try {
handle(record); // 业务处理,可能写库、调下游
} catch (Exception e) {
// 处理失败:不提交位点,让这条消息下次重新投递
// 注意这里要跳出,避免把后续消息的位点也提交上去
break;
}
}
consumer.commitSync(); // 只提交已成功处理的位点
}

commitSync 提交的是当前已 poll 到的位点,所以代码里必须保证"只提交已处理成功的部分"。上面用 break 跳出循环、再提交,是为了防止把失败消息之后的消息位点一起提交掉------这是实际项目里很常见的错误,一旦发生,失败的那条消息连带后面一批都被跳过。
重平衡是 Kafka 消费者侧的另一个重复来源。消费者组内成员变化会触发分区重新分配,如果消费者在重平衡前处理了消息但没来得及提交位点,重平衡后新消费者会从上次提交的位点重新消费。Kafka 提供 ConsumerRebalanceListener 让你在分区被回收前提交位点,但这也只是缩小窗口,无法根除。
RabbitMQ 的消费确认有三个动作:basic.ack 确认成功、basic.nack 拒绝(可带 requeue 参数决定是否重回队列)、basic.reject 拒绝(同样可带 requeue)。重回队列的坑在于:如果一条消息因为业务逻辑 bug 反复处理失败、反复 requeue,它会无限循环地投递,占满消费者。正确做法是设置 requeue=false 并配合死信队列,把处理不了的消息转走,而不是让它反复撞墙。
端到端仍然"至少一次"
三段确认都做对了,得到的保证是什么?是 at-least-once,也就是"至少一次",而不是"恰好一次"。
原因在于每一段确认都只能保证"不丢",但无法同时保证"不重复"。生产者重试可能重复写,消费者崩溃重投可能重复处理,重平衡也会导致重复消费。这些重复是确认机制的固有代价------要绝对不丢,就必须允许失败后重来,而重来一次就可能重复。
所以可靠投递的最后一公里落在业务侧的幂等。常见做法有三类:
唯一键去重。用消息里的业务唯一标识(订单号、流水号)建唯一索引,重复插入直接失败,捕获后当作已处理。这是最简单也最常用的方式,依赖数据库的唯一约束兜底。
去重表/幂等表。单独维护一张表记录已处理的消息 ID,处理前先查、处理后写入。适合业务主表没有天然唯一键的场景,代价是多一次读写,且要处理"写去重表成功但业务处理失败"的一致性。
状态机。让业务对象的状态流转本身具备幂等性,比如订单从"待支付"到"已支付",重复执行时检查当前状态已经满足目标状态就直接返回。这种方式不用额外存储,但要求业务状态设计得足够严谨。
怎么选?看业务对重复的敏感度和主表结构。有天然唯一键的优先用唯一键;没有的用去重表;状态流转清晰的用状态机。反过来,如果业务本身天然幂等(比如"把余额设为 100"而不是"加 100"),可以省掉这层。
收束
这篇文章拆的是 ACK 在一条消息链路上的三个位置:生产者确认解决了"有没有进 Broker",Broker 的落盘和副本同步解决了"进了之后会不会丢",消费者确认解决了"处理完之后能不能安全移除"。三段叠加的结论只能是至少一次,恰好一次要靠业务幂等补齐。
审查任意一条消息链路的可靠性时,可以用一个判断方法:先定位每个确认发生在哪一步,再问"如果这一步之后立刻失败,重来一次会怎样"。如果重来会丢数据,说明确认点放早了;如果重来会重复处理而业务不幂等,说明缺少去重层。ACK 是确认点,不是保证------把这句话记住,比记住任何一个参数配置都有用。