Kafka 消费者卡死排查:一个 topic 堵了 18 小时,监控全绿没人发现

一个 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 <组名>

LAGCURRENT-OFFSET。LAG 一直涨而 CURRENT-OFFSET 完全不动,说明消息进来了但消费者没往前走,是卡住。两个都不动则要回头查生产端。

第 2 步,看消费组成员还在不在。 同一条 --describe 输出里看 CONSUMER-IDHOST。如果分区的 owner 是空的,或者消费组状态在 PreparingRebalanceCompletingRebalance 之间反复跳,那就是消费者被踢出组了,基本可以锁定单条消息处理超时。

第 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 万硬上限,后面还有几篇更邪门的。

系列都发在公众号 啫煲捞饭,同名,搜一下就能找到。都是一手生产环境的坑和真实数字,不写八股。

相关推荐
java1234_小锋1 天前
【免费】基于Spark实时交通流量分析与拥堵预测系统(Java版本+可视化大屏+Kafka+SpringBoot+Vue3) 锋哥原创出品,必属精品
java·大数据·spark·kafka·实时交通流量分析与拥堵预测
敲个大西瓜1 天前
Kafka一百道核心面试题
kafka
后端观测站1 天前
第三篇:消息队列如何保证消息可靠性?
kafka·java-rocketmq
布鲁飞丝2 天前
kafka 副本集设置和理解
分布式·kafka
香菜TTT3 天前
Kafka_深度解析_从架构原理到生产实践
分布式·架构·kafka·linq
java1234_小锋3 天前
【免费】基于Spark实时金融交易风险监控与预测系统(Java版本+可视化大屏+Kafka+SpringBoot+Vue3) 锋哥原创出品,必属精品
大数据·spark·kafka·金融交易风险监控与预测
梦想画家3 天前
FilePulse:Kafka Connect 的“智能文件网关”,重塑数据接入新范式
kafka·文件连接器·filepulse
Devin~Y3 天前
从本地生活电商到 AI RAG:互联网大厂 Java 面试场景完整实战
java·spring boot·redis·elasticsearch·spring cloud·kafka·rag
斯普润布特3 天前
Kafka KRaft 三节点 ARM64 Docker 部署
分布式·kafka