揭开 Kafka 水位线的秘密:深度解析 LEO 与 HW 的同步机制
揭开 Kafka 水位线的秘密:深度解析 LEO 与 HW 的同步机制
摘要:在分布式存储中,数据复制是保证高可用的核心。但你是否想过:Follower 是怎么把数据从 Leader 那里“搬”过来的?消费者为什么只能看到一部分数据?HW(高水位)到底是怎么涨上去的?本文将深入 Kafka 的日志复制协议,拆解 LEO 与 HW 的爱恨情仇。
1. 核心概念:什么是 LEO 和 HW?
在深入流程之前,必须先对这两个术语进行精准定义。它们是 Kafka 日志中的两个“游标”。
1.1 LEO (Log End Offset)
- 定义:日志末端位移。它代表下一条消息将被写入的位置。
- 数值:
LEO = 最后一个消息的 Offset + 1。 - 特性:
- 每个副本(Leader 和 Follower)都有自己的 LEO。
- 只要有新消息写入(或同步)成功,LEO 就会
+1。
1.2 HW (High Watermark)
- 定义:高水位线。它定义了消息的可见性和数据的安全边界。
- 数值:所有 ISR(同步副本集合) 中,最小的那个 LEO。
- 公式:
HW = min(LEO_Leader, LEO_Follower1, LEO_Follower2...)(假设都在 ISR 中)。
- 公式:
- 特性:
- 对消费者:消费者只能拉取到
offset < HW的消息。HW 之后的数据对消费者不可见(因为可能还没同步给所有 ISR,随时可能丢失)。 - 对副本:HW 是数据截断(Truncation)的依据。如果 Follower 的数据超过了 HW 但没被确认,重启后会将多出的部分截断。
- 对消费者:消费者只能拉取到
2. 宏观图解:日志结构
假设一个 Partition 有 3 个副本,Offset 0-4 都已同步,Offset 5 刚写入 Leader 但未同步。
graph LRsubgraph Log_Structure [日志文件逻辑结构]direction LRMsg0[Msg 0]Msg1[Msg 1]Msg2[Msg 2]Msg3[Msg 3]Msg4[Msg 4]Msg5[Msg 5]Empty[空位...]Msg0 --- Msg1 --- Msg2 --- Msg3 --- Msg4 --- Msg5 --- Empty%% 标记 HWHW_Point((HW=5))style HW_Point fill:#fbc02d,stroke:#333Msg4 --- HW_Point%% 标记 LEOLEO_Point((LEO=6))style LEO_Point fill:#29b6f6,stroke:#333Msg5 --- LEO_PointHW_Point -->|消费者只能看到 HW 之前的数据| Msg4LEO_Point -->|下一条写入这里| Emptyend
3. 微观拆解:同步流程 (Fetch Request)
Kafka 的复制机制是 Pull(拉取) 模式。Follower 主动向 Leader 请求数据。
这个过程最精妙的地方在于:Leader 和 Follower 的 HW 更新是不同步的,通常需要两个 Fetch 请求周期才能完成更新。
我们通过一个场景来演示:Producer 发送了一条消息(Offset 0)。
阶段一:Leader 写入本地
- Producer 发送消息
m1。 - Leader 写入本地 Log。
- Leader 状态:
LEO = 1,HW = 0(因为 Follower 还没拿,最小 LEO 还是 0)。
阶段二:Follower 第一次 Fetch (拉取数据)
- 请求:Follower 发送
FetchRequest(fetch_offset=0)。 - Leader 处理:
- Leader 读取 Log,读到了
m1。 - Leader 更新内存中该 Follower 的
Remote LEO = 0。 - Leader 尝试更新 HW:
min(Leader LEO=1, Remote LEO=0) = 0。HW 保持不变。 - 返回:把
m1数据和Leader HW=0返回给 Follower。
- Leader 读取 Log,读到了
- Follower 处理:
- 写入
m1到本地 Log。 - 更新自己的
LEO = 1。 - 更新自己的 HW:
min(自己的LEO=1, Leader的HW=0) = 0。注意:此时 Follower 虽然有数据了,但 HW 还是 0。
- 写入
阶段三:Follower 第二次 Fetch (确认同步 + 更新 HW)
- 请求:Follower 发送
FetchRequest(fetch_offset=1)。- 潜台词:“我已经有 Offset 0 了,请给我 Offset 1 的数据”。
- Leader 处理:
- 收到
fetch_offset=1,Leader 知道 Follower 已经同步完m1了。 - 更新内存中该 Follower 的
Remote LEO = 1。 - 更新 HW:
min(Leader LEO=1, Remote LEO=1) = 1。Leader 的 HW 更新为 1。 - 返回:没有新数据了(空包),但带回
Leader HW = 1。
- 收到
- Follower 处理:
- 收到空包。
- 更新自己的 HW:
min(自己的LEO=1, Leader的HW=1) = 1。Follower 的 HW 终于更新为 1。
4. 时序图解:两次交互的艺术
sequenceDiagramautonumberparticipant P as Producerparticipant L as Leaderparticipant F as FollowerNote over L, F: 初始状态: LEO=0, HW=0%% Step 1: 生产消息P->>L: 发送消息 Msg(0)Note over L: 写本地 Log<br/>L.LEO = 1<br/>L.HW = 0 (因 F.LEO未知)%% Step 2: 第一轮 Fetch (拉数据)F->>L: FetchRequest (offset=0)Note over L: 知道 F 想要 0<br/>判定 F.LEO = 0L-->>F: Response (Msg0, LeaderHW=0)Note over F: 写本地 Log<br/>F.LEO = 1<br/>F.HW = min(1, 0) = 0%% Step 3: 第二轮 Fetch (带回确认)F->>L: FetchRequest (offset=1)Note over L: 收到 offset=1<br/>更新 F 状态: F.LEO = 1Note over L: 计算新 HW<br/>min(L.LEO=1, F.LEO=1) = 1<br/>Leader HW 更新为 1 ✅L-->>F: Response (空数据, LeaderHW=1)Note over F: 更新 HW<br/>min(F.LEO=1, LeaderHW=1) = 1<br/>Follower HW 更新为 1 ✅Note over L, F: 同步完成,数据对消费者可见
5. 存在的缺陷与进化:Leader Epoch
上面的 LEO/HW 机制在正常运行时很完美,但在 Broker 宕机重启 或 Leader 切换 的极端边缘场景下,可能会导致:
- 数据丢失。
- 数据不一致(Divergence):Leader 和 Follower 同一个 Offset 上的数据不一样。
原因:Follower 依赖 HW 进行截断,但 Follower 的 HW 更新有滞后性(如上图所示,慢一拍)。如果此时挂了,Follower 可能会错误地把本来已经有的数据截断掉。
解决方案:Leader Epoch (版本号机制)
从 Kafka 0.11 开始,引入了 Leader Epoch 机制。
- 每当 Leader 变化一次,Epoch 加 1。
- 副本截断不再单纯依赖 HW,而是通过对比
(Epoch, Offset)对。 - 这就像给数据加了“朝代纪年法”,清朝的数据不能用明朝的剑来斩,从而完美解决了 HW 机制的数据丢失隐患。
6. 总结
Kafka 的副本同步协议是保证数据一致性的基石。
- LEO 是进度的终点,HW 是安全的终点。
- Follower 主动拉取:通过 Fetch 请求携带
fetch_offset,既是求数据,也是向 Leader 汇报进度。 - HW 更新滞后性:Follower 的 HW 更新往往滞后于 Leader 一个 RPC 周期。
- 消费可见性:只有 HW 之前的数据,才会被消费者看到,这是 Kafka 保证
at-least-once和避免读到脏数据的关键。
理解了这个“推拉”细节,你再看 Kafka 的监控指标(如 UnderReplicatedPartitions)时,就会有上帝视角了。
