LAG 这个数字,先别急着信
运维面板上最醒目的数字是消费组的 LAG。它涨了,说明有积压;它降了,说明在追。但「涨了」背后至少有三种完全不同的故障,处置方式互相冲突:
- 生产端突然提速,消费端能力没变;
- 消费端变慢或卡住,生产端没变;
- 分区倾斜,总量看起来还行,某个分区已经单独爆掉。
还有一种更隐蔽的情况:消费者其实处理得很快,只是位移提交跟不上,LAG 也会涨。这时候加消费者没用,改提交方式才有用。
这一节从一个可控的积压实验开始,把「一个数字」拆成三列、三步、两种卡死方式。
先把三列读对
kafka-consumer-groups.sh --describe 的每个分区一行,关键三列是:
| 列 | 含义 | 谁维护 |
|---|---|---|
CURRENT-OFFSET | 该分区已提交的消费位移 | 消费者提交,存在 __consumer_offsets |
LOG-END-OFFSET | 该分区当前日志末端位移(LEO) | broker |
LAG | LOG-END-OFFSET - CURRENT-OFFSET | 工具现算 |
这三个量的关系在 6.1 节讲过,这里只强调两个容易忽略的点。
第一,LAG 用的是「已提交位移」,不是「消费者当前处理到哪」。 如果消费者关闭了自动提交、又在处理完一批之后才手动提交,那么一批处理到一半时,LAG 包含了「已经拉下来但还没提交」的消息。这部分消息既不在积压里,也不在可重复消费的范围内,它是「在途」。
第二,LOG-END-OFFSET 是 LEO,不是 HW。 对普通 read_uncommitted 消费者,两者通常相等;有未提交事务时,LEO 会大于消费者可见的末端。用 LAG 判断积压时,要知道自己看的是哪一个口径。
--describe 还会在 CONSUMER-ID / HOST 两列显示成员信息。成员消失时这两列变成 -,但 CURRENT-OFFSET 和 LAG 仍然在——这是判断「消费者是否还活着」最快的方式,比看进程更可靠,因为它反映的是消费组协调器眼里的状态。
实验装置:一个限速消费者
要制造「生产快、消费慢」的积压,消费端必须可控。本机用一个最小的 Java 消费者,每拉一批就睡一会儿:
Properties p = new Properties();
p.put(ConsumerConfig.BOOTSTRAP_SERVERS_CONFIG,
"127.0.0.1:19241,127.0.0.1:19242,127.0.0.1:19243");
p.put(ConsumerConfig.GROUP_ID_CONFIG, group);
p.put(ConsumerConfig.KEY_DESERIALIZER_CLASS_CONFIG, StringDeserializer.class.getName());
p.put(ConsumerConfig.VALUE_DESERIALIZER_CLASS_CONFIG, StringDeserializer.class.getName());
p.put(ConsumerConfig.ENABLE_AUTO_COMMIT_CONFIG, "true");
p.put(ConsumerConfig.AUTO_COMMIT_INTERVAL_MS_CONFIG, "1000");
p.put(ConsumerConfig.MAX_POLL_RECORDS_CONFIG, "10");
p.put(ConsumerConfig.AUTO_OFFSET_RESET_CONFIG, "earliest");
try (KafkaConsumer<String, String> c = new KafkaConsumer<>(p)) {
c.subscribe(Collections.singletonList(topic));
long end = System.currentTimeMillis() + durationMs;
while (System.currentTimeMillis() < end) {
ConsumerRecords<String, String> rs = c.poll(Duration.ofMillis(500));
if (!rs.isEmpty()) {
Thread.sleep(180); // 每批 10 条睡 180ms,约 50 条/秒
}
}
}
生产端用官方压测工具限速到每秒 500 条:
docker exec mq-e1 /opt/kafka/bin/kafka-producer-perf-test.sh \
--topic e1.lag.demo --num-records 30000 --record-size 100 --throughput 500 \
--producer-props acks=all bootstrap.servers=mq-e1:9092,mq-e2:9092,mq-e3:9092
topic 是 6 分区 3 副本。消费组 e1-lag-g 先启动,等它就位后再启动生产者,然后每 5 秒采一次样。
采样:LAG 从 0 涨到 26821
第 0 次采样时,生产端刚启动、还没有消息落盘,六个分区全是 0。为可读性下面只保留前四列(省略 CONSUMER-ID / HOST / CLIENT-ID),数值原样:
GROUP TOPIC PARTITION CURRENT-OFFSET LOG-END-OFFSET LAG
e1-lag-g e1.lag.demo 0 0 0 0
e1-lag-g e1.lag.demo 1 0 0 0
e1-lag-g e1.lag.demo 2 0 0 0
e1-lag-g e1.lag.demo 3 0 0 0
e1-lag-g e1.lag.demo 4 0 0 0
e1-lag-g e1.lag.demo 5 0 0 0
约 7 秒后:
e1-lag-g e1.lag.demo 0 220 1056 836
e1-lag-g e1.lag.demo 1 0 689 689
e1-lag-g e1.lag.demo 2 148 1280 1132
e1-lag-g e1.lag.demo 3 0 0 0
e1-lag-g e1.lag.demo 4 0 662 662
e1-lag-g e1.lag.demo 5 0 527 527
约 59 秒后,生产结束(LEO 总和 30000),消费仍在继续:
e1-lag-g e1.lag.demo 0 514 6671 6157
e1-lag-g e1.lag.demo 1 264 5507 5243
e1-lag-g e1.lag.demo 2 488 5591 5103
e1-lag-g e1.lag.demo 3 1517 3524 2007
e1-lag-g e1.lag.demo 4 264 4649 4385
e1-lag-g e1.lag.demo 5 132 4058 3926
本机实测(Temurin 25.0.5+8 / kafka-clients 4.3.0 / Kafka 4.3.0 KRaft 三节点 / 2026-10-29)。从两组数据能直接算出速率:
- LEO 总和从 4214 涨到 30000,52 秒增加 25786,约 496 条/秒,与生产者设定的 500 条/秒吻合;
- 已提交位移总和从 368 涨到 3179,52 秒增加 2811,约 54 条/秒,与消费者每批 10 条睡 180ms 的估算吻合;
- LAG 总和从 3846 涨到 26821,增量约等于两者之差。
这就是「生产快、消费慢」的典型形态:LEO 和 CURRENT-OFFSET 都在动,只是前者更快。
三步判定法
拿到一个 LAG,不要直接猜原因,按下面三步各采一次样。
第一步:LOG-END-OFFSET 还在涨吗
隔 10 秒采两次,比较六个分区的 LEO 总和。
- 在涨:生产端还在写。可能是流量上涨、重试风暴、或某个生产者补数据。
- 不涨:积压是一次性的,消费端只要不挂就能追平。这时候重点看消费速率,而不是生产。
实验里 LEO 从 4214 涨到 30000,答案明确:生产在持续写入。
第二步:CURRENT-OFFSET 还在推进吗
同样隔 10 秒比较已提交位移总和。
- 在推进:消费者活着,只是速度不够。看单条处理耗时、下游依赖、
max.poll.records。 - 不动:消费者可能已经退出、被踢出组、卡在长事务里、或者提交失败。看
CONSUMER-ID是否为-。
实验里消费者退出后,采样立刻变成这样:
e1-lag-g e1.lag.demo 0 1364 6671 5307 - - -
e1-lag-g e1.lag.demo 1 264 5507 5243 - - -
e1-lag-g e1.lag.demo 2 488 5591 5103 - - -
e1-lag-g e1.lag.demo 3 1517 3524 2007 - - -
e1-lag-g e1.lag.demo 4 264 4649 4385 - - -
e1-lag-g e1.lag.demo 5 132 4058 3926 - - -
CURRENT-OFFSET 冻结在 4029 的总和上,CONSUMER-ID 变成 -。这时候 LAG 是 25971,但它不再变化——静止的 LAG 和持续增长的 LAG 是两种病。
第三步:各分区的 LAG 均衡吗
把六个分区的 LAG 排序。实验里的分布是 6157 / 5243 / 5103 / 4385 / 3926 / 2007,最大值是最小值的 3 倍左右。这属于轻度不均衡,原因在后面解释。
如果分布是 30000 / 0 / 0 / 0 / 0 / 0,那是倾斜,加消费者也没用:多出来的消费者分不到那个热分区,只能闲着。倾斜的判据不是「有没有差异」,而是「差异是否稳定且指向同一个 key/分区」。
倾斜实验:一个 key 打满一个分区
单独建一个 6 分区 topic,先用 60 条无 key 消息建立消费组位移,再写入 3000 条相同 key 的消息:
docker exec mq-e1 /opt/kafka/bin/kafka-console-producer.sh \
--bootstrap-server mq-e1:9092,mq-e2:9092,mq-e3:9092 \
--topic e1.skew.demo \
--property parse.key=true --property key.separator=:
输入 3000 行 book-hot:payload-N。本机实测(Kafka 4.3.0 KRaft 三节点 / 2026-10-29):
e1-skew-g e1.skew.demo 0 0 0 0
e1-skew-g e1.skew.demo 1 0 0 0
e1-skew-g e1.skew.demo 2 0 0 0
e1-skew-g e1.skew.demo 3 60 3060 3000
e1-skew-g e1.skew.demo 4 0 0 0
e1-skew-g e1.skew.demo 5 0 0 0
3000 条全部落在分区 3,其余分区 LAG 为 0。这是同 key 同分区的直接后果:分区器对同一个 key 做哈希,结果固定。
有意思的是基线那 60 条也全在分区 3。生产者没有指定 key,为什么不是均匀分布?因为 4.x 的默认分区器是「粘性分区器」:在攒批期间会把一批消息发往同一个分区,小批量、低并发的场景下看起来就像倾斜。这个现象在 3.3 节讲过,它提醒我们:看到倾斜先确认 key,没有 key 也可能倾斜。
消费者卡住与消费者慢,表现完全不同
「消费慢」和「消费者卡住」在 LAG 上看起来都是「不降」,但机制和处置相反。最典型的卡住方式是 max.poll.interval.ms 超时。
Kafka 的心跳线程在后台跑,所以消费者进程活着、心跳正常,不代表它在处理消息。协调器要求两次 poll() 之间的间隔不超过 max.poll.interval.ms(默认 300000,即 5 分钟)。超过之后,消费者会被移出消费组,分区被收回。
本机把参数压到 8 秒,消费一批后睡 12 秒,日志里出现:
[kafka-coordinator-heartbeat-thread | e1-evict2-g] WARN ... consumer poll timeout has expired.
This means the time between subsequent calls to poll() was longer than the configured
max.poll.interval.ms, which typically implies that the poll loop is spending too much
time processing messages. You can address this either by increasing max.poll.interval.ms
or by reducing the maximum size of batches returned in poll() with max.poll.records.
[kafka-coordinator-heartbeat-thread | e1-evict2-g] INFO ... sending LeaveGroup request
to coordinator 127.0.0.1:19243 ... due to consumer poll timeout has expired.
醒来后 poll() 返回,但分配已经空了:
joined assignment=[e1.lag.demo-4, e1.lag.demo-3, e1.lag.demo-5, e1.lag.demo-0, e1.lag.demo-2, e1.lag.demo-1] records=5
committed, now sleep 12000ms > max.poll.interval.ms=8000
poll after sleep records=0 assignment=[]
assignment after wakeup=[]
对比一下两种形态:
| 特征 | 消费者慢 | 被 max.poll.interval 踢出 |
|---|---|---|
| 进程 | 活着 | 活着 |
CONSUMER-ID | 有值 | 一段时间后变 - |
CURRENT-OFFSET | 缓慢推进 | 停止推进 |
| 客户端日志 | 处理耗时高 | consumer poll timeout has expired + LeaveGroup |
| 分区分配 | 保持 | 被收回,可能触发 rebalance |
| 处置 | 提高并行度/优化单条耗时 | 减小 max.poll.records 或增大 max.poll.interval.ms |
关键区别在位移是否还在动。只要 CURRENT-OFFSET 在推进,就不该按「卡死」处理;反过来,CONSUMER-ID 变成 - 而进程还在,几乎可以确定是被踢出了组。
还有第三种:处理快、提交慢
LAG 用的是已提交位移。如果消费者处理完一批后迟迟不提交,LAG 也会高,但消费者其实是健康的。
常见原因有三个:
- 关闭了自动提交,手动提交放在批次末尾,批次很大;
commitSync()被下游阻塞拖慢;- 提交请求本身失败重试(协调器负载高、网络抖动)。
判据是看消费者的 position 与 committed offset 的差。客户端指标里 records-lag-max 用的是 position,和 --describe 的 LAG 不是同一个口径。两者差得很大,说明问题在提交环节,不在处理环节。
这也解释了为什么「加消费者」有时完全无效:积压不在处理能力上,而在提交链路上,增加成员只会让 rebalance 更频繁。
处置动作对照
| 判定 | 处置 | 不要做 |
|---|---|---|
| 生产变快 | 扩容消费者到分区数上限;或对上游限流 | 盲目加消费者超过分区数 |
| 消费慢 | 优化单条耗时、批量写下游、提高 max.poll.records | 直接调大 max.poll.interval.ms 掩盖问题 |
| 被踢出组 | 减小 max.poll.records 或异步处理 | 只加 session.timeout.ms |
| 分区倾斜 | 改 key、加盐、自定义分区器 | 加消费者(分不到热分区) |
| 提交慢 | 改提交策略、缩短提交间隔 | 关闭自动提交却不补手动提交 |
| 消费者已退出 | 查进程、查 OOM、查部署 | 只看 LAG 不看 CONSUMER-ID |
三个常见误判
第一,LAG=0 不等于没有延迟。 LAG 只反映「还有多少没消费」,不反映「最新一条消息从生产到消费用了多久」。端到端延迟要看时间戳或监控埋点,不能看 LAG。
第二,LAG 不降不等于消费者没工作。 生产速率大于等于消费速率时,LAG 会持平甚至上涨,但消费者可能已经满负荷。判断要看 CURRENT-OFFSET 的增量,不是 LAG 的绝对值。
第三,总 LAG 会掩盖倾斜。 6 个分区总 LAG 3000,可能是每个 500,也可能是一个 3000、其余 0。前者加消费者有效,后者无效。看总量之前先看分布。
小结
这一节把 LAG 拆成了三步:
- LEO 是否增长——判断生产端是否还在加压;
- CURRENT-OFFSET 是否推进——判断消费者是慢、是停、还是被踢出组;
- 各分区是否均衡——判断是能力问题还是倾斜问题。
实验数据给出了三种清晰形态:生产快消费慢(LEO 496 条/秒、提交 54 条/秒)、消费者退出(位移冻结、CONSUMER-ID 为 -)、分区倾斜(3000 条全在一个分区)。再加上 max.poll.interval.ms 的驱逐日志,日常遇到的积压基本都能归到其中一类。
下一节换一个方向:如果问题不是「消费到哪」,而是「副本上的数据是否一致」,LAG 和 --describe 都帮不上忙,需要直接对比日志段。
阅读导航:上一节 14.1 故障图谱 | 下一节 14.3 数据不一致的取证
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。