停机之后,只有两种结局
一个三副本的 topic,其中一个 follower 停机半小时。期间生产没有停,日志涨了几百 MB。现在把 broker 拉起来。
接下来发生的事只有两种:
- 接着追:follower 本地日志还在,从自己的 LEO 继续向 leader fetch,只补落后的部分;
- 重建:本地日志没法接着用了,把本地段丢掉,从 leader 当前的 log start 重新拉。
两种结局的代价差一个数量级,而且从运维命令上不一定看得出来。这一节把两种情形都实测一遍,再把限流参数跑通。
实验 topic 是 d1.rebuild,1 分区 3 副本,初始 Leader: 2 Replicas: 2,3,1。所有写入都用 kafka-producer-perf-test.sh,1 KB × 10 万条,约 99 MiB。
追上和重建的区别
Kafka 的 follower 没有单独的「重建模式」,它永远在做同一件事:向 leader 发 fetch,从自己的 LEO 开始要数据。区别在于 leader 怎么回答。
| 情形 | follower 的 LEO 位置 | leader 的响应 | 结果 |
|---|---|---|---|
| 普通追赶 | 在 leader 的 log start 之后 | 从 LEO 开始返回后续批次 | 保留本地日志,只补缺口 |
| 重建 | 在 leader 的 log start 之前 | OFFSET_OUT_OF_RANGE,并告知新的起始位移 | 丢弃本地段,从 log start 重拉 |
| 截断 | 本地有 leader 不承认的尾部 | 返回截断点 | 先截断再追 |
第二种为什么会发生?因为 leader 的 log start 会前进:保留策略删了老段、kafka-delete-records.sh 手工删了记录、或者 follower 停机太久,它本地最后一条记录已经被 leader 清理掉了。此时 follower 想从自己的 LEO 继续,leader 只能说「那个位移已经不存在了」。
重建的代价不是「多拉一点」,而是先丢掉本地已有的数据,再从更晚的位置重拉。如果 follower 停机前有 300 GB 日志、leader 的 log start 前进了 290 GB,重建后本地只剩 10 GB 有效数据——原来那 290 GB 白存了。
replica.lag.time.max.ms 管的是「什么时候踢出去」
在讲追赶之前,先把这个参数和 ISR 的关系理清。
replica.lag.time.max.ms 默认 30000(本机 --all 输出确认),含义是:follower 超过这个时间没有向 leader 发起有效 fetch,leader 就把它移出 ISR。
它和 13.2 里看到的两种 ISR 收缩不是一回事:
| 触发方式 | 收缩耗时 | 走的是哪个机制 |
|---|---|---|
docker stop(优雅关闭) | 250 毫秒内 | broker 主动注销 |
docker kill(SIGKILL) | 约 7 秒(本机实测) | broker.session.timeout.ms=9000 |
| follower 活着但复制慢 | 约 30 秒 | replica.lag.time.max.ms |
第三种才是这个参数的地盘:进程还在、心跳还在,但 fetch 跟不上。生产上遇到「ISR 莫名其妙少了一个但机器没挂」,通常就是这一条。
一个容易被忽略的限制:replica.lag.time.max.ms 不能动态修改。 本机尝试用 kafka-configs.sh 改成 10 秒:
kafka-configs.sh --bootstrap-server mq-d1:9092 \
--alter --entity-type brokers --entity-name 3 \
--add-config replica.lag.time.max.ms=10000
本机返回:
Caused by: org.apache.kafka.common.errors.InvalidRequestException:
Cannot update these configs dynamically: [replica.lag.time.max.ms]
只能改 server.properties 后重启。这一点在第 4 章提过,这里再确认一次,因为它直接影响「重建窗口内 ISR 会不会被反复踢出再加入」的判断。
follower 什么时候重新加入 ISR?当它持续 fetch 并追到 leader 的 LEO 附近,leader 会把它加回来。本机在限流实验里观察到的加入时刻很干脆:日志大小与 leader 一致的同一秒,ISR 从 1,2 变成 1,2,3。
实测一:不限流追赶
停掉 mq-d3,写 10 万条,再启动:
docker stop mq-d3
kafka-producer-perf-test.sh --topic d1.rebuild \
--num-records 100000 --record-size 1024 --throughput -1 \
--producer-props bootstrap.servers=mq-d1:9092 acks=all
docker start mq-d3
写入完成后 leader 的 LEO 是 100000,ISR 是 1,2。启动 d3 后,用 kafka-log-dirs.sh 轮询它的副本大小:
kafka-log-dirs.sh --bootstrap-server mq-d1:9092 \
--describe --topic-list d1.rebuild
本机实测(Temurin 25.0.5+8 / Kafka 4.3.0 KRaft 三节点 / 2026-10-29):
12:49:03 docker start mq-d3
12:49:05 broker 3 尚未出现在 log-dirs 结果里
12:49:08 broker 3 size=103706687 字节(约 98.9 MiB),lag 字段=0
从 docker start 算起 5.2 秒
5.2 秒追平 99 MiB。 这个数字包含 broker 启动(约 2–3 秒)和日志追平两部分,是单次运行,随调度波动,不作基准。能给出的结论只有相对量级:本地 Docker 网络下,百 MiB 级的落后可以在数秒内追完。
注意这里的 5.2 秒是「日志大小与 leader 一致」,不是「ISR 已恢复」。两者在本机不是同一个时刻,判断时要分开看,后文会解释为什么。
offsetLag 字段会骗人
kafka-log-dirs.sh 的输出里有一个 offsetLag 字段,看起来正是为这个场景准备的。但本机实测发现,副本掉出 ISR 之后,这个字段可能是 0。
限流实验进行到一半时,同时查 leader 和 follower 的大小:
12:53:01 broker 2(leader) size=296.7 MiB
broker 3(follower)size=198.3 MiB offsetLag=0 ISR=[1,2]
follower 落后将近 100 MiB,offsetLag 仍然是 0。
这个坑的后果很直接:如果只看 offsetLag,会以为副本已经追平,提前把限流撤掉或者提前重启下一台机器。正确的判据是两条一起看:
kafka-topics.sh --describe里该副本是否回到了 ISR;kafka-log-dirs.sh里 follower 与 leader 的size是否接近。
本机这次就是先被 offsetLag=0 误导了一次:第一次限流实验 8.3 秒后读到 0,以为追平了,再看 size 才发现只拉了 46 MiB。
实测二:限流追赶
真实集群里,一个落后几百 GB 的 broker 全速追赶会占满网卡和磁盘 IO,把正常读写拖慢。所以需要限流。
限流要同时配两层:
# 第一层:每个 broker 的复制速率上限(字节/秒)
for i in 1 2 3; do
kafka-configs.sh --bootstrap-server mq-d1:9092 \
--alter --entity-type brokers --entity-name $i \
--add-config 'follower.replication.throttled.rate=1048576,leader.replication.throttled.rate=1048576'
done
# 第二层:这个 topic 的哪些副本要被限流
kafka-configs.sh --bootstrap-server mq-d1:9092 \
--alter --entity-type topics --entity-name d1.rebuild \
--add-config 'leader.replication.throttled.replicas=*,follower.replication.throttled.replicas=*'
两层的分工:
follower.replication.throttled.rate设在 follower 上,限制它拉取的速度;leader.replication.throttled.rate设在 leader 上,限制它向外推的速度;- topic 上的
*.replication.throttled.replicas决定哪些副本参与限流,*表示全部。
只配速率、不配副本列表,限流不会生效;只配副本列表、不配速率,同样不会生效。两边都要有。
本机验证配置已写入:
Dynamic configs for broker 3 are:
follower.replication.throttled.rate=1048576
leader.replication.throttled.rate=1048576
Dynamic configs for topic d1.rebuild are:
leader.replication.throttled.replicas=*
follower.replication.throttled.replicas=*
然后停 d3、再写 10 万条(LEO 到 200000),启动 d3,每 12 秒采样一次 broker 3 的日志大小:
12:53:01 t= 1.7s b3=198.3 MiB b2=296.7 MiB isr=[1,2]
12:53:16 t= 16.7s b3=213.2 MiB rate=0.99 MiB/s
12:53:31 t= 31.6s b3=228.1 MiB rate=1.00 MiB/s
12:53:45 t= 46.2s b3=242.0 MiB rate=0.96 MiB/s
12:54:00 t= 61.2s b3=256.9 MiB rate=0.99 MiB/s
12:54:15 t= 75.8s b3=271.8 MiB rate=1.02 MiB/s
12:54:30 t= 90.5s b3=285.8 MiB rate=0.95 MiB/s
12:54:44 t=105.1s b3=296.7 MiB isr=[1,2,3]
稳态速率稳定在 0.95–1.02 MiB/s,和配置的 1048576 字节/秒吻合。从 docker start 到 ISR 恢复共 161 秒,搬运约 195 MiB。两个数字都是单次运行,随调度波动,不作基准;这里可信的是速率与配置值的吻合,不是具体的秒数。
第一次为什么没限住
第一次做这个实验时,我把配置写入和停机只隔了 4 秒。broker 重启后要先追上元数据日志、应用这条动态配置,中间有几秒窗口:前 5 秒它仍以约 9 MiB/s 拉了 46 MiB,之后才降到 1 MiB/s。
这不是限流失灵,是配置生效有时差。生产上的做法是:在停机窗口之前就把限流配好,并确认目标 broker 已经应用(查 kafka-configs.sh --describe 不够,那只是元数据里的值;要观察实际速率),再让它下线。或者在重启后先观察几秒,确认速率已经降下来。
限流参数还有哪些
除了普通复制,Kafka 还有两类数据移动可以限流:
| 场景 | 参数 | 配在哪 |
|---|---|---|
| follower 追 leader | follower.replication.throttled.rate | 目标 broker |
| leader 推给 follower | leader.replication.throttled.rate | leader broker |
| 副本在同一 broker 的日志目录间移动 | --replica-alter-log-dirs-throttle | kafka-reassign-partitions.sh 命令参数 |
| 分区在 broker 间迁移 | --throttle | kafka-reassign-partitions.sh 命令参数 |
第三行就是常说的 replica-alter 限流。本机 kafka-reassign-partitions.sh --help 里的原文:
--replica-alter-log-dirs-throttle <Long: replicaAlterLogDirsThrottle>
The movement of replicas between log directories on the same broker
will be throttled to this value
它和 kafka-configs.sh 配的复制限流是两套东西:前者管「同一台机器换磁盘目录」,后者管「网络上的副本同步」。给 broker 加了 follower.replication.throttled.rate,不会限制日志目录迁移;反之亦然。
实验做完要撤掉限流,否则正常复制也会一直被压在 1 MiB/s:
for i in 1 2 3; do
kafka-configs.sh --bootstrap-server mq-d1:9092 \
--alter --entity-type brokers --entity-name $i \
--delete-config 'follower.replication.throttled.rate,leader.replication.throttled.rate'
done
kafka-configs.sh --bootstrap-server mq-d1:9092 \
--alter --entity-type topics --entity-name d1.rebuild \
--delete-config 'leader.replication.throttled.replicas,follower.replication.throttled.replicas'
本机删除后 --describe 只剩空行,ISR 保持 1,2,3。
实测三:真正的重建
前面的追赶都保留了本地日志。要触发重建,得让 leader 的 log start 越过 follower 的 LEO。
步骤:停 d3,再写 10 万条(LEO 到 400000),然后把 leader 上 350000 之前的记录删掉,最后启动 d3。
docker stop mq-d3
kafka-producer-perf-test.sh --topic d1.rebuild \
--num-records 100000 --record-size 1024 --throughput -1 \
--producer-props bootstrap.servers=mq-d1:9092 acks=all
printf '{"version":1,"partitions":[{"topic":"d1.rebuild","partition":0,"offset":350000}]}' \
> /tmp/del.json
kafka-delete-records.sh --bootstrap-server mq-d1:9092 --offset-json-file /tmp/del.json
本机删除结果:
Records delete operation completed:
partition: d1.rebuild-0 low_watermark: 350000
此时 leader 的 LEO 是 400000,log start 是 350000,只剩 5 万条(约 49 MiB)。而 d3 本地停在 300000。启动 d3:
docker start mq-d3
本机从 broker 3 的 server.log 里摘到的三行,把重建过程说得很清楚:
[2026-10-29 04:55:42,771] INFO [Partition d1.rebuild-0 broker=3] Unexpected offset in append
to d1.rebuild-0. First offset 349995 is less than log start offset 350000. Since this is the
first record to be appended to the follower's log, will start the log from offset 349995.
[2026-10-29 04:55:42,772] INFO [LocalLog partition=d1.rebuild-0, dir=/tmp/kafka-logs/d1.rebuild-0]
Deleting segments as part of log truncation: LogSegment(baseOffset=350000, size=0, ...)
[2026-10-29 04:55:42,796] INFO [UnifiedLog partition=d1.rebuild-0, dir=/tmp/kafka-logs]
Incremented log start offset to 350000 due to leader offset increment
第一行里的 349995 不是笔误:删除位移 350000 落在某个批次的中间,这个批次的 base offset 是 349995。follower 的日志是空的,于是从批次起点 349995 开始建段;对外的 log start 仍然是 350000。
重建完成后看文件:
-rw-r--r-- 1 appuser appuser 51858539 00000000000000349995.log
只有一个约 49 MiB 的段,base offset 349995。原来那个 311,120,061 字节(约 296.7 MiB)、base offset 为 0 的段去哪了?
本机在 60 秒后看到它被物理删除:
[2026-10-29 04:56:42,746] INFO [LocalLog partition=d1.rebuild-0, ...]
Deleting segment files LogSegment(baseOffset=0, size=311120061, ...)
60 秒正好是 log.segment.delete.delay.ms 的默认值(本机 --all 确认 60000)。段文件不是立刻删除,而是先改名成 .deleted,等延迟到期再回收。所以重建刚完成时,du 看到的磁盘占用会偏高,这是正常的。
从 docker start 到重新拉齐,本机第一次采样(9 秒后)大小已经到了 49.5 MiB;随后查 ISR,已回到 1,2,3。这个数字同样是单次运行,不作基准。
重建和追赶的差别在磁盘上,不在耗时上。 这次重建只拉了 49 MiB,看起来比前面追赶 99 MiB 还快;但如果 leader 的 log start 只前进了 1 GB、follower 本地有 300 GB,重建丢掉的就是 300 GB 里的一大半。
不限流会怎样
用算术说明代价,不给假的带宽数字。
假设一个 broker 落后 1 TiB,需要重新同步:
- 限流 1 MiB/s:1,048,576 MiB ÷ 1 MiB/s ≈ 1,048,576 秒 ≈ 12.1 天;
- 限流 50 MiB/s:约 5.8 小时;
- 不限流、链路能跑 500 MiB/s:约 35 分钟,但这 35 分钟里网卡和磁盘带宽基本被复制流量吃满。
最后一行就是问题所在。Kafka 的生产写入、消费读取、副本复制共用同一块网卡和同一组磁盘。一个 broker 全速追赶时,同机其他分区的 produce 延迟会明显上升,严重时 ISR 因为 fetch 超时继续收缩,触发更多复制,形成正反馈。
所以限流不是「让重建慢一点」的保险,而是把重建的带宽预算固定下来,让正常读写有下限。代价是重建窗口变长:1 TiB 的落后在 1 MiB/s 下要 12 天,这显然不可接受。实际取值要在「业务能忍受的追赶时长」和「业务能忍受的性能下降」之间找平衡,常见做法是按网卡带宽的 30%–50% 设上限,再按数据量反推窗口。
还有一点:限流是每个 broker 的总预算,不是每个分区。一个 broker 上有 100 个分区同时追,1 MiB/s 是所有分区共享的,不是每个分区 1 MiB/s。
操作清单
停机前:
- 记录目标 broker 上每个分区的 LEO 与 leader 的 LEO,估算落后量;
- 如果落后量超过「业务能忍受的全速追赶时间」,提前配好两层限流;
- 确认配置已生效(观察实际速率,不只看
--describe)。
重启后:
- 用
kafka-topics.sh --describe看 ISR 是否恢复; - 用
kafka-log-dirs.sh对比 follower 与 leader 的size; - 不要只看
offsetLag,本机实测它对未进 ISR 的副本可能报 0; - 如果触发了重建,确认新段的 base offset 和 leader 的 log start 对得上。
恢复后:
- 撤掉 broker 与 topic 两层的限流配置;
- 等
--under-replicated-partitions输出为空; - 观察 60 秒后的磁盘回收(
log.segment.delete.delay.ms到期)。
三个实践判断
第一,先判断是追赶还是重建,再估算时间。 两者的数据量可能差一个数量级。判断依据是 follower 的 LEO 与 leader 的 log start 的相对位置,不是「停机了多久」。
第二,限流要提前配,并且两层都配。 broker 级的速率和 topic 级的副本列表缺一不可;配置生效有几秒到几十秒的时差,停机后再配会有一段全速突发。
第三,replica.lag.time.max.ms 只能重启改。 它决定「复制慢多久会被踢出 ISR」,不能动态调整。规划重建窗口时如果发现 30 秒不够,只能改配置文件并安排重启,不能在线调。
小结
三句话收束这一节:
- follower 永远在 fetch,区别在于 leader 认不认它的 LEO。 LEO 在 log start 之后是追赶,之前就是重建;重建会丢弃本地段,代价不止是流量。
- 限流是两层配置、一个总预算。
leader/follower.replication.throttled.rate配上 topic 的*.replication.throttled.replicas才生效;本机实测稳态 0.95–1.02 MiB/s。 offsetLag不能单独作为追平判据。 本机在落后近 100 MiB 时它仍报 0,必须结合 ISR 和size一起看。
到这一节,计划内的重启和计划外的落后都覆盖了。下一章转向真正的故障:URP、离线分区、脑裂、磁盘满、fd 耗尽——每种故障在指标上长什么样,以及怎么用几条命令缩小范围。
阅读导航:上一节 13.2 滚动重启与最小可用 | 下一节 14.1 故障图谱
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。