先算「能不能追上」,再决定怎么处置
看到 lag 上涨,第一反应通常是「加消费者」。但加消费者只对一种情况有效:瓶颈在单个消费者的处理能力。如果瓶颈在下游数据库、在分区数、在 CPU,加机器只会把队列从 Kafka 搬到进程内存里,延迟照旧。
先把三个量写出来:
| 符号 | 含义 | 怎么取 |
|---|---|---|
| L | 当前积压 | LEO − committed,见 5.1 |
| λ | 上游到达速率 | 生产端发送速率,按秒统计 |
| μ | 单组消费速率 | 已提交位移的增长斜率 |
追平时间:
T = L / (μ - λ) 当 μ > λ
追不上 当 μ <= λ
三个结论直接决定动作:
- μ > λ 且 T 可接受:什么都不用做,等它追平;这时候扩容只是让 T 变小。
- μ > λ 但 T 不可接受:扩容或提高 μ,前提是还有可用的并行度。
- μ ≤ λ:永远追不上。要么提高 μ,要么降级生产(限流),要么丢弃一部分(跳过)。加消费者只有在能真正提高 μ 时才算数。
举一组本机测到的数字。消费端单实例的稳定处理能力约 4300 条/秒(见下一节的曲线),如果上游持续写入 5000 条/秒,积压只会涨,加一个消费者能不能救取决于新消费者能否真的并行。如果所有消费者共享同一个下游数据库,μ 不会因为多一个进程而变大。
实验:造 9 万条积压
环境同前:Temurin 25.0.5+8、kafka-clients 4.3.0、apache/kafka:4.3.0 单节点 KRaft,2026-10-29。单次运行,随调度波动,不作基准。
主题 library.loan.created.backlog,3 分区。用官方压测工具灌 9 万条:
/opt/kafka/bin/kafka-producer-perf-test.sh \
--bootstrap-server 127.0.0.1:9092 \
--topic library.loan.created.backlog \
--num-records 90000 --record-size 100 --throughput -1 \
--command-property acks=all
输出:
90000 records sent, 108695.652174 records/sec (10.37 MB/sec), 71.94 ms avg latency, 344.00 ms max latency, 76 ms 50th, 102 ms 95th, 111 ms 99th, 114 ms 99.9th.
三个分区的 LEO 分别是 30784、29008、30208,合计 90000。
曲线用常驻 JVM 的 AdminClient 每秒采一次,而不是 shell 循环调 kafka-consumer-groups.sh:后者单次调用在本机要 3.6 秒(新起 JVM + AdminClient),曲线分辨率会掉到 4 到 6 秒,看不出启动阶段。采样器每轮做两件事:
Map<TopicPartition, OffsetAndMetadata> committed =
admin.listConsumerGroupOffsets(group).partitionsToOffsetAndMetadata().get();
Map<TopicPartition, OffsetSpec> specs = new HashMap<>();
committed.keySet().forEach(tp -> specs.put(tp, OffsetSpec.latest()));
Map<TopicPartition, ListOffsetsResult.ListOffsetsResultInfo> ends =
admin.listOffsets(specs).all().get();
每个分区算 ends.get(tp).offset() - committed.get(tp).offset(),求和就是 committed 口径的 lag。
路径一:加消费者
先跑 1 个消费者,max.poll.records=100,每条处理 200 微秒,每 1000 条提交一次。曲线摘录:
t=0.0 lag=0 committed=0 (组还没建好)
t=4.3 lag=86000 committed=4000
t=10.3 lag=57984 committed=32016
t=16.4 lag=28968 committed=61032
t=22.5 lag=952 committed=89048
t=24.5 lag=0 committed=90000
提交位移每秒增长 4000 到 5000 条,整段平均约 4260 条/秒;端到端 90000 条用了 24.1 秒,平均 3741 条/秒。两个数字差在启动:前 4.3 秒组还不存在,消费者在加入组、分配分区、拉第一批数据,这期间 lag 一动不动。讨论「追平时间」时要区分端到端速率和稳态速率,前者包含启动税,后者才是扩容决策的依据。
同样的积压,换成 3 个消费者同组:
t=0.0 lag=0 committed=0 (组还没建好)
t=7.5 lag=81000 committed=9000
t=10.7 lag=41784 committed=48216
t=13.9 lag=5568 committed=84432
t=17.0 lag=0 committed=90000
稳态斜率约 8500 条/秒,端到端 16.8 秒,平均 5360 条/秒。三个消费者各自的日志:
done processed=30208 elapsedSec=16.8 rate=1796/s
done processed=29008 elapsedSec=16.5 rate=1755/s
done processed=30784 elapsedSec=16.8 rate=1828/s
单看每个消费者只有 1800 条/秒,比单实例的 5000 条/秒低得多。原因很直接:本机只有 4 vCPU,三个消费者加 broker、采样器抢同一批核,单实例的处理速度被摊薄。这就是「加消费者」的第一层天花板:并行度受宿主机 CPU 限制,不是加一个就多一份算力。
第二层天花板是分区数。给 3 分区主题启动 4 个消费者,--members --verbose 的输出:
CONSUMER-ID #PARTITIONS CURRENT-ASSIGNMENT
backlog-...-dabbf3a4 0 -
backlog-...-c8495338 1 library.loan.created.backlog:2
backlog-...-7afcbaa8 1 library.loan.created.backlog:0
有一个成员分到 0 个分区。它照样占着一个 JVM、照样发心跳,但不消费任何数据。另一个独立实验里,4 个消费者之一日志全程是:
progress processed=0 rate=0/s lag=-1
progress processed=0 rate=0/s lag=0
同组消费者数超过分区数,多出来的是纯开销。 扩容前先看分区数,不够就先扩分区(但扩分区会破坏 key 到分区的映射,见 10.3)。
第三层天花板在下游。如果每条记录都要写同一个数据库,消费者再多也共享同一个连接池和同一把行锁,μ 不会线性增长。判断方法很简单:加消费者后看 μ 的斜率,如果没有明显上升,瓶颈就不在消费进程里。
路径二:提高批量
把 max.poll.records 从 100 提到 1000,提交间隔从 1000 提到 5000,其他不变。曲线:
t=5.4 lag=85000 committed=5000
t=11.7 lag=59584 committed=30416
t=17.9 lag=29168 committed=60832
t=23.1 lag=3752 committed=86248
t=25.2 lag=0 committed=90000
整段平均约 4290 条/秒,端到端 26.1 秒,平均 3454 条/秒。这一轮不但没变快,还比单消费者那轮慢。 这不是批量没用,而是批量省下的是固定开销,而这一轮的成本几乎全在「每条 200 微秒」的处理上,固定开销占比太小,差异被机器负载的波动淹没了。
为了把固定开销单独放大,再做一个没有处理成本的对照:30 万条记录,processMicros=0,其他参数相同。
batch=100 done processed=300000 elapsedSec=7.7 rate=38874/s
batch=1000 done processed=300000 elapsedSec=6.8 rate=43862/s
批量放大 10 倍,吞吐从 38874 提到 43862,约 1.13 倍。这就是批量的真实作用域:它削掉的是每次 poll、每次提交、每批序列化的固定成本,不是每条记录的业务成本。 当单条处理很轻(纯转发、纯内存计算)时,批量能带来百分之十几的提升;当单条处理很重(一次数据库写入、一次远程调用)时,批量几乎无效。
所以「提高批量」适合作为低成本的顺手优化,不适合当作积压急救的主手段。真正决定 μ 的是单条处理耗时和并行度。
路径三:跳过
当积压里的数据已经过期、或者下游可以从别处重建,跳过是最快的手段。实现上就是把组位移直接推到各分区末尾:
Map<TopicPartition, Long> end = new HashMap<>();
admin.listOffsets(specs).all().get()
.forEach((tp, info) -> end.put(tp, info.offset()));
try (KafkaConsumer<String, String> consumer = new KafkaConsumer<>(props)) {
consumer.assign(partitions);
Map<TopicPartition, OffsetAndMetadata> before =
consumer.committed(new HashSet<>(partitions));
Map<TopicPartition, OffsetAndMetadata> commits = new HashMap<>();
long skipped = 0;
for (TopicPartition tp : partitions) {
long current = before.containsKey(tp) ? before.get(tp).offset() : 0L;
long leo = end.get(tp);
skipped += Math.max(0, leo - current);
commits.put(tp, new OffsetAndMetadata(leo));
System.out.printf("partition=%d before=%d leo=%d discard=%d%n",
tp.partition(), current, leo, leo - current);
}
consumer.commitSync(commits);
System.out.printf("skip done skippedTotal=%d%n", skipped);
}
实验里先让一个消费者处理一部分,再执行跳过。跳过前:
PARTITION CURRENT-OFFSET LOG-END-OFFSET LAG
0 9472 30784 21312
1 8528 29008 20480
2 0 30208 30208
跳过输出:
partition=0 before=9472 leo=30784 discard=21312
partition=1 before=8528 leo=29008 discard=20480
partition=2 before=0 leo=30208 discard=30208
skip done group=libraryd-backlog-d2 skippedTotal=72000
跳过后 LAG 全部为 0。被丢弃的 72000 条不会回来,除非你记下了范围并手动回放。
这里有一个真实的坑。第一次执行跳过时,消费者刚被 kill -TERM,组里还留着它的成员记录,提交直接失败:
Offset commit failed on partition library.loan.created.backlog-2 at offset 30208:
The coordinator is not aware of this member.
org.apache.kafka.clients.consumer.CommitFailedException: Commit cannot be completed since the group has already rebalanced
等 50 秒、组变成 Empty 之后重试,同样的代码成功。原因是组里还有活跃成员时,非成员用 generationId=-1 提交会被协调器拒绝;必须等成员过期(默认 session.timeout.ms=45000)或消费者优雅退出。跳过操作的第一步是确认组已空,不是直接改位移。
跳过的业务前提,比技术操作重要得多:
| 前提 | 不满足的后果 |
|---|---|
| 被跳过的数据可以从别处重建 | 数据永久丢失 |
| 下游能接受这段状态缺失 | 投影长期不一致 |
| 跳过的范围被记录下来 | 无法回放、无法审计 |
| 有对账任务兜底 | 不一致没人发现 |
| 相关方知情并同意 | 「系统自己把数据扔了」 |
技术上能跳,不等于业务上该跳。跳过是把「消息延迟」换成了「数据缺失」,这笔交易必须由业务方签字。
降级:既不扩容也不丢数据
降级是第三条路:消息照常消费,但砍掉每条记录上的非核心工作,把 μ 提上来。
public void handle(LoanCreated event) {
projection.apply(event); // 核心:必须执行
if (mode == Mode.FULL) {
notifier.send(event); // 可降级:通知可以晚点补
recommender.update(event); // 可降级:推荐可以重算
}
}
三个判断标准:
- 这条副作用能不能事后补算? 能补的才允许降级,不能补的(扣款、发券)必须保留。
- 降级期间的数据够不够重建? 只写核心投影、丢掉通知,事后还能从事件重放补通知;如果连事件都没存,就补不回来。
- 降级开关谁来关? 必须有明确的恢复条件和负责人,否则「临时降级」会变成永久。
降级不会减少 lag 的绝对值,但它能把 μ 从「追不上」拉到「追得上」。相比跳过,它不丢数据;相比扩容,它不依赖分区和 CPU。三者不是互斥的,常见组合是「先降级稳住 μ,再加消费者,最后把降级期间欠的副作用补回来」。
生产端限流也属于降级的一种:把 λ 压到 μ 以下,给消费端争取时间。这需要生产端配合,通常是最难推动、但最有效的手段。
处置顺序
把上面的结论收成一张决策表:
| 观察 | 判断 | 动作 |
|---|---|---|
| μ > λ,T 可接受 | 假警报 | 不处理,确认曲线会自然回落 |
| μ > λ,T 太长 | 有并行空间 | 加消费者,先确认分区数和 CPU |
| μ ≤ λ,下游是瓶颈 | 加消费者无效 | 降级非核心副作用,或限流生产 |
| 分区已用满 | 加消费者无效 | 扩分区(注意 key 映射)或提高单实例 μ |
| 数据已过期 / 可重建 | 不需要追 | 跳过,记录范围,事后对账 |
| 不确定 | 先别动 | 采样 10 分钟,看 λ 和 μ 的斜率 |
一个反直觉但重要的原则:积压本身不是故障,追不上才是。 一个稳定在 10 万条的 lag,只要 μ > λ、端到端延迟在 SLO 内,就不需要处置。反过来,lag 只有 1000 条但 μ < λ,它迟早会涨到不可收拾。看斜率,不看绝对值。
回到 libraryd
libraryd 的借阅事件在开学季会集中爆发。处置预案可以写成三段:
- 先看
library.loan.created的 committed 口径 lag 和提交位移斜率,算出 T; - T 超过借阅确认的 SLO,先降级推荐和通知,把核心投影的 μ 拉起来;
- 仍追不上再扩消费者,扩之前确认主题分区数大于消费者数,且数据库连接池有余量。
跳过只留给一种场景:这批事件对应的借阅已经结束、投影可以从借阅主表全量重建,并且运维记录下了跳过的分区与位移区间。
下一节换一个角度:不追积压,而是从源头控制消费速率,让下游永远不过载。
阅读导航:上一节 5.1 lag 的三种口径与误读 | 下一节 5.3 消费速率控制与背压
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。