follower 是拉,不是被推
很多人对副本同步的第一印象是「leader 写完把消息推给 follower」。Kafka 不是这样做的。
真正的方向相反:follower 主动向 leader 发 Fetch 请求,和消费者拉消息用的是同一类协议。leader 不维护每个 follower 的发送队列,也不主动推送,它只在收到 fetch 时从本地日志读出数据返回。这个选择带来两个后果:
- follower 的进度完全由自己的 fetch 循环驱动,leader 不需要为每个 follower 保存发送状态;
- follower 挂掉再回来,只需要从自己的 LEO 继续 fetch,不需要 leader 做特殊的「补发」流程。
一次复制的时间线大致是:
follower leader
│ FetchRequest(offset=LEO) │
├───────────────────────────────▶│
│ │ 从本地日志读数据
│ FetchResponse(records) │
│◀───────────────────────────────┤
│ 追加到本地日志,更新 LEO │
│ 下一轮 fetch │
acks=all 的含义就落在这条线上:leader 把消息写进自己的日志之后,还要等 ISR 里的 follower 通过 fetch 把这条消息拉走并追加成功,才向生产者返回。生产者多等的不是一次「推送」,而是 follower 的一轮「拉取 + 落盘」。
四个参数决定一次 fetch 的形状
broker 端控制复制 fetch 的参数都在 server.properties 里。本机从容器内的 server.log 读到的是默认值:
$ docker exec mq-c1 grep -nE 'replica.fetch|num.replica.fetchers|replica.lag.time' \
/opt/kafka/logs/server.log | head -8
428: replica.fetch.max.bytes = 1048576
429: replica.fetch.min.bytes = 1
431: replica.fetch.wait.max.ms = 500
427: replica.fetch.backoff.ms = 1000
435: replica.socket.receive.buffer.bytes = 65536
379: num.replica.fetchers = 1
433: replica.lag.time.max.ms = 30000
逐个说清楚它们管什么:
| 参数 | 默认值 | 作用 | 调错的后果 |
|---|---|---|---|
replica.fetch.max.bytes | 1 MB | 单次 fetch 每个分区最多带回的字节数 | 调得太小,大消息复制变慢;调得太大,一次响应占用更多内存 |
replica.fetch.min.bytes | 1 | 攒够多少字节才返回 | 默认 1 表示有数据就回,不等 |
replica.fetch.wait.max.ms | 500 | 没数据时最多等多久 | 不是每条消息固定加 500 ms,有数据时立即返回 |
replica.fetch.backoff.ms | 1000 | fetch 失败后的重试间隔 | 调大只会让落后副本追得更慢 |
num.replica.fetchers | 1 | 每个 broker 的副本拉取线程数 | 分区很多时单线程可能成为复制瓶颈 |
replica.lag.time.max.ms | 30000 | 超过多久没跟上就踢出 ISR | 与故障发现时间不是一回事,4.1 节已实测 |
最容易误解的是 replica.fetch.wait.max.ms。它和消费者的 fetch.max.wait.ms 是同一个思路:fetch 到达时如果 leader 没有新数据,就把这个请求挂起,最多等 500 ms;一旦有新消息写入,挂起的 fetch 会被立即完成。所以它影响的是「空闲时的轮询频率」,不是「每条消息的固定延迟」。
replica.fetch.max.bytes 则是吞吐上限的一部分。假设单条消息 1 MB、follower 每轮只能带回 1 MB,那么复制吞吐就受限于 fetch 的往返频率。生产环境里如果消息体很大,通常要同时调大这个值、message.max.bytes 和消费者的 fetch.max.bytes,否则会出现「生产者能写、follower 追得慢」的错位。
实验:同一负载下 acks=1 与 acks=all
实验用两节点集群,两个 broker 都是 broker,controller,topic 为 6 分区、RF=2、min.insync.replicas=1。生产者跑在容器内,避免宿主机到容器的网络变量:
docker exec mq-c1 sh -c 'env -u JMX_PORT /opt/kafka/bin/kafka-producer-perf-test.sh \
--topic c1.repl --num-records 200000 --record-size 1024 --throughput -1 \
--producer-props bootstrap.servers=127.0.0.1:9092 acks=all linger.ms=5 batch.size=16384'
注意容器内要用 127.0.0.1:9092。另外容器里设置了 JMX_PORT 给 broker 用,跑 CLI 时必须 env -u JMX_PORT,否则 CLI 自己的 JVM 会去抢同一个 JMX 端口然后启动失败——这是本机实际踩到的坑。
每种配置跑 20 万条 1 KB 消息,先跑两轮丢弃(JVM 预热、页缓存未热),然后成对跑三轮。本机实测(Temurin 25.0.5+8 客户端 / Kafka 4.3.0 KRaft 两节点 / 2026-10-29,单次运行,随调度波动,不作基准):
| 轮次 | acks=1 条/秒 | acks=1 MB/s | acks=all 条/秒 | acks=all MB/s | 倍数 |
|---|---|---|---|---|---|
| 1 | 110,742 | 108.15 | 47,226 | 46.12 | 2.34× |
| 2 | 63,755 | 62.26 | 29,317 | 28.63 | 2.17× |
| 3 | 47,608 | 46.49 | 37,044 | 36.18 | 1.29× |
三轮都是 acks=1 更快,但倍数从 2.34 倍到 1.29 倍不等。绝对值的波动更大(acks=1 从 4.7 万到 11 万条/秒),因为这台机器 load average 常年 70 以上,两个 broker 与生产者共享 4 个 vCPU。可以说的只有方向:在 RF=2 下,acks=all 稳定地比 acks=1 慢,因为多了一次到 follower 的复制往返与落盘等待。 具体慢多少,本机数据不足以给出可复用的系数。
延迟的差距比吞吐更稳定:
| 轮次 | acks=1 平均延迟 | acks=all 平均延迟 |
|---|---|---|
| 1 | 118.93 ms | 506.01 ms |
| 2 | 308.72 ms | 853.54 ms |
| 3 | 456.69 ms | 656.41 ms |
acks=all 的平均延迟是 acks=1 的 1.4 到 4.3 倍。这里的绝对值(几百毫秒)明显高于正常生产集群,原因是本机资源紧张,不能当作 Kafka 的延迟水平;有意义的是同一台机器上的相对关系。
为什么 acks=all 更慢:多等了一个人
把一次 acks=all 写入拆开,生产者等待的段落是:
- 生产者把批次发给 leader;
- leader 追加到本地日志(写页缓存即返回,是否刷盘由
flush.messages/flush.ms决定); - leader 把请求挂起,等 ISR 中所有 follower 的 fetch 把这条消息拉走;
- follower 追加成功,下一轮 fetch 带上新的 LEO;
- leader 确认 ISR 都到位,唤醒挂起的请求,返回生产者。
acks=1 在第 2 步之后就返回,省掉第 3 到 5 步。省掉的是「至少一个网络往返 + follower 的写入」。RF=2 时只有一个 follower,所以是等一个人;RF=3 时是等两个人,延迟取最慢的那个。
这也解释了一个常见现象:acks=all 的尾延迟对「最慢的 follower」非常敏感。三个副本里只要有一个所在机器磁盘抖动,生产者看到的就是那个抖动的延迟,而不是 leader 自己的延迟。
三种 acks 各自在等谁
把三种取值放在一张表里,等待对象的差别就清楚了:
| acks | leader 何时向生产者返回 | 掉一个副本会怎样 | 本节是否实测 |
|---|---|---|---|
0 | 请求发出后立即返回,不等任何确认 | 无法感知,可能直接丢 | 未测 |
1 | leader 本地追加成功即返回 | 若 leader 随后挂掉且未复制,消息丢失 | 实测 |
all | ISR 中所有副本都追加成功才返回 | 只要 ISR 不缩到 1,仍有一份以上 | 实测 |
acks=0 没有纳入本节的对照实验。它不需要等待任何响应,吞吐通常最高,但连「写没写进 leader」都不知道;它适合可以容忍丢失的日志、指标类数据,不适合借阅、支付这类事件。
副本数改变的是「等几个人」:
- RF=1 时没有 follower,
acks=all与acks=1几乎没有区别; - RF=2 时等 1 个 follower,本节测的就是这一档;
- RF=3 时等 2 个 follower,延迟取两者中更慢的那个。
所以讨论「acks=all 有多慢」必须带上副本数。脱离 RF 谈 acks,和脱离 ISR 谈可靠性一样,都是不完整的。
ISR 收缩:不是变快,是少等了一个人
把 mq-c2 优雅停掉,等 ISR 收缩:
docker stop mq-c2
sleep 14
docker exec mq-c1 sh -c 'env -u JMX_PORT /opt/kafka/bin/kafka-topics.sh \
--bootstrap-server 127.0.0.1:9092 --describe --topic c1.repl'
本机实测输出(节选):
Topic: c1.repl PartitionCount: 6 ReplicationFactor: 2 Configs: min.insync.replicas=1
Partition: 0 Leader: 1 Replicas: 2,1 Isr: 1
Partition: 1 Leader: 1 Replicas: 1,2 Isr: 1
Partition: 2 Leader: 1 Replicas: 1,2 Isr: 1
六个分区的 ISR 都只剩 broker 1。此时继续用 acks=all 写:
| 场景 | 条/秒 | MB/s | 平均延迟 |
|---|---|---|---|
| RF=2、ISR=2、acks=all(上表三轮) | 29,317–47,226 | 28.63–46.12 | 506–854 ms |
| ISR=1、acks=all | 55,127 | 53.83 | 401.80 ms |
| ISR=1、acks=all(第二次) | 79,713 | 77.84 | 253.23 ms |
| ISR=1、acks=1 | 67,317 | 65.74 | 329.91 ms |
写入没有被拒绝,因为 min.insync.replicas=1:ISR 大小为 1,仍然 ≥ 1。吞吐和延迟回到与 acks=1 同一量级,因为 acks=all 此时等的是「ISR 里所有副本」,而 ISR 里只有 leader 自己。
这就是 4.2 节讲过的退化:acks=all 不是一个独立开关,它的严格程度由 ISR 大小决定。把 min.insync.replicas 设为 1,等于允许集群在只剩一份数据时继续写;写入是成功的,但这份数据已经没有冗余。吞吐变快不是优化,是可靠性下降的副产品。
反过来,如果这里配的是 min.insync.replicas=2,同样的操作会返回 NOT_ENOUGH_REPLICAS,写入被拒绝。两种配置没有对错,取决于业务是「宁可停写也不能丢」还是「先保证可用」。
ISR 收缩有三条不同的路径,速度差很多。4.1 节在三节点集群上做过对照,这里引用它的结论,并标明不是本节的二节点实验:
| 失效方式 | 触发机制 | 4.1 节实测 |
|---|---|---|
docker stop 优雅关闭 | broker 主动注销,controlled shutdown | 2 秒内收缩 |
docker pause 冻结进程 | broker.session.timeout.ms,默认 9 秒 | 冻结期间不变,超时后收缩 |
| 进程活着但复制慢 | replica.lag.time.max.ms,默认 30 秒 | 到期后收缩 |
也就是说,replica.lag.time.max.ms 只负责第三种情况。把它当成「故障发现时间」来调,会得到与预期相反的结论:broker 能主动告别的场景,ISR 早就收缩完了;不能告别的场景,还要等 session 超时。
还有一个细节值得注意:mq-c2 是被 docker stop 优雅停掉的,它在退出前走了 controlled shutdown,日志里能看到:
[BrokerLifecycleManager id=2] Transitioning from PENDING_CONTROLLED_SHUTDOWN to SHUTTING_DOWN
优雅关闭会尝试走 controlled shutdown,本机停止后六个分区的 leader 全部在 broker 1,与「退出前把 leadership 迁到存活副本」的行为一致。但这不等于「两节点集群能容忍一个节点故障」——两个 voter 的多数派是 2,真正掉一个时 controller quorum 就没了,元数据操作会不可用。11.3 节会再区分「副本还能读」与「集群还能改」。
重启后的追赶:约 11 秒,约 600 MB
停掉 mq-c2 期间又写了三轮,共 60 万条约 600 MB。把节点重新启动,轮询 ISR:
docker start mq-c2
for i in $(seq 1 30); do
n=$(docker exec mq-c1 sh -c 'env -u JMX_PORT /opt/kafka/bin/kafka-topics.sh \
--bootstrap-server 127.0.0.1:9092 --describe --topic c1.repl' | grep -c 'Isr: 1,2\|Isr: 2,1')
echo "$(date +%T) ISR 完整分区=$n/6"
[ "$n" = "6" ] && break
sleep 2
done
本机实测(同一集群,2026-10-29,单次运行):
12:44:57 tick=1 isr_full_partitions=0/6
12:45:04 tick=2 isr_full_partitions=0/6
12:45:08 tick=3 isr_full_partitions=6/6
从 docker start 到六个分区全部回到双副本,约 11 秒。这 11 秒包含 broker 启动、注册、controller 感知,以及 follower 追平停机期间写入的数据。追赶完成后两边日志目录大小接近:
mq-c1: 2,635,484 KB
mq-c2: 2,631,052 KB
差约 4 MB,是最后一段尚未完全对齐的部分。做个粗略除法:600 MB ÷ 11 秒 ≈ 55 MB/s。但这 11 秒里包含 broker 启动、注册与 controller 感知,真正的复制阶段只占其中一部分,所以 55 MB/s 是下界,不是复制带宽。要说明的是:这是本地 Docker 网络加页缓存的数字,不能外推到跨机房环境。跨机房的追赶时间由带宽和 num.replica.fetchers 共同决定,本机没有做限速与跨机架实验。
追赶期间 follower 的 fetch 会持续占满复制链路,这段时间内 acks=all 的尾延迟通常会上升。生产上做副本重建要限流,用的不是 replica.fetch.*,而是副本流量限流参数;13.3 节会展开。这里只需要记住:副本追赶和正常写入共享同一条网络与同一块磁盘,追赶不是免费的。
复制链路上的三个可观测点
想把「副本同步慢」定位到具体一段,至少看三个地方:
| 观察点 | 指标/命令 | 说明 |
|---|---|---|
| follower 是否在拉 | ReplicationBytesInPerSec(follower 侧) | 长期接近 0 说明 fetch 没发出去或 leader 没数据 |
| ISR 是否抖动 | IsrShrinksPerSec / IsrExpandsPerSec | 频繁抖动通常是网络或磁盘,不是参数 |
| 落后多少 | --describe 的 ISR、kafka-replica-verification.sh | ISR 只说明「在不在集合里」,不说明落后多少 |
UnderReplicatedPartitions 只能告诉你「有副本不在 ISR」,不能告诉你原因。真正的定位顺序是:先看 follower 的 fetch 是否正常,再看 leader 的磁盘读是否慢,最后才怀疑网络。14.1 节会给一张完整的故障对照表。
跨机架与跨 AZ:本机没有测
broker.rack 打开机架感知后,Kafka 会尽量把同一分区的副本分散到不同机架。好处是单机架故障不丢数据,代价是 follower 的 fetch 必须跨机架:
acks=all的延迟从「同机架往返」变成「跨 AZ 往返」,增加多少取决于两端 RTT;- 跨 AZ 流量计费与带宽成为容量规划的一项;
- 副本追赶时会把跨 AZ 链路打满,需要专门的副本流量限流参数,13.3 节会展开。
本机是单主机两容器,没有真实机架,所以这一节未在本机执行。要复现,需要在 compose 里给每个 broker 设 KAFKA_BROKER_RACK=az-a/az-b,建 RF=2 的 topic 后看 --describe 的副本分布,再用网络延迟注入工具在 broker 之间加 RTT(本机镜像未预装这类工具)。预计形态是:副本分布跨 rack,acks=all 延迟随注入的 RTT 上升。这里不编具体数字。
调参的判断
几条来自机制而非 benchmark 的判断:
replica.fetch.max.bytes不要小于常见批次大小。默认 1 MB 对多数业务够用;消息体经常接近 1 MB 时要一起调大,否则 follower 每轮只能带回一条。num.replica.fetchers在分区多、磁盘多时调到 2–4。默认 1 意味着单个 fetcher 线程驱动所有分区的复制。本机没有做 1 与 4 的对照实验,不给倍数。- 不要为了降延迟调小
replica.fetch.wait.max.ms。它管的是空闲等待,不改变有数据时的返回速度;调小只会增加空轮询。 acks=all的延迟要看最慢的 follower。想降低尾延迟,优先排查副本所在机器的磁盘,而不是调 broker 参数。min.insync.replicas与acks必须成对看。acks=all + min.insync.replicas=1在 ISR 收缩后会静默退化成单副本写入,本节的实验就是证据。
本节判据
- follower 是拉还是被推? 答:拉。follower 主动发 Fetch,leader 只响应,不为每个 follower 维护发送队列。
acks=all比acks=1多等什么? 答:多等 ISR 中所有 follower 完成一轮 fetch 并追加,RF=2 时是一个网络往返加一次远端写入。- ISR 缩到 1 后
acks=all为什么变快? 答:ISR 里只剩 leader,「等所有 ISR」退化成「等自己」,与acks=1接近;这是可靠性下降,不是优化。
下一节换一个维度:分区数。它既是并行度的来源,也是 fd、内存、元数据和选举时间的成本来源。我们会用 1、10、50、200 个分区的 topic 把账单逐项量出来。
阅读导航:上一节 11.1 网络线程与请求队列 | 下一节 11.3 分区数的非线性成本
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。