一、核心概念:三个"成功"的标准
在分布式日志系统中,"写入成功"有三个不同的定义维度,它们之间的时序差异往往是问题的根源。
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 核心要点
-
三个成功标准:
- LEO:数据已落盘(单机可见,最快但不安全)
- HW:数据已复制(多副本安全,消费者可见)
- Checkpoint (Snapshot):状态已持久化(恢复时可用,最慢)
-
时间差导致的问题:
- 三个标准推进速度不同:LEO > HW > Checkpoint
- Crash 可能发生在任意两个阶段之间
- Fluss #1386 问题发生在 HW 后、Checkpoint 前
-
Writer 过期的副作用:
- 内存状态被清理
- 但日志数据还在
- 恢复时"首次发现"导致 seq 检查失败
-
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 过期机制,导致恢复时冲突
这展示了当多个"成功标准"不一致时,恢复流程需要做出的艰难权衡。