刚接手一个消息中间件项目,或者线上已经跑了好几年但时不时冒点幺蛾子,你多半会翻遍搜索引擎找"mq常见问题"。我这几年代运维、做架构评审、处理生产事故,碰到的MQ相关问题十个手指头根本数不过来。很多问题表面看起来各不相同,往深了挖都是同一批根子。这篇博文就把我在实际项目中反复踩过、排查过、修复过的高频问题按我自己的理解重新梳理一遍——不按教科书分类,就按事故现场的真实顺序来。
先说个题外话,为什么MQ的问题总让人头疼?因为它是一整条链路:生产者发消息、Broker存消息、消费者拉/收消息,每一段都有自己的语义和坑。你以为消息发出去了,Broker可能已经悄悄丢了;你以为消费成功了,本地的业务数据可能压根没落库。排查的时候如果脑子里没有一张完整的地图,就会陷入"改一个参数碰运气"的泥潭。
这篇博文适合谁看?负责维护MQ集群的、正在写生产者和消费者的、刚接手一个消息系统的,都适合。我会把问题拆成几大类:消息丢失、重复消费、消息堆积、顺序问题、消费组异常,以及运维侧那些"见怪不怪"的坑。每部分都会给出我实际用过的排查思路和根治方法,尽量让你能直接照着操作。
1. 消息丢失:从生产端到消费端的全链路排查
消息丢失是MQ里最要命的故障,好多人一上来就怀疑Broker,实际上大部分消息都是在前半段丢的。我通常把消息的完整路径分成三段:生产端 → Broker存储 → 消费端,每一段都有自己的确认机制和丢消息可能性,排查时一段一段过,千万别跳。
1.1 生产端"发完就算成功"的错觉
最常见的第一种丢失,是生产者用了异步发送又不关心回调。代码写了个send(),以为把消息丢给MQ就完事了,实际上网络闪断、Broker拒绝、序列化失败都可能导致消息根本没进Broker。我见过一个团队,生产者的发送失败率在某个时段高达2%,但因为只打了日志没监控告警,业务方压根没发现。等下游对账的时候发现少了一大批数据,才追查到这里。
生产端的正确姿势是:
- 必须使用带有确认回执的发送方式:同步发送、异步发送+回调、或者事务消息。
- 设置合理的发送超时时间。太短容易误报,太长会拖垮调用方。我们当时线上用的超时是3秒,重试2次,重试之间加一点间隔。
- 生产者自身得有重试机制。但不是所有异常都适合重试:网络异常可以重试,业务校验异常、消息体序列化失败这种重试一万次也没用。
这里有一个我踩过很深的坑:重试会造成消息重复,这是后面要说的重复消费问题的温床,所以生产端的"至少一次"语义是默认事实,不要妄图在生产端做"只发一次"。
1.2 Broker存储环节的丢失点
第二段是Broker收到消息之后、返回确认之前。如果这时候Broker把它“落盘"了,那基本安全;如果只是放在内存里就回执,Broker一旦重启或宕机,内存消息就会丢。
我处理过一个事故:某个节点配置的刷盘策略是异步刷盘,高峰期积压了几千条消息在内存里,恰巧物理机断电,重启之后那些消息全没了。checkpoint没有及时推进,消息没来得及落盘。对,就是异步刷盘这个"高性能"配置引发的。
关于Broker存储,我的建议是这样:
- 核心业务数据,强制使用同步刷盘到磁盘,或者至少保证副本数大于等于2。这意味着性能会有损耗,但数据安全性优先。
- 副本同步策略要看清楚。消息写入主节点后,是同步复制到从节点再返回还是返回后再异步复制,两者丢数据的窗口完全不同。
- 磁盘快满的时候,很多MQ会把"拒绝写入"而不是"继续写但随时丢"。这个经典问题后面单独说。
1.3 消费端最容易忽略的自动ack陷阱
第三段丢消息的重灾区是消费者。很多消费端框架默认是自动ack:Broker把消息推给消费者(或者消费者主动拉取一段),只要网络连接正常、没有抛出异常,就认为是消费成功了,然后推进offset。但你的业务逻辑可能是在ack之后才真正落库的,比如先更新缓存再写数据库,或者先调外部接口再落库,结果消息被标记为已消费,业务侧却还没做完。
我之前复盘过一起数据丢失事故,就是消费端在处理消息时先调了一个第三方接口,第三方接口超时,代码里catch了异常,打印了日志,然后就返回了"消费成功"。那条消息的数据由此永久丢失。所以我的经验是:凡是核心数据,必须自己控制offset/游标的提交时机,消费逻辑全部成功之后再提交。
如果用的是手动ack,还需要注意ack的粒度:一批消息要么全部成功提交,要么全部不提交,否则会有重复和丢失同时出现的诡异情况。这块我觉得值得花一段独立来说。
1.4 一个Keepalive关键参数的复盘
还有一个特别隐蔽的坑,发生在生产者和Broker之间,以及消费者和Broker之间。很多MQ的客户端和Broker之间存在心跳机制,用来判断对方是否存活。如果网络很抖,心跳超时,客户端会被从连接上踢掉。踢掉之后,客户端会重连,但重连过程可能影响生产端的发送路由,也可能让消费组触发重平衡。最气人的是你查服务端日志,发现一切正常,但客户端日志里全是一堆超时重连。
我们线上曾经有过一个低频却稳定的现象:每天凌晨零点附近会出现几秒的生产超时,量不大但令人难受。后来排查到是某个网络设备每晚定时做策略更新导致了几秒的丢包,而客户端的心跳超时设置偏短,于是在这期间误判连接断开。把超时参数调到合理范围(例如心跳间隔30秒、超时90秒,具体取决于MQ的默认语义),同时配合客户端重连时的退避策略,这个凌晨波动就消失了。
所以排查丢消息的时候,不要只盯着业务代码,客户端和服务端的心跳、超时、重试参数也是一个整体。一个参数值失配,可能让你在业务代码里找半天。
2. 重复消费:幂等不是"加个唯一ID"这么简单
重复消费几乎是每个用MQ的人都会遇到的"房间里的大象"。因为大多数MQ给的是at-least-once语义——消息不丢,但可能重复。重复的来源千奇百怪:生产者重试、Broker重试、消费者收到消息后处理超时导致服务端认为没消费完重新投递、消费端在提交offset前宕机重启后从头消费。
2.1 先认清三种投递语义的代价
- at-most-once(最多一次):发送后不管结果,或者消费时先提交offset再处理业务。可能丢消息,但绝不重复。
- at-least-once(至少一次):发送后等确认但确认可能超时重试,或者消费端处理完再提交offset。不丢消息,但可能重复。
- exactly-once(精确一次):理论上是大家都想要的,但工程上成本极高,真正到端到端是很难的,大部分场景做不到。
所以我一般给团队的建议是:不要追求不可能精确的exactly-once,而是把业务逻辑设计成"允许重复,但重复时幂等"。
2.2 真正的幂等方案是什么
很多人说"加个唯一索引不就好了?"听起来简单,但实际做起来有一堆细节。
我参与的一个交易类项目,用消息消费结果往订单流水表里插数据。刚开始直接用订单号做唯一键,插入时捕获重复键错误,结果发现两个问题:第一,同一条消息重复消费时,如果第二次不是插入而是更新操作,那么用唯一键就兜不住;第二,如果消息内容本身有多个状态流转(比如已支付、已退款),同一笔订单的不同阶段发的是不同消息,那唯一键怎么设计?用订单号会把不同阶段的消息互相误判为重复。
踩过后总结出来的实用套路是三层:
- 消费幂等表:单独建一张消费记录表,主键可以是"业务唯一标识+消息标识"。处理消息前先尝试插入消费记录,插入成功说明这条消息是第一次来,继续处理;插入失败说明之前处理过,直接返回成功,不执行业务。
- 业务状态机:对于有状态流转的业务,不要把全部消息都幂等到"同样的输入同样的输出",而是靠业务状态字段做判别:例如这条消息本身就代表着“已支付”,消费时先查询订单当前状态,如果状态已经是"已支付"或更新的状态,就跳过处理。
- 分布式锁兜底:高并发下同一个业务标识的多条消息可能同时到达,消费记录的唯一索引和数据库事务可以兜底,但如果在缓存场景下没有底库事务,就需要分布式锁。
还有一种容易踩的坑:消费者线程在消息处理中途宕机。按理说offset没提交,重启后会重新消费,但有些框架重启后拉取的是最大offset或者是新的消费者组,导致这段消息真的被跳过了。所以消费端的位移提交策略和存储方式值得单独测试。
2.3 消费端手动提交的正确节奏
我见过太多把手动提交做成"有条件但条件很松"的实现。比如消费了一批消息,处理了其中一条,累了,直接提交整批的offset。这样如果后面几条消息没处理成功,它们就被永久跳过了。正确做法是按消息粒度或小批量粒度来提交,并且提交前确认这个粒度内的所有消息都处理成功了。
如果框架不支持细粒度提交,那就在业务处理失败时抛异常、返回失败,让框架根据配置决定是否重投。注意,重投也可能导致重复,所以幂等依然是底线。
3. 消息堆积:从持久化瓶颈到消费吞吐的完整定位思路
消息堆积是最容易感知的一类问题,因为监控图上lag(积压数量)蹭蹭上涨。但堆积的原因千差万别,不一定是"消费者处理太慢"。我用一个固定流程来定位,屡试不爽。
3.1 先判断堆积发生在哪个环节
堆积的本质是消息生产速率 > 消费处理速率。但"消费处理速率慢"其实要拆成三段:
- 第一段:拉取本身。消费者从Broker拉取消息时是不是有瓶颈?比如单次拉取数量太小、拉取频率太低、消费者所在机器网络带宽受限。
- 第二段:消息处理。消费者真正执行业务逻辑的时间是不是太长?比如下游数据库慢查询、外部接口超时、死循环或者锁等待。
- 第三段:Broker存储层。磁盘IO是不是已经接近极限?页缓存的命中率如何?如果Broker已经扛不住了,那消费者拉取的响应就会变慢,lag自然上涨。
我建议排查的时候先看哪个环节最可疑:
| 症状 | 最可能的原因 | 优先排查方向 |
|---|---|---|
| 消费者机器CPU、内存正常,但拉取量上不去 | 单次拉取条数或字节上限太保守;网络带宽限制 | 调大拉取参数;检查消费者网络 |
| 消费者CPU飙高、频繁GC | 业务逻辑太重或反序列化耗CPU | 优化业务处理逻辑;增大批量处理 |
| 消费者日志中有大量外部调用超时 | 下游是瓶颈,消息进程在等IO | 隔离慢调用;引入熔断降级;异步化 |
| Broker磁盘IO使用率长期90%以上 | 存储层拥堵,副本同步被拖慢 | 扩容Broker;检查刷盘策略;做读写分离 |
| 消费者进程正常但lag就是不动 | 消费线程数不足或者消费组内分区分配不均 | 确认分区数,合理增加消费者实例/线程 |
3.2 提升消费吞吐的几种做法
我把最常用的手段按性价比排个序:
- 提高批量消费能力:大多数MQ客户端都可以一次拉取多条消息。把处理逻辑改成批量处理(比如攒够N条或者每隔几毫秒批量落库),吞吐成倍上涨。
- 增加消费者实例或线程:但这里有一个大前提——消息队列的分区数决定了扩展上限。如果一个topic只有4个分区,那最多只能有4个消费者实例同时消费(如果每个实例只分配一个分区)。你要提吞吐,先看分区数够不够。我见过好几个人往一个4分区的topic上配了20个消费者实例,结果只有4个在干活,剩下的都在空转,lag一点没降。
- 消费逻辑异步化:如果消费消息必须调外部接口,而外部接口又慢,那可以把"调外部接口"放在单独线程池里做,消费线程只管接收消息、做本地校验、然后提交给线程池。但要注意,这样做的代价是消息是先被拉取了,但业务还没做完,如果此时提交offset,宕机就会丢消息。所以异步化必须配合持久化台账和补偿机制。
- 调整消费端的拉取参数:单次拉取的字节数、拉取最长等待时间、消费者侧的队列缓存阈值,很多框架都有默认值,默认值往往偏保守,适合"稳妥"但不够"高效"。
3.3 堆积时间长了会不会导致"旧消息被清掉"?
这个要分两种。有些MQ支持消息过期时间(TTL),堆积超过了TTL会被删除,这对业务来说就是数据丢失,非常危险。另一些MQ虽然不清消息,但磁盘满后会拒绝新消息,或者触发其他异常策略。所以lag监控不是只看趋势,还要算一下存量消息会不会在预期处理时间内超过生命周期。我的经验是:给消息设置TTL时,TTL一定要大于业务允许的最大积压时间,否则高峰期一堆积,自动丢一堆数据。
4. 顺序消息:全局顺序是伪需求,局部顺序才是常态
顺序问题往往在"好像没什么问题"的时候突然爆发。我碰到过最典型的一个案例:订单状态更新的两条消息,因为生产者并发发出,Broker上的两条消息顺序颠倒,消费端先处理了"已完成",再处理"已支付",结果把订单状态写回成一个更旧的状态。
很多业务一听说"顺序消息",第一反应就是"让所有消息都严格有序"。这实际上是把全局顺序当成了需求。全局顺序意味着所有消息进同一个分区、由同一个消费者串行处理,吞吐基本就废了。
真实场景绝大多数是局部顺序:同一个订单、同一个用户、同一个设备上的事件需要有序,不同订单之间没有先后关系。所以正确做法是:按业务主键(比如订单号)做哈希,把同主键的消息路由到同一个分区,然后保证这个分区内的消息在消费端要串行处理。
4.1 生产端的顺序保证
一旦决定用分区来保证局部顺序,生产端就不能随便乱来了:
- 如果使用的是生产框架,发送消息时要指定分区路由键,或者使用带业务标识的消息key。
- 发送方式推荐同步或者带确认的异步。如果用了重试,要注意重试消息不能改变原始的路由选择。否则主键A的两条消息,第一条发送失败后重试被路由到分区2,第二条成功进了分区1,顺序就乱了。
- 如果发送时设置了超时,超时后重试,业务上要做好"消息可能重复但顺序还是应该一致"的设计。
4.2 消费端的顺序破坏点
我见过生产者端顺序没问题,但消费端仍然乱序的情况,原因基本就两种:
- 消费线程并发:明明只有一个分区,但消费者内部用了线程池并发处理消息,两条消息被两个线程同时处理。要想保证局部顺序,消费者内部的处理要么是单线程串行,要么必须保证同一主键的串行化。
- 消费失败重投导致的后到先处理:消息2先到达消费者,但处理失败重试,而消息3后到达却处理成功了。这时如果消息2和消息3是同一个主键且消息3是后发的事件,就可能在某个时间窗口内出现"新状态先落地、旧状态后覆盖"的错乱。
对于第二种情况,我常用的兜底方案是在消费端对同一个主键做状态机判断,或者把同一主键的处理串行化。如果在分布式环境中多实例消费,可以用分片锁(比如基于Redis的有界锁)来保证同一个主键的串行处理。这个方案相对稳妥,也不至于把所有消费实例退化成单线程。
4.3 不要迷信"设置里有个顺序开关"
有些MQ确实内置了顺序消息模式,但开启之后通常伴随性能惩罚或者对分区策略的强约束。我自己的体会是:自带顺序模式适合小流量、少量分区,对于核心大流量业务,最好还是在业务层面自己设计和验证顺序机制。因为即便是开启顺序模式,消息重试、消费失败、客户端线程池配置这些还是可能破坏顺序,最终根治都得靠业务层的局部串行和状态校验。
5. 消费组"集体失联"的排查链路
比起丢消息、堆积,消费组整体退出、消费者全部不在线这种情况是最慌的,因为监控会直接"拉红"。我第一次处理这种问题时花了整整两个多小时才定位到根因,这里把完整链路写出来,希望能帮后面的人少走弯路。
5.1 现象描述与初始判断
那一天的现象是:某个topic的消费组在下午3点后lag开始直线上升,消费组状态显示"无消费者"或类似含义,所有消费实例都不在线。重启消费服务后,lag能短暂下降,但过一会儿又开始上升,消费者再次失联。每次失联前,业务日志里并没有明显的异常,只有零星的连接超时。
我第一次处理的错误做法是直接重启了消费服务——这不是排查,是碰运气。重启后确实恢复了,但一会儿又挂了,问题还在原地。
5.2 顺着几个维度逐一排除
我后来整理出一套排查顺序,效率高很多:
| 排查维度 | 检查命令/工具 | 关键点 |
|---|---|---|
| 消费进程是否存活 | 查看应用日志、进程状态 | 进程挂了还是一直在运行? |
| 线程是否阻塞 | 线程快照(如 jstack 类工具) | 消费线程是不是卡在下游IO或锁上 |
| 网络连接状况 | 网络抓包、连接数统计 | 到Broker的连接是否正常,有没有大量断开重连 |
| 心跳与重平衡日志 | Broker端消费组状态、消费者心跳日志 | 有没有频繁触发重平衡,谁踢掉了谁 |
| GC停顿 | 监控GC耗时 | 老年代GC是不是STW了非常长时间 |
| 下游依赖 | 依赖服务的超时和错误率 | 消费者是不是在等慢接口导致线程池耗尽 |
那天我是这样一步步查的:先看进程,还活着;然后打线程快照,发现绝大多数据线程卡在调用某个外部服务的接口上,等待时间非常长;再看GC监控,没有明显停顿;接着连到Broker侧查消费组状态,发现消费组反复发生重平衡。
线索连起来就很清晰了:外部服务变慢 -> 消费线程全部阻塞在等待IO上 -> 心跳发送线程也被拖累(因为整个消费者进程的CPU/线程资源被占满或线程池耗尽) -> 心跳超时 -> Broker认为消费实例已死 -> 触发重平衡把实例踢出消费组 -> 踢出后没有人消费消息,lag上升。由于线上有多个消费实例,每次重平衡还会引发其他实例短暂暂停消费,整体雪上加霜。
5.3 最终根因与修复
根因就是下游外部依赖出现了长时间阻塞,而消费端没有对这些调用做线程隔离和超时控制。我修复的过程分三步:
- 紧急止血:把消费端的外部调用超时时间调短,从30秒降到3秒,并给外部调用加了一个独立线程池和信号量/熔断机制。这样即使下游慢,也不会把消费线程全部拖死。
- 消费组稳定性加固:调大心跳超时时间和重平衡相关的保护参数,让消费实例在网络抖动或短暂GC时有更大的容忍度。但这个只是缓解,真正的修复是第一步。
- 长期改造:外部调用异步化,消息先落库再异步调用外部接口,用本地任务表做补偿;同时给"外部服务耗时"和"消费线程阻塞数"加上监控告警,让这种情况在爆发前就能被发现。
这个案例之后,我给消费端服务定了一个规矩:任何消费逻辑中涉及外部IO的,必须有独立的超时控制、线程池隔离和熔断降级,否则不允许上线。顺序消费模式想提速,也绕不开这个基本法。
6. 运维侧那些"见怪不怪"的坑
前面讲的都是业务侧的,运维侧的问题相对少,但一出就是大事故,这里挑几个高频的聊一下。
6.1 磁盘写满前的死亡螺旋
MQ集群最怕的不是CPU高,而是磁盘快满。磁盘水位到达阈值后,很多MQ会停止接收新消息,同时因为刷盘失败,内存里积压大量未落盘数据。这时候生产者发消息开始报错,lag快速上涨,消费端可能还在正常消费旧数据,看起来像单纯堆积,实际上已经快写不进去了。
我的经验是:给磁盘水位设置至少两级告警,第一级80%就通知,第二级90%必须马上处理。高峰期要提前估算一天的消息总量,按"至少三天冗余"准备磁盘空间。如果集群存储节点比较多,不要等topic上报错才扩容,磁盘是"用着用着就见底"的,不是突然就满了。
6.2 监控告警的三个盲区
很多团队的MQ监控只做了lag,这可不够。我至少会配这几类:
- 生产端发送成功率:每秒发送总量、失败量、平均耗时,失败率超过0.1%就要报警,不用等业务反馈。
- 消费端处理耗时:消费一条消息的平均耗时、P99耗时。耗时上涨往往比lag早几十分钟暴露问题。
- Broker服务端指标:磁盘IO、页缓存命中率、网络连接数、文件描述符占用、GC耗时。这些指标异常时,再强的业务代码也没用。
6.3 消费者实例数量与分区数量匹配
很多运维或者开发看到消费慢就加实例,这是个极大的误区。消费者实例不是越多越好,当实例数量超过分区数量时,多出来的实例纯属浪费。我曾经在一个集群上看到40个消费者实例去消费一个12个分区的topic,结果12个实例在干活,28个在空转,而且每增加一个实例还会触发一次重平衡,导致在线消费暂停。后来把topic的分区数扩了,消费者实例精简到与分区匹配,吞吐反而提升了。
所以扩容前先确认分区数上限,否则可能做了无效扩容还制造了故障。
6.4 复制与副本的那些"你以为有但实际没有"
Broker之间消息副本的同步策略,不同MQ差别很大。有些是同步复制,有些是异步复制。如果是异步复制,主节点宕机那一刻丢失的就是一小部分消息。我见过有团队以为"有3个副本就不会丢消息",结果因为副本是异步复制,主节点坏了,重启后发现丢了几百条消息。
建议核心topic的副本同步方式强制使用同步级或半同步级,并且定期做主节点故障演练,验证故障转移后数据是否真的完整。不要等到真出事才验证,到时候数据没了一堆,找谁说理去。
7. 最后:我给新接手MQ项目的"体检清单"
如果要我总结一个给后来者的最务实建议,我会说:趁业务量还不大的时候,把上面这些问题逐个过一遍,而不是等线上爆炸再来学。
流程可以这样:
- 梳理一遍核心消息的链路图:谁生产、谁存储、谁消费,每个环节用什么确认机制。
- 验证生产端的“发送失败告警”是不是真的能触发,模拟一次断网看看有没有人知道。
- 验证消费端在“消费失败、重复投递、宕机重启、审计日志”四种场景下是否都能正确处理数据。
- 给核心消费逻辑补上幂等,不要赌“不会重复”。
- 写一页纸的MQ排查手册,包含:查看lag的入口、看消费组状态的入口、线程快照命令、常用监控指标。真出故障时,这页纸比任何文档都管用。
我这些年最大的感受是:MQ系统的故障很少是大爆炸式的,更多是“小问题潜伏很久,然后在一个神秘时刻一起爆发”。与其把精力花在出事时灵光一现,不如平时就把这些常见问题当作体检项目定期自查。你花半天做的幂等方案,可能会在两年后救回一次几百万的资损事故——这种事,我可是见过不止一次了。