老读者应该知道,我写过不少 Kafka 运维相关的内容,但一直没专门聊过 HW(High Watermark,高水位)和 LEO(Log End Offset,日志末端偏移)这两个数。不是不想写,是这两个概念乍一看很简单,真正用起来却特别容易翻车。线上问题里,消费者读不到最新消息、副本切换后丢数据、acks=all 还是出现信息差额,追到最后十有八九都指向这两个值。
这篇我打算完全按照自己平时的排查思路来讲,不照搬文档,把 HW/LEO 是怎么更新的、什么时候会卡住、和acks/min.insync.replicas这些生产参数怎么联动,以及出了问题如何顺藤摸瓜,一次说清楚。适合正在维护 Kafka 集群的工程师,也适合准备深入源码但不想被各种支离破碎资料劝退的朋友。
1. 先把概念摆正:HW 和 LEO 分别是什么
搞懂 Kafka 副本同步,最怕的就是拿宏观印象去猜微观行为。很多人以为副本同步就是把消息从 leader 拷到 follower,拷完两边就一样了,HW 就是个“已拷贝位置”。这个直觉对了一半,但另一半会误导你,尤其是把 HW 当成一个全局统一的值。
1.1 LEO:每个副本自己“写到哪了”
LEO 的全称是 Log End Offset。某个副本的日志里,下一条要写入消息的偏移量是多少,LEO 就是多少。
举例:一个分区从偏移量 0 开始写,写完第一条偏移 0,写完第二条偏移 1,此时该副本的 LEO 就等于 2。它不代表“最后一条已经存在的消息是 2”,而是“下一条该落盘的位置是 2”。
关键点在于:每个副本各自维护自己的 LEO。leader 有 leader 的 LEO,follower 有 follower 的 LEO。消息到了 leader 本地,leader 的 LEO 往前推;follower 把这条消息拉过来追加到自己日志,follower 的 LEO 才会往前推。两边不是同时动的。这叫各自推进,谁也别替谁做主。
1.2 HW:整个副本组“都写到哪了”
HW 在学习阶段经常被一句话带过:高水位,表示已提交消息的位置,消费者只能读 HW 以下的消息。这句没错,但光知道这个远远不够。
HW 的准确含义是:当前 ISR 集合里所有副本的 LEO 的最小值。也就是说,leader 不是拿自己的 LEO 给消费者当水位,而是把所有还在 ISR 里的副本的 LEO 拉出来,取最小值。这个值才是 HW,或者说,HW 是“整个副本组整体写到的位置”。
打个比方。三个人一起抄黑板上的公式,每个人都有一份自己的笔记。LEO 就是每个人自己抄到了哪一行。HW 是三个人当中抄得最慢的那个人抄到哪一行。只有在这个进度之前的内容,才算大家都有了,才能对外说“这是安全的”。
HW 这个设计逻辑其实非常朴素的:如果一条消息只在 leader 上,follower 还没拿到,leader 当场宕机,那这条消息就没了,消费者更不应该提前看到它。所以 Kafka 宁可让 HW 慢,也不让 HW 虚高。
这里顺带解决一个常见困惑:HW 是一个“水位分界线”,不是一条真实消息的偏移量。HW=3 表示偏移量 0、1、2 已经安全提交,消费者最多能读到偏移量 2。偏移量 3 本身还没生效,即使它已经在 leader 磁盘上了。
2. ISR 推进过程:HW 和 LEO 到底是怎么动的
明白了两个定义,紧接着就是第二个坑:HW 不是定时更新的,也不是每次写入都立刻更新。它是在一次次 fetch 交互里“挤”出来的。
2.1 ISR 集合如何扩张和收缩
ISR 的全称是 In-Sync Replicas,直译是“在同步中的副本”。一个分区有多个副本,但并不是所有副本都处于“同步中”的状态。某个 follower 如果落后太久,就会被 leader 移出 ISR,剩下还在 ISR 里的副本集合,才参与 HW 的计算。
至于什么叫“落后太久”,由参数replica.lag.time.max.ms控制,默认 30 秒。follower 每隔一段时间向 leader 发 fetch 请求,leader 能看到每个 follower 现在已经拉到哪里。如果某个 follower 在replica.lag.time.max.ms时间内一直没能跟上,leader 就判定它要么网络有问题,要么卡在 GC 或者磁盘 IO 上,直接把它从 ISR 里请出去。
ISR 收缩之后,HW 的计算范围就变了。举例:三个副本 leader、follower1、follower2,LEO 分别是 100、90、95,那么 HW 是 90。如果 follower1 落后时间过长,被移出 ISR,那么 ISR 变成 leader 和 follower2,HW 会立刻从 90 跳到 95。这就是为什么在某些事故里,ISR 一缩,消费者突然发现消息能读到了,Lag 一下子变小。可这不是什么好事,因为最慢的那个副本已经不参与“安全确认”了。
等落后副本追上来、重新进入 ISR,HW 的计算集合又变回三个副本。如果它追得非常慢,HW 可能又会被它拖住。所以 ISR 的扩张收缩直接影响 HW 的移动轨迹。
2.2 leader 和 follower 各自怎么更新自己
很多人默认所有副本的 HW 都一样,其实不是。leader 的 HW 和 follower 的 HW 来源不同,更新时机也不同,可能短暂不一致。
- leader 侧:每次消息写入,或者收到 follower 的 fetch 请求时,会重新计算 HW,公式就是“当前 ISR 内所有副本 LEO 的最小值”。
- follower 侧:follower 自己不会按“ISR 最小 LEO”来算,它只知道 leader 在 fetch 响应里带回来的 HW 是多少。follower 把这个值记下来,作为自己本地 HW 的更新依据。
手工推演一个时序:
- leader 本地写入消息 A,leader 的 LEO 变成 101。
- follower 发起 fetch,leader 算出当前 ISR 的最小 LEO 是 98,于是响应里带上 HW=98。
- follower 收到响应,把消息 A 追加到本地日志,follower 的 LEO 变成 101,同时把本地 HW 更新为 98。
- 下一轮 fetch,leader 再次计算 HW。此时 follower 的 LEO 已经追上,leader 算出 HW=101。
注意这时候才真正把消息 A 标记为已提交。步骤 2 里 leader 的 HW 还是 98,步骤 3 里 follower 的 LEO 虽然已经到 101,但它的 HW 也只是 98。整个过程存在一个窗口期,在这个窗口里消息 A 已经躺在所有副本磁盘上,但包括消费者在内,所有人都还不能把它当成“已提交”来读。
为什么不能?因为 leader 还没确认 follower 已经把 A 拉到。如果 leader 立刻把 HW 推到 101,然后 follower 才在 fetch 响应里看到这个新 HW,但自身还没来得及把 A 落盘,那这个 HW 就是空中楼阁。所以 Kafka 宁可让 HW 慢半拍,也不能虚报。
2.3 HW 为什么总是“慢半拍”
理解上面这个时序之后,很多人会问:那 HW 就一直落后于 leader 的 LEO 吗?答案是大概率会落后,但不会永远落后。真正的推进动力来自 follower 的 fetch 请求。follower 每来一次,leader 就能拿到最新状态,更新一次 HW。
所以一个非常反直觉的事实是:写入速度再快,如果 leader 不处理 fetch 请求,HW 也不会自动涨。生产环境里,正常情况下 follower 的 fetch 请求是很频繁的,单位时间几十次,HW 和 LEO 的差距通常很小。但一旦 follower 卡住或者网络不畅,HW 就会长时间停在原地,消费者就会出现“明明一条消息已经写进去了,就是读不到”的现象。
这个“慢半拍”不是 bug,更不是设计失误。它像一个投票系统,leader 得等每个 ISR 成员都表态“我拿到了”,才能对外宣布“这是已提交的”。表态本身需要时间,宣布也永远发生在表态之后。
3. 几个高频线上场景:读不到、丢消息、重复消费
概念上的 HW/LEO 说到这儿,接下来看真正让运维头疼的现场。
3.1 ISR 里只剩 leader 一个副本时会发生什么
假设replication.factor=3,但两个 follower 都挂掉了,ISR 里只剩 leader 一个。这时候 HW 的计算集合就只剩 leader,HW 等于 leader 的 LEO,写入一条 HW 就跟一条。
这看上去是写入延迟最低、消费者实时性最好的状态。但实际上非常危险。当 HW=LEO 时,Kafka 等于向消费者承诺:这条消息已经安全了。可它只存在于 leader 一台机器上,另外两台什么都没有。如果这台 leader 立刻宕机,而选举出来的新 leader 又是那两台空壳之一,那所有被消费者读过的“已提交”消息全部人间蒸发。
这也是为什么我始终建议生产环境开启min.insync.replicas=2。它的作用是在写入时强制检查 ISR 数量,如果 ISR 少于 2,直接返回NotEnoughReplicasException,宁可让写入失败,也不能让只有一份数据的时候对外宣称已提交。
3.2 follower 追不上,消费者被“卡看”
有一次线上值班,业务反馈某分区消费者好几分钟没拿到新消息,但生产者写入完全正常。我看了一眼消费组的LOG-END-OFFSET和CURRENT-OFFSET,发现 Lag 并不大,很奇怪。再往下挖,发现有一个 follower 的 fetch 延迟很高,持续在 ISR 的边缘疯狂试探。ISR 还没把它踢出去,但它已经严重拖低了所有 ISR 副本 LEO 的最小值,HW 停在一个很老的位置。
消费者的 fetch 请求只会拿到 HW 以下的消息,HW 不动,消费者拿到的数据就不动。所以 Lag 数字看起来很“健康”,业务端却已经明显延迟。这种“假健康”最迷惑人。后来把那个 follower 摘除,HW 立刻恢复,消费者马上追平。
遇到过这种情况之后,我现在排查消费延迟,从来不会只看 Lag 一个指标,一定会顺带看副本状态。HW 落后是 Lag 背后的原因,光看 Lag 等于只看结果不看病理。
3.3 切换 leader 之后,为什么会出现丢消息和重复消息
HW 卡住时,消费者读到某个位置,之后 leader 宕机,ISR 里剩下某个 follower 被选为新 leader。此时这个新 leader 的 LEO 可能比旧 leader 的 HW 还低。也就是说,消费者之前读过的部分消息,在新 leader 上根本不存在。
这会导致两类很典型的现象:
- 消费者如果重启并从提交的 offset 开始消费,会发现 offset 已经越过新 leader 日志的末端,触发
Reset offset策略,可能直接跳到最新位置,中间那一段消息相当于被跳过了。 - 如果消费者还没提交 offset,下一次拉取时可能从旧的位置重新读到一批消息,产生重复。
旧版本的 Kafka 里,这种问题更容易扩大化,因为它主要靠 HW 做截断和恢复依据。所以下面的版本演进值得单独讲一讲。
4. 为什么现代 Kafka 不再单纯用 HW 做截断
网上很多陈旧教程会把“副本日志截断到 HW”当成标准知识点。这个说法在很老的 Kafka 版本里确实成立,但现在直接拿它去理解线上行为,会越学越乱。
4.1 依赖 HW 截断留下的“脏日志”问题
先说“脏日志”。它指的是:某台副本的日志里,存在一些比当前 leader 对应 epoch 更新的记录。通俗讲,就是这台机器以前当过 leader,写入了一些消息,后来被降级,日志里那些“上一任”的数据就成了孤儿。
旧版本处理这种孤儿数据的办法很粗暴:截断到 HW。但 HW 有一个先天缺陷,它依靠 ISR 的 LEO 计算,而 LEO 又是通过 follower 的 fetch 请求异步上报的。HW 可能被某个落后副本卡住,也可能在 leader 切换的一瞬间已经不再适用于新 leader。用一个“可能过期的全局水位”去做数据截断,就会出现两种荒谬结果:
- 有的副本被截掉过多数据,而这些数据实际已经在新 leader 上存在,于是丢消息。
- 有的副本少截了一段,日志里留下脏数据,FK 之后整个副本组数据发散,重复消息满天飞。
很多老工程师应该对类似事故有印象:明明副本数、ISR、HW 看起来都正常,但某次 broker 宕机后,topic 里出现莫名重复或丢失,查到最后发现是 HW 截断时序的坑。
4.2 Leader Epoch 如何补齐这个缺口
Kafka 0.11 开始引入了 Leader Epoch 机制,核心思想是:每个副本不只记录日志偏移,还记录这段日志是从哪个 leader 任期写入的。当副本需要截断或恢复时,不再只参考 HW,而是先向当前 leader 询问对应 epoch 的末端偏移,再用这个结果决定截断点。
简单说,旧方案是“大家按一个外部统一水位线裁齐”,新方案是“大家以新 leader 的日志为权威,按任期对齐”。
这套机制后来成为 Kafka 副本一致性版本演进里的关键一环。HW 仍然存在,仍然决定消费者可见性,但不再单独担当“截断基准点”的重任。所以你把“HW 是提交水位”和“HW 是截断依据”这两件事彻底分开,看待很多新版本行为就顺了。
4.3 生产参数和 HW/LEO 的联动关系
聊完机制,落到实际配置上。有一张常驻我笔记里的表,现在也列给各位:
| 参数 | 典型设置 | 和 HW/LEO 的关系 |
|---|---|---|
acks | all | 要求 leader 在 ISR 都确认复制后才向生产者返回成功,HW 推进是提交标志 |
min.insync.replicas | 2 | 强制 ISR 数量下限,低于该值时写入直接失败 |
replication.factor | 3 | 总副本数,决定了 ISR 最多有几个成员,也影响 HW 的最小值下限 |
unclean.leader.election.enable | false | false 时不允许非 ISR 副本竞选 leader,避免 HW 以上数据被“空壳”leader 接管 |
尤其注意acks=all和min.insync.replicas=1这种组合。表面上一个保证了持久性,一个允许最小同步数只有 1。如果 ISR 只有 leader 一台,acks=all也会正常返回成功,因为“当前 ISR 里的所有副本”确实都同步了。可这个 ISR 只有一台啊。所以说到底,acks=all到底安不安全,不由acks自己决定,而由 ISR 当时的构成决定。
想把安全边界提高,就设置min.insync.replicas=2,同时别把replication.factor设成 1。replication.factor=1配上min.insync.replicas=2是矛盾配置,写入永远失败,这事我在新环境检查时就踩过。
5. 排查 HW 和 LEO 问题的实战速查
最后聊排查手段。这部分没太多奇技淫巧,但可以帮大家少走弯路。
5.1 最容易迷惑消费者的三个观测点
第一个是消费组的 Lag。执行kafka-consumer-groups.sh --describe --group xxx,里面显示的LOG-END-OFFSET是 leader 的 LEO,CURRENT-OFFSET是消费者已经读取并提交的位置。两者之差是消费者距离日志末端的差距。这里注意,它不是和 HW 做差,所以会让很多人误以为“只要 Lag 是 0,消息就一定被消费干净了”。不一定,HW 可能还卡在更前面,只是 leader 的 LEO 已经追上。
第二个是kafka-topics.sh --describe --under-replicated-partitions。这个命令能迅速找出存在“ISR 内副本数小于总副本数”主题,是判断 HW 是否会卡住的快速入口。
第三个是复制距。kafka-log-dirs.sh --describe能看到每个副本的offsetLag,它描述的是当前副本和 leader 之间的落后量。如果某台 broker 上大量副本offsetLag都很高,基本可以认定是这台 broker 的网络、IO 或 GC 出了问题。
5.2 我一般按什么顺序排查
遇到“消费者延迟但 Lag 不大”的现象,我的排查顺序固定化:
- 先跑
kafka-topics.sh --describe --under-replicated-partitions,看有没有副本不在 ISR。 - 再看 broker 的 JMX 指标,重点看
UnderReplicatedPartitions、IsrShrinksPerSec、IsrExpandsPerSec,判断 ISR 有没有反复横跳。 - 用
kafka-log-dirs.sh定位具体落后副本,对比同 broker 其他分区是否有同样趋势。 - 查副本所在节点的 CPU、磁盘平均等待时间和 GC 停顿,多数 follower 落后不是 Kafka 配置问题,而是机器本身扛不住。
- 如果确实发生过 leader 切换,重点看新 leader 是否追平原 leader 的 HW,以及消费者 group 有没有触犯 offset 重置。
5.3 一张现场速查表
| 现象 | 大概率原因 | 先查什么 |
|---|---|---|
| ISR 数量长期小于 replication.factor | follower 追不上或掉线 | 落后副本的网络、IO、GC |
| 消费者延迟但 Lag 偏小 | HW 被最慢 ISR 副本卡住 | kafka-topics --describe --under-replicated |
| 切换 leader 后丢消息 | 新 leader 日志末端低于原 HW | unclean.leader.election.enable,min.insync 配置 |
| 写入报 NotEnoughReplicas | ISR 数量低于 min.insync.replicas | 副本数量与 ISR 收缩原因 |
| 同样配置下两次行为不一致 | ISR 构成不同,HW 推进路径不同 | 关注当时 ISR 里有哪些副本 |
5.4 不要迷信“看源码能解决一切”
关于 HW/LEO 的实现,源码里到处都是细节,但我发现很多人把源码读完了反而更糊涂,因为他们忽略了一个前提:HW 不是一把绝对的时间戳,而是一个依赖拓扑状态和网络节奏的“协商结果”。
理解了这一点,监控和排查就有方向了。你不用记住每条maybeIncrementHighWatermark的调用路径,你只需要在线上遇到诡异延迟时,反复问自己三件事:
- 当前 ISR 是哪几个副本?
- 最慢的那个副本 LEO 是多少?
- leader 上一次收到它的 fetch 请求是什么时候?
这三个问题问完,绝大多数 HW/LEO 相关的坑都能定位到八九不离十。
我个人后来养成了一个习惯:每当接触一个不熟悉的集群,会先挑一个小流量 topic 做一次“实战演练”。手动停掉一个 follower,观察 HW 变化,再观察消费组 Lag,然后恢复副本,重新看 ISR 扩张行为。十几分钟就能把纸上概念和线上行为完全对上。这种经验带来的判断力,比背再多参数都管用。