一条消息的丢与重: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 参数。acks0 时生产者把消息写进网络发送缓冲就宣布成功Broker 是否收到、是否写入完全未知任何一次网络抖动或 Broker 拒绝都直接表现为静默丢失。acks1 时 Leader 把消息写入本地日志后即返回确认此时若 Leader 在把数据同步给 Follower 之前宕机新选出的 Leader 上没有这条消息它就丢了——这一档的危险在于它平时表现完全正常只在切主的瞬间暴露。acksall等价于 -1时 Leader 要等到同步副本集合ISR内的所有副本都写入之后才返回确认配合足够的最小同步副本数单机故障不再造成已确认消息的丢失。值得注意的是确认强度与吞吐延迟之间的换算并不像直觉那样悬殊副本同步走的是内网复制正常状态下 acksall 相对 acks1 的延迟增量通常在毫秒量级。Kafka 从 3.0 版本起把生产者默认值改成了 acksall这个默认值的变化本身就是社区对可靠优先共识的背书。真正需要主动选择 acks1 甚至 acks0 的是日志采集、指标上报这类量大且允许缺失的场景。2、重试的副作用重复与乱序把 retries 设大只解决了不丢的一半同时引入两个新问题。第一是重复Broker 实际写入成功、但确认响应在网络中丢失时生产者按超时判定失败并重发日志里就出现两条相同的消息——这不是缺陷而是至少一次语义的必然代价。第二是乱序当 max.in.flight.requests.per.connection 大于 1 时同一连接上有多个批次在途前一批失败重试、后一批成功写入顺序就与发送顺序颠倒了对依赖分区内顺序的业务如同一订单的状态变更流是致命的。3、幂等生产者与事务Kafka 用幂等生产者同时解决这两个问题。开启 enable.idempotencetrue 后每个生产者实例获得一个生产者编号PID发往每个分区的消息携带单调递增的序列号Broker 按〈PID分区〉记录已写入的最大序列号收到重复序列号的批次时直接丢弃并照常返回确认收到跳号的批次则报错。这样确认丢失后的重试不再产生重复且在 max.in.flight 不超过 5 的前提下保持分区内有序。Kafka 3.0 起幂等生产同样是默认开启的。与重试相关的参数还有一处新旧交替值得注意直接调大 retries 的做法已经被 delivery.timeout.ms 取代——后者从 send() 返回开始计时把攒批等待、发送在途、重试退避全部框进一个总预算默认两分钟预算耗尽即向回调报告失败。这带来两条实践要求其一发送回调里的失败分支必须真正处理至少要把失败消息与异常记入可检索的日志或补偿表静默吞掉回调异常等于自愿回到发出即忘其二进程正常退出前必须调用 close() 或 flush() 等待在途批次清空容器化部署时要给停机宽限期留出这段时间否则每次滚动发布都是一次小规模的消息丢失演习。必须认清幂等生产者的边界它只在单生产者会话、单分区范围内去重。生产者进程重启后 PID 更换重启前后各发一次的同一业务消息Broker 视为两条不同消息应用代码自己的重发逻辑比如上游重推、人工补偿产生的重复序列号机制同样无法识别。跨会话、跨分区的原子性要靠事务生产者设置 transactional.id 后多个分区的写入与消费位点的提交可以捆绑为一个事务配合消费端 isolation.levelread_committed未提交事务的消息对下游不可见。事务的主要战场是流式处理内部的消费—转换—再生产闭环普通的业务收发场景用幂等生产者加消费端幂等即可事务带来的吞吐损耗与运维复杂度往往得不偿失。二RabbitMQ 的确认与路由兜底1、Publisher Confirm 与事务模式RabbitMQ 为生产端提供两种到达性保证。AMQP 事务txSelect / txCommit是同步阻塞的每次提交一个网络往返吞吐损耗可达一个数量级实践中基本弃用。主流方案是发布确认Publisher Confirm信道进入 confirm 模式后每条消息获得递增的序号Broker 在消息被所有目标队列接收之后回送 basic.ack——对持久化消息这个确认发生在落盘之后。生产者注册异步的 ConfirmListener用序号关联发送时暂存的消息体收到 ack 就从暂存区清除收到 nackBroker 内部错误或超时未确认则取出重发。这里的暂存区设计正是可靠性的关键消息在拿到确认之前必须留有重发的底稿底稿放在内存里进程一死就没了所以核心链路的底稿要落在数据库里——这就自然过渡到后文的事务发件箱。2、mandatory 与备份交换器Publisher Confirm 有一个极易被误解的盲区它确认的是Broker 收到了不是消息进了队列。当路由键在交换器上匹配不到任何绑定时RabbitMQ 的默认行为不是报错而是直接丢弃消息并照常返回 ack——生产者看到的一切都是成功消息却已经消失这是 RabbitMQ 体系里最隐蔽的丢失点。封堵它有两条路其一发布时设置 mandatorytrue不可路由的消息会通过 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、若干 FollowerFollower 持续从 Leader 拉取日志能在 replica.lag.time.max.ms 时间内跟上进度的副本留在同步副本集合ISR里掉队的被移出——ISR 是一个动态收缩与扩张的集合。acksall 的真实含义是ISR 内所有成员都确认于是问题变成ISR 收缩到只剩 Leader 自己时acksall 悄悄退化成了 acks1。封住这个退化路径的参数是 min.insync.replicas设为 2 时若 ISR 成员数不足 2Broker 直接拒绝写入并向生产者抛出 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 重启后造成丢失交换器声明时 durabletrue否则重启后交换器不复存在、生产者发布直接报错队列声明时 durabletrue否则重启后队列连同其中的消息一起消失消息发布时 deliveryMode2否则即使队列是持久的非持久消息也不会被写入磁盘。三者备齐后还有最后一段窗口消息写入与刷盘之间存在批量延迟若要求确认落盘必须配合 Publisher Confirm——持久化消息的 ack 在落盘后才返回这正是两个机制要成对使用的原因。2、从镜像队列到仲裁队列单机持久化挡不住机器级故障队列需要自己的副本机制。经典镜像队列是 RabbitMQ 的第一代答案但它的同步模型存在网络分区下丢消息、重新镜像期间不可用等长期诟病的问题官方已将其弃用并在 4.0 版本中移除。替代者是仲裁队列Quorum Queue基于 Raft 协议做多数派复制消息要被多数节点确认后才向生产者返回 confirmLeader 故障时由携带完整日志的成员接任——语义上与 Kafka 的 acksall 加 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.committrue默认时客户端每隔 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 的消费确认是逐条的。autoAcktrue 时消息一经投递即被 Broker 标记完成消费者随后崩溃这条消息不会重投——等价于先确认后处理只适合允许丢失的场景。可靠消费必须 autoAckfalse处理成功后显式 basic.ack处理失败时用 basic.nack可批量或 basic.reject 拒绝并用 requeue 参数决定去向。未确认消息在消费者连接断开后会自动重新投递给其他消费者重投的消息带有 redelivered 标记——这个标记只说明投递过至少一次不能说明处理过判断是否真正处理过依然要靠业务幂等。此外要注意消费确认有超时默认 30 分钟一条消息占着未确认状态超过时限整个信道会被强制关闭引发这条信道上所有未确认消息的集体重投。2、prefetch 与 requeue 风暴basic.qos 的 prefetch 参数限制每个消费者未确认消息的在途数量它同时是限流阀与故障半径的控制器prefetch 过大消费者崩溃时一大批在途消息集体重投prefetch 过小网络往返成为吞吐瓶颈。经验做法是从消费单条耗时与目标吞吐反推常见起点在几十到几百之间压测校准。更危险的是 requeue 的滥用处理失败后 nack(requeuetrue) 把消息塞回队头若失败原因是消息本身脏数据、反序列化失败它会立刻被再次投递、再次失败——消费者陷入全速空转的死循环还把队头堵死。正确的姿势是区分失败类型依赖抖动类下游超时可控次数地重试消息自身缺陷类毒丸消息绝不 requeue直接送入死信通道。五、失败消息的去处重试与死信一RabbitMQ 的死信拓扑处理失败的消息需要一个正式的去处RabbitMQ 为此内置了死信机制。队列声明时通过 x-dead-letter-exchange及可选的 x-dead-letter-routing-key指定死信交换器三种事件会把消息变成死信送过去消费者 basic.reject / basic.nack 且 requeuefalse、消息 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.guaranteeexactly_once_v2 正是这套机制的封装。但绝大多数业务消费者的副作用发生在 Kafka 之外写数据库、调外部接口、发短信。位点提交在 Kafka 里业务写入在数据库里两者又是一次跨系统双写——事务机制鞭长莫及。消费者在数据库已写入、位点未提交的缝隙里崩溃重启后消息重放数据库就被写了第二次。把外部系统也纳入恰好一次理论出路有两条把位点与业务数据存进同一个数据库、用同一个本地事务提交消费位点表模式随后用 seek 恢复位置或者让外部副作用本身幂等。前者约束重、适用面窄后者就是全行业的主流答案。所以工程上的结论是一句话端到端恰好一次 至少一次投递 幂等消费。恰好一次不是从中间件买来的功能而是自己在消费端挣来的性质。二幂等的四层防线幂等消费不是一个技术点而是一组按代价与强度排布的防线实践中按业务分量组合使用。第一层前置快速去重。用 Redis 的 SETNX 以消息标识为键做短窗口拦截成本最低、见效最快能挡住重投风暴里的大部分密集重复。但必须想清楚它的定位Redis 与业务库不在一个事务里Redis 置位成功、业务写入失败或缓存节点故障都会让它失效——它是性能优化不是正确性依据任何把正确性押在这一层上的设计都是错的。第二层去重表。在业务数据库里建一张消费记录表以消息标识为唯一索引业务副作用与去重记录的插入放在同一个本地事务里重复消息在插入去重记录时触发唯一键冲突整个事务回滚业务副作用一并撤销。这是真正的原子屏障也是大多数业务场景的主力防线。代价是表的持续增长需要按时间分区清理且保留期必须覆盖系统里可能出现的最大重复窗口比如死信回放发生在一周后去重记录就至少要留一周。第三层业务唯一键。订单号、支付流水号、对账单号这些业务标识天然唯一直接在业务表上建唯一索引重复插入自己就会失败。它的独特价值在于不依赖消息标识上游系统重推、人工补单这类业务层面的重发会携带新的消息标识前两层全部失效只有业务键还认得它们。局限是只保护插入类操作。第四层状态机与条件更新。对更新类操作把业务建模为显式的状态迁移用条件更新执行UPDATE orders SET status 已支付 WHERE order_no ? AND status 待支付——重复执行时 WHERE 条件不再满足影响行数为零天然幂等乐观锁版本号WHERE version ?是同一思想的通用形式。这一层的额外收益是同时抵御乱序迟到的旧状态变更无法覆盖新状态这对经过重试通道、顺序保证已被放弃的消息尤为重要。三消息设计的配合幂等防线要靠消息本身的设计来配合。每条消息在生产侧生成全局唯一的消息标识业务单号或雪花算法生成的标识写进消息头这是所有去重手段的锚点携带业务时间戳与版本号供状态机层判断新旧消息体自包含携带处理所需的完整上下文或者反过来极简化只带标识、消费时回查源库两种风格各有拥趸但要全局统一——半自包含的消息在重放时最容易踩到上下文已变化的坑。分区键的选择也与正确性相关同一业务实体的消息路由到同一分区才能获得分区内顺序用订单号做分区键同一订单的创建、支付、发货事件才不会在不同分区上被并行消费。七、把可靠性做成清单与演练一参数清单两套系统对照前六部分的结论可以压缩成一张上线前的核对清单。生产端Kafka 确认 acksall、幂等生产开启、发送回调处理失败分支、进程退出前 flushRabbitMQ 开启 Publisher Confirm 异步确认、mandatory 加退回监听或备份交换器、消息 deliveryMode2核心链路业务写库与发消息之间用事务发件箱衔接消息体携带全局唯一标识。存储端Kafka 副本因子至少 3、min.insync.replicas2、禁止脏选举、保留期覆盖最长消费停摆RabbitMQ 交换器队列消息三重持久化、核心队列用仲裁队列、队列上限配 reject-publish 溢出策略。消费端Kafka 关闭自动提交、处理完成后提交位点、静态成员加协作式再均衡、再均衡回调里同步提交RabbitMQ 关闭 autoAck、prefetch 按耗时压测校准、失败区分类型决定重试或直送死信兜底层去重表加状态机、重试分级加死信收容加回放入口。二监控、对账与故障演练配置正确只是起点可靠性需要持续的证据。监控上盯三类信号堆积类——消费滞后量consumer lag与死信队列深度的持续增长说明主流程处理能力不足或带病运行异常类——生产端确认失败率、退回消息数、ISR 收缩事件、再均衡频率任何一项的突增都值得告警守恒类——这是最容易被省略也最有价值的一层用定时对账验证消息守恒上游业务库里今天产生了多少笔订单事件下游的处理结果表里就应该有多少笔缺失即丢、多出即重对账是唯一能发现监控全绿但数据不对这类问题的手段。最后是故障演练。清单上的每一项配置都对应一种可以人为注入的故障杀掉刚调用完 send 的生产者进程验证发件箱把消息补发出来强制 Kafka 分区切主验证 acksall 加最小同步副本数没有丢下任何已确认消息在业务处理进行到一半时杀掉消费者验证重放后去重表挡住了第二次副作用向队列注入一条无法反序列化的毒丸消息验证它在有限次重试后安静地躺进死信队列而不是堵死分区。每次演练后的验收标准只有一条消息守恒——业务结果不缺一条也不多算一条。能通过这套演练的系统才有资格在架构图上写下可靠消息四个字。八、总结回到开头的问题消息为什么会丢失或重复因为一条消息的旅程由多段跨进程、跨网络的移交组成而不可靠网络上不存在既原子又可靠的交接回执——丢失发生在确认边界之前重复发生在确认边界之后二者不可能同时归零。工程的应对不是追求零而是做一次清醒的选择用至少一次语义把不丢做进链路的每一段——生产端的 acksall 与 Publisher Confirm、双写缝隙上的事务发件箱、存储端的多数派副本与三重持久化、消费端先处理后确认的铁律然后接受由此必然产生的重复用消费端的四层幂等防线把重复变得无害再为处理失败的消息修好重试分级与死信收容的正式去处配上监控、对账与故障演练去持续证明这一切真的有效。Kafka 与 RabbitMQ 在机制命名上千差万别——位点对应答、ISR 对仲裁队列、重试主题对死信交换器——但底下的原理完全同构。理解了确认边界这一个概念两套系统的所有可靠性参数就不再是需要背诵的配置项而是同一道选择题在不同位置的答案。参考链接Apache Kafka 官方文档消息传递语义Message Delivery SemanticsApache Kafka 官方文档生产者与消费者配置参数KIP-98Exactly Once Delivery and Transactional MessagingKafka 事务与幂等生产设计文档KIP-345Static 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.ioTransactional Outbox 事务发件箱模式Debezium 博客基于发件箱模式的微服务可靠数据交换Spring for Apache Kafka 官方文档错误处理、重试主题与死信发布Uber 工程博客Building Reliable Reprocessing and Dead Letter Queues with Apache Kafka