一条消息的丢与重:Kafka 与 RabbitMQ 可靠投递的确认、位点、死信与幂等全链路

目录

一、消息为什么会丢、也会重

(一)一条消息要跨过的三段旅程

(二)三种投递语义:不丢与不重为何不可兼得

二、生产端:把"发出去了"变成"确认写入了"

(一)Kafka 的确认与重试

1、acks 参数的三档语义

2、重试的副作用:重复与乱序

3、幂等生产者与事务

(二)RabbitMQ 的确认与路由兜底

1、Publisher Confirm 与事务模式

2、mandatory 与备份交换器

(三)双写困境与事务发件箱

三、Broker 存储端:持久化与副本才是承诺的底座

(一)Kafka 的副本机制

1、ISR 与最小同步副本数

2、脏选举与刷盘策略

(二)RabbitMQ 的持久化与队列高可用

1、三个持久化开关缺一不可

2、从镜像队列到仲裁队列

3、容量上限与溢出策略

四、消费端:确认时机决定丢与重

(一)Kafka 的位点管理

1、自动提交的双重风险

2、手动提交的正确姿势

3、再均衡:成批重复的最大来源

(二)RabbitMQ 的消费确认

1、autoAck 与手动应答

2、prefetch 与 requeue 风暴

五、失败消息的去处:重试与死信

(一)RabbitMQ 的死信拓扑

(二)Kafka 的重试主题与死信主题

(三)重试策略的设计纪律

六、重复不可避免:幂等消费的工程分层

(一)恰好一次的真实边界

(二)幂等的四层防线

(三)消息设计的配合

七、把可靠性做成清单与演练

(一)参数清单:两套系统对照

(二)监控、对账与故障演练

八、总结

参考链接


干货分享,感谢您的阅读!

消息队列被引入系统的那一刻,很少有人当场追问一句:这条消息会不会丢、会不会被处理两次。

等到对账系统报出一笔订单没有扣减库存,或者用户投诉同一笔退款到账了两回,团队才会回头审视整条链路——然后发现丢失与重复并不是某个组件的缺陷,而是分布式消息传递与生俱来的属性。

我们沿着一条消息的完整旅程展开:先建立一张全链路失效地图,说明丢失与重复分别发生在哪些位置、由什么机制引起;再逐段给出治理手段——生产端的确认与重试、幂等生产者与事务发件箱,存储端的副本同步集合与持久化开关、仲裁队列,消费端的位点提交时机、应答模式与再均衡收敛,失败消息的重试分级与死信收容;最后回答那个绕不开的问题:为什么恰好一次在端到端意义上无法由中间件单独兑现,以及幂等消费的四层防线如何把不可避免的重复变得无害。

浙西我们以 Kafka 与 RabbitMQ 两套主流系统对照展开,所有结论都落到具体参数、拓扑与代码上,可直接用于设计评审与上线检查。

一、消息为什么会丢、也会重

(一)一条消息要跨过的三段旅程

把"生产者发一条消息、消费者收到并处理"这件事拆开看,一条消息从产生到发挥业务效果,至少要完成三段移交:从生产者进程的内存缓冲区跨过网络抵达 Broker;在 Broker 上从操作系统页缓存落到磁盘、再复制到其他副本;被消费者拉取或推送过去,完成业务处理并向 Broker 汇报"我处理完了"。每一段移交都跨越了进程边界、网络边界或机器边界,而任何一次跨边界的通信都可能出现三种结局:对方没收到、对方收到了但我不知道、对方收到了两次。

丢失与重复的所有具体成因,都可以挂到这三段旅程的八个薄弱点上。生产端有四个:发送后不等待确认(发出即忘),进程崩溃时客户端缓冲区里尚未刷出的批次随内存蒸发,发送超时后盲目重试造成 Broker 侧写入两份,以及业务数据库与消息系统之间的双写不一致。存储端有三个:消息只写进页缓存尚未刷盘时机器断电,副本尚未同步完成时发生主从切换导致已确认数据被截断,队列或主题达到容量、时间上限后按策略清除数据。消费端有三个以上:确认发生在处理完成之前(丢),确认发生在处理完成之后但确认本身没能落地(重),以及消费组成员变动引发的位点回退(成批地重)。

这张地图上有一个值得反复咀嚼的规律:丢失几乎都发生在"确认边界之前"——某个环节还没有真正把数据交出去,系统就宣布了成功;重复几乎都发生在"确认边界之后"——数据实际已经交出去了,但交接回执丢了,发送方只能再来一遍。两类故障是同一枚硬币的两面,硬币的名字叫作"缺少既原子又可靠的交接回执"。在不可靠网络上,两个节点无法通过有限次通信就"某件事恰好做了一次"达成绝对共识,这是两军问题给出的理论约束。工程上能做的不是消灭这个约束,而是选择让硬币落在哪一面,以及为落下的那一面准备好代价最小的兜底。

(二)三种投递语义:不丢与不重为何不可兼得

消息系统的可靠性承诺被归纳为三种语义。最多一次(at-most-once):发送后不确认、不重试,消费侧先提交进度再处理,消息可能丢失但绝不重复,换来最高的吞吐与最低的延迟,适合监控指标、行为埋点这类容忍缺失的数据。至少一次(at-least-once):发送方在没有拿到确认前必须重试,消费方在业务处理完成之后才提交进度,消息绝不丢失但可能重复,这是工程实践的事实标准。恰好一次(exactly-once):不丢也不重,听起来是终点,但它有严格的边界——后文第六部分会专门拆解为什么它在端到端意义上更像一种由"至少一次管道加幂等消费"组合出来的效果,而不是某个可以直接打开的开关。

理解三种语义的关键在于看清一个不对称性:丢失是无法在下游补救的,重复可以。一条消息丢了,除非上游还保留着原始数据并主动重发,否则任何下游手段都无能为力;而一条消息重复了,只要消费逻辑具备幂等性,重复执行的效果与执行一次完全相同。这个不对称性决定了几乎所有核心业务链路的选型:宁可重复、绝不丢失——把"不丢"做进管道的每一段,把"不重"交给消费端的幂等设计兜底。本文余下的全部内容,本质上都是这一句话在各个环节的展开。

还有一点需要在开始逐段拆解之前先立住:投递语义是端到端属性,不是单个组件的属性。生产端配置得再保守,消费端一个 autoAck 就能把整条链路拉回最多一次;反过来,消费端幂等做得再完善,生产端发出即忘造成的丢失它也无从弥补。评审一条消息链路的可靠性时,正确的问法不是"我们的 Kafka 可靠吗",而是"从业务事务提交到下游副作用生效,这中间每一次移交的确认边界在哪里、确认丢失时由谁重试、重试造成的重复由谁消化"。三个问题在链路的每一段都能问一遍,答案连起来才构成这条链路真实的语义等级。

二、生产端:把"发出去了"变成"确认写入了"

(一)Kafka 的确认与重试

1、acks 参数的三档语义

Kafka 生产者调用 send() 之后,消息先进入客户端本地的记录累加器攒批,再由后台发送线程推给分区 Leader。真正决定"什么时候算成功"的是 acks 参数。acks=0 时生产者把消息写进网络发送缓冲就宣布成功,Broker 是否收到、是否写入完全未知,任何一次网络抖动或 Broker 拒绝都直接表现为静默丢失。acks=1 时 Leader 把消息写入本地日志后即返回确认,此时若 Leader 在把数据同步给 Follower 之前宕机,新选出的 Leader 上没有这条消息,它就丢了——这一档的危险在于它平时表现完全正常,只在切主的瞬间暴露。acks=all(等价于 -1)时 Leader 要等到同步副本集合(ISR)内的所有副本都写入之后才返回确认,配合足够的最小同步副本数,单机故障不再造成已确认消息的丢失。

值得注意的是确认强度与吞吐延迟之间的换算并不像直觉那样悬殊:副本同步走的是内网复制,正常状态下 acks=all 相对 acks=1 的延迟增量通常在毫秒量级。Kafka 从 3.0 版本起把生产者默认值改成了 acks=all,这个默认值的变化本身就是社区对"可靠优先"共识的背书。真正需要主动选择 acks=1 甚至 acks=0 的,是日志采集、指标上报这类量大且允许缺失的场景。

2、重试的副作用:重复与乱序

把 retries 设大只解决了"不丢"的一半,同时引入两个新问题。第一是重复:Broker 实际写入成功、但确认响应在网络中丢失时,生产者按超时判定失败并重发,日志里就出现两条相同的消息——这不是缺陷,而是至少一次语义的必然代价。第二是乱序:当 max.in.flight.requests.per.connection 大于 1 时,同一连接上有多个批次在途,前一批失败重试、后一批成功,写入顺序就与发送顺序颠倒了,对依赖分区内顺序的业务(如同一订单的状态变更流)是致命的。

3、幂等生产者与事务

Kafka 用幂等生产者同时解决这两个问题。开启 enable.idempotence=true 后,每个生产者实例获得一个生产者编号(PID),发往每个分区的消息携带单调递增的序列号;Broker 按〈PID,分区〉记录已写入的最大序列号,收到重复序列号的批次时直接丢弃并照常返回确认,收到跳号的批次则报错。这样确认丢失后的重试不再产生重复,且在 max.in.flight 不超过 5 的前提下保持分区内有序。Kafka 3.0 起幂等生产同样是默认开启的。

与重试相关的参数还有一处新旧交替值得注意:直接调大 retries 的做法已经被 delivery.timeout.ms 取代——后者从 send() 返回开始计时,把攒批等待、发送在途、重试退避全部框进一个总预算(默认两分钟),预算耗尽即向回调报告失败。这带来两条实践要求:其一,发送回调里的失败分支必须真正处理,至少要把失败消息与异常记入可检索的日志或补偿表,静默吞掉回调异常等于自愿回到发出即忘;其二,进程正常退出前必须调用 close() 或 flush() 等待在途批次清空,容器化部署时要给停机宽限期留出这段时间,否则每次滚动发布都是一次小规模的消息丢失演习。

必须认清幂等生产者的边界:它只在"单生产者会话、单分区"范围内去重。生产者进程重启后 PID 更换,重启前后各发一次的同一业务消息,Broker 视为两条不同消息;应用代码自己的重发逻辑(比如上游重推、人工补偿)产生的重复,序列号机制同样无法识别。跨会话、跨分区的原子性要靠事务生产者:设置 transactional.id 后,多个分区的写入与消费位点的提交可以捆绑为一个事务,配合消费端 isolation.level=read_committed,未提交事务的消息对下游不可见。事务的主要战场是流式处理内部的"消费—转换—再生产"闭环,普通的业务收发场景用幂等生产者加消费端幂等即可,事务带来的吞吐损耗与运维复杂度往往得不偿失。

(二)RabbitMQ 的确认与路由兜底

1、Publisher Confirm 与事务模式

RabbitMQ 为生产端提供两种到达性保证。AMQP 事务(txSelect / txCommit)是同步阻塞的,每次提交一个网络往返,吞吐损耗可达一个数量级,实践中基本弃用。主流方案是发布确认(Publisher Confirm):信道进入 confirm 模式后,每条消息获得递增的序号,Broker 在消息被所有目标队列接收之后回送 basic.ack——对持久化消息,这个确认发生在落盘之后。生产者注册异步的 ConfirmListener,用序号关联发送时暂存的消息体:收到 ack 就从暂存区清除,收到 nack(Broker 内部错误)或超时未确认则取出重发。这里的暂存区设计正是可靠性的关键:消息在拿到确认之前必须留有重发的底稿,底稿放在内存里进程一死就没了,所以核心链路的底稿要落在数据库里——这就自然过渡到后文的事务发件箱。

2、mandatory 与备份交换器

Publisher Confirm 有一个极易被误解的盲区:它确认的是"Broker 收到了",不是"消息进了队列"。当路由键在交换器上匹配不到任何绑定时,RabbitMQ 的默认行为不是报错,而是直接丢弃消息并照常返回 ack——生产者看到的一切都是成功,消息却已经消失,这是 RabbitMQ 体系里最隐蔽的丢失点。封堵它有两条路:其一,发布时设置 mandatory=true,不可路由的消息会通过 basic.return 退回生产者,配合 ReturnListener 记录现场并告警;其二,声明交换器时指定 alternate-exchange 参数,让不可路由消息自动转入一个备份交换器(通常是 fanout 类型),落入专门的收容队列等待人工处理。两者同时设置时备份交换器优先生效。工程上更推荐备份交换器:它把异常处理从每个生产者的回调里收敛到一个统一的收容点。

(三)双写困境与事务发件箱

以上机制都在解决"应用到 Broker"这一跳,但核心业务场景的起点通常更靠前:先在本地数据库提交一个业务事务(下单、扣款),再发一条消息通知下游。两个动作分属两个系统,无法放进同一个原子操作——先提交事务再发消息,提交成功后进程崩溃,消息永远缺失;先发消息再提交事务,发送成功后事务回滚,下游收到一个业务上从未发生过的幽灵事件。靠"try-catch 里补偿"是堵不住的,崩溃可以发生在任何一行代码之间。

事务发件箱(Transactional Outbox)把这个跨系统问题降级回单系统问题:在业务库中建一张发件箱表,业务数据变更与"待发送消息"记录的插入放在同一个本地事务里提交——要么都成功,要么都回滚,消息在业务提交的瞬间就"逻辑上已经发出"。随后由独立的投递环节把消息搬运到 Broker:轮询扫描发件箱表中状态为待发送的记录,或者用变更数据捕获(CDC,如 Debezium)订阅数据库日志,发送成功并拿到 Broker 确认后回写状态为已发送。

发件箱模式有一个必须在设计时就写进文档的性质:搬运环节在"发送成功"与"回写状态"之间崩溃时,重启后会把同一条消息再发一遍——它天然是至少一次语义。因此发件箱里的每条消息都必须携带全局唯一的消息标识,由消费端据此幂等。另一个工程细节是发件箱表的清理:已发送记录按时间分区归档或删除,否则这张表会成为业务库里增长最快的表。RocketMQ 的事务消息(半消息加事务回查)在中间件层面解决同一个问题,但 Kafka 与 RabbitMQ 都没有内置等价物,发件箱是这两个生态里的主流答案。

三、Broker 存储端:持久化与副本才是承诺的底座

(一)Kafka 的副本机制

1、ISR 与最小同步副本数

生产端拿到的那个"确认"值多少钱,完全取决于 Broker 侧的存储策略。Kafka 的答案是多副本:每个分区一个 Leader、若干 Follower,Follower 持续从 Leader 拉取日志,能在 replica.lag.time.max.ms 时间内跟上进度的副本留在同步副本集合(ISR)里,掉队的被移出——ISR 是一个动态收缩与扩张的集合。acks=all 的真实含义是"ISR 内所有成员都确认",于是问题变成:ISR 收缩到只剩 Leader 自己时,acks=all 悄悄退化成了 acks=1。封住这个退化路径的参数是 min.insync.replicas:设为 2 时,若 ISR 成员数不足 2,Broker 直接拒绝写入并向生产者抛出 NotEnoughReplicas 异常——宁可停止服务,也不接受一份随时可能丢失的数据。可靠写入的完整组合因此是四件套:replication.factor ≥ 3 提供容灾冗余,min.insync.replicas = 2 保证确认强度,acks = all 让生产者等待这个强度,再加上下面这个开关。

2、脏选举与刷盘策略

unclean.leader.election.enable 控制着一个残酷的取舍:分区的全部 ISR 成员都不可用时,允不允许一个掉队的旧副本竞选 Leader?允许,分区立刻恢复可用,但新 Leader 缺失的那段已确认日志被永久截断——生产者明明收到过确认的消息就此消失;不允许,分区保持不可写,直到原 ISR 成员恢复。这个参数自 Kafka 0.11 起默认为 false,核心业务集群不要动它。另一个常被问到的点是刷盘:Kafka 默认不做同步刷盘,消息写入操作系统页缓存即算写入,持久性依赖副本冗余而非 fsync——单机断电时页缓存里的数据确实会丢,但只要这条消息已经复制到了另一台机器,它就还在。理解了"用副本换刷盘"这个设计,就不会试图去调 log.flush.interval.messages 这类参数来求安心,跨机器、跨机架乃至跨可用区的副本分布才是正确的努力方向。

(二)RabbitMQ 的持久化与队列高可用

1、三个持久化开关缺一不可

RabbitMQ 的持久化是三个独立的开关,任何一个漏掉都会在 Broker 重启后造成丢失:交换器声明时 durable=true,否则重启后交换器不复存在、生产者发布直接报错;队列声明时 durable=true,否则重启后队列连同其中的消息一起消失;消息发布时 deliveryMode=2,否则即使队列是持久的,非持久消息也不会被写入磁盘。三者备齐后还有最后一段窗口:消息写入与刷盘之间存在批量延迟,若要求确认落盘,必须配合 Publisher Confirm——持久化消息的 ack 在落盘后才返回,这正是两个机制要成对使用的原因。

2、从镜像队列到仲裁队列

单机持久化挡不住机器级故障,队列需要自己的副本机制。经典镜像队列是 RabbitMQ 的第一代答案,但它的同步模型存在网络分区下丢消息、重新镜像期间不可用等长期诟病的问题,官方已将其弃用并在 4.0 版本中移除。替代者是仲裁队列(Quorum Queue):基于 Raft 协议做多数派复制,消息要被多数节点确认后才向生产者返回 confirm,Leader 故障时由携带完整日志的成员接任——语义上与 Kafka 的 acks=all 加 min.insync.replicas 组合等价。新建核心业务队列时应默认选择仲裁队列,同时注意它对资源的要求更高(每队列的 Raft 日志、更多的内存与磁盘占用),海量小队列的场景要做容量评估。

3、容量上限与溢出策略

还有一类容易被忽视的"策略性丢失":队列不是无底洞。设置了 x-max-length 或 x-max-length-bytes 的队列在到达上限后,默认策略 drop-head 会静默丢弃队头最老的消息;配置为 reject-publish 则拒绝新消息并让生产者的 confirm 收到 nack。核心队列应选择 reject-publish——让压力显式地传导回生产端,而不是无声地吃掉存量数据。

与容量相邻的另一个话题是内存压力。RabbitMQ 是内存敏感型系统,消息大量堆积时经典队列会触发内存告警并阻塞所有发布连接(流控),表现为生产端莫名其妙地整体卡死。历史上的缓解手段是惰性队列(消息尽早落盘、不驻留内存),自 3.12 版本起这已成为经典队列的默认行为,仲裁队列则从设计上就以磁盘为主。对使用者的启示是:RabbitMQ 的定位是消息在队列里快进快出的通道,不是数据仓库;如果业务形态就是会出现小时级、天级的大量堆积(比如下游批处理窗口),Kafka 的日志存储模型天然更适合这种"堆着慢慢读"的形态,选型阶段就该把堆积形态纳入考量,而不是等生产环境流控报警再补救。同样的思路适用于 Kafka 的保留期:retention.ms 必须覆盖消费端可能的最长停摆时间(含故障排查与积压追赶),否则消费者还没来得及读,日志段已经被清理了——这也是一种丢失,只是发生在"存储到消费"的边界上。

四、消费端:确认时机决定丢与重

(一)Kafka 的位点管理

1、自动提交的双重风险

Kafka 消费者不逐条确认消息,而是周期性地把"我已经消费到分区的第几条"(位点,offset)提交到内部主题 __consumer_offsets。enable.auto.commit=true(默认)时,客户端每隔 auto.commit.interval.ms(默认五秒)在 poll 调用内部自动提交上一批的位点。自动提交的风险是双向的:一方面,若这一批消息尚在处理中、位点已被提交,进程随后崩溃,重启后从新位点继续,未处理完的消息就丢了——先确认后处理;另一方面,若处理完成但还没到提交时刻进程就崩溃,重启后从旧位点重拉,这一批被处理两遍——先处理后确认。同一个机制,取决于崩溃时刻落在提交周期的哪一侧,既可能丢也可能重,而业务对两者的容忍度截然不同,这正是"自动提交不适合核心链路"的根本原因。

2、手动提交的正确姿势

关闭自动提交后,原则只有一条:位点提交必须发生在业务副作用完成之后。同步提交(commitSync)可靠但每次阻塞一个往返;纯异步提交(commitAsync)高效但失败不重试,且要小心旧位点的迟到提交覆盖新位点。实践中的标准组合是:正常循环内用异步提交摊薄开销,在消费者关闭与再均衡回调处用同步提交守住最后一道门。

线程模型是手动提交最容易翻车的地方。KafkaConsumer 不是线程安全的,poll 与 commit 必须在同一个线程里调用;而业务处理一旦转交给工作线程池,"处理完成"与"提交位点"就分属两个线程,直接在工作线程里提交会抛出并发访问异常,先记下位点回到 poll 线程统一提交则要小心一个陷阱——同一分区内位点大的消息先处理完,若直接提交大位点,就等于把还没处理完的小位点消息一并确认了,崩溃后它们不会重放。正确做法是维护每个分区的"连续完成水位":只提交从上次提交点开始连续完成的最大位点,中间有空洞就停在空洞之前。这套簿记并不复杂,但必须显式设计出来,任何"默认它自然正确"的假设都会在压测里现形。

批量拉取时还有一个细节:提交的位点是"下一条要消费的位置",按批处理、按批提交时要确保整批都成功后再提交批尾位点,部分成功的批次要么整体重放(依赖幂等),要么把失败消息移交重试通道后再提交。

3、再均衡:成批重复的最大来源

单条消息的重复通常影响有限,真正制造事故的是成批重复,而它的最大来源是消费组再均衡(rebalance)。消费者进程重启、扩缩容、处理单批耗时超过 max.poll.interval.ms、心跳超时——任何一种都会触发分区重新分配。此时那个被判离组的消费者可能已经处理到了位点 260,但最后一次成功提交停在 200:接管分区的新消费者从 200 开始,60 条消息被完整地重放一遍。若消费逻辑不幂等,一次普通的发布重启就是一次生产事故。

收敛再均衡的代价有四个抓手。缩小重复窗口:控制 max.poll.records 让单批更小、处理完尽快提交,长耗时任务从 poll 线程剥离转异步。减少触发:静态成员资格(group.instance.id)让计划内重启不引发重分配,协作式分配器(CooperativeStickyAssignor)把"全组停摆再重分"变成增量迁移。挂钩兜底:实现 ConsumerRebalanceListener,在 onPartitionsRevoked 回调里同步提交当前实际进度,把损失压缩到正在处理中的那一批。最后是接受现实:再均衡无法根除,幂等消费是唯一的最终防线。

(二)RabbitMQ 的消费确认

1、autoAck 与手动应答

RabbitMQ 的消费确认是逐条的。autoAck=true 时消息一经投递即被 Broker 标记完成,消费者随后崩溃,这条消息不会重投——等价于先确认后处理,只适合允许丢失的场景。可靠消费必须 autoAck=false,处理成功后显式 basic.ack;处理失败时用 basic.nack(可批量)或 basic.reject 拒绝,并用 requeue 参数决定去向。未确认消息在消费者连接断开后会自动重新投递给其他消费者,重投的消息带有 redelivered 标记——这个标记只说明"投递过至少一次",不能说明"处理过",判断是否真正处理过依然要靠业务幂等。此外要注意消费确认有超时(默认 30 分钟):一条消息占着未确认状态超过时限,整个信道会被强制关闭,引发这条信道上所有未确认消息的集体重投。

2、prefetch 与 requeue 风暴

basic.qos 的 prefetch 参数限制每个消费者未确认消息的在途数量,它同时是限流阀与故障半径的控制器:prefetch 过大,消费者崩溃时一大批在途消息集体重投;prefetch 过小,网络往返成为吞吐瓶颈。经验做法是从消费单条耗时与目标吞吐反推,常见起点在几十到几百之间,压测校准。更危险的是 requeue 的滥用:处理失败后 nack(requeue=true) 把消息塞回队头,若失败原因是消息本身(脏数据、反序列化失败),它会立刻被再次投递、再次失败——消费者陷入全速空转的死循环,还把队头堵死。正确的姿势是区分失败类型:依赖抖动类(下游超时)可控次数地重试;消息自身缺陷类(毒丸消息)绝不 requeue,直接送入死信通道。

五、失败消息的去处:重试与死信

(一)RabbitMQ 的死信拓扑

处理失败的消息需要一个正式的去处,RabbitMQ 为此内置了死信机制。队列声明时通过 x-dead-letter-exchange(及可选的 x-dead-letter-routing-key)指定死信交换器,三种事件会把消息变成死信送过去:消费者 basic.reject / basic.nack 且 requeue=false、消息 TTL 过期、队列长度超限被挤出。死信交换器只是一个普通交换器,按死信路由键把消息路由到死信队列,消息头的 x-death 数组会累积记录每一次死信事件的来源队列、原因与计数。

这套机制还能反过来实现延迟重试:业务队列失败的消息 nack 进入一个设置了 TTL、没有任何消费者的重试队列,TTL 到期后消息过期成为死信,经重试队列的死信交换器路由回业务队列——一条消息在"业务队列 → 重试队列 → 业务队列"之间循环,每圈间隔一个 TTL,用 x-death 的计数判断已重试几轮,超过上限后改道进入终态死信队列等待人工介入。需要多档延迟时为每档建一个不同 TTL 的重试队列即可。注意不要在同一个队列里混用逐条消息 TTL 来做多档延迟:RabbitMQ 只检查队头消息的过期,队头一条长 TTL 消息会挡住后面已到期的短 TTL 消息。

(二)Kafka 的重试主题与死信主题

Kafka 的 Broker 没有内置死信概念,但分区内顺序消费的模型让"失败消息的去处"问题更加尖锐:队头一条毒丸消息若原地无限重试,堵塞的不是一条消息而是整个分区。生态里的通行答案是重试主题分级加死信主题(DLT),由框架层实现:主流程消费失败后,把消息连同原始主题、分区、位点、异常栈等元数据一起发布到 retry-5s 主题;独立的重试消费者按约定延迟消费该主题,仍失败则升级到 retry-1m、retry-10m,逐级拉长间隔;全部重试耗尽后进入死信主题。Spring Kafka 的 DefaultErrorHandler 配合 DeadLetterPublishingRecoverer 提供了开箱即用的实现,@RetryableTopic 注解甚至能自动创建整条重试主题链。这套结构的取舍也要讲清楚:消息一旦进入重试通道,它与主流程消息之间的顺序保证就被放弃了——需要严格顺序的业务要么在重试期间暂停整个分区的消费(Spring Kafka 的暂停恢复机制),要么在业务层用版本号抵御乱序。

(三)重试策略的设计纪律

无论哪套系统,重试策略共享同一组纪律。第一,指数退避加抖动:固定间隔的重试会在下游故障恢复瞬间形成同步冲击波,退避间隔上叠加随机抖动才能把重试流量摊开。第二,设上限并区分错误类型:可恢复错误(超时、限流)值得重试,不可恢复错误(参数非法、反序列化失败、业务规则拒绝)第一次就该直送死信,重试只是浪费资源并推迟发现。第三,死信队列必须配齐三件套——容量与堆积告警(死信持续增长说明主流程带病运行)、按消息标识检索现场的能力、以及可控速率的人工回放入口。没有回放入口的死信队列只是一个体面一点的黑洞:消息进去了,业务结果依然是丢的。

回放本身也要按生产变更的标准对待。回放前先确认根因已修复,否则消息回去转一圈还会回来;回放要限速,一次性把积压数天的死信灌回主流程,等于对下游发起一次自制的流量洪峰;回放的消息天然是"迟到的旧消息",与主流程的新消息之间必然乱序,这再次回到幂等与状态机的兜底——去重表挡住已成功过的,条件更新挡住试图把新状态改回旧状态的。把这三条写进死信处理的标准操作程序,回放才是修复手段而不是二次事故的引信。

六、重复不可避免:幂等消费的工程分层

(一)恰好一次的真实边界

现在可以正面回答那个高频问题了:Kafka 不是号称支持 exactly-once 吗?它支持的确切范围是:幂等生产者保证"单会话、单分区"的管道内不重;事务生产者把"从 Kafka 读、处理、再写回 Kafka、同时提交消费位点"这个闭环做成原子操作,配合 read_committed 隔离级别,实现流式处理拓扑内部的恰好一次——Kafka Streams 的 processing.guarantee=exactly_once_v2 正是这套机制的封装。但绝大多数业务消费者的副作用发生在 Kafka 之外:写数据库、调外部接口、发短信。位点提交在 Kafka 里,业务写入在数据库里,两者又是一次跨系统双写——事务机制鞭长莫及。消费者在"数据库已写入、位点未提交"的缝隙里崩溃,重启后消息重放,数据库就被写了第二次。

把外部系统也纳入恰好一次,理论出路有两条:把位点与业务数据存进同一个数据库、用同一个本地事务提交(消费位点表模式,随后用 seek 恢复位置),或者让外部副作用本身幂等。前者约束重、适用面窄,后者就是全行业的主流答案。所以工程上的结论是一句话:端到端恰好一次 = 至少一次投递 + 幂等消费。恰好一次不是从中间件买来的功能,而是自己在消费端挣来的性质。

(二)幂等的四层防线

幂等消费不是一个技术点,而是一组按代价与强度排布的防线,实践中按业务分量组合使用。

第一层,前置快速去重。用 Redis 的 SETNX 以消息标识为键做短窗口拦截,成本最低、见效最快,能挡住重投风暴里的大部分密集重复。但必须想清楚它的定位:Redis 与业务库不在一个事务里,"Redis 置位成功、业务写入失败"或缓存节点故障都会让它失效——它是性能优化,不是正确性依据,任何把正确性押在这一层上的设计都是错的。

第二层,去重表。在业务数据库里建一张消费记录表,以消息标识为唯一索引,业务副作用与去重记录的插入放在同一个本地事务里:重复消息在插入去重记录时触发唯一键冲突,整个事务回滚,业务副作用一并撤销。这是真正的原子屏障,也是大多数业务场景的主力防线。代价是表的持续增长,需要按时间分区清理,且保留期必须覆盖系统里可能出现的最大重复窗口(比如死信回放发生在一周后,去重记录就至少要留一周)。

第三层,业务唯一键。订单号、支付流水号、对账单号这些业务标识天然唯一,直接在业务表上建唯一索引,重复插入自己就会失败。它的独特价值在于不依赖消息标识:上游系统重推、人工补单这类"业务层面的重发"会携带新的消息标识,前两层全部失效,只有业务键还认得它们。局限是只保护插入类操作。

第四层,状态机与条件更新。对更新类操作,把业务建模为显式的状态迁移,用条件更新执行:UPDATE orders SET status = '已支付' WHERE order_no = ? AND status = '待支付'——重复执行时 WHERE 条件不再满足,影响行数为零,天然幂等;乐观锁版本号(WHERE version = ?)是同一思想的通用形式。这一层的额外收益是同时抵御乱序:迟到的旧状态变更无法覆盖新状态,这对经过重试通道、顺序保证已被放弃的消息尤为重要。

(三)消息设计的配合

幂等防线要靠消息本身的设计来配合。每条消息在生产侧生成全局唯一的消息标识(业务单号或雪花算法生成的标识,写进消息头),这是所有去重手段的锚点;携带业务时间戳与版本号,供状态机层判断新旧;消息体自包含(携带处理所需的完整上下文)或者反过来极简化(只带标识、消费时回查源库),两种风格各有拥趸,但要全局统一——半自包含的消息在重放时最容易踩到"上下文已变化"的坑。分区键的选择也与正确性相关:同一业务实体的消息路由到同一分区才能获得分区内顺序,用订单号做分区键,同一订单的创建、支付、发货事件才不会在不同分区上被并行消费。

七、把可靠性做成清单与演练

(一)参数清单:两套系统对照

前六部分的结论可以压缩成一张上线前的核对清单。生产端:Kafka 确认 acks=all、幂等生产开启、发送回调处理失败分支、进程退出前 flush;RabbitMQ 开启 Publisher Confirm 异步确认、mandatory 加退回监听或备份交换器、消息 deliveryMode=2;核心链路业务写库与发消息之间用事务发件箱衔接,消息体携带全局唯一标识。存储端:Kafka 副本因子至少 3、min.insync.replicas=2、禁止脏选举、保留期覆盖最长消费停摆;RabbitMQ 交换器队列消息三重持久化、核心队列用仲裁队列、队列上限配 reject-publish 溢出策略。消费端:Kafka 关闭自动提交、处理完成后提交位点、静态成员加协作式再均衡、再均衡回调里同步提交;RabbitMQ 关闭 autoAck、prefetch 按耗时压测校准、失败区分类型决定重试或直送死信;兜底层去重表加状态机、重试分级加死信收容加回放入口。

(二)监控、对账与故障演练

配置正确只是起点,可靠性需要持续的证据。监控上盯三类信号:堆积类——消费滞后量(consumer lag)与死信队列深度的持续增长,说明主流程处理能力不足或带病运行;异常类——生产端确认失败率、退回消息数、ISR 收缩事件、再均衡频率,任何一项的突增都值得告警;守恒类——这是最容易被省略也最有价值的一层,用定时对账验证消息守恒:上游业务库里今天产生了多少笔订单事件,下游的处理结果表里就应该有多少笔,缺失即丢、多出即重,对账是唯一能发现"监控全绿但数据不对"这类问题的手段。

最后是故障演练。清单上的每一项配置,都对应一种可以人为注入的故障:杀掉刚调用完 send 的生产者进程,验证发件箱把消息补发出来;强制 Kafka 分区切主,验证 acks=all 加最小同步副本数没有丢下任何已确认消息;在业务处理进行到一半时杀掉消费者,验证重放后去重表挡住了第二次副作用;向队列注入一条无法反序列化的毒丸消息,验证它在有限次重试后安静地躺进死信队列而不是堵死分区。每次演练后的验收标准只有一条:消息守恒——业务结果不缺一条,也不多算一条。能通过这套演练的系统,才有资格在架构图上写下"可靠消息"四个字。

八、总结

回到开头的问题:消息为什么会丢失或重复?因为一条消息的旅程由多段跨进程、跨网络的移交组成,而不可靠网络上不存在既原子又可靠的交接回执——丢失发生在确认边界之前,重复发生在确认边界之后,二者不可能同时归零。工程的应对不是追求零,而是做一次清醒的选择:用至少一次语义把"不丢"做进链路的每一段——生产端的 acks=all 与 Publisher Confirm、双写缝隙上的事务发件箱、存储端的多数派副本与三重持久化、消费端"先处理后确认"的铁律;然后接受由此必然产生的重复,用消费端的四层幂等防线把重复变得无害;再为处理失败的消息修好重试分级与死信收容的正式去处,配上监控、对账与故障演练去持续证明这一切真的有效。Kafka 与 RabbitMQ 在机制命名上千差万别——位点对应答、ISR 对仲裁队列、重试主题对死信交换器——但底下的原理完全同构。理解了"确认边界"这一个概念,两套系统的所有可靠性参数就不再是需要背诵的配置项,而是同一道选择题在不同位置的答案。

参考链接

  • Apache Kafka 官方文档:消息传递语义(Message Delivery Semantics)
  • Apache Kafka 官方文档:生产者与消费者配置参数
  • KIP-98:Exactly Once Delivery and Transactional Messaging(Kafka 事务与幂等生产设计文档)
  • KIP-345:Static Membership 静态成员协议(减少消费者再均衡)
  • Confluent 博客:Exactly-Once Semantics Are Possible——Kafka 如何实现恰好一次
  • RabbitMQ 官方文档:Reliability Guide 可靠性总览
  • RabbitMQ 官方文档:Consumer Acknowledgements and Publisher Confirms 确认机制
  • RabbitMQ 官方文档:Dead Letter Exchanges 死信交换器
  • RabbitMQ 官方文档:Quorum Queues 仲裁队列
  • microservices.io:Transactional Outbox 事务发件箱模式
  • Debezium 博客:基于发件箱模式的微服务可靠数据交换
  • Spring for Apache Kafka 官方文档:错误处理、重试主题与死信发布
  • Uber 工程博客:Building Reliable Reprocessing and Dead Letter Queues with Apache Kafka

相关新闻

电池AI编程实战营系列之三:告别复制粘贴,用AI把电池数据处理写成可复用函数+批量流水线

电池AI编程实战营系列之三:告别复制粘贴,用AI把电池数据处理写成可复用函数+批量流水线

投票钩子:你写电池数据处理脚本,是"一次性的"还是"能复用的"?【A】一次跑完就行 🏃 【B】努力封装成函数 📦 【C】已经做成模块了 🏗️本系列第03篇培训教材:教你告别复制…

2026/10/20 11:10:05 阅读更多 →
STM32F407单相PWM整流双闭环控制实战

STM32F407单相PWM整流双闭环控制实战

简介:本资源是针对2022年全国大学生电子设计竞赛A题‘单相交流电子负载’的完整嵌入式实现方案,面向电赛备赛学生、STM32初/中级开发者及电力电子方向实践者,重点解决单相系统下高精度锁相与PWM整流控制这一核心难点。工程基于STM32F407VET6主…

2026/10/20 11:09:04 阅读更多 →
斯坦福ToddlerBot——用于行走操控且与机器学习兼容的0.56m小人形:6000美刀成本下,可VR遥操、可RL训练

斯坦福ToddlerBot——用于行走操控且与机器学习兼容的0.56m小人形:6000美刀成本下,可VR遥操、可RL训练

前言 我司q4的几个侧重点: 在深圳手搓人形世界模型及WAM叠衣服继续双足全身vla/WAM继续强化我司「插拔/装配、搬运/分拣」垂直模型 本点中的“插拔/装配”,涉及RSI的落地/实践不断强化RL运控实力 故持续欢迎敢闯、敢干的年轻人,随我们每周…

2026/10/20 11:09:04 阅读更多 →

最新新闻

Pygame性能优化:把静态障碍画进背景并用Mask碰撞检测

Pygame性能优化:把静态障碍画进背景并用Mask碰撞检测

我先说自己踩过的一个坑:做一个小游戏,十几种障碍物各自做成独立精灵对象,视觉上没问题,但数量一多,每帧要遍历、更新、碰撞检测,开销一下上去了。后来改成把所有障碍直接画到背景面上,再配合 m…

2026/10/20 11:58:56 阅读更多 →
VS2019下DuiLib从零配置WIN32空项目完整指南

VS2019下DuiLib从零配置WIN32空项目完整指南

DuiLib 这名字,做 Windows 客户端的老兵肯定不陌生。当年从 MFC 切到 DirectUI 的朋友,多少都摸过这套库,或者至少听过它的大名。到现在,VS2019 出来都这么多年了,网上翻来覆去找资料,很多还是老版本的方案…

2026/10/20 11:58:56 阅读更多 →
MPU6050 DMP直接输出pitch/roll/yaw原理与实战解析

MPU6050 DMP直接输出pitch/roll/yaw原理与实战解析

简介:本资源是一套基于MPU6050传感器DMP(数字运动处理器)功能实现姿态解算的完整嵌入式工程,面向嵌入式初学者、智能硬件开发者及飞控/机器人方向实践者,解决传统姿态解算中万向锁、算法复杂、滤波调试难等痛点。资源无…

2026/10/20 11:58:56 阅读更多 →
贝叶斯优化实战:从高斯过程到深度学习超参数调优

贝叶斯优化实战:从高斯过程到深度学习超参数调优

简介:面向机器学习与深度学习从业者,这份资源聚焦贝叶斯优化超参数调优,旨在解决手动调参效率低、模型性能难提升的痛点。压缩包共4个文件,核心是两个Python脚本,一个面向支持向量机、随机森林等传统机器学习模型&…

2026/10/20 11:58:56 阅读更多 →
CutMix数据增强在医学病灶检测中的实践:稳住训练与提升小病灶召回

CutMix数据增强在医学病灶检测中的实践:稳住训练与提升小病灶召回

1. 病灶检测的“过山车式”训练:问题到底出在哪做医疗图像的病灶检测有一段时间的人,大概率都见过这样一种场景:训练曲线前期掉得很快,一切看起来都正常,然后到某个阶段开始来回震荡,loss曲线像过山车一样上…

2026/10/20 11:58:56 阅读更多 →
如意报表 Ming-WebReport-V3 安装配置与避坑指南

如意报表 Ming-WebReport-V3 安装配置与避坑指南

简介:如意报表(Ming-WebReport-V3)安装程序面向需要在Web环境中搭建数据可视化与报表系统的开发者及企业信息化人员,帮助快速完成报表工具的部署与集成。资源包共408个文件,约26.45MB,涵盖o、mrf报表模板、…

2026/10/20 11:57:55 阅读更多 →

日新闻

CLRC663芯片Pin-to-Pin替代实战:从评估到量产全记录

CLRC663芯片Pin-to-Pin替代实战:从评估到量产全记录

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

2026/10/20 1:01:30 阅读更多 →
ESP32舵机控制指南:原理、多路驱动与避坑实战

ESP32舵机控制指南:原理、多路驱动与避坑实战

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

2026/10/20 1:01:30 阅读更多 →
位移法实现一阶低通滤波:单片机ADC采样降噪实战

位移法实现一阶低通滤波:单片机ADC采样降噪实战

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

2026/10/20 1:01:30 阅读更多 →

周新闻

基于GMM的语音识别系统原理与MATLAB实现详解

基于GMM的语音识别系统原理与MATLAB实现详解

说到基于高斯混合模型(GMM)的语音识别系统,很多人第一反应就是一堆数学公式,觉得门槛很高。但如果你真的在MATLAB里把它落地跑通,会发现核心其实就是三件事:把声音变成特征向量,用GMM给每个类别…

2026/10/20 10:02:13 阅读更多 →
数字图像处理入门:反白、灰度化、调色板修改与导出txt实践

数字图像处理入门:反白、灰度化、调色板修改与导出txt实践

数字图像处理这四个字听起来挺唬人,但落到具体操作上,无非就是让计算机按你的想法“摆弄”像素。我在一个模拟项目X里用了一套特别经典的基础处理流程:对图像做反白、改调色板、彩色转灰度,最后把像素数据导出成txt文件。单看每一…

2026/10/19 17:32:41 阅读更多 →
机器人大脑技术盘点:具身智能的感知、决策与数据闭环

机器人大脑技术盘点:具身智能的感知、决策与数据闭环

如果你的信息流最近也天天刷到“机器人大脑”这个词,你会发现一个很有意思的现象:每个人说的“大脑”根本不是同一个东西。有人指的是大语言模型,有人指的是运动控制系统,还有人指的是能理解物理世界的端到端模型。在2026年这个时…

2026/10/20 2:50:50 阅读更多 →

月新闻

我发现了一个新思路:用 Remotion + Claude Code 像写代码一样自动化生成短视频

我发现了一个新思路:用 Remotion + Claude Code 像写代码一样自动化生成短视频

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

2026/10/20 10:02:08 阅读更多 →
Windows下 Codex 中 Chrome 和 Computer Use 插件不可用问题排查及解决参考方式:TaoToken 统一 Key 配置与验证

Windows下 Codex 中 Chrome 和 Computer Use 插件不可用问题排查及解决参考方式:TaoToken 统一 Key 配置与验证

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

2026/10/20 9:50:17 阅读更多 →
黑夜航拍船只数据集训练YOLOV5模型全流程解析

黑夜航拍船只数据集训练YOLOV5模型全流程解析

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

2026/10/20 9:50:45 阅读更多 →