凌晨两点,你被告警短信叫醒:订单通知队列堆积了 200 万条消息,消费延迟超过 6 小时。打开监控一看,生产速率没变,是消费端变慢了——这时候该做什么?**先重启消费者?加机器?还是把消息扔了?**顺序错了,轻则白忙一场,重则把小故障放大成大事故。
消息积压(Message Backlog)是消息队列最常见、也最能检验工程功力的问题。它不像消息丢失那样隐蔽,但一旦发生就是"明牌":监控上 Lag 曲线一路飙升,业务方追着问"我的消息怎么还没到"。上一篇文章《消息不丢失全链路保障》的评论区有同学问到积压问题,这篇就把它一次讲透。
本文按"认识它 → 定位它 → 止血它 → 根治它 → 预防它"的顺序展开,配 12 张原创图解 + 10 段代码,覆盖 Kafka / RabbitMQ / RocketMQ 三大主流 MQ。
一句话总结全文: ① 积压的本质是生产速率 > 消费速率的水位差,治理目标只有两个:让速率比回到 1 以上,以及追平存量; ② 应急三板斧:扩消费者实例(受分区数约束)→ 扩分区/转发 → 提单实例并发与批量能力,顺序和约束不能搞错; ③ 追不上时要算账:追平时间 = 积压量 ÷ (消费速率 - 生产速率),算出来追不平就要果断重置 offset + 归档回放,而不是硬扛。
一、认识积压:一个必然出现的"水位差"
先给积压下个定义:积压 = 已写入 MQ 但尚未被消费的消息总量,在 Kafka 里叫 Lag,在 RabbitMQ 里叫队列的 messages 深度,在 RocketMQ 里叫消费位点差值。
任何一个正常的系统,都存在短暂的积压——生产速率天然是波动的,午高峰、大促、整点任务都会带来瞬时洪峰,只要消费端能在一小段时间内追平,这点积压就是健康的缓冲。真正的问题出在持续性的速率差:

积压本身不可怕,可怕的是它的三个连锁反应:
- 延迟放大:200 万积压 ÷ 300 条/秒 ≈ 111 分钟。对"订单结果通知"这类时效敏感的业务,延迟 2 小时基本等于故障;
- 存储压力:RabbitMQ 消息堆在内存 + 磁盘,触及内存水位线后会阻塞生产者(memory/disk alarm),把故障传染给上游;Kafka 虽有 retention 兜底,但磁盘占用和页缓存污染会拖慢整个 Broker;
- 过期丢失:如果消息设置了 TTL 或 RocketMQ 定时清理,积压期间的慢消费可能让消息还没被消费就过期删除——积压演变成丢失。
一个常见误区:把"有积压"直接当故障处理。判断标准不是 Lag 的绝对值,而是趋势和追平时间:Lag 稳定在小数值、且消费速率 ≥ 生产速率,就是健康;Lag 持续上涨,才是真问题。
二、先诊断再动手:积压排查三板斧
看到积压就冲上去加机器,是积压治理最常见的错误。如果根因是消费端代码里的慢 SQL,加多少台消费者都没用——每台都在慢 SQL 上排队,只会把数据库压垮得更快。正确的顺序是先花 10 分钟诊断,搞清楚三个问题。
第一板斧:确认是"生产突增"还是"消费变慢"
这两个根因的处置完全不同:生产突增要评估是不是合理的业务洪峰(如大促、重推任务),消费变慢要找消费端的新变化(发版、依赖抖动)。看两条曲线的对比:

第二板斧:看消费者组健康状态
很多"消费变慢"其实不是慢,是消费者根本不在线:实例被 OOM 杀掉、发版期间滚动重启、再均衡风暴导致部分分区无人认领。三大 MQ 的查看方式:
# Kafka:查看消费者组状态与各分区 Lag
kafka-consumer-groups.sh --bootstrap-server localhost:9092 \
--describe --group order-notify-group
# 重点看:
# ① CONSUMER-ID 数量 —— 在线实例是否比部署实例少?
# ② 有没有分区没有分配给任何消费者(CONSUMER-ID 为空)
# ③ LAG 列 —— 哪些分区积压最多(是否集中在个别分区 → 倾斜)
# RabbitMQ:队列深度与消费者数
rabbitmqctl list_queues name messages consumers
# RocketMQ:查看消费位点差
mqadmin consumerProgress -g order-notify-group
第三板斧:看单条消息的处理耗时
在消费逻辑的关键节点打点,或直接看日志时间戳差。经验值:单条处理 < 10ms 属于快消费,瓶颈大概率不在业务逻辑;单条 > 100ms 就要警惕,200 万消息哪怕每条 100ms,单线程也要跑 55 小时。把耗时分布和下面第三节的根因清单对照,基本能锁定问题。

三、根因清单:消费慢的六大常见原因
根据线上事故的统计规律,消费变慢的原因高度集中,按出现频率排序:

其中第⑥条值得单独展开。Rebalance 风暴是新手最容易踩的坑:Kafka 消费者每次 poll 间隔不能超过 max.poll.interval.ms(默认 5 分钟),如果一批消息处理太久没来得及下一次 poll,Broker 会认为消费者死了,把它踢出消费者组并触发全组 Rebalance。分区被重新分配后,那条没处理完的消息会被重投给别的消费者,又处理很久,又超时——整个消费者组就在"分配→超时→重分配"里空转,一条消息都消费不完。
四、应急止血三板斧
诊断清楚后进入止血阶段。目标很纯粹:把消费速率提上去,先让速率比回到 1 以上,再谈追平存量。三板斧有严格的先后顺序和适用条件。
第一板斧:水平扩容消费者(首选,但有一条硬约束)
加实例是最直接的方案:同样规格的机器再起几台,加入同一个消费者组,MQ 会自动把分区重新分配。但这里有一条所有 Kafka 使用者都必须刻在脑子里的硬约束:
**一个分区同一时刻只能被消费者组内的一个消费者消费。**所以消费者实例数超过分区数之后,多出来的实例完全空闲——分区数就是消费并行的天花板。

所以扩容前先回答:分区够不够?Kafka 新建 topic 常见默认 3 分区、16 分区,如果分区只有 3 个,第一优先级其实是把分区数提上去(见第二板斧),而不是无脑加实例。RabbitMQ 没有分区概念,加实例总是有效的(每台实例各占队列一部分消息);RocketMQ 的 MessageQueue 同 Kafka 逻辑,消费者数 > 队列数同样空闲。
还要注意扩容动作本身会触发 Rebalance:Rebalance 期间全组停止消费(stop-the-world),几秒到几十秒不等。洪峰期间反复扩缩容,会反复打断消费,反而加重积压。
第二板斧:分区不够时,扩分区 + 临时转发
当实例已加到分区数上限、消费速率还不够时,就得突破天花板。Kafka 有个经典约束:带 key 的消息按哈希路由,扩分区会打乱 key 的映射(同一个 key 扩容前在分区 2,扩容后可能到分区 5),如果业务依赖"同 key 顺序",直接扩分区会破坏顺序性。所以生产上更稳的做法是临时 topic 转发方案:

// 转发消费者:只搬砖不做业务,单条耗时微秒级
@KafkaListener(topics = "order-notify", groupId = "bridge-group",
concurrency = "3") // 最多 3,受老 topic 分区数限制
public void bridge(ConsumerRecord<String, String> record) {
// 不解析业务,原样搬运;需要顺序的业务按 key 转发保持局部有序
kafkaTemplate.send("order-notify-emergency",
record.key(), record.value());
}
RocketMQ 有个天然优势:队列数可以在线扩(
mqadmin updateTopic -r 40 -w 40),且扩队列不会破坏同 key 路由的既有语义(新消息按新队列数哈希)。但同样要注意顺序消息场景下的局部乱序,重要业务仍建议走临时 topic。
第三板斧:单实例内提并发 + 批量化
前两板斧都在加"进程级"并行,第三板斧在"线程级"榨干单机。一个消费者实例拉到一批消息后,可以交给线程池并发处理,而不是串行处理:

// 线程池消费 + 水位线位移提交(简化示例)
Map<TopicPartition, Long> pending = new ConcurrentHashMap<>();
@KafkaListener(topics = "order-notify", groupId = "order-notify-group")
public void listen(List<ConsumerRecord<String, String>> records,
Acknowledgment ack) {
List<CompletableFuture> futures = new ArrayList<>();
for (ConsumerRecord<String, String> r : records) {
pending.merge(new TopicPartition(r.topic(), r.partition()),
r.offset(), Math::max);
futures.add(CompletableFuture.runAsync(
() -> handle(r), workerPool)); // 8 线程并发处理
}
CompletableFuture.allOf(futures.toArray(new CompletableFuture[0]))
.join(); // 全部完成才提交
ack.acknowledge(); // 更精细可按水位线提交
}
如果消息之间不需要严格顺序,还可以直接把消费模型改成批量:一次 poll 拉 500 条,批量写库代替逐条写库,这是对根因①最高效的修复:
# application.yml —— 批量消费
spring:
kafka:
listener:
type: batch # 一次回调拿到一整批
ack-mode: batch # 整批处理完再提交位移
consumer:
max-poll-records: 500 # 单次拉取上限
fetch-max-bytes: 52428800 # 50MB
-- 逐条 INSERT 500 次,耗时 ~5s;批量一条语句,耗时 ~30ms
INSERT INTO notify_record (order_id, status, created_at)
VALUES (?, ?, ?), (?, ?, ?), (?, ?, ?) ... -- 500 行
ON DUPLICATE KEY UPDATE status = VALUES(status);

RabbitMQ 侧对应的是调大 prefetch(默认很小,比如 Spring AMQP 默认 250 之前的老版本只有 1),RocketMQ 侧是调大 pullBatchSize 和 consumeThreadMin/Max,原理相同。
五、根治:让消费速度回到正常水位
三板斧是止血,但如果根因是②③类(慢 SQL、外部调用),扩容只是把慢性病复制到了更多机器上。根治要做三件事:
1. 消灭逐条写库
所有"每条消息一次 DB 往返"的逻辑,优先改成批量(上文图 8),其次是检查 SQL 索引:UPDATE ... WHERE order_id = ? 走没走索引,用 EXPLAIN 确认。一个丢失的索引可以让单条耗时从 1ms 涨到 200ms,这是很多"发版后消费突然变慢"的真凶。
2. 外部调用必须配超时和熔断
// 消费逻辑里的外部调用:超时 + 熔断 + 失败快速返回
@CircuitBreaker(name = "smsClient", fallbackMethod = "smsFallback")
@TimeLimiter(name = "smsClient")
public CompletableFuture<Boolean> sendSms(NotifyMsg msg) {
return CompletableFuture.supplyAsync(() ->
smsHttpClient.post("/send", msg, Duration.ofSeconds(2)) // 硬超时 2s
);
}
public CompletableFuture<Boolean> smsFallback(NotifyMsg msg, Throwable t) {
// 短路时不阻塞消费:先落"待重试"表,由补偿任务异步补发
retryRepo.save(msg);
return CompletableFuture.completedFuture(true);
}
下游抖动时,没有超时的消费端会每条挂 30 秒(TCP 默认超时),吞吐从 500 掉到 0.03;配上 2 秒超时 + 熔断,最坏也就是"暂时降级,消息进重试表",消费不会停摆。
3. 重试风暴隔离
消费失败重试要有退避(指数退避 + 抖动),并且重试消息走独立 topic / 独立线程池(如 RocketMQ 的 %RETRY% 队列、Kafka 自建 retry-topic)。否则失败消息的密集重试会和新消息抢同样的资源,"重试风暴"叠加洪峰就是雪上加霜。超过最大重试次数的消息进死信队列,人工介入,绝不无限循环。
六、极端情况:积压太大追不上怎么办
三板斧全上之后,要算一笔账:
追平时间 = 积压量 ÷ (扩容后消费速率 - 生产速率) 例:积压 200 万,扩容后消费 4000 条/秒,生产 500 条/秒 → 追平时间 = 2,000,000 ÷ 3,500 ≈ 10 分钟。追。 但如果消费只有 800 条/秒,追平时间 = 200 万 ÷ 300 ≈ 111 分钟,而消息 TTL 是 1 小时——还没追平就开始过期,硬追没有意义。
追不平的时候,要面对一个残酷的问题:**这批积压消息还要不要?**决策树如下:

重置 offset 的具体操作(Kafka):
# ① 先把消费组停掉(running 状态的组不能 reset)
# ② 把当前积压位点先导出留档(用于事后补偿)
kafka-get-offsets.sh --bootstrap-server localhost:9092 \
--topic order-notify --time latest # 记录每个分区的最新位点
# ③ 重置到最新:跳过全部积压,从最新消息开始消费
kafka-consumer-groups.sh --bootstrap-server localhost:9092 \
--group order-notify-group \
--topic order-notify \
--reset-offsets --to-latest --execute
# ④ 需要按时间跳过时(如跳过 3 小时前的积压):
kafka-consumer-groups.sh --bootstrap-server localhost:9092 \
--group order-notify-group --topic order-notify \
--reset-offsets --to-datetime 2026-09-04T18:00:00.000 --execute
重置 offset 之前,务必先把积压段的消息导出归档(用 kafka-console-consumer.sh --offset 指定区间导出,或开一个独立消费者组专门"扫一遍"落库)。积压的消息不等于无用的消息——订单通知可以过时,但订单状态数据往往要补。先保住数据,再决定怎么补:离线脚本补发短信、跑对账任务修复状态,都比"从头硬消费"可控得多。
七、预防体系:让积压在苗头期被发现
线上最好的积压事故,是根本没有发生的事故。预防体系分三层:
第一层:三个核心指标 + 分级告警

指标采集:Kafka 用 kafka-exporter + Prometheus(kafka_consumergroup_lag),RabbitMQ 用 rabbitmq-exporter(rabbitmq_queue_messages),RocketMQ 用 mqadmin consumerProgress 定时拉取或 RocketMQ-Exporter。滞后时间 = 最老消息时间戳与当前的差值,Kafka 的 exporter 直接提供。
第二层:容量规划留余量
上线的 topic 不是拍脑袋定 3 个分区的。估算公式:
分区数 ≥ 峰值生产速率 ÷ 单分区可消费速率 × 冗余系数(2~3)
例:峰值 5000 条/秒,单分区消费能力实测 500 条/秒
→ 至少 5000/500 × 2 = 20 个分区
→ 这样日常洪峰 2 倍时,加一倍实例就能扛住,不用动 topic
注意单分区可消费速率要用压测实测,不同业务(纯落库 vs 调外部服务)能差 100 倍。分区一旦创建,Kafka 只能加不能减,所以一次到位留足冗余比事后扩分区(打乱 key 路由)划算得多。
第三层:预案演练
把第四节的内容写成跑得通的预案文档:扩容脚本、转发 topic 的创建命令、重置 offset 的完整命令、归档补偿脚本,每季度在预发环境演练一遍。事故凌晨两点执行预案时,你需要的不是"大概知道有这回事",而是复制粘贴就能跑的命令。
八、实战复盘:200 万积压 90 分钟追平
把前面的知识串成一个真实感强的案例。背景:订单中心向下游发"订单状态变更通知",Kafka topic 30 分区,正常 6 个消费者实例,消费速率 3000 条/秒。某天 20:00,上游系统故障恢复后批量重推了历史订单,生产速率冲到 30000 条/秒,消费端同时因为 Elasticsearch 集群抖动,单条处理耗时从 8ms 涨到 400ms。

这个案例里有两个值得记住的细节:
- 顺序不能错:先修 ES 熔断(根因)再扩容。如果反过来先扩 30 个实例,等于 30 个消费者同时打已经在抖动的 ES,可能把 ES 直接打挂,故障升级;
- 算账决定策略:20:45 时速率比回到 1,理论上 170 万 ÷ 15000 ≈ 2 小时可追平,但业务要求 1 小时内——所以才上了临时 topic 转发。如果业务能接受 2 小时,就不必引入转发架构的复杂度。手段要服从算出来的账,而不是追求"最快"。
九、总结:积压处置速查表

最后用三句话收束全文:**积压的表象是 Lag 上涨,本质是速率差;止血的手段是三板斧,但顺序是"先修根因、再扩容量、最后算账";长期的安全感来自三个指标监控、留足冗余的容量规划和演练过的预案。**下一篇 MQ 系列我们聊顺序消息与幂等消费的深入实现,欢迎关注。