Kafka HW与LEO:高水位和日志末尾偏移量如何影响副本同步与数据一致性 做 Kafka 的同学绕不开两个缩写HWHigh Watermark高水位和 LEOLog End Offset日志末尾偏移量。只要你在生产环境遇到过消费者读不到最新消息、副本老是同步不齐、甚至切换 Leader 之后“离奇”丢数据大概率都能在这两个概念上找到根因。HW 和 LEO 是 Kafka 副本同步机制里最核心的两个位置指针一个代表着“这条消息真正安全了”另一个代表着“日志本地写到了哪”。本文不聊源码逐行解析主要从实战角度把这两个概念、更新流程、经典坑位和排查手段一次性捋清楚适合刚接触 Kafka 的开发者也适合被副本问题折腾过的运维老手。1. LEO 与 HW 是什么先搞清楚两个偏移量的角色1.1 LEOLog End Offset下一条消息该写在哪LEO 的全称是 Log End Offset意思是当前日志段Log Segment里“下一条待写入消息”的偏移量。注意它不是一个已存在消息的偏移量而是末尾位置。如果一个分区里已经有 offset 0、1、2 三条消息那么 LEO 就是 3如果一条消息都没有LEO 就是 0。理解 LEO 最直接的方式是把分区日志想象成一个 Append-only 的数组。生产者不断往里追加消息日志尾部不断往后推进LEO 就是那个“下一支笔落下去的位置”。它跟消费进度没关系只反映本地日志写到哪了。每个副本都有自己的 LEO。Leader 副本的 LEO 由生产者的写入请求推动Follower 副本的 LEO 由拉取Fetch请求推动。也就是说Leader 和 Follower 的 LEO 天然就不是同一时刻一致的它们之间的差值就是最常见的“副本滞后量”。1.2 HWHigh Watermark真正能被看见的水位线HW 的全称是 High Watermark中文常叫高水位。它表示“在这个偏移量之前的消息已经被认为是安全提交的”。消费者能看到的、能读到的消息只能到 HW 为止HW 之后的那些消息哪怕已经在 Leader 的日志文件里躺着了消费者也不能读。这里有个容易混淆的细节HW 指向的是“下一条不可见消息”的偏移量。如果 HW 2说明 offset 0 和 1 可见offset 2 及之后不可见。类比一下HW 就像是老师批改作业批到了第几题LEO 是同学们写到第几题。写完了不代表算数只有老师红笔批到的地方才算“有效进度”。消费者能做的就是参考老师的批改进度没批到的地方即使你卷面已经写满了也得等。1.3 从公式看两者关系HW 是所有 ISR 副本的最小 LEOHW 不是凭空拍脑袋出来的。它有一个非常明确的计算逻辑Leader 副本的 HW 等于当前 ISRIn-Sync Replicas同步副本集合中所有副本 LEO 的最小值。这里需要稍微展开说一下 ISR。Kafka 里每个分区会有多个副本但并不是所有副本都参与数据同步的“交付判定”。只有那些跟得上 Leader、一直处于同步状态的副本才会待在 ISR 集合里。Follower 如果拉取太慢落后时间超过replica.lag.time.max.ms默认 30 秒就会被 Leader 踢出 ISR。一旦被踢出Leader 计算 HW 时就不再考虑这个副本的 LEO因为 ISR 之外属于“不太靠谱”的成员它们的进度不能拖累整体的提交水位。所以公式可以写成Leader HW min(所有 ISR 副本的 LEO)至于 Follower 侧的 HW则是另一个口径Follower HW min(Leader 返回的 HW本地 LEO)这个公式会在下一节展开涉及 Follower 从 Leader 拉取数据后如何推进自己的水位。先有个概念就行HW 始终是一个“保守”的下限值宁可低一点也不能高估安全性。2. HW 与 LEO 的更新流程从 Produce 到 Fetch 的完整链条2.1 一次正常写入LEO 和 HW 如何变化假设一个分区有三个副本Leader 是 AFollower 是 B 和 C三者都在 ISR 里。现在生产者往 Leader 写入一条消息这条消息的 offset 将会是 3。整个过程大致是这样步骤发生位置变化内容1. 生产者发送消息Leader AA 追加日志A 的 LEO 从 3 变成 42. Follower 发起 FetchB 和 C 各自请求Leader A 把新消息返回给 B、C同时在响应里带上 A 当前的 HW3. Follower 追加日志B 和 CB、C 各自追加消息B 的 LEO 变成 4C 的 LEO 变成 4随后 B、C 用min(Leader HW, 本地 LEO)更新自己的 HW4. Follower 回报进度下一个 Fetch 请求B、C 在下一个 Fetch 请求中把自己最新的 LEO 告诉 A5. Leader 推进 HWLeader AA 收到 B、C 的 LEO 后重新计算min(ISR 所有副本的 LEO)此时三个副本 LEO 都是 4A 的 HW 推进到 46. 消费者可见性Leader A消费者可以读到新消息offset 3因为 3 HW4这里能看到一个关键规律消息并不是写入 Leader 就算“提交成功”它要等到所有 ISR 副本都拉到这条消息之后HW 才会往前走一步。HW 的推进天然是滞后于 Leader 的 LEO 的永远慢一步。2.2 为什么 HW 总是慢半拍Follower 视角的更新逻辑很多新手会问为什么 HW 不能直接等于 Leader 的 LEO原因很简单如果 Leader 自己写成功了但 Follower 还没来得及拉取这时 Leader 突然宕机被提升上来的 Follower 是没有这条消息的。如果消费者已经在旧 Leader 上读到了这条消息那么新 Leader 上任后这条消息相当于“消失”了这就不满足一致性要求。所以 HW 必须保守。它代表的是“就算我现在立刻宕机也依然存在于绝大多数副本上”的安全位置。这就是为什么 Kafta 的提交语义跟 HW 深度绑定Producer 可以在消息写入 Leader 后收到 ack但消费者能不能看到这条消息完全取决于 HW 是否越过了它。这里有个大家很关心的关联点acks参数。生产端配置acksall时Leader 要等所有 ISR 副本确认写入后才给生产者返回成功。但这个“确认”跟 HW 推进并不完全同步ISR 只要还有副本没拉完HW 就还是老位置。所以在极端情况下生产者已经收到成功响应消费者却依然看不到这条消息这不是 bug而是 HW 还没跟上而已。我个人的理解是LEO 是物理进度HW 是“经过多方确认”的逻辑进度。物理进度推进快逻辑进度推进慢这个差就是数据安全裕量。2.3 消费者读取与副本同步的分界HW 的双重身份HW 在 Kafka 里承担着双重角色。对消费者来说它决定了哪些消息可以被看到也就是“可见性水位线”。对副本同步来说它决定了日志截断时以什么位置为基准也就是“安全恢复水位线”。消费者从 Leader 副本拉取消息时Leader 返回的数据只会到 HW 为止。注意Kafka 的消费者不会从 Follower 副本读数据所有读流量都打在 Leader 上。所以你问“为什么消费者读不到最新一条”第一反应应该去查 Leader 的 HW而不是去查生产者的发送是否成功。有一种特殊情况需要单独留意如果启用了事务并且消费者设置了isolation.levelread_committed消费者还需要额外等待事务标记transaction marker写入后才能读到已提交事务里的消息。这种情况下的“可见性”问题就不是单纯 HW 能解释的了但在基础的数据可见性逻辑上HW 依然是那根最底层的尺子。Follower 侧则不同。Follower 从 Leader 拉取数据后不能用自己本地日志的 LEO 直接作为对外可见的水位它必须用min(Leader HW, 本地 LEO)来约束自己。这么做的意义在于Follower 不能假设自己领先于 Leader 的已提交水位更不能在 Leader 还没标记提交之前就把本地日志当成“已提交”来让后续流程处理。3. HW 滞后带来的数据丢失风险经典坑与 Leader Epoch 修复3.1 经典场景Leader 切换后消息凭空消失HW 滞后最直接的后果就是 Leader 切换时可能发生数据丢失。这个场景在 Kafka 0.11 之前的版本里非常经典值得每个做 Kafka 的人认真理解一遍。设想一个最简单的集群分区有两个副本A 是 LeaderB 是 Follower都在 ISR 里。生产者写入一条 offset 为 1 的消息配置的是acksall。A 收到消息后本地 LEO 变成 2B 也通过 Fetch 拉到了这条消息B 的 LEO 也变成 2。但是B 的 HW 可能还停在 1A 的 HW 也还没来得及推进到 2。就在这时A 宕机了。控制器检测到 Leader 不可用把 B 提升为新的 Leader。B 在旧协议下启动时会先把自己的日志截断到 HW 的位置。由于 B 的 HW 还是 1它会把已经复制到本地的 offset 1 这条消息给截掉。随后 A 恢复作为 Follower 重新加入它发现新 Leader B 的日志比自己短又会把自己的日志截断到 B 的长度。结果就是这条消息明明已经写进了两个副本的本地日志却因为 HW 还没推进最终两边都被截掉了。消费者永远读不到它生产者却早已收到成功 ack。这就是经典的“已提交数据丢失”。3.2 旧协议为什么丢HW 截断的问题问题出在“无条件按 HW 截断”这个策略上。旧版本里每个副本在重启、Leader 变更、或者作为 Follower 重新同步时都会把日志尾部超出 HW 的部分直接切掉。之所以这样做原本是为了确保所有副本最后能收敛到一个一致的状态。但这个策略有一个盲区HW 只表示“所有 ISR 副本都已经确认复制到的位置”并不能表示“某个副本本地到底复制到了哪里”。B 明明已经复制了 offset 1 的消息只是这条消息还没达到 HW 而已。HW 的更新受网络、调度、Fetch 频率等多方面因素影响天然会滞后。用这个本来就滞后的位置当作截断基准就会把无辜的“尾部数据”一起误杀。打个比方团队里有一份共享文档组长说“大家以我看到第 3 节为准”但组员 B 其实已经把第 4 节也编辑完了。这时候组长换了新组长说“我们只保留到第 3 节”B 辛苦写的第 4 节就被清掉了哪怕这份内容在 B 这边是完整的。旧协议的 HW 截断就是这么个粗暴的逻辑不管你能不能追上凡是超过 HW 的一律视为无效。3.3 KIP-101 与 Leader Epoch从根上修复截断误判好在 Kafka 0.11 版本引入了 Leader Epoch 机制对应提案是 KIP-101。这个机制的核心思路是不再单纯依赖 HW 作为唯一的安全恢复基准而是引入一个“Leader 任期纪元”的概念。每个 Leader 任期都会有一个递增的 epoch 号同时日志中会记录 (epoch, offset) 这样的映射关系。每当 Leader 切换时新 Leader 都会拿到一个新的 epoch。当一个 Follower 重新与 Leader 同步时它先通过OffsetsForLeaderEpochRequest向 Leader 询问某个 epoch 对应的日志末尾位置然后再决定自己到底需要截断到哪里。这样一来B 被提升为 Leader 后不会再盲目把日志截断到自己旧的 HW。它会先检查 A 恢复回来后A 的日志与自己的日志在哪个 epoch 发生了分叉然后只在真正分叉的位置做截断。上面那个场景里两条副本在同一个 epoch 内都已经复制了相同数据就可以判定为“没有分叉”数据就不会被误删。这是 HW 机制一个非常关键的修复。现在生产环境基本都是 0.11 以上的版本理论上不会再因为“HW 滞后 无条件截断”产生经典丢失问题。但这不代表我们就可以忽略 HW 滞后带来的影响因为 HW 依然决定了消费者可见性也影响着 ISR 判定和故障恢复速度。3.4 生产参数怎么设才能把风险压到最低虽然 Leader Epoch 修复了截断误删但从运维角度最好的局面还是尽可能不让“已提交但 HW 未推进”这种窗口期出现。我的建议是三步走第一步Producer 端一定要配acksall。只用acks1的话Leader 写入成功就返回任何 Follower 复制延迟都可能变成数据丢失这个没有讨论余地。第二步Topic 级别设置min.insync.replicas2配合复制因子 3。acksall的实际效果取决于 ISR 里有多少副本。如果 ISR 里只有 1 个副本acksall本质上也退化成单副本写入。只有min.insync.replicas限制了“至少有几个副本确认”才有可能在 Leader 宕机时交出可用的新 Leader。第三步不要轻易把replica.lag.time.max.ms调得太大。这个参数决定 Follower 落后多久会被踢出 ISR。调大了ISR 里就会保留更多“勉强跟上”的副本HW 的推进会被这些慢副本拖住消费者可见性延迟也会变大。如果为了追求集群稳定把参数调到几分钟一旦真的发生 Leader 切换风险窗口会非常大。4. 生产环境排查与监控怎么判断副本真的“追平”了4.1 排查告警UnderReplicatedPartitions 到底在说什么日常运维里最常见的告警之一是UnderReplicatedPartitions。这个指标来自 Kafka 的 JMX表示当前有多少分区处于副本不足同步的状态。看到这个告警第一反应就是 ISR 集合收缩了也就是有 Follower 掉队了。但注意ISR 收缩本身不一定是故障。比如 Follower 正在重启、正在执行 Leader 迁移、或者刚好赶上 GC 停顿都可能短暂掉出 ISR。关键在于看持续时间。一般建议连续超过 5 分钟还没恢复就要介入。持续性的 UnderReplicatedPartitions 往往说明 Follower 的磁盘 IO、网络带宽或 CPU 已经出现瓶颈Follower 根本拉不过来。还有一类情况是 ISR 集合已经完全空掉只剩 Leader 自己。这个时候如果 Leader 再宕机分区就不可用。再叠加min.insync.replicas的设置生产者请求会被拒绝。从监控上要额外关注 ISR 的最小数量而不只是 UnderReplicatedPartitions 是否大于 0。4.2 用命令和工具定位 LEO、HW命令行、图形工具与磁盘文件排查 HW 和 LEO生产上最常用的是下面几种手段。一是用 Kafka 自带的命令行工具。比如# 查看分区 Log End OffsetLEO kafka-get-offsets.sh --bootstrap-server localhost:9092 \ --topic test-topic --time -1这里的--time -1表示取每个分区最新的 offset也就是 LEO。如果想看最早可用的 offset用--time -2。但要注意这个命令拿到的是 LEO不是 HW。要直接看 HW命令行层面稍麻烦一点。图形工具方面推荐直接用 Offset Explorer以前叫 Kafka Tool。它能在分区详情里清楚列出每个副本的 LEO、HW、ISR 状态和副本是否 Leader。对快速判断“这个分区到底哪里不一致”特别有用尤其是发生 Leader 切换前后对比一眼就能定位问题。还有一个被很多人忽略的视角磁盘上的 checkpoint 文件。Kafka 的数据目录下有一个replication-offset-checkpoint文件里面记录了每个分区当前持久化的 HW。这个文件是 Kafka 内部用来在重启后恢复水位用的默认每 5 秒刷新一次。手动查看可以加深理解但生产环境不要随便改动它否则会导致水位恢复异常find /data/kafka-logs -name replication-offset-checkpoint -print里面能看到类似这样的记录version: 0 topic,partition,highwatermark test-topic,0,1234567结合这个文件的内容再对比kafka-get-offsets拿到的 LEO就能算出每个分区当前大概还有多少数据处于“未提交”状态。4.3 实操心得从 HW 滞后反推集群健康度我在实际维护 Kafka 集群时会特别关注一组“差值”而非单个指标每个副本的 LEO 与 HW 之间的差值以及 Leader 的 HW 与最近确认的 ISR 副本 LEO 之间的差值。如果某个分区长期存在“Leader LEO 远大于 HW”的情况说明 ISR 里至少有一个副本一直拉不动。这个“拉不动”的副本会在一个replica.lag.time.max.ms周期后被踢出 ISR届时 HW 会突然往前跳一大截。如果你在监控上看到 HW 呈现“长时间平台期 突然跳变”的形态那基本不是正常流量波动而是副本掉队 / 恢复的循环。还有一种情况容易被误判消费者组延迟很大但消费者本身 CPU、网络都正常。这时候检查 HW往往发现 HW 本身就没怎么涨。顺着副本方向查下去多半是某个 Follower 的磁盘 IO 满了拖住了整个 ISR 的 HW 推进进而拖住了消费者的可见性。这个链路是副本慢 → ISR 最小 LEO 低 → HW 不涨 → 消费者看不到新数据。最终消费者表现成“消费延迟高”但根因根本不在消费端。4.4 实战建议把 HW 和 LEO 相关的监控配齐生产环境我建议至少监控这几项kafka.server:typeReplicaManager,nameUnderReplicatedPartitions看 ISR 是否收缩。kafka.server:typeReplicaManager,nameIsrShrinksPerSec和IsrExpandsPerSec看 ISR 变化频率。kafka.log:typeLog,nameLogEndOffset,topic...,partition...看每个分区的 LEO。客户端库暴露的records-lag-max等消费延迟指标从消费端反推可见性。磁盘上的 checkpoint 文件采集虽然偏底层但在需要精确知道 HW 的时候很管用。有条件的话还可以写一个定时脚本定期比较每个副本之间的 LEO 差值。如果同一个分区Leader LEO 与任意一个 ISR 副本的 LEO 差值长期超过一个阈值比如几万条仍然不收敛那就要重点检查网络和磁盘了。正常情况下ISR 内的副本 LEO 应该在非常短的时间内追平追不平必有异常。最后一个经验升级 Kafka 版本时重点关注 Leader Epoch 机制相关的兼容性。老版本升级到新版本通常需要滚动重启这个过程中会触发日志截断逻辑。确认所有 broker 的版本都进入新协议之后再放开生产写入。不然混合版本运行期间老版本 broker 会按旧协议处理截断HW 的相关风险依然存在。