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

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

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

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

vbscript 复制代码
┌─────────────────────────────────────────────────────────────┐
│  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) 视角:数据已复制

ini 复制代码
┌─────────────────────────────────────────────────────────────┐
│  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

arduino 复制代码
┌─────────────────────────────────────────────────────────────┐
│  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 读取,数据安全
Snapshot Writer 状态持久化 周期性 checkpoint 本地文件,可重建 恢复时重建 writer 状态

关键洞察

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

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


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

3.1 加入 HW 的完整时间线

ini 复制代码
时间轴 ──────────────────────────────────────────────────────►

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 问题发生的完整时间线

ini 复制代码
┌─────────────────────────────────────────────────────────────┐
│  时间线(问题场景)                                          │
│                                                             │
│  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 核心矛盾的可视化

ini 复制代码
┌─────────────────────────────────────────────────────────────┐
│                     两个世界的冲突                           │
├─────────────────────────────────────────────────────────────┤
│                                                             │
│   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 的历史记录"

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

// 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 修复后的恢复流程

ini 复制代码
恢复时扫描到 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 根本问题:状态管理和数据生命周期的不一致

bash 复制代码
┌─────────────────────────────────────────────────────────────┐
│  根本矛盾:                                                  │
│                                                             │
│  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 关键代码路径

scss 复制代码
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 过期机制,导致恢复时冲突

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

相关推荐
Carsene2 小时前
🎉 AutoScan v1.1.0 发布 - 通配符包扫描、排除过滤、自定义注解三大新特性
spring boot·后端
PaytonD2 小时前
基于 GPUI 实现 WebScoket 服务端之服务篇
后端·rust
毕设源码-邱学长2 小时前
【开题答辩全过程】以 咖啡馆管理系统的设计与实现为例,包含答辩的问题和答案
java
NGC_66112 小时前
JDK1.7 与 JDK1.8 ConcurrentHashMap:从分段锁到桶级锁的进化
java·开发语言
用户8356290780512 小时前
使用 Python 精准控制 Word 段落格式
后端·python
leaves falling2 小时前
C++类和对象(3)(初始化列表,类型转换,static成员,友元)
java·开发语言·c++
StackNoOverflow2 小时前
Spring Boot 整合 MyBatis + 自动配置原理详解
spring boot·后端·mybatis
色空大师2 小时前
【网站开发-java】
java·linux·服务器·开发语言·网站·搭建网站
于先生吖2 小时前
远程考试系统搭建 JAVA 国际版源码与多国语言集成方案
java·开发语言