一个 Kafka topic 堵了 18 小时,监控全绿、没有任何报错
Kafka 消费者卡死的一次真实故障复盘:
LAG一直涨、CURRENT-OFFSET不动、消费者被反复踢出组,而告警群里一条消息都没有。凶手是三个单独看都"没毛病"的设计叠在一起。文末第六节是可直接照抄的 5 步排查清单(含kafka-consumer-groups.sh/jstack命令),遇到"消息不动了但不报错"照着走一遍就能定性。
故障现象
一个 Kafka topic 堵了 18 个小时,后面排队的活动全部上不了线,而告警群里一条消息都没有。
没有异常、没有堆栈、没有超时日志,监控面板一片祥和。去数据库里查那批卡住的活动模板,状态字段明明白白写着已部署。
状态是对的,活动死活不上线。
排查到最后,凶手是三个设计缺陷的叠加。如果你的服务里也有"在 Kafka 消费者里干重活"的代码,建议对着自己的代码看一遍。
一、第一个骗局:状态是"已完成",不代表活儿干完了
先交代场景。活动配置有条链路:模板审批通过后要"部署",而部署这个动作在代码里是两步。
第一步,把模板状态改成已部署,秒级就好。第二步,对这个活动受影响的所有用户做批量开关,可能是几十万到上百万个用户。
问题就藏在这:两步写在同一个方法、同一个线程里串行执行,中间没有 return。
scss
void activateCampaignTemplate(...) {
deployTemplate(...); // 第①步:改状态为"已部署",秒级
processAffectedUsers(...); // 第②步:百万用户逐个处理,可能几小时
}
所以当我看到数据库里写着"已部署"时,下意识以为这个模板处理完了。错。状态变更只是第①步,方法根本没 return,线程还死死卡在第②步里跑那上百万用户。
这是排查这类问题最容易踩的坑:别把"状态字段 = 完成态"当真。 状态是代码某一行写进去的,它后面可能还跟着一长串同步执行的代码。
我最后是靠翻日志、盯那个线程最新一条日志的时间戳,才发现它从任务开始到我观测的那一刻,已经连续跑了 9 个多小时,还在一个一个地调下游接口。
二、那第二步,为什么能跑到 18 小时
顺着往下挖。第②步的 processAffectedUsers,逻辑是对每个用户逐个同步调用下游接口做资格校验,查 KYC 等级、查用户详情,一个用户要打 3 到 4 次 Feign 调用。
平时这没事。因为大多数活动只影响几百几千个用户,几秒钟就跑完了。
但这次那个活动影响的是上百万用户。上百万乘以每人 3 到 4 次同步远程调用,还是一个循环里一个接一个串行打。算算就知道这不是几秒钟的事,是好几个小时的事。它不报错,是因为每一次调用都成功了,只是慢,慢到累积成灾难。
scss
for (user : 上百万用户) {
feign.queryKycLevel(user); // 同步阻塞
feign.queryUserDetail(user); // 同步阻塞
// ... 每个用户 3-4 次远程调用,全程串行
}
病根是:把一个数据量会随业务增长的批处理,写成了逐条同步的远程调用,既没有并发,也没有超时保护。
当初这么写大概率不算错。那时用户量小,这点耗时可以忽略,"改状态 + 迁用户"看起来就是一个原子的激活动作。直到用户规模涨到百万级,这个"耗时可忽略"的假设才被彻底打破。
所以真正该记住的是:批处理耗时和数据规模强相关的代码,不能假设历史耗时数据永远成立。 今天跑 3 秒,不代表明天喂它 100 倍数据还是 3 秒。
三、真正的元凶:单分区消费者,一条慢消息等于全线停摆
到这还只能解释"慢",解释不了为什么别的活动也跟着卡死。把"一个大活动慢"放大成"全线瘫痪"的,是这条链路的消息入口。
触发部署的入口是一个 Kafka topic。而这个 topic,当初只配了 1 个 partition 加 1 个 consumer 线程。
设计初期这么配没问题,模板审批频率很低,消息量小,单分区绰绰有余。但没人预料到,某一条消息背后会牵动上百万用户、跑上好几个小时。
单分区 topic 有个致命性质:它天生没有"一条消息慢不影响其他消息"的隔离性。 消息在单分区里严格排队,消费者一次只处理一条,当前这条不处理完(不 return、不提交 offset),后面所有消息只能干等,不管后面排的是哪个品牌、哪个业务的活动。
那条"上百万用户"的消息,就像高速唯一车道上抛锚的卡车。它自己走得慢是一回事,要命的是它把整条路堵死了,后面所有车全部动弹不得。这就是为什么运营看到的是"所有新活动都不上线",而不是"就那一个大活动慢"。
四、rebalance 不是安全网,它是二次伤害
排查到这本以为收工了,结果又冒出一个更阴的细节。
Kafka 有个自我保护机制:如果消费者处理单条消息的时间超过 max.poll.interval.ms(默认 5 分钟内没回来 poll),broker 会认定这个消费者挂了,把它踢出消费组,触发 rebalance,把分区重新分给别的消费者。
听起来像个安全网。轮到我们这个场景,它反而是二次伤害:
那条消息处理了几个小时,远超阈值,broker 早就判定 consumer 失活、踢出组了。等这条消息终于处理完,消费者想提交 offset(commitSync),却发现自己已经被踢出组,提交失败。offset 没提交成功就会回退,这条消息被重新消费一遍。于是那个刚跑完好几个小时的巨型任务,又从头开始跑了一遍。
那这里不是该有幂等去重挡住重复消费吗?有,但去重键只挡了一个维度。当时的去重 key 只按其中一个业务维度去重,而这条消息还带着另一个维度没被覆盖,于是重复消费的判定没生效,同一个慢任务大摇大摆又进来一次。
这段的教训我记得格外牢:对 Kafka 消费者,永远要问自己一句"如果单条消息处理时间超过 max.poll.interval.ms,会发生什么?" rebalance 不是免费的安全网,它会导致 offset 回退、消息重放。而一旦幂等去重没覆盖到所有维度,重放的就是那个本就要命的慢任务。
五、怎么修:先止血,再根治,最后升级架构
修复分三层,由急到缓。
第一层,应急止血。 先用 SQL 把卡住的、重复的模板直接标成拒绝状态,解除阻塞,让后面排队的活动先走起来。纯止血,不治本。
第二层,根治那个同步批处理。 把"逐用户同步调用"从消费者线程里彻底挪出去:消费者线程只负责快速确认消息收到了,真正的百万用户批处理丢给独立线程池或异步任务队列去慢慢跑。再给单条消息加一道超时保护,处理超过阈值就主动提交 offset 加告警,绝不让一条消息拖死整个 poll 循环。
这里有个必须主动接受的代价:改成异步之后,"状态显示已部署"就不再等于"用户已经全部迁移完成"了。运营看到已部署时,后台用户迁移可能还在跑。这是从准实时退成异步最终一致的体验代价,换来的是消费者线程不再被拖死、其他活动不再被一个大活动连累。这笔账划算。
第三层,架构升级(选做)。 更彻底的做法,是把带副作用的消费者从"定时批处理"改成 Kafka 原生的常驻流式消费,并在单分区约束下按业务 key 分片并行:
bash
主线程持续 poll
→ 每条消息按业务 key 的 hash 路由到固定 stripe
→ 每个 stripe 单线程顺序执行
→ offset 只提交"从上次已提交处起、最大的连续完成区间"末尾
这样能拿到"同一个业务实体有序、不同实体之间并行"的效果,既提了吞吐,又没破坏顺序敏感的业务。但它有三条铁律不能忘。头一条,顺序只需保到"同业务实体"粒度,不是整个分区。再有,offset 必须按连续完成区间提交,还要配 DLQ 防"毒丸消息"把 offset 永久卡死。最后,副作用必须幂等,因为重复消费永远无法完全避免。
说句实在的,单分区按 key 分片终究只是"兼容既有 topic"的过渡方案。中长期更干净的做法是直接扩分区:生产侧按业务 key 路由,消费侧按 partition 并行,这才是顺着 Kafka 原生模型走。单分区里做并行,是在给历史设计打补丁。
六、Kafka 消费卡死的 5 步排查清单
这次之后我把它整理成一份清单,贴在自己的排查笔记里。下次遇到"消息不动了但没有报错",按顺序走这五步。
第 1 步,先确认是"卡住"还是"没收到"。
css
kafka-consumer-groups.sh --bootstrap-server <broker> \
--describe --group <组名>
看 LAG 和 CURRENT-OFFSET。LAG 一直涨而 CURRENT-OFFSET 完全不动,说明消息进来了但消费者没往前走,是卡住。两个都不动则要回头查生产端。
第 2 步,看消费组成员还在不在。 同一条 --describe 输出里看 CONSUMER-ID 和 HOST。如果分区的 owner 是空的,或者消费组状态在 PreparingRebalance、CompletingRebalance 之间反复跳,那就是消费者被踢出组了,基本可以锁定单条消息处理超时。
第 3 步,别信状态字段,去抓线程栈。
bash
jstack <pid> > /tmp/s1.txt
sleep 30
jstack <pid> > /tmp/s2.txt
diff /tmp/s1.txt /tmp/s2.txt
找那个 Kafka 消费线程。两次栈停在同一个业务方法上,说明它还在跑第一条消息没 return。栈顶如果是 socket read 之类的阻塞调用,那基本就是在同步等下游。
第 4 步,算最坏耗时,和 max.poll.interval.ms 比。 找到卡住的那个方法,数清楚它对单条消息要做多少次远程调用或多少行 DB 操作,乘上当前最大数据量。只要这个乘积可能超过 max.poll.interval.ms,就已经定性了,不用再猜。
第 5 步,查幂等去重键覆盖全不全。 因为 offset 回退导致的重放躲不掉,这步是看重放会不会造成二次伤害。把去重 key 的字段和消息体的业务维度逐个对一遍,少覆盖一个维度,重复消费就会从那个缺口进来。
顺带说一句,前两步只用一条命令就能出结论,真正卡人的往往是第 3 步:很多人看到数据库状态是"完成",就不往线程栈那一层走了。
小结
最坑的故障往往不报错。它不崩、不抛异常,只是慢,慢到把整条队列堵死,而你还在数据库里看着一个写着"已完成"的状态字段发呆。
这次故障能给出的三条通用结论:
- 状态字段是代码某一行写的,它后面可能还跟着几小时的同步逻辑,别拿它当完成态。
- Kafka 消费者里干重活之前,先算一笔最坏账:数据量涨 100 倍,单条消息要跑多久。
- 单分区 topic 没有消息间的隔离性,一条慢消息就是全线停摆。业务量一旦演进到"单条消息可能很重",分区策略就得重新评估。
你的服务里,有没有哪个 Kafka 消费者正埋着一条"平时很快、极端情况能跑几小时"的消息?欢迎在评论区聊聊你踩过的消费者坑。
这类"不报错但要命"的线上故障,我每次排查完都会把完整过程记成案例,攒成了一个 「经验怪谈」 系列。已经写了 DELETE 锁全表删 3344 万行、数据库连接数撞 RDS 1.6 万硬上限,后面还有几篇更邪门的。
系列都发在公众号 啫煲捞饭,同名,搜一下就能找到。都是一手生产环境的坑和真实数字,不写八股。