《Java 消息中间件·Kafka 内核与客户端》4.1 副本、LEO 与高水位

生产者明明返回了 offset 5 到 9,消费者却一条都读不到——这不是 bug,是 LEO 与高水位分离的正常状态。本节用 docker pause 冻结一个 follower,在同一秒里同时读出「leader 磁盘上有 19 条」和「消费者只能看到 16 条」,并给出 stop、pause、崩溃三种失效下 ISR 收缩的真实时序。

一次「写成功但读不到」的观测

这是我在三节点集群上截下来的一段原始输出:

PRE_HW=5
READY
SENT_OFFSETS=[5, 6, 7, 8, 9] costMs=69
HW_T0=5
HW_T2S=5
HW_T17S=5

生产者发了五条,全部返回成功,拿到的 offset 是 5 到 9。然后立刻查「消费者能读到的最新位点」,得到的却是 5。两秒后还是 5,十七秒后还是 5。

也就是说:消息确实写进去了,位置也确实分配了,但消费者在十七秒里一条都读不到。

同一时刻,topic 的 ISR 是这样的:

Topic: hw.demo  Partition: 0  Leader: 2  Replicas: 2,3,1  Isr: 1,2,3

三个副本都在 ISR 里,没有掉线,没有缩小。这不是故障,这是 Kafka 一致性模型里最核心的一组状态的正常表现。

理解这两组数字为什么能同时存在,就理解了 Kafka 副本机制的全部要点。这一节讲清楚三件事:LEO 是什么、HW 是什么、它们为什么会分离。

三个偏移量,先分清

Kafka 里「offset」这个词被用在至少三个不同的地方,混在一起讲必然糊涂。

第一个:日志位置偏移(log offset)。它标识一条消息在分区日志里的绝对位置,从 0 开始单调递增,永不复用。生产者返回的 offset、消费者位点记录的 offset,都是这个。

第二个:LEO(log end offset)。分区日志的下一条待写入位置。如果日志里最后一条消息的 offset 是 18,那么 LEO 就是 19。注意 LEO 是「长度」而不是「最后一条的下标」——这个差一在本节后面还会出现。

第三个:HW(high watermark,高水位)。消费者能看到的上界。HW 的值同样以「下一条位置」表示:HW=16 意味着 offset 0 到 15 可见,16 及以后不可见。

所以第一节里那组数字翻译过来是:

  • 生产者写入了 offset 5、6、7、8、9;
  • leader 的 LEO 已经从 5 推进到 10;
  • 但 HW 停在 5;
  • 消费者只能读到 0 到 4。

LEO 是「写到了哪」,HW 是「读到哪」。 中间那段空间是消息已经落地、但还不允许被消费的状态。

副本不是平权的

要理解 HW 为什么落后于 LEO,得先看副本之间的分工。

每个分区有若干副本,其中一个被选为 leader,其余是 follower。读写请求只走 leader——这是 Kafka 和 Raft 类系统的一个显著差异,Kafka 的 follower 不参与读,只负责复制。

复制是 follower 主动拉,不是 leader 主动推:

follower ──Fetch(offset=N)──▶ leader
         ◀──records N..M─────
follower ──Fetch(offset=M+1)─▶ leader
         ◀──────────────────

这个「拉」的方向很重要。它意味着 leader 不知道 follower 什么时候会来,只知道 follower 上次来是什么时候、追到了哪里。replica.lag.time.max.ms 之所以是时间而不是条数,根源就在这里:leader 唯一可靠的信号是「多久没见到你」。

三个副本的 LEO 各自独立推进。leader 收到生产者的消息,把 LEO 往前挪;follower 拉走消息写入本地日志,各自把自己的 LEO 往前挪。任何时刻,三个 LEO 完全可能不相等。

HW 的定义就是:ISR 里所有副本 LEO 的最小值。

leader LEO   = 19
follower A   = 19
follower B   = 16
─────────────────
HW = min(19,19,16) = 16

这条定义解释了一切。HW 想往前走,必须等最慢的那个副本追上来。只要 ISR 里有一个副本卡住,HW 就卡住。

实测:把 HW 和 LEO 拉开

正常情况下,三个副本追得很快,HW 和 LEO 的差距是毫秒级,根本观测不到。要看到分离,得人为制造一个「在 ISR 里但追不上」的副本。

先试最直觉的做法:把 follower 停掉。

docker stop k2

结果和直觉相反——两秒内 ISR 就从 1,2,3 变成了 1,3。

[1] Isr: 1,3
[2] Isr: 1,2,3     ← k2 重启后恢复

为什么这么快?因为 docker stop 发的是 SIGTERM,broker 会优雅关闭:它主动向 controller 注销自己,controller 立刻把它移出 ISR。这条路径根本不经过 replica.lag.time.max.ms 的计时。

优雅关闭后,ISR 只剩两个副本,HW 只由这两个副本决定,自然立刻追上 LEO。所以「stop 一个 broker」制造不出分离。

换一种停法:冻结进程,但不断开连接。

docker pause k1

docker pause 用 cgroup freezer 把容器里所有进程挂起。进程还在,TCP 连接还在,端口还在监听,只是不再执行任何指令。对 leader 来说,这个 follower 没有注销、没有断开、没有报错,只是安静地不再发 Fetch 请求。

这才是「活着但跟不上」的形态。

冻结 follower 之后,立刻用 acks=1 写入三条:

g1 g2 g3

这意味着 leader 收到就算成功,不等 follower 确认。同一条命令里紧接着读两组数:

# leader 磁盘上到底有多少条
kafka-dump-log.sh --files /tmp/kafka-logs/hw.demo-0/00000000000000000000.log \
  --print-data-log | grep -c '^| offset:'

# 消费者可见的上界
kafka-get-offsets.sh --bootstrap-server ... --topic hw.demo --time -1

# ISR
kafka-topics.sh --bootstrap-server ... --describe --topic hw.demo

本机实测(Temurin 25.0.5+8 / Kafka 4.3.0 KRaft 三节点 / 2026-10-29),同一秒内的输出:

leader(k2) 磁盘上的记录数: 19
消费者可见位点 HW: 16
ISR: Isr: 1,2,3
checkpoint 里落盘的 HW: 16
量值含义
日志记录数19 条(offset 0–18)leader 的 LEO = 19
HW16消费者只能读到 offset 0–15
差值3 条正是刚写入的 g1、g2、g3
ISR1,2,3冻结的 k1 仍在 ISR 里

三条消息在磁盘上、分配了 offset、acks=1 返回成功,但在 HW 追上来之前,任何消费者都读不到它们。

然后解冻:

docker unpause k1

k1 恢复执行,立刻发出积压的 Fetch 请求,拉走那三条,把自己 LEO 推到 19。HW 随之抬到 19:

k1 解冻 @ 12:11:00
[1] HW=:19

一次解冻,HW 追平。整个过程没有任何消息丢失、没有任何重复。

为什么必须有 HW

到这一步,自然会问:既然消息已经在 leader 磁盘上了,为什么不让消费者读?

答案藏在一个具体的故障里。

假设没有 HW,leader 写完就允许消费。消费者读到 offset 18,处理完,提交位点。

这时 leader 挂了。controller 从 ISR 里选一个新 leader。但因为原 leader 没来得及把这些消息复制出去,新 leader 的日志只到 offset 15。

于是:

  1. 新 leader 把本地日志长度作为新基准,offset 16 及以后的消息不存在了;
  2. 消费者提交的位点却是 19;
  3. 消费者重启后从 19 继续读——新 leader 的 LEO 只有 16,消费者卡死;
  4. 更要命的是,那三条被处理过的消息,在系统里的痕迹只剩「位点 19」,内容已经消失。

HW 就是为了堵住这个洞。 它的规则是:只有被 ISR 里所有副本确认过的消息,才算「已提交」(committed),才允许被消费。这样即使 leader 立刻挂掉,任何一个在 ISR 里的副本都至少拥有到 HW 为止的全部消息,选举出来的新 leader 不会丢已提交的数据。

这就是「已提交」在 Kafka 里的精确定义:offset < HW。不是「写入成功」,不是「落盘了」,而是「ISR 全员都有」。

用一句更短的话概括:

LEO 是 leader 的私事,HW 是对外的承诺。

HW 不会轻易后退,但会后退

HW 的设计目标是单调不减。但有一类场景它必须后退:日志截断。

新 leader 上任时,它的日志可能比原 leader 短(原 leader 还有没复制出去的部分),也可能比原 leader 长(原 leader 曾短暂失联,新 leader 在旧 leader 不知情时写过东西——这就是「不干净选举」,4.3 节讲)。

为了让所有副本收敛到同一份日志,Kafka 引入了 leader epoch:每次 leader 变更,epoch 加一,并在日志里记一条 LeaderChange 记录。

follower 重新加入时,用 Fetch 请求带上自己的 epoch 和 LEO。leader 比对之后告诉它:

  • 「你的日志在我的 epoch 里是有效的,从 offset N 继续拉」;
  • 或者「你的 epoch 我已经不认识了,回退到 offset M,从那里开始覆盖」。

第二条就是截断。follower 会把 M 之后的本地日志删掉,重写。此时它的 LEO 会下降,HW 随之下降。

这也是为什么消费者看到的位点偶尔会「倒退」——不是数据丢了,是集群在收敛。

三种失效,三种时序

这一节最容易被写错的地方,是把「broker 没了」当成一种情况。实际上它是三种,触发机制完全不同。本机三种都实测了:

失效方式触发机制实测 ISR 变化观测耗时
docker stop(SIGTERM 优雅关闭)broker 主动注销1,2,3 → 1,32 秒内
docker pause(进程冻结,连接保持)broker.session.timeout.ms(本机设 60000)1,2,3 保持,超时后 → 1,2冻结期间不变,约 60 秒后收缩
follower 活着但复制慢replica.lag.time.max.ms(本机设 300000)到期后收缩未取得稳定数据

第三种没有拿到数据,原因是它需要 follower 进程正常运行、心跳正常、只是拉取跟不上——本机做不出「心跳正常但复制停住」的状态。docker pause 把心跳一起冻住了,所以它走的是第二条路径。

这个区分有实用价值。很多资料写「杀掉 broker 后要等 30 秒 ISR 才收缩」,这句话在优雅关闭的场景下是错的。真实的规则是:

  • 能主动告别的,立刻移出 ISR;
  • 不能告别的,等 broker session 超时(默认 9 秒);
  • 还在告别的,但拉取跟不上,等 replica lag 超时(默认 30 秒)。

replica.lag.time.max.ms 只管最后一种。把它当作「故障发现时间」来调,是常见的配置错误。

顺带一个实操细节:replica.lag.time.max.ms 不能在运行时通过 kafka-configs.sh --alter 修改。本机尝试时返回:

org.apache.kafka.common.errors.InvalidRequestException:
  Cannot update these configs dynamically: [replica.lag.time.max.ms]

要改只能写进 broker 的 server.properties 后重启。本节所有实验都是把它和 broker.session.timeout.ms 一起写进 compose 的 environment,重建集群后做的。

怎么观测这些值

四个量,四种观测手段,都有坑。

HW:kafka-get-offsets.sh --time -1 返回的就是 HW。这是最可靠的方式,因为它走的是消费者可见的语义,和你真正关心的问题一致。

kafka-get-offsets.sh --bootstrap-server k1:9092,k2:9092,k3:9092 \
  --topic hw.demo --time -1
# hw.demo:0:16

LEO:没有直接的管理命令。两条路:读 leader 的段文件,或者看 kafka-log-dirs.sh。段文件方式最准:

kafka-dump-log.sh --files /tmp/kafka-logs/hw.demo-0/00000000000000000000.log \
  --print-data-log | grep '^| offset:' | tail -1
# | offset: 18 ...

这里有个计数陷阱。我第一次统计时用了 grep -c 'offset:',得到 20 条,比实际多了一条。原因是 kafka-dump-log.sh 每条记录输出两行,一行以 baseOffset: 0 lastOffset: 0 开头,一行以 | offset: 0 开头,而两行里都含子串 offset:。正确的模式是 ^| offset:,它只匹配记录明细行。这个错误会直接导致「HW 比 LEO 差 4 条还是 3 条」判断错位。

HW 的落盘快照:/tmp/kafka-logs/replication-offset-checkpoint 里定期写入每个分区的 HW:

0
1
hw.demo 0 16

格式是「topic 分区 HW」。这个文件由 replica.high.watermark.checkpoint.interval.ms(默认 5000)驱动刷盘,所以它可能比实时 HW 落后几秒,不能当作实时值用,但可以用来确认 HW 曾经到达过哪里。

ISR:kafka-topics.sh --describe。4.x 的输出里多了 Elr 和 LastKnownElr 两列,这是 ELR(Eligible Leader Replicas)机制引入的,卷三再展开。读输出时不要以为列数对不上就是版本不兼容。

三个常见误解

误解一:HW 就是「所有副本都有的位置」。准确说是「ISR 里所有副本都有的位置」。被踢出 ISR 的副本不参与 HW 计算——这正是 ISR 存在的意义。一个掉队副本不会拖住整个分区。

误解二:acks=all 返回成功,消费者就能读。不是。acks=all 保证的是「ISR 全员写入」,但 ISR 全员写入之后,HW 的推进还需要一次额外的传播——leader 在收到所有 follower 的下一次 Fetch 响应后才能确认它们的位置,然后才推进 HW。所以「写入成功」到「可消费」之间还有一个短暂的窗口,通常是毫秒级,但网络抖动时会拉长。

误解三:HW 落后说明集群有问题。持续落后才是有问题。瞬时落后是设计的一部分,是可靠性的代价。

和生产者参数的关系

HW 的推进速度直接决定了「写入成功」和「可消费」之间的延迟,而这个延迟又反过来影响生产者的吞吐。

acks=1 时,leader 写完就返回,生产者拿到 offset 5 到 9 只花了 69 毫秒。但 HW 要等 follower 追上才动。如果持续用 acks=1,HW 会一直落后,消费者的读取延迟(consumer lag 之外的那部分)会变大。

acks=all 时,leader 等 ISR 全员写入才返回,这时 HW 已接近 LEO,写入成功和可消费几乎同时发生,代价是单次写入的延迟变高。

这不是「哪个更好」的问题,是你把延迟放在哪一侧的问题。3.2 节讨论过 acks 对丢消息的影响,这里补上它对可见性延迟的影响——同一个参数,两个方向的代价。

小结

这一节的三句话:

  1. LEO 是写到了哪,HW 是读到哪。 HW = ISR 中所有副本 LEO 的最小值。
  2. HW 存在的唯一理由是防止已提交数据在 leader 切换中丢失。 代价是「写入成功」和「可消费」之间必然有一段窗口。
  3. ISR 收缩有三条路径,对应三种失效方式,replica.lag.time.max.ms 只管其中一条。

下一节把 ISR 用到生产上:当 ISR 收缩到只剩一个副本时,acks=all 会退化成 acks=1,此时唯一能拦住你的就是 min.insync.replicas。4.2 节会用三节点集群把 ISR 从 3 收缩到 2、再到 1,看写入是怎么从成功变成被拒的。

阅读导航:上一节 3.3 分区器与键设计 | 下一节 4.2 ISR 收缩与 min.insync.replicas

继续阅读

探索更多技术文章

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

全部文章 返回首页

「books」更多文章

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