☰
MQ消息队列高频故障排查:丢失、重复、积压与乱序
2026/10/13 5:06:44 网站建设 项目流程

做后端这些年,消息队列基本是每个项目里都绕不开的基础设施。异步、解耦、削峰,这些词谁都能说两句,但真到了线上,问题往往集中在那么几个点上:消息发出去不见了、消费端收了一堆重复数据、积压从几千条涨到几百万条、明明该先处理的订单却跑了后面。这篇文章就是把我在多个项目里梳理过的MQ常见问题做一次汇总,讲讲问题怎么发生的、怎么定位、怎么处理,最后附上一份可以直接拿来参考的排查速查表。

话说在前头,我不打算讲某款具体中间件的配置细节,因为不同产品差异太大。我按问题域来讲,思路是通用的——无论你用的是高吞吐日志型、AMQP协议型还是事务消息型,丢消息、重复消费、积压、乱序这些坑,底层原因和处理框架都差不多。

1. 消息队列的核心价值与问题总览

1.1 为什么需要消息队列

在讲问题之前,先把MQ的定位说清楚。消息队列解决的是三个非常朴素的诉求:

  • 异步:调用方把任务丢给MQ,不等着结果,先干自己的事。比如用户下单后发短信、发通知,没必要同步等。
  • 解耦:上游只管发消息,下游自己决定怎么消费。生产和消费逻辑被中间件隔开,任何一边挂了或改版,另一边不受影响。
  • 削峰:瞬时流量打进来,后端处理能力有限,MQ先把请求存起来,下游按自己的节奏消费。这也是大促压测里最常用的玩法。

理解这几个诉求之后,你会发现MQ问题的根源基本都藏在“异步”和“存储”这两个词里。异步意味着状态不确定,存储意味着有落盘和持久化的取舍。丢失、重复、乱序、积压,本质上都是异步引入的一致性问题。

1.2 常见问题分类

我习惯把MQ问题分成五类:

问题类别典型现象影响
消息丢失消费端始终没收到,业务数据缺失数据不一致,严重时资金账目对不上
重复消费同一条消息被处理多次重复下单、重复发钱、脏数据
消息积压堆积量持续增长,消费延迟变大业务超时、客户投诉、磁盘打满
顺序错乱消息处理顺序和发送顺序不一致订单状态倒退、缓存与库不一致
事务一致性问题MQ发送成功但本地事务回滚,或反过来两边数据不一致

这五类是出现频率最高的。下面的章节我会逐个展开,先说问题成因,再给定位手段,最后落到能直接落地的处理方案。

2. 消息丢失问题:三个环节逐一排查

2.1 丢消息到底丢在哪

消息从生产到消费,完整链路分三段:生产者发送、MQ保存、消费者拉取。任何一段有漏洞,都会造成消息丢失。

我见过不少项目,开发一上来就说“MQ丢消息了”,其实真正的问题是出在自己的代码里。所以排查的第一步不是扒中间件,而是先确认消息丢在哪一段。怎么确认?把三段分开看:生产端有没有发送成功回调、中间件存储有没有数据、消费端有没有收到过记录。这三段日志一对,基本就能圈定问题范围。

2.2 生产阶段:发送成功不代表真的成功

最典型的第一类坑,是用“发后即忘”的方式发消息。调用发送接口后没有校验返回值,也没有回调处理。如果网络抖动或者Broker刚好在刷盘间隙,消息可能根本没写进去。

一个更隐蔽的场景是:业务代码里先执行了数据库操作,然后发MQ消息,发消息的代码抛异常了,但数据库事务已经提交。结果就是库里多了一条数据,MQ里没消息,下游不知道。这类问题的本质是本地事务和MQ发送的不一致。

处理方法上,有几个成熟的做法:

  • 发送时必须等待确认,不能只发不管。大部分MQ客户端支持同步发送或异步带回调,至少在回调里打日志、做告警。
  • 发送失败要重试,但重试要有限度,超过次数进入本地“待发消息表”或死信队列,人工介入手工补发。
  • 本地事务和MQ发送要尽量保证一致。常见解法是把消息和业务数据放在同一个数据库事务里写,然后由后台任务扫描未发送的消息,统一补发。这种做法被戏称为“本地消息表”,实现简单,可靠性也够用。

注意:生产阶段丢消息,一般不会立刻暴露,往往要等下游对账或者用户反馈“没收到通知”才发现。所以生产端必须有发送成功率和失败率的监控告警。

2.3 存储阶段:Broker宕机导致的数据丢失

消息到了消息中间件服务端,如果服务端宕机,消息也可能丢。这主要看持久化策略、刷盘策略和副本机制是否配置到位。

  • 如果用的是磁盘存储型中间件,刷盘策略默认可能是定时批量刷盘,宕机时内存里未落盘的数据会全部丢失。生产环境建议开启强制刷盘,或者至少保证配置满足你的可靠性要求。
  • 单节点部署本身就是高风险,Broker挂了数据全没了。至少要主从部署,主节点故障时能自动切换,消息从副本上恢复。
  • 副本同步也有讲究,异步复制模式下主节点还没把数据同步给副本就挂了,消息依然会丢。对可靠性要求高的场景,要配置同步复制。

我给出的经验是:存储阶段丢消息的修复成本最高,必须在架构层面提前定好策略。很多团队觉得“消息丢了再发一次不就行了”,但消息可能是好几个系统共用的,线上漏一条要命的数据,排查链路会非常痛苦。

2.4 消费阶段:偏移量提交时机不对

消费者这一侧的丢失,基本都出在“把消息标记为已消费”的时机上。

现在主流MQ的消费者都会维护一个消费位点(offset),消费成功后提交偏移量,表示这条消息处理完了。问题在于:

  • 如果开启自动提交,消费者拿到消息就先提交偏移量,然后才开始业务处理。处理过程中进程崩了,这条消息就再也不会被重新投递,等价于丢了。
  • 有些同学以为自动提交省事,其实是最容易丢消息的配置。

正确做法是关掉自动提交,改成手动提交。业务处理成功后再提交偏移量。如果处理失败,不提交,MQ会重新投递。

这里有一个很容易被忽略的细节:手动提交的时机也有讲究。如果业务处理耗时较长,提交偏移量太早,重启后可能重复消费;提交太晚,MQ可能重复投递。实际操作中我的建议是:先写完业务(至少把核心数据落库),再提交偏移量,然后在代码里配合幂等做兜底。

注意:消费阶段丢消息和重复消费往往是一对双胞胎。给消费者增加可靠性,通常意味着增加重复投递的概率。这也是为什么下面要单独讲幂等。

3. 消息重复消费问题:幂等设计是唯一出路

3.1 重复消息是正常现象

很多人第一次遇到重复消费时很慌,觉得是中间件坏了。其实不是,重复投递是MQ的可靠性机制带来的正常副作用。

一条消息被消费了,但消费者还没来得及提交偏移量就挂了,消息会被重新投递。下游已经处理了一半,或者处理完了但没提交,都会造成重复。这是系统设计时就要接受的事实,而不是出了bug再补。

所以问题的关键不是“消除重复”,而是“让重复无害化”。这就引出了幂等设计。

3.2 幂等的几种落地方式

幂等方案有很多,适用场景不同,我按推荐顺序讲一下。

第一种,唯一业务ID + 消费记录表。每条消息带一个唯一的消息ID或业务ID(比如订单号、支付流水号),消费者处理前先查这张表,如果这个ID已经处理过,直接跳过。如果没有,处理并插入记录。这个做法通用性最强,数据量和业务复杂度都能接受时,基本用它。

第二种,数据库唯一约束。如果消息对应的是写入一张表,并且业务上有天然的唯一键,直接在表上建唯一索引。重复插入时数据库直接报错,我们捕获错误当作“已处理”。比如支付流水表,用支付单号做唯一约束,重复消息进来插入失败,说明之前已经处理过。这个方案实现成本低,性能也好。

第三种,Redis SETNX 或分布式锁。消费前用业务ID作为key执行SETNX,如果设置成功说明是第一次处理,处理失败则说明有人处理过。注意这个方案要处理锁过期、误删别的请求设置的key等边界问题,可靠性不如数据库方案。

第四种,状态机校验。适合有明确状态流转的业务场景,比如订单:待支付到已支付,已支付到已发货。处理消息前先看当前状态,如果消息要执行的状态在当前状态之前或相同,直接驳回。这个方法不用建额外的记录表,但业务复杂时状态判断逻辑可能很绕。

3.3 幂等设计的一个真实教训

我之前帮一个项目排查过重复发奖的问题。表象是用户重复收到同一笔奖励,深入排查发现,消费者处理完奖励发放后,刚好在提交偏移量前进程被kill了。消息重新投递,奖励又发了一次。

他们当时的做法是:拿到消息先查询用户是否已经领过奖,领过就不发。看起来没问题,但查库和发奖之间不是原子的,两个进程可能同时查到“未领取”,然后同时发奖。

后来改成把“用户ID+活动ID”作为唯一约束的消息落库,把发奖动作和记录动作放在同一个事务里。重复消息进来后,插入记录失败,直接跳过了。问题才算解决。

这个例子的核心是:幂等校验一定要和处理动作在同一个原子边界内,不能“先判断再处理”,要“处理时同步校验重复”。

4. 消息积压问题:从应急到根治

4.1 积压是怎么发生的

消息积压的判定标准很简单:队列里待消费的消息数量持续增长,消费延迟越来越大。常见的诱因:

  • 消费者的并发度不够,单机消费能力撑不住生产速度。
  • 消费者处理逻辑变慢,比如依赖的下游接口超时、数据库锁等待、慢SQL变多。
  • 消费者批量挂了,又没有自动拉起,机器在重启循环里出不来。
  • 生产端流量激增,比如大促、秒杀、或者某个运营活动带来突发流量。

积压的可怕之处是会像滚雪球一样恶化。消息越堆越多,磁盘压力增大,消费者越来越慢,然后堆得更多。所以积压一定要尽早发现,不要等到消费延迟到分钟级才去处理。

4.2 排查积压的标准流程

先看监控。如果系统里有消息堆积监控,直接看三个指标:总堆积量、生产速率、消费速率。这三个数字能快速定位问题方向。

如果生产速率远高于消费速率,就是消费能力不足,优先扩容或优化消费逻辑。如果生产速率正常,消费速率明显偏低,就要去查消费者的瓶颈。

消费速率低,重点排查几个位置:

  • 消费者日志里有没有大量异常、超时、重试?
  • 消费者线程池是不是被打满了?
  • 下游数据库或外部接口的响应时间最近是不是恶化了?
  • 消息体是不是变大了?如果一个消息从几KB涨到几MB,消费耗时必然猛涨。

排查顺序建议从消费者日志开始,因为日志最直接。先看日志里有没有报错,再看线程池活跃数,然后查下游依赖的响应时间,最后看消息体大小。这样一步一排除,比漫无目的地抓包高效得多。

4.3 应急处理的四个动作

积压爆发时,先止血再治病。

第一个动作是扩容。大多数MQ在消费端支持水平扩展,直接把消费者实例加几倍,把分区数确认一下够不够分。如果分区数不够,扩容消费者也没用,每条消息只能被一个实例消费,还是得先把分区或队列数量扩上去。

第二个动作是临时切换逻辑。如果积压的消息是非核心业务,比如埋点日志、通知类消息,可以在紧急情况下把消费逻辑临时改写为“只记录不处理”或“批量快速丢弃”。注意这个操作要非常慎重,确认消息真的可以丢再动手。

第三个动作是拆分大消息。遇到单条消息体特别大的情况,可以把消息体压缩,或者把业务数据放到对象存储,消息里只放地址。这样消费耗时会明显下降。

第四个动作是降级下游。如果积压的原因在下游接口,比如外部服务限流或者数据库慢,可以把调用方式从同步改成异步,或者直接熔断,保证消费者至少能把消息取出来落库,后续再补处理。

应急处理之后,一定要留时间做根治分析,把消费链路里真正的慢点找出来,否则同样的积压还会再来。

5. 消息顺序性问题:局部有序才是常态

5.1 什么场景需要严格有序

先想清楚一个前提:业务真的需要全局有序吗?绝大多数场景不需要。比如用户浏览日志、统计埋点,谁先谁后根本不重要。强一致的业务,往往也只是要求“同一个业务实体的消息有序”。

举个例子,订单状态变化:待支付 -> 已支付 -> 已发货。两条状态消息如果顺序颠倒,消费方先看到“已发货”再看到“已支付”,状态就乱了。这种情况,要求的是同一个订单内部的顺序,而不是所有订单之间的顺序。

如果业务确实要求全局严格有序,那基本只能用同一个队列、单消费者串行处理,吞吐量会非常低,大部分场景接受不了。所以现实方案里,大家做的都是局部有序。

5.2 局部有序的实现套路

实现局部有序的核心思路,是让同一类消息永远进入同一个队列或同一个分区,然后保证这个分区内的消费是串行的。

生产端:路由规则按业务ID取模。比如订单号hash后对分区数取模,保证同一个订单的消息总是被发到同一个分区。

消费端:每个分区的消息要串行处理。如果消费者的线程池是多线程并发消费,一定要让同一分区的消息交给同一个线程,或者干脆按订单维度做内存队列,每个队列一个线程。

注意一个坑:单纯靠“一个消费者实例消费一个分区”还不够,如果业务处理内部有多个步骤异步执行,顺序还是会乱。要对内部的处理逻辑也做顺序约束,比如用状态机校验,后一条消息执行时校验当前状态是否满足前置条件。

5.3 乱序问题的一个排查案例

有个项目反馈说“订单状态会在界面上倒退”,排查下来发现,消费者收到的消息并未错序,错的是消费线程池里的提交顺序。

他们开了16个线程并发消费,同一个订单的两条消息分到不同线程,线程B处理完“已发货”提交了,线程A的“已支付”才刚处理完提交。数据库里最终状态被后提交的“已支付”覆盖了。

这个问题的修复方案有两步:一是生产端按订单ID取模保证同一订单进同一分区;二是消费端对同一个分区的消息做串行执行,或者按订单ID加锁,让同一订单的消息实际上被串行应用。

注意:用分布式锁解决乱序时,锁粒度要按业务ID来,不能全局一把锁。全局锁虽然能保证顺序,但把整个系统的吞吐量全锁没了,因小失大。

6. 常见问题排查速查表

6.1 一张表快速定位

把前面几个部分浓缩成一份速查表,方便线上出问题时快速对号入座。

现象优先怀疑快速验证处理方向
消息丢失,下游始终没收到生产端发送后未确认查生产日志发送回调、是否有异常加确认机制、重试、本地消息表
消息丢失,消费端处理过但消失偏移量提交过早查消费端是否开启自动提交改手动提交,处理成功后再提交
重复消费消费者处理完未提交偏移量查日志是否有同一消息多次处理消费幂等,唯一约束或幂等表
积压持续增长消费速率低于生产速率看吞吐监控扩容、优化慢逻辑、拆分大消息
处理顺序颠倒多线程并发消费同一分区查线程池并发度与提交顺序生产端ID路由、消费端串行处理
本地事务与MQ不一致业务库提交了但消息没发查事务边界和发送顺序本地消息表,先写表再发消息

6.2 几个容易被忽略的坑

聊点常规文档里不会写的经验。

第一,监控告警要区分“堆积量”和“堆积增长速率”。只看堆积量,高峰期几千条也可能正常;但堆积量匀速上涨,哪怕绝对值不大,也要警惕。建议两个指标一起看,环比跌到告警阈值就出来。

第二,测试环境也要开启MQ的持久化和确认机制。很多问题在测试环境复现不出来,就是因为测试环境用的是默认配置,消息直接存在内存里,而且发送后完全不管。等上了生产才暴雷,排查成本翻倍。

第三,写重试代码时要注意指数退避。不加退避地疯狂重试,会把MQ和自己的下游一起打到雪崩。重试间隔建议按 1s、2s、4s、8s 递增,超过最大次数就进死信。

第四,容器化环境下消费者实例会动态扩缩容,扩容时分区数和消费者实例数的匹配关系很容易被忽略。消费者实例数大于分区数时,多出来的实例是空闲的,等于白扩;消费者实例数小于分区数时,部分分区的消息没有进程在消费,堆积会持续上涨。

说几点我个人实际做MQ相关工作的体会。第一,MQ问题没有银弹,所有方案都是在可靠性和吞吐量之间做取舍。你要先想清楚业务对数据一致性的要求有多高,再决定要不要开启强制同步、要不要做幂等表。第二,每一个MQ线上问题,排查到最后都能落到“发送-存储-消费”三段中的某一段,先明确问题在哪一段,再动手处理,能省下大量时间。第三,建议在项目早期就建立消息链路的完整监控,从发送量、确认数、积压量、消费延迟到错误数,全部埋点。别等出了问题再补监控,那时候缺失的数据会让你非常被动。

最后分享一个实践技巧:在压测阶段就把“杀掉消费者进程”作为常规演练。人为杀掉正在消费的进程,观察消息是否重复投递、业务是否幂等、积压是否能自动恢复。这几个演练做一遍,比看十篇文档都有用。MQ的可靠性不是说出来的,是故障演练练出来的。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询