Fluss#1386: 从日志恢复中的 OutOfOrder 来看 LEO、HW 与 Checkpoint 的区别

5 阅读8分钟

一、核心概念:三个"成功"的标准

在分布式日志系统中,"写入成功"有三个不同的定义维度,它们之间的时序差异往往是问题的根源。

1.1 Log End Offset (LEO) 视角:数据已落盘

┌─────────────────────────────────────────────────────────────┐
│  LEO (Log End Offset)                                       │
│                                                             │
│  定义:数据已成功写入本地日志文件(.log segment)              │
│  触发:客户端收到 Produce 响应的 ACK                          │
│                                                             │
│  Client ──► Produce Request ──► Server                      │
│     │◄───── ACK (success) ─────┤                            │
│     │                            ▼                          │
│     │                      ┌──────────┐                     │
│     │                      │  PageCache │ ◄── 数据已写入      │
│     │                      └──────────┘                     │
│     │                            │                          │
│     │                            ▼                          │
│     │                      LEO += 1                         │
│     │                      视为写入成功                      │
└─────────────────────────────────────────────────────────────┘

关键特性

  • LEO 推进是同步的(写入 PageCache 即返回)
  • 不等待刷盘(fsync),也不等待复制
  • 客户端收到 ACK 后,认为写入成功
  • 仅保证单机可见,不保证故障不丢失

1.2 High Watermark (HW) 视角:数据已复制

┌─────────────────────────────────────────────────────────────┐
│  HW (High Watermark)                                        │
│                                                             │
│  定义:数据已被复制到足够多的副本(Leader + Followers)        │
│  触发:Follower 拉取数据并确认                                │
│                                                             │
│  Leader                    Follower-1      Follower-2       │
│    │                          │              │              │
│    ├── batch(seq=0) ────────►│────────────►│              │
│    │                          │              │              │
│    │◄──── ACK ───────────────┤◄─────────────┤              │
│    │                          │              │              │
│    │  HW = 0(复制完成)        │  LEO = 1     │  LEO = 1     │
│    │                          │              │              │
│    ▼                          ▼              ▼              │
│  只有 HW 之前的数据才保证:                                   │
│  - 故障时不丢失                                              │
│  - 消费者可见                                                │
└─────────────────────────────────────────────────────────────┘

关键特性

  • HW 推进是异步的,依赖 Follower 复制
  • 只有 HW 之前的数据才保证故障安全
  • Consumer 只能读到 HW 之前的数据
  • LEO 与 HW 之间的数据:单机可见,但故障可能丢失

1.3 Writer Snapshot 视角:状态已 checkpoint

┌─────────────────────────────────────────────────────────────┐
│  Writer Snapshot (Checkpoint)                               │
│                                                             │
│  定义:Writer 的状态(writerId, lastSequence 等)已持久化      │
│  触发:周期性 checkpoint(默认间隔)或 clean shutdown         │
│                                                             │
│  内容(JSON 格式):                                          │
│  {                                                          │
│    "writer_id": 12345,                                      │
│    "last_batch_sequence": 100,                              │
│    "last_batch_base_offset": 1000,                          │
│    "last_batch_timestamp": 1699123456789                    │
│  }                                                          │
│                                                             │
│  存储:{tabletDir}/{offset}.writer-snapshot                  │
└─────────────────────────────────────────────────────────────┘

关键特性

  • Snapshot 是异步的,周期性触发
  • 一个 snapshot 可能覆盖多个 LEO 推进
  • 两次 snapshot 之间,writer 状态只在内存中

二、三个成功标准的对比

成功标准定义触发时机持久化保证主要用途
LEO数据写入本地日志写入 PageCache 立即返回单机,不保证客户端 ACK,流式写入
HW数据复制到多数副本Follower 确认复制完成多副本,故障安全Consumer 读取,数据安全
SnapshotWriter 状态持久化周期性 checkpoint本地文件,可重建恢复时重建 writer 状态

关键洞察

  • LEO 最快:写入即成功,但最不安全
  • HW 中等:复制完成才成功,保证故障不丢
  • Snapshot 最慢:周期性保存,仅用于恢复

矛盾根源:三个标准推进速度不同,Crash 时可能处于不一致状态


三、矛盾的根源:三个成功标准的时间差

3.1 加入 HW 的完整时间线

时间轴 ──────────────────────────────────────────────────────►

T0: Client 发送 batch(seq=0)
    │
    ▼
T1: Leader 写入 .log 文件
    LEO = 1
    返回 ACK 给 Client
    │◄──── Client 认为写入成功!(LEO 标准)
    │
    ├──► 异步复制到 Follower
    │       │
    │       ▼
    │    Follower 写入完成
    │       │
    │       ▼
T2: 收到 Follower ACK
    HW = 1
    │◄──── 数据现在才真正安全!(HW 标准)
    │
T3: 触发 checkpoint
    保存 writer-snapshot
    │◄──── 状态持久化成功!(Snapshot 标准)

关键观察:
- T1 < T2 < T3,三个标准依次推进
- [T1, T2]:数据单机可见,但故障可能丢失
- [T2, T3]:数据安全,但状态未持久化
- Crash 发生在不同阶段,恢复行为不同

3.2 三种 Crash 场景的对比

Crash 时机数据状态恢复行为潜在问题
T1-T2 之间
(LEO 后 HW 前)
数据在 Leader,未复制从 Leader 日志恢复如果 Leader 损坏,数据丢失
T2-T3 之间
(HW 后 Snapshot 前)
数据已复制,状态未保存从日志重建 writer 状态Fluss #1386 问题发生在这里
T3 之后
(Snapshot 后)
数据和状态都安全从 Snapshot 加载最理想的情况

Fluss #1386 的问题属于第二种场景

  • 数据已经在 HW 之后(安全复制)
  • 但 Writer Snapshot 还没保存
  • 恢复时需要从日志重建状态
  • 又因为 writer 过期导致冲突

四、Fluss #1386 的问题:WriterStateManager 的困境

4.1 问题发生的完整时间线

┌─────────────────────────────────────────────────────────────┐
│  时间线(问题场景)                                          │
│                                                             │
│  Day 0 10:00                                                │
│  ├── Writer A 写入 batch(seq=0)                             │
│  ├── Writer A 写入 batch(seq=1)                             │
│  └── ...(持续写入,seq 递增)                               │
│                                                             │
│  Day 0 22:00                                                │
│  ├── Writer A 写入 batch(seq=17871)  ◄── 最后一条写入        │
│  └── LEO 推进到 17872                                       │
│                                                             │
│  Day 0 22:00 - Day 1 10:00(12小时)                         │
│  ├── Writer A 空闲,无写入                                   │
│  ├── Writer A 在内存中过期(>12小时)                        │
│  │   └── WriterStateManager 清除内存状态                     │
│  │                                                             │
│  ├── 但 .log 文件中还有 Writer A 的记录!                     │
│  │   └── seq=0,1,2,...,17871 都在                           │
│  │                                                             │
│  └── 可能触发过 checkpoint                                   │
│      └── 但 Writer A 已过期,snapshot 中过滤掉               │
│                                                             │
│  Day 1 10:05                                                │
│  └── [CRASH! Server 重启]                                   │
│                                                             │
│  Day 1 10:05(恢复时)                                       │
│  ├── 加载 writer-snapshot                                   │
│  │   └── 没有 Writer A(已过期被过滤)                        │
│  ├── 扫描 .log segment                                       │
│  │   └── 发现 Writer A 的记录(seq=17871)                    │
│  ├── 创建新的 WriterAppendInfo                               │
│  │   └── lastBatchSeq = -1(初始值)                         │
│  ├── 检查序列号                                              │
│  │   └── 17871 == -1 + 1?❌ 失败!                          │
│  └── OutOfOrderSequenceException!                           │
│      └── 恢复失败!                                          │
└─────────────────────────────────────────────────────────────┘

4.2 核心矛盾的可视化

┌─────────────────────────────────────────────────────────────┐
│                     两个世界的冲突                           │
├─────────────────────────────────────────────────────────────┤
│                                                             │
│   WriterStateManager 的世界          Log 文件的世界          │
│   (内存状态)                       (持久化数据)           │
│                                                             │
│   ┌─────────────────────┐          ┌─────────────────────┐  │
│   │ Writer A: 已过期     │          │ Writer A: seq=0     │  │
│   │ lastSeen: 22:00     │          │ Writer A: seq=1     │  │
│   │ status: EXPIRED ❌   │          │ Writer A: seq=2     │  │
│   │                     │          │ ...                 │  │
│   │ (内存已清除)        │          │ Writer A: seq=17871 │  │
│   └─────────────────────┘          │ (数据还在!)        │  │
│                                    └─────────────────────┘  │
│                                                             │
│   恢复时的冲突:                                              │
│   ─────────────────                                          │
│   1. 加载 snapshot:Writer A 不存在(已过期过滤)              │
│   2. 扫描 log:发现 Writer A 的记录                           │
│   3. 重建状态:创建新的 WriterAppendInfo(lastSeq=-1)          │
│   4. 验证 seq:17871 vs -1,不匹配!                          │
│   5. 结果:OutOfOrderSequenceException                       │
│                                                             │
└─────────────────────────────────────────────────────────────┘

五、Fluss #1386 的修复方案

5.1 修复思路:识别"过期 writer 的历史记录"

// 修复后的核心逻辑(伪代码)

// 1. 判断 batch 是否来自过期 writer
boolean isWriterInBatchExpired = 
    (currentTime - batch.timestamp) > writerIdExpirationTime;

// 2. 序列号检查时增加特殊处理
private boolean inSequence(int lastBatchSeq, int nextBatchSeq, 
                           boolean isWriterInBatchExpired) {
    // 特殊情况:writer 刚重建(lastSeq=-1)且 batch 已过期
    if (lastBatchSeq == NO_BATCH_SEQUENCE && isWriterInBatchExpired) {
        return true;  // 跳过检查,这是历史记录
    }
    
    // 正常检查
    return nextBatchSeq == lastBatchSeq + 1L
        || (nextBatchSeq == 0 && lastBatchSeq == Integer.MAX_VALUE);
}

5.2 修复后的恢复流程

恢复时扫描到 Writer A 的 batch(seq=17871, timestamp=Day0 22:00)
    │
    ▼
创建新的 WriterAppendInfo(lastSeq=-1)
    │
    ▼
检查:isWriterInBatchExpired?
    (Day1 10:05 - Day0 22:00) > 12小时? 
    = 12小时05分 > 12小时? 
    = true ✓
    │
    ▼
特殊处理:lastSeq==-1 && isExpired==true
    → 跳过严格检查
    │
    ▼
接受 batch,更新状态 lastSeq=17871
    │
    ▼
继续恢复 ✓

六、更深层的思考:设计权衡

6.1 为什么不用"更简单"的方案?

方案说明为什么不选
A: 恢复时不检查 seq直接取最大 seq 作为 lastSeq失去幂等性保护,可能接受真正的乱序写入
B: 同步更新 snapshot每次写入都更新 snapshot性能太差,snapshot 是昂贵的 IO 操作
C: 过期时清理日志writer 过期时删除其日志数据丢失风险,违反持久性保证
D: Fluss #1386 的方案识别过期场景,特殊处理妥协方案,保留检查但放宽过期场景

6.2 根本问题:状态管理和数据生命周期的不一致

┌─────────────────────────────────────────────────────────────┐
│  根本矛盾:                                                  │
│                                                             │
│  WriterStateManager 的生命周期                               │
│  ├── 基于"活跃度"(12小时无写入则过期)                       │
│  └── 内存状态,可重建                                        │
│                                                             │
│  Log 数据的生命周期                                          │
│  ├── 基于"保留策略"(时间/大小)                             │
│  └── 持久化存储,独立管理                                    │
│                                                             │
│  两者独立管理 → 可能不一致 → 恢复时冲突                       │
│                                                             │
│  理想的解决方案?                                             │
│  ├── 方案1:统一生命周期管理                                  │
│  │   └── writer 过期时,其日志也标记为可清理                  │
│  │   └── 但保留到 snapshot 覆盖的 offset                     │
│  │                                                             │
│  └── 方案2:恢复时完全信任日志                                │
│      └── 不检查 seq 连续性,只取最大值                         │
│      └── 幂等性通过其他机制保证(如去重窗口)                   │
│                                                             │
└─────────────────────────────────────────────────────────────┘

七、总结

7.1 核心要点

  1. 三个成功标准

    • LEO:数据已落盘(单机可见,最快但不安全)
    • HW:数据已复制(多副本安全,消费者可见)
    • Checkpoint (Snapshot):状态已持久化(恢复时可用,最慢)
  2. 时间差导致的问题

    • 三个标准推进速度不同:LEO > HW > Checkpoint
    • Crash 可能发生在任意两个阶段之间
    • Fluss #1386 问题发生在 HW 后、Checkpoint 前
  3. Writer 过期的副作用

    • 内存状态被清理
    • 但日志数据还在
    • 恢复时"首次发现"导致 seq 检查失败
  4. Fluss #1386 的修复

    • 通过时间戳识别"过期 writer 的历史记录"
    • 特殊处理,跳过严格检查
    • 妥协方案,非根本解决

7.2 关键代码路径

LogTablet.create()
  └── LogLoader.load()
        └── rebuildWriterState()
              ├── loadFromSnapshot()  ← 加载快照,过滤过期 writer
              └── loadWritersFromRecords()  ← 扫描日志
                    └── updateWriterAppendInfo()
                          └── WriterAppendInfo.append()
                                └── maybeValidateDataBatch()
                                      └── inSequence()  ← Fluss #1386 修改这里

7.3 启示

分布式系统中的"一致性"是多维度的:

  • 数据一致性(Log / HW):数据是否安全、可复制
  • 状态一致性(WriterState):写入状态是否正确跟踪
  • 元数据一致性(Checkpoint):状态是否持久化可恢复

Fluss #1386 的核心矛盾

  • 数据已经在 HW 之后(复制安全)
  • 但 WriterState 在 Checkpoint 之前(未持久化)
  • 加上 Writer 过期机制,导致恢复时冲突

这展示了当多个"成功标准"不一致时,恢复流程需要做出的艰难权衡