老读者应该知道我写过不少 Kafka 运维相关的内容但一直没专门聊过 HWHigh Watermark高水位和 LEOLog End Offset日志末端偏移这两个数。不是不想写是这两个概念乍一看很简单真正用起来却特别容易翻车。线上问题里消费者读不到最新消息、副本切换后丢数据、acksall 还是出现信息差额追到最后十有八九都指向这两个值。这篇我打算完全按照自己平时的排查思路来讲不照搬文档把 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 的 LEOfollower 有 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 是一个“水位分界线”不是一条真实消息的偏移量。HW3 表示偏移量 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、follower2LEO 分别是 100、90、95那么 HW 是 90。如果 follower1 落后时间过长被移出 ISR那么 ISR 变成 leader 和 follower2HW 会立刻从 90 跳到 95。这就是为什么在某些事故里ISR 一缩消费者突然发现消息能读到了Lag 一下子变小。可这不是什么好事因为最慢的那个副本已经不参与“安全确认”了。等落后副本追上来、重新进入 ISRHW 的计算集合又变回三个副本。如果它追得非常慢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 本地写入消息 Aleader 的 LEO 变成 101。follower 发起 fetchleader 算出当前 ISR 的最小 LEO 是 98于是响应里带上 HW98。follower 收到响应把消息 A 追加到本地日志follower 的 LEO 变成 101同时把本地 HW 更新为 98。下一轮 fetchleader 再次计算 HW。此时 follower 的 LEO 已经追上leader 算出 HW101。注意这时候才真正把消息 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.factor3但两个 follower 都挂掉了ISR 里只剩 leader 一个。这时候 HW 的计算集合就只剩 leaderHW 等于 leader 的 LEO写入一条 HW 就跟一条。这看上去是写入延迟最低、消费者实时性最好的状态。但实际上非常危险。当 HWLEO 时Kafka 等于向消费者承诺这条消息已经安全了。可它只存在于 leader 一台机器上另外两台什么都没有。如果这台 leader 立刻宕机而选举出来的新 leader 又是那两台空壳之一那所有被消费者读过的“已提交”消息全部人间蒸发。这也是为什么我始终建议生产环境开启min.insync.replicas2。它的作用是在写入时强制检查 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 的关系acksall要求 leader 在 ISR 都确认复制后才向生产者返回成功HW 推进是提交标志min.insync.replicas2强制 ISR 数量下限低于该值时写入直接失败replication.factor3总副本数决定了 ISR 最多有几个成员也影响 HW 的最小值下限unclean.leader.election.enablefalsefalse 时不允许非 ISR 副本竞选 leader避免 HW 以上数据被“空壳”leader 接管尤其注意acksall和min.insync.replicas1这种组合。表面上一个保证了持久性一个允许最小同步数只有 1。如果 ISR 只有 leader 一台acksall也会正常返回成功因为“当前 ISR 里的所有副本”确实都同步了。可这个 ISR 只有一台啊。所以说到底acksall到底安不安全不由acks自己决定而由 ISR 当时的构成决定。想把安全边界提高就设置min.insync.replicas2同时别把replication.factor设成 1。replication.factor1配上min.insync.replicas2是矛盾配置写入永远失败这事我在新环境检查时就踩过。5. 排查 HW 和 LEO 问题的实战速查最后聊排查手段。这部分没太多奇技淫巧但可以帮大家少走弯路。5.1 最容易迷惑消费者的三个观测点第一个是消费组的 Lag。执行kafka-consumer-groups.sh --describe --group xxx里面显示的LOG-END-OFFSET是 leader 的 LEOCURRENT-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.factorfollower 追不上或掉线落后副本的网络、IO、GC消费者延迟但 Lag 偏小HW 被最慢 ISR 副本卡住kafka-topics --describe --under-replicated切换 leader 后丢消息新 leader 日志末端低于原 HWunclean.leader.election.enablemin.insync 配置写入报 NotEnoughReplicasISR 数量低于 min.insync.replicas副本数量与 ISR 收缩原因同样配置下两次行为不一致ISR 构成不同HW 推进路径不同关注当时 ISR 里有哪些副本5.4 不要迷信“看源码能解决一切”关于 HW/LEO 的实现源码里到处都是细节但我发现很多人把源码读完了反而更糊涂因为他们忽略了一个前提HW 不是一把绝对的时间戳而是一个依赖拓扑状态和网络节奏的“协商结果”。理解了这一点监控和排查就有方向了。你不用记住每条maybeIncrementHighWatermark的调用路径你只需要在线上遇到诡异延迟时反复问自己三件事当前 ISR 是哪几个副本最慢的那个副本 LEO 是多少leader 上一次收到它的 fetch 请求是什么时候这三个问题问完绝大多数 HW/LEO 相关的坑都能定位到八九不离十。我个人后来养成了一个习惯每当接触一个不熟悉的集群会先挑一个小流量 topic 做一次“实战演练”。手动停掉一个 follower观察 HW 变化再观察消费组 Lag然后恢复副本重新看 ISR 扩张行为。十几分钟就能把纸上概念和线上行为完全对上。这种经验带来的判断力比背再多参数都管用。