
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 持久化、消费端手动确认来兜底顺序性靠局部哈希路由和串行消费来保证别轻易碰全局有序幂等性靠数据库状态机这类与业务绑定的手段来完成单纯的技术组件只能起辅助作用消息积压则要先量化、找根因、再扩容永远不要让扩容破坏已有的顺序语义。我自己的项目里现在的基本设计原则是“可靠性和幂等当作默认防御线顺序性当作局部约束积压当作容量问题而不是代码问题”。这套思路不会让你写出多惊艳的高并发代码但能让你的消息链路在凌晨三点出问题时不必靠重启碰运气。希望这篇分享能帮你少踩几个我踩过的坑。