在处理 MQ 堆积时,很多人的第一反应是:
消息堆积了?那就加消费者。
这个判断听起来很合理。
Kafka Lag 飙高,说明消息生产速度大于消费速度。消费慢,那就扩容消费者,提高消费并发。
但线上最怕的,恰恰就是这种"看起来很合理"的操作。
因为消费者扩容之后,Kafka 的消费速度确实可能上来了,但压力不会凭空消失。
它会顺着消费逻辑继续往下游传:
rust
Kafka -> 消费者 -> MySQL -> Redis -> 第三方接口 -> 后续 MQ
如果下游没有接住,最后看到的现象可能是:
Kafka Lag 还没完全降下来;
MySQL 连接池先打满了;
第三方接口开始超时;
失败消息不断重试;
系统从"消息堆积"变成了"全链路雪崩"。
这类问题最隐蔽的地方在于:
你以为自己在解决堆积,其实只是把堆积从 Kafka 转移到了数据库、线程池和第三方接口。
一、现场还原:分账执行消息开始堆积
假设有这样一条分账执行链路。
支付成功后,交易服务通过本地消息表发出分账创建消息。分账服务创建分账单后,再发送一条分账执行消息:
lua
share-execute-topic
消费者收到消息后,会做几件事:
markdown
1. 查询分账单
2. 查询订单和商户配置
3. 写分账流水
4. 更新分账状态
5. 调用渠道分账接口
6. 记录渠道返回结果
正常情况下,这条链路跑得比较平稳。
但某次发布后,下游渠道接口开始变慢,单次调用从原来的 200ms 涨到了 2s 以上。
一开始只是消费变慢。
很快 Kafka Lag 开始上升:
rust
share-execute-topic Lag: 5,000 -> 30,000 -> 120,000
这时候大家的第一反应很自然:
消费者不够了,先扩容。
于是把分账消费者从 4 个实例扩到 12 个实例。
过了一会儿,Lag 的增长速度确实慢了一点。
但系统出现了新的问题:
MySQL 连接池活跃连接打满;
分账流水插入变慢;
Redis 锁等待时间变长;
渠道接口超时更多;
失败消息开始进入重试;
分账服务线程池队列持续上涨。
最后 Lag 没有真正恢复,反而把原本局部的消费堆积,扩大成了下游系统整体抖动。
二、那段看起来没问题的消费逻辑
分账执行消费者代码大概是这样:
csharp
@KafkaListener(
topics = "share-execute-topic",
groupId = "share-execute-service",
concurrency = "8"
)
public void consume(ShareExecuteMessage message) {
String shareOrderNo = message.getShareOrderNo();
log.info("收到分账执行消息,shareOrderNo={}", shareOrderNo);
ShareOrder shareOrder = shareOrderMapper.selectByShareOrderNo(shareOrderNo);
if (shareOrder == null) {
return;
}
if (!ShareStatus.INIT.equals(shareOrder.getStatus())) {
log.info("分账单状态已变化,shareOrderNo={}, status={}",
shareOrderNo, shareOrder.getStatus());
return;
}
RLock lock = redissonClient.getLock("lock:share:" + shareOrderNo);
lock.lock();
try {
shareFlowMapper.insert(buildShareFlow(shareOrder));
shareOrderMapper.updateStatus(
shareOrderNo,
ShareStatus.INIT,
ShareStatus.PROCESSING
);
ChannelShareResult result = channelClient.share(buildChannelRequest(shareOrder));
if (result.isSuccess()) {
shareOrderMapper.updateStatus(
shareOrderNo,
ShareStatus.PROCESSING,
ShareStatus.SUCCESS
);
} else {
shareOrderMapper.updateStatus(
shareOrderNo,
ShareStatus.PROCESSING,
ShareStatus.FAIL
);
}
} finally {
lock.unlock();
}
log.info("分账执行完成,shareOrderNo={}", shareOrderNo);
}
这段代码单独看,问题似乎不大。
它做了状态判断。
它加了锁。
它有分账流水。
它也根据渠道结果更新状态。
所以当 Lag 飙高时,扩容消费者看起来也没问题:
rust
消费慢 -> 增加消费者 -> 提高消费速度
但这个推理只看到了 Kafka,没有看到消费者后面的下游。
三、第一个坑:消费者不是越多越好
Kafka 的消费并发不是无限增加的。
在同一个 Consumer Group 里,一个 Partition 同一时刻只会分配给一个消费者实例。
也就是说,如果一个 topic 只有 6 个 partition,那么同一个 group 下有效消费实例最多大致就是 6 个。
ini
partition = 6
consumer instance = 12
真正能消费的实例最多约 6 个;
剩下的实例即使启动了,也可能分不到 partition。
所以第一层问题是:
如果 partition 数不够,单纯加消费者实例,未必能提高有效消费并发。
当然,实际项目里还可能存在单个实例内部多线程处理、业务线程池异步处理、topic partition 数足够多等情况。
这时扩容确实能提高消费并发。
但这又会带来第二个问题:
消费者能拉得更快,不代表下游能处理得更快。
四、第二个坑:真正的瓶颈可能不在 Kafka
分账执行这类消息,消费一次并不是简单打印日志。
它可能会打到一整条下游链路:
查订单
查商户配置
写分账流水
更新分账状态
抢 Redis 锁
调用渠道接口
写渠道返回结果
发送后续通知
当你把消费者实例数、消费线程数、批量拉取数量都调大后,本质上是在增加这些动作的并发:
并发查库增加
并发写库增加
Redis 锁竞争增加
渠道接口调用增加
线程池占用增加
失败重试增加
如果 MySQL 原本只能稳定承受 200 QPS 的分账写入,你把消费者扩到可以打出 800 QPS,并不会让系统更快。
它只会让数据库先进入饱和。
数据库一慢,消费者处理时间就更长。
消费者处理时间变长,单条消息提交进度也会变慢。
提交变慢,Lag 又继续涨。
这时候就会形成一个很典型的恶性循环:
rust
Lag 上升
-> 扩容消费者
-> 下游压力上升
-> DB / 接口变慢
-> 消费耗时变长
-> 失败和重试增加
-> Lag 继续上升
所以很多时候,消息堆积不是 Kafka 自己的问题。
Kafka 只是最先把问题暴露出来。
真正的瓶颈,可能在 MySQL、Redis、第三方接口,甚至某个本地线程池。
五、把时间线拉开看
这类事故通常不是瞬间发生的,而是一点点被放大的。
| 时间点 | 操作 / 现象 | Kafka | MySQL | 第三方接口 | 消费者 |
|---|---|---|---|---|---|
| T1 | 渠道接口变慢 | Lag 小幅上升 | 正常 | RT 从 200ms 升到 2s | 消费耗时变长 |
| T2 | Lag 持续上涨 | Lag 破 3w | 正常 | 偶发超时 | 线程占用增加 |
| T3 | 开始扩容消费者 | 拉取速度提升 | 连接数上升 | 调用并发上升 | 消费并发增加 |
| T4 | 下游接近饱和 | Lag 增速变慢 | 连接池打满 | 超时增多 | 消费失败增加 |
| T5 | 失败消息开始重试 | Lag 继续增长 | 慢 SQL 增多 | 大量超时 | 重试线程增加 |
| T6 | 系统整体抖动 | Lag 不降反升 | DB 写入变慢 | 成功率下降 | 线程池队列堆积 |
最关键的是 T3。
扩容消费者这个动作本身不是错。
错的是:扩容前没有先确认下游能不能接住。
六、第三个坑:失败重试会形成二次流量
消息堆积时,下游通常已经不健康了。
这时候如果失败消息还在快速重试,就会出现二次冲击。
比如一条分账消息调用渠道接口超时,消费者抛异常,消息进入重试流程。
这里的重试方式可能有很多种:
原 topic 重新消费
重试 topic 延迟投递
业务异常表定时扫描
补偿任务重新发起
人工后台重推
实现方式不同,但本质一样:
失败消息会再次占用消费者、数据库、Redis 和第三方接口资源。
如果重试策略很激进:
失败后立即重试
重试 3 次
每次都重新查库、写日志、调用渠道接口
那实际流量就不只是新消息。
而是:
diff
新消息流量
+
失败重试流量
+
补偿任务流量
+
人工重推流量
有些系统被打崩,不是因为正常流量突然大到离谱。
而是因为故障发生后,重试流量把系统又打了一遍。
尤其是第三方接口超时时,如果没有退避策略,消费者会不断发起新的请求。
第三方越慢,本地越重试。
本地越重试,第三方越慢。
最后双方一起抖。
这就是重试风暴。
七、第四个坑:批量消费不是简单把 batch 调大
还有一种常见处理方式是调大批量拉取。
比如:
arduino
max.poll.records: 100 -> 500
或者业务侧把单次处理批量扩大。
这个动作也不是不能做。
但前提是:业务处理本身支持批量化。
如果代码只是一次 poll 拉更多消息,然后仍然一条条查库、一条条写库、一条条调接口,那么 batch 变大可能只是把压力堆在本地内存和线程池里。
它会带来几个问题:
arduino
单次 poll 后本地积压更多消息
单批处理时间变长
失败影响范围变大
更容易触发 max.poll.interval.ms
Rebalance 风险增加
比如消费者一次拉了 500 条消息,但每条都要调用渠道接口。
如果每条平均 2 秒,哪怕并发处理,整批耗时也可能非常长。
一旦超过 Kafka 的消费间隔限制,就可能触发 Rebalance。
Rebalance 后,原本处理中的消息可能又被其他消费者重新拉取。
于是堆积问题又和重复消费问题叠在了一起。
八、看到 Lag 飙高,第一步不是扩容
看到 Lag 飙高时,第一步不应该是直接加机器。
而是先判断:
这次堆积到底卡在哪里?
至少要先把这几类指标拉出来看。
Kafka 侧要看:
sql
Lag 增长速度
消费速率
partition 分布
Rebalance 次数
消费者侧要看:
单条消息处理耗时
消费线程池活跃数
队列长度
失败率
重试次数
MySQL 要看:
sql
连接池活跃连接
慢 SQL
行锁等待
TPS / QPS
CPU / IO
Redis 要看:
vbnet
慢查询
锁等待时间
热点 key
连接数
第三方接口要看:
RT
超时率
限流率
成功率
如果 Lag 上升的同时,MySQL 连接池已经接近打满,那继续猛加消费者,只会把 DB 推得更快。
如果第三方接口 RT 已经从 200ms 涨到 2s,那继续提高消费并发,只会制造更多超时和重试。
这个时候,Kafka Lag 不是唯一指标。
它只是告诉你:系统开始欠债了。
但要怎么还债,得看谁还有余力。
九、更稳的恢复方式:先止血,再追 Lag
消息堆积时,恢复目标不应该是:
立刻把 Lag 打下来。
更稳的目标应该是:
先让系统恢复到可持续处理状态,再逐步消化堆积。
这两个目标不一样。
如果只盯着 Lag,很容易采取激进扩容。
如果目标是可持续处理,就要先保护下游。
1. 先把消费速度压到下游能接住的范围
必要时,可以临时降低单实例消费线程数,减少单批处理数量,或者暂停部分低优先级 topic 的消费。
这不是放弃消费,而是防止消费者继续把下游打穿。
2. 对关键下游做隔离和限流
比如分账执行里调用渠道接口,可以先做单实例并发控制:
java
private final Semaphore channelSemaphore = new Semaphore(50);
public ChannelShareResult callChannel(ShareOrder order) {
boolean acquired = channelSemaphore.tryAcquire();
if (!acquired) {
throw new ChannelBusyException("渠道接口繁忙,稍后重试");
}
try {
return channelClient.share(buildRequest(order));
} finally {
channelSemaphore.release();
}
}
这段代码的目的不是提高吞吐。
而是防止消费者把渠道接口继续打穿。
不过要注意,这只是单实例保护。
如果有 10 个实例,每个实例都放 50 个并发,总并发还是可能达到 500。
如果要做集群级限流,就需要统一限流组件,或者按实例分配额度。
3. 失败重试要退避
失败后不要立即高频重试。
更稳的是:
第 1 次失败:10 秒后重试
第 2 次失败:1 分钟后重试
第 3 次失败:5 分钟后重试
超过最大次数:进入死信队列或异常表
重试的目的应该是修复偶发问题,不是把已经不健康的系统继续压垮。
4. 补偿任务要错峰
很多系统在消息堆积时,还有补偿任务在跑。
比如扫描:
分账中超时
待分账超时
渠道无回调
消息发送失败
如果补偿任务也在高峰期全速跑,就会和消费者抢同一批资源。
这时候应该临时降低补偿任务频率,或者限制每批扫描数量。
否则你以为自己在修复数据,实际上是在给下游继续加压。
5. 按业务优先级恢复
并不是所有消息都一样重要。
比如:
支付成功后的核心分账消息
普通通知消息
统计类消息
非核心埋点消息
恢复时可以优先保障核心链路。
非核心消息可以延后处理、降级处理,甚至短时间暂停消费。
不要所有 topic、所有 consumer 一起抢资源。
十、扩容不是不能做,但要带着刹车
这篇不是说消费者不能扩容。
扩容当然是常见手段。
但扩容之前要先知道:
sql
topic partition 是否足够?
消费者处理耗时主要卡在哪里?
MySQL 连接池还有多少余量?
第三方接口有没有限流?
失败重试会不会放大流量?
业务是否支持批量处理?
扩容时也不要一步拉满。
更稳的是逐步增加:
rust
4 个实例 -> 6 个实例 -> 8 个实例
每一步都观察:
Lag 是否下降
单条处理耗时是否上升
MySQL 是否接近瓶颈
第三方超时率是否上升
失败重试是否增加
如果 Lag 下降,但 DB 连接池打满、接口超时飙升,那说明你只是把问题转移了。
这时候应该停下来,先处理下游瓶颈,而不是继续加消费者。
十一、总结
Kafka 消息堆积后,扩容消费者不是错。
错的是只盯着 Kafka Lag,把消费者扩容当成唯一解。
消费者不是独立工作的。
它后面通常连着:
数据库
缓存
分布式锁
第三方接口
本地线程池
后续 MQ
当你提高消费并发时,本质上是在提高这些下游资源的并发压力。
所以,消息堆积真正要处理的不是"让消费者跑得更猛",而是:
先确认瓶颈在哪里;
先保护下游;
再控制节奏恢复消费;
失败重试要退避;
补偿任务要错峰;
扩容要逐步观察。
Kafka Lag 高,说明系统开始欠债。
但还债不能靠透支下游。
否则 Lag 可能还没还完,数据库、接口和线程池先被拖垮了。