1. 别只盯着吞吐量:四个问题其实是一条链路
这些年做后台开发,被问得最多的不是“MQ 怎么接入”,而是四个更扎心的问题:消息可靠性怎么保证,顺序性会不会乱,重复消息怎么幂等,消息积压怎么处理。这四个词看起来是独立考点,实际在项目里是绑在一起爆发的。前阵子我梳理一套内部订单通知链路,从生产端、Broker 到消费端过了一遍,今天就把我对这四件事的完整思路、踩过的坑和可复用的排查方法写出来。无论你是刚开始接触消息队列,还是已经在上线前焦虑地看监控,这篇文章都能给你一个相对完整的参考。
1.1 为什么可靠性、顺序性、幂等性总是绑在一起
很多人习惯把“消息不丢”和“消息不重复”分开看,但真实场景里它们往往是同一件事的两面。消息队列为了性能,通常会采用“至少一次投递”的语义。也就是说,生产者发送消息后可能超时,超时不代表消息没到 Broker;消费者处理完消息后,如果 ack 包在网络中丢失,Broker 也会重新投递一次。任何一个环节出现超时或重试,都会带来重复消息。
这就产生了一个连锁反应:你为了保证可靠性而重发,结果产生了重复;你为了保证不丢而使用手动确认,结果业务逻辑执行完之后还没来得及 ack,进程重启,消息再次被消费。所以可靠性的终局,必然落到幂等性上。顺序性也是同理,很多业务要求“先创建订单,再支付,再发货”,如果这些消息被多个消费者并发处理,发送顺序是对的,执行顺序却可能完全反过来。可以说,这四件事本质上是同一条消息在不同阶段的投影,分开排查往往会顾此失彼。
1.2 从“最终一致”角度看 MQ 定位
我刚接触消息队列时也犯过错误,总想让 MQ 像数据库事务一样保证强一致。后来想明白一件事:消息队列是给“最终一致”用的,不是给“强一致”用的。上游只需要保证一件事,就是消息成功进 Broker;下游则需要保证:只要消息不丢,最终一定能被正确消费。至于消费过程中出现重复、乱序,都依赖业务系统自己解决。
这句话听起来很怂,但很现实。你把 MQ 当异步管道用,就会得到极高的吞吐和可用性;你把 MQ 当分布式事务用,就会在可靠性、顺序性、幂等性上被反复折磨。想清楚了这一点,后面很多设计取舍就顺了:不追求全局有序,只追求局部有序;不追求绝对不重复,只追求重复无副作用;不追求永远不积压,只追求积压时可观测、可快速恢复。
2. 消息可靠性:从生产端到消费端的全链路保障
2.1 生产端:要区分“已发送”和“已确认”
我在梳理项目时,先把生产端的代码从头看了一遍。很多可靠性问题其实在发送阶段就埋下了。常见的写法是调用发送接口后只判断“有没有抛异常”,异常了就打日志,然后继续下一条。这在低并发场景下没问题,一旦出现网络抖动或 Broker 短暂不可用,消息就悄悄丢了。
正确的做法是判断“发送结果”。主流消息队列都提供发送状态回执,发送成功后返回的是一个确认结果,而不是“没报错就算成功”。如果发送超时,情况更微妙:消息可能已经进了 Broker,只是确认包没回来。这时候你重发,可能造成重复;你不重发,又可能丢失。所以生产端的标配是“发送重试 + 幂等键”。重试次数不要太多,我一般设置为 3 次,间隔 1 秒、2 秒、4 秒。超过重试次数还没确认,就进本地失败表或者直接告警,人工介入,而不是无限重试把 Broker 打挂。
注意:生产端重试只是增加可靠性,不能保证不重复。真正让重复无害化的,是消费端的幂等设计。你如果只做了发送重试,却没有做消费幂等,等于把风险推迟到了下游。
2.2 Broker和消费端:持久化与手动确认才是兜底
Broker 这层通常不太容易出问题,但配置不当也会丢消息。核心是持久化策略和副本策略。持久化方面,如果是财务、订单这类高价值消息,不要用纯异步刷盘,要等消息真正写入磁盘再返回成功。虽然单条延迟会高一些,但你能明确知道“这条消息已经落地了”。多副本也是同理,至少要同步写入一个副本再返回确认,否则主节点宕机时内存里的消息可能全部丢失。
副本数不是越多越好,3 副本以上的收益会急剧下降。2 到 3 个副本足够应对绝大多数场景。很多团队为了省磁盘,副本数设成 1,这等于把全部可靠性押在一台机器上,不是不行,而是你要想清楚能不能接受机器重启时丢消息。
消费端是可靠性链路里最容易翻车的地方。我刚接手这个项目时,看到消费回调里在处理业务之前就调用了自动确认接口,理由是“防止处理失败反复重试”。这就是经典的“为了不重试而丢消息”。手动确认才是标准姿势:先把业务逻辑执行完,入库成功后再提交 offset。如果业务执行失败,不要提交 offset,让 Broker 重新投递,配合死信队列做兜底。
2.3 死信队列与重试参数的取舍
重试不是越狠越好。无脑重试 10 次、20 次,会在下游服务故障时形成收益递减的“重试风暴”。我在实际项目中通常把重试次数和间隔分开配置:普通消息失败后重试 3 次,间隔 1 秒、2 秒、4 秒;超过次数进入死信队列。死信队列不是垃圾桶,而是一个“待人工处理”的队列,要有专人关注,也要有告警。死信消息积压太久,说明下游有系统性 bug,而不是偶发抖动。
如果某个消费函数吞掉了异常,又不提交确认,消息会一直处于“消费中”状态,计数器不断重试,日志里却什么都看不到。我给团队定了一条规矩:消费回调里不允许空 catch;要么抛出异常走重试,要么捕获后标记 DLQ,严禁“吃了不认账”。
3. 顺序性:有序是成本很高的奢侈品
3.1 先搞清楚:你要全局有序还是局部有序
技术的本质是trade-off,顺序性是个很好的例子。所谓全局有序,就是所有消息严格按照发送顺序被一个消费者处理。这种方案等价于把 MQ 用成了单机队列,吞吐量会严重受限,而且一旦下游处理变慢,整个链路都会阻塞。
所以真实业务里,绝大多数场景要的都是局部有序。比如订单状态机,订单 A 和订单 B 之间没有顺序关系,但订单 A 自己的“创建、支付、发货”必须有顺序;比如库存扣减,不需要所有 SKU 顺序一致,但同一个 SKU 的扣减顺序必须一致。先想清楚你的顺序维度是什么,再谈方案。凡是说“我要求全局有序”的需求,先问一句:这个拓扑用单机数据库加一个表也能做,为什么还要上 MQ?这样能过滤掉大部分伪需求。
3.2 路由哈希与消费端加锁的配合
要保证局部有序,最常用的做法是“按业务主键哈希到同一个队列”。具体来说,生产者发送消息时,用订单号、用户 ID 这类业务键做哈希,让同一个键的消息永远落进同一个队列。Broker 内部对单个队列的追加顺序是稳定的,所以消息在存储层的顺序没有问题。但存储顺序对不代表消费顺序对,真正的坑在消费端。
我见过一个项目,消息路由是对的,但消费端用一个线程池并发拉取队列里的消息,同一个订单的支付消息和退款消息被两个线程同时处理,最终还是乱序了。解决办法有两个层面:底层要保证同一个分区/队列只能被一个消费者实例拉取,上层在这个实例内部再按业务键做局部串行化。最简单粗暴的做法是对业务键加锁,或者把同一个队列的消息交给单线程处理;如果你非要并发,就按业务键哈希到固定数量的子线程,确保同一个键永远落到同一个子线程。
注意:顺序消费最忌讳“跳过失败消息继续往后消费”。如果第 5 条消息处理失败,你不处理它直接去消费第 6 条,第 6 条的业务状态可能依赖第 5 条的结果,最终数据就是脏的。正确的顺序消费应该把失败的那条卡住,让它重试或进死信,后续消息等它结束后再继续。
3.3 顺序消息的典型坑
顺序性设计里,扩容是一个非常隐蔽的坑。原本你定义了 8 个队列,订单号哈希到 queue 5。某天消息积压严重,你脑门一热把队列数扩到 16,订单号重新哈希之后可能就到了 queue 13。表面上看队列数变多了,消费能力变强了,但同一个订单的消息被分散到两个队列里,顺序随之被打乱。
这种场景下,扩容的正确姿势不是直接改队列数。如果当前业务对顺序要求极高,你要先暂停消息发送,把存量消息消费完,再以新的队列数重建路由,然后恢复写入。如果积压已经非常严重,也可以新建一个临时 Topic,用更大队列数承载积压消息,等消费完再销毁,原 Topic 保持不动。这样虽然麻烦,但顺序不会出问题。
4. 幂等性:重复消息不是 Bug,是常态
4.1 重复从哪来:至少一次投递与网络不确定性
很多团队第一次做 MQ 接入时,看到消费端收到的重复消息都会很慌,第一反应是“是不是消息队列配置错了”。其实不是配置错了,而是“至少一次投递”本身就允许重复。生产者发送后没收到确认,会重发;消费者处理完 ack 丢失,Broker 会重投;消费者自身逻辑异常触发了重试,也会消费到同一条消息。只要消息链路里有网络,重复就是必然事件。
所以幂等设计不是“万一遇到重复怎么办”,而是“每条消息都必须能安全重复”。我通常用一个业务幂等键来判断。注意这个键不一定是消息 ID,因为同一条业务事件在重试时可能生成不同的消息 ID。更好用的是业务订单号、流水号、或者“订单号 + 事件类型”的组合,它才能代表这个业务动作本身。
4.2 三层幂等方案,按业务场景选型
我把幂等方案分成三层,你可以根据业务场景组合使用。
第一层是数据库唯一键。在接收消息时,把业务幂等键写入一张去重表,并给这个键建唯一索引。第一次插入成功,继续处理;第二次插入冲突,直接丢弃。这种做法简单、可靠,任何数据库都支持,缺点是每次消费都多一次插入操作,且去重表会越来越大,需要定期归档。
第二层是分布式缓存加锁。处理消息前先尝试写入一个带过期时间的占位键,写入成功的人执行,失败的人说明已经有人在处理。这个方案性能好,但缓存有丢失和过期风险,不能单独作为强幂等方案,最好结合数据库兜底。用它做“并发控制”是合适的,用它做“最终幂等”则不保险。
第三层是业务状态机更新。这是我最推荐的主力方案,因为幂等和业务逻辑天然结合在一起——不是判断“这条消息我见过没有”,而是判断“这个业务现在允不允许执行这个动作”。
4.3 状态机更新:比唯一键更贴近业务
拿订单支付举例。消费端收到“订单已支付”消息时,不该无脑把订单更新成已支付。正确的 SQL 应该是这样的:
UPDATE order_info SET status = 'PAID', paid_time = NOW() WHERE order_no = 'xxx' AND status = 'CREATED';这里的关键条件是status = 'CREATED'。如果这条 UPDATE 影响的行数是 1,说明当前订单确实处于待支付状态,我执行成功;如果影响行数是 0,说明订单已经不是待支付状态了,可能已经被支付过,也可能是退款消息先到了,此时直接忽略即可。这比查一次再更新的方式强得多,它把“检查状态”和“修改状态”合并成了一个原子操作,天然防止了并发和重复。
用状态机做幂等,要求你先把业务状态流转理清楚,比如创建 -> 支付 -> 发货 -> 完成,或者创建 -> 取消。状态流转必须是单向的,不允许倒着走,否则乱序消息会把状态回退。除了状态字段,还可以加一个版本号字段,每次更新带上version = old_version,也能达到同样的效果。
5. 消息积压:先把确认证据找齐,再谈扩容
5.1 积压排查路径与量化指标
消息积压是最能体现基本功的问题。很多人一看 lag 很高,就急着加消费者实例,结果下游数据库连接池被打爆,积压反而更严重。实际处理积压,第一步永远是量化。
最核心的两个指标是 Lag 和 Lag 增长速度。Lag 表示当前未消费消息的数量,但光看数量没用,你要看它是在上升、不变还是下降。Lag 曲线斜率上升,说明消费速度小于生产速度;斜率不变但处在高位,说明系统瓶颈稳定;斜率下降才是真的在消耗积压。排查时先找到 Lag 开始上涨的时间点,对比这个时间点前后有没有发布记录、有没有下游慢 SQL、有没有消息体变大,大概率能直接定位到根因。
5.2 临时扩容与积压处理策略
确认根因之后,处理手段要分场景。如果瓶颈在下游数据库,加消费者实例是火上浇油。你应该先看慢 SQL,是不是缺索引,是不是锁竞争,是不是连接池太小;先让单条消息的消费耗时降下来,再考虑扩容。如果瓶颈真的在消费端 CPU 或者网络 IO,可以扩容消费者实例,但前提是每个实例都能分配到一个独立的队列分区,否则加实例只会让一部分实例空转。
批量消费是经常被忽视的手段。如果每条消息处理的耗时主要是网络 RTT,比如调一个下游接口要 20 毫秒,那逐条消费肯定慢。你可以在消费端拉取一批消息,然后并发调用下游接口,整体吞吐能翻几倍甚至十几倍。不过批量方式要特别注意事务边界和异常处理,一批里只要有一条失败,整批的确认策略就要想清楚,不能把没处理成功的消息也一起确认掉。
5.3 防止次生问题:扩容反而是乱序源头
我在顺序性那一节里提到过,直接增加队列数会破坏哈希路由,导致同一个业务键的消息分散到多个队列,最终乱序。在积压处理里,这个问题会被放大。有些团队的流程是:发现积压 -> 加队列 -> 加消费者 -> Lag 降下来了 -> 业务方反馈订单状态乱了。这不是扩容的错,而是扩容方式不对。
处理有顺序要求的积压消息,我建议用“双 Topic 切换法”。新起一个 Topic,队列数按目标容量设计好,生产者把后续新消息切到新 Topic;存量积压的老消息继续用旧路由消费,消费完成后再切换回来。这样既保证了容量升级,又不会让消息在切换过程中乱序。如果没有顺序要求,那临时扩容可以粗暴一点,但也要确保消费者实例数量不超过队列/分区数量。
6. 常见问题速查与我的避坑笔记
6.1 高频故障对照表
| 症状 | 直接原因 | 正确处置 | 最容易踩的坑 |
|---|---|---|---|
| 消息丢失 | 消费端自动确认,业务还没执行完 offset 已提交 | 改手动确认,业务成功后再提交 | 以为手动确认会增加延迟,其实影响可控 |
| 消息偶发重复 | 发送超时、ack丢失、Broker重投 | 消费端做业务幂等,用状态机或唯一键 | 只用 msgId 去重,业务重试时 msgId 会变 |
| 订单状态倒退 | 同一订单消息被并发处理,无局部顺序 | 按订单号哈希路由,消费端局部串行 | 加了线程池但没有按 key 隔离线程 |
| Lag 居高不下 | 单条消息消费耗时过长 | 先优化 SQL,再考虑批量消费和扩容 | 一上来就加消费者,打爆下游连接池 |
| 死信不断增多 | 消费逻辑抛异常但没被记录 | 加结构化日志,按业务键重新入队前先修数据 | 直接重置 offset,导致滞后的错账消息重复消费 |
6.2 一些花钱买来的心得
第一,所有关键消息必须带完整的链路上下文。我在改造项目时,强制每条消息都带上 eventId、bizKey、topic、生产时间、源调用链 ID,排查效率提高不止一倍。没有这些字段,你看到一条死信消息,根本不知道它从哪来、经过什么逻辑、应该恢复到什么状态。
第二,重试参数要分场景设计。网络抖动可以快速重试,下游服务 5 秒超时就要拉长间隔。不要一套参数打天下。我在订单场景用的是 1 秒、2 秒、4 秒,在推送场景用的是 10 秒、30 秒、1 分钟,因为推送业务对延迟不敏感,但对下游压力敏感。
第三,监控不能只看平均 Lag。平均值会掩盖单分区堆积问题。我见过某个消费者实例挂了一台机器,另外三台正常消费,平均 Lag 看起来很低,但那一台负责的分区已经堆了几百万消息。要针对每个队列/分区分别看 Lag,再配合消费者存活状态,才是完整的监控视角。
第四,幂等设计要在所有消费端同时做。上游一条消息可能被下游多个服务消费,如果你只在订单服务做了幂等,但积分服务没有做,重复消息照样会把积分加两遍。消息链路上的每一环,都要当作可能收到重复消息来设计。
6.3 回到开头的四件事
如果你也正在被这四件事困扰,我最终的体会是:消息可靠性靠生产端确认、Broker 持久化、消费端手动确认来兜底;顺序性靠局部哈希路由和串行消费来保证,别轻易碰全局有序;幂等性靠数据库状态机这类与业务绑定的手段来完成,单纯的技术组件只能起辅助作用;消息积压则要先量化、找根因、再扩容,永远不要让扩容破坏已有的顺序语义。
我自己的项目里,现在的基本设计原则是“可靠性和幂等当作默认防御线,顺序性当作局部约束,积压当作容量问题而不是代码问题”。这套思路不会让你写出多惊艳的高并发代码,但能让你的消息链路在凌晨三点出问题时,不必靠重启碰运气。希望这篇分享能帮你少踩几个我踩过的坑。