《Java 消息中间件·Kafka 内核与客户端》11.2 副本同步的吞吐与延迟

follower 不是被动接收推送,而是主动向 leader 发 fetch。本节用两节点 RF=2 集群实测 acks=1 与 acks=all 的写入速率差,停掉一个 broker 后观察 min.insync.replicas=1 为何仍能写、吞吐为何反而变快,并解释 replica.fetch.* 四个参数各自管什么。

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.bytes1 MB单次 fetch 每个分区最多带回的字节数调得太小,大消息复制变慢;调得太大,一次响应占用更多内存
replica.fetch.min.bytes1攒够多少字节才返回默认 1 表示有数据就回,不等
replica.fetch.wait.max.ms500没数据时最多等多久不是每条消息固定加 500 ms,有数据时立即返回
replica.fetch.backoff.ms1000fetch 失败后的重试间隔调大只会让落后副本追得更慢
num.replica.fetchers1每个 broker 的副本拉取线程数分区很多时单线程可能成为复制瓶颈
replica.lag.time.max.ms30000超过多久没跟上就踢出 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/sacks=all 条/秒acks=all MB/s倍数
1110,742108.1547,22646.122.34×
263,75562.2629,31728.632.17×
347,60846.4937,04436.181.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 平均延迟
1118.93 ms506.01 ms
2308.72 ms853.54 ms
3456.69 ms656.41 ms

acks=all 的平均延迟是 acks=1 的 1.4 到 4.3 倍。这里的绝对值(几百毫秒)明显高于正常生产集群,原因是本机资源紧张,不能当作 Kafka 的延迟水平;有意义的是同一台机器上的相对关系。

为什么 acks=all 更慢:多等了一个人

把一次 acks=all 写入拆开,生产者等待的段落是:

  1. 生产者把批次发给 leader;
  2. leader 追加到本地日志(写页缓存即返回,是否刷盘由 flush.messages / flush.ms 决定);
  3. leader 把请求挂起,等 ISR 中所有 follower 的 fetch 把这条消息拉走;
  4. follower 追加成功,下一轮 fetch 带上新的 LEO;
  5. leader 确认 ISR 都到位,唤醒挂起的请求,返回生产者。

acks=1 在第 2 步之后就返回,省掉第 3 到 5 步。省掉的是「至少一个网络往返 + follower 的写入」。RF=2 时只有一个 follower,所以是等一个人;RF=3 时是等两个人,延迟取最慢的那个。

这也解释了一个常见现象:acks=all 的尾延迟对「最慢的 follower」非常敏感。三个副本里只要有一个所在机器磁盘抖动,生产者看到的就是那个抖动的延迟,而不是 leader 自己的延迟。

三种 acks 各自在等谁

把三种取值放在一张表里,等待对象的差别就清楚了:

acksleader 何时向生产者返回掉一个副本会怎样本节是否实测
0请求发出后立即返回,不等任何确认无法感知,可能直接丢未测
1leader 本地追加成功即返回若 leader 随后挂掉且未复制,消息丢失实测
allISR 中所有副本都追加成功才返回只要 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,22628.63–46.12506–854 ms
ISR=1、acks=all55,12753.83401.80 ms
ISR=1、acks=all(第二次)79,71377.84253.23 ms
ISR=1、acks=167,31765.74329.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 shutdown2 秒内收缩
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.shISR 只说明「在不在集合里」,不说明落后多少

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 收缩后会静默退化成单副本写入,本节的实验就是证据。

本节判据

  1. follower 是拉还是被推? 答:拉。follower 主动发 Fetch,leader 只响应,不为每个 follower 维护发送队列。
  2. acks=all 比 acks=1 多等什么? 答:多等 ISR 中所有 follower 完成一轮 fetch 并追加,RF=2 时是一个网络往返加一次远端写入。
  3. ISR 缩到 1 后 acks=all 为什么变快? 答:ISR 里只剩 leader,「等所有 ISR」退化成「等自己」,与 acks=1 接近;这是可靠性下降,不是优化。

下一节换一个维度:分区数。它既是并行度的来源,也是 fd、内存、元数据和选举时间的成本来源。我们会用 1、10、50、200 个分区的 topic 把账单逐项量出来。

阅读导航:上一节 11.1 网络线程与请求队列 | 下一节 11.3 分区数的非线性成本

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「books」更多文章

  1. 《Java 消息中间件·事件驱动架构》附录 B 架构决策记录模板
  2. 《Java 消息中间件·事件驱动架构》附录 A 术语表
  3. 《Java 消息中间件·事件驱动架构》15.3 全书验收