# go-kv 存储引擎设计文档

## 1. 项目定位

### 1.1 目标

构建一个高性能、功能完善的 KV 存储引擎，使用 Go 语言实现。

### 1.2 聚焦方向

- **写吞吐极致** — 写入路径优化到极致，这是核心竞争力
- **读性能不拖后腿** — 不追 B+Tree 的延迟，但不应比同类 LSM 引擎差超过 20%

### 1.3 具体数字目标

- 待引擎跑出 benchmark 后确定
- 方向：持久化写吞吐尽可能高，非持久化写 20 万 ops/s 量级

### 1.4 架构风格

- **架构简洁** — 以最少的代码实现最强的性能
- **可配置** — 关键参数可调，不同场景灵活适配

### 1.5 功能范围

#### 包含

- 有序 KV（LSM-Tree）
- 范围扫描（Range Scan）
- 可串行化事务，多写者并发（SSI）
- MVCC 内置在存储引擎
- KV 分离（小值内联，大值走 Value Log）
- 嵌入模式（Go 库）
- 网络层（自定义 TCP 二进制协议）— 后续

#### 不包含（后续扩展）

- Watch / 变更通知
- TTL / 过期机制
- 快照 / 备份
- Redis 数据类型（String/List/Hash/Set）
- 分布式

---

## 2. 整体架构

```
┌─────────────────────────────────────────────┐
│              嵌入式 API / 网络层（后续）       │
├─────────────────────────────────────────────┤
│           事务层（MVCC + SSI）                │
├─────────────────────────────────────────────┤
│               存储引擎核心                    │
│                                             │
│  写入路径：                                  │
│  Client → WAL → MemTable → Immutable        │
│                        MemTable → SSTable    │
│                                             │
│  读取路径：                                  │
│  Client → MemTable → Immutable → SSTable    │
│                                             │
│  后台任务：                                  │
│  Compaction + Value Log GC                  │
│                                             │
│  加速组件：                                  │
│  Bloom Filter + Block Cache                 │
├─────────────────────────────────────────────┤
│              Value Log（大值分离）            │
├─────────────────────────────────────────────┤
│            文件系统（SSTable / WAL / VLog）   │
└─────────────────────────────────────────────┘
```

---

## 3. 存储引擎核心

### 3.1 底层结构

**LSM-Tree（Log-Structured Merge Tree）**

选择理由：
- MVCC 多版本：追加写天然支持，B+Tree 需要在页面内维护版本链
- 多写者并发：写入路径简单（WAL → 内存），无需复杂的页面级锁协议
- KV 分离：SSTable 不可变，compaction 只搬 key 和指针，不搬大 value
- 高写吞吐：顺序写 WAL + 追加写 Value Log，避免随机写

---

### 3.2 WAL（预写日志）

**职责：** 崩溃恢复。写入数据前先追加到日志文件，确保数据不丢失。

#### 持久化策略

- 默认采用 **durable group commit**：多条写入合并为一个 WAL Batch，Batch 写入并 fsync 成功后，Batch 内每条写入才分别返回成功
- 默认语义下，`Put` 返回成功表示该写入所在 WAL Batch 已经持久化；进程崩溃或机器掉电后，可通过 WAL 恢复
- 提供配置项允许用户显式选择异步刷盘；异步模式追求吞吐，但允许丢失最近尚未 fsync 的写入

#### 写入流程

```
① 写请求进入 commit queue
② WAL writer 收集当前队列中的多条写入，组成 WAL Batch
③ 等待组提交触发（500µs 或 32KB，先到者触发）
④ 完成 Batch 校验与 WAL 编码前置准备
⑤ 预留 MemTable Arena 容量；不足时先冻结当前 MemTable 并切换到新的可写 MemTable
⑥ 为 Batch 内每条写入分配递增的 sequence number，并在内存中编码 WAL Batch
⑦ 将编码后的 WAL Batch 拆分为一个或多个 Physical Record，追加写入 WAL 文件
⑧ 写入 MemTable，但标记为 pending / unpublished
⑨ WAL Batch fsync 落盘
⑩ 发布 publishedSequence，使 Batch 内写入对普通读可见
⑪ 唤醒 Batch 内每个调用方，分别返回客户端"写入成功"
```

默认模式下，写入成功发生在 WAL fsync 之后；但 fsync 的粒度是 WAL Batch，不是单条 `Put`。因此多条写入共享一次 fsync 成本，同时每条写入仍然只有在自身所在 WAL Batch 持久化后才算成功。

步骤 ④ 和 ⑤ 是 WAL 副作用之前的强制资源关口：WAL writer 必须先执行 WAL Batch 资源上限校验，确认 encoded batch 在当前配置下可以写入一个空 WAL segment，再按 Batch 内所有 entry 的最大内存占用（key、value 或 ValueLogPointer、skiplist 节点、arena 对齐与层高开销）计算需要预留的 Arena 字节数。若 Batch 超过 WAL 资源上限、超过空 WAL segment 可承载上限，或单个 Batch 的最大可能内存占用超过 MemTable 上限，该 Batch 必须在 sequence 分配和 WAL write 之前按普通错误拒绝。若当前可写 MemTable 剩余容量不足，必须先把它冻结为 Immutable MemTable，并创建新的可写 MemTable 后再继续。若 Immutable MemTable 队列已达上限，该 Batch 必须在 WAL write 之前等待后台 flush 释放容量。实现不得在 WAL write / append 已尝试之后，再因为 Batch 超限、segment 容量不可能容纳或 Arena 满而让 MemTable pending 写入失败。

步骤 ⑧ 与 ⑩ 之间存在明确的内存序约束：MemTable skiplist / arena 节点必须先通过原子发布机制写入读路径可见结构（例如 `atomic.Pointer` store-release，或等价的 release publish），并且 Batch 内所有 entry 的节点都完成发布后，才能用 `atomic.Uint64.Store` 推进 `publishedSequence`。普通读者必须先 `Load` 当前 `publishedSequence`，再遍历 MemTable；读到 entry 后仍以 `entry.sequence <= loadedPublishedSequence` 判断可见性。该顺序保证弱内存序架构上读者不会先观察到已推进的 `publishedSequence`，却看不到对应已发布的 skiplist 节点。

步骤 ⑥ 必须只做内存内编码，不得触碰 WAL 文件或任何可能被 recovery 看到的 WAL 缓冲区。若 Batch 校验、MemTable 容量预留或内存编码在步骤 ⑥ 之前失败，且尚未分配 sequence，该 Batch 可以按普通错误返回，调用方可安全地认为本次提交没有进入 WAL。若实现已经分配了 sequence 且不复用，即使失败尚未触碰 WAL，也必须进入 write-stopped 状态；否则 `publishedSequence` 无法跳过失败 Batch 推进，后续成功 Batch 也会因为 sequence 空隙而不可见。因此实现应尽量把可失败的校验、容量预留与编码前置到 sequence 分配之前，减少无 WAL 副作用的普通错误触发 write-stopped。

步骤 ⑦ 一旦尝试 WAL write / append，后续错误的判断标准不再是 Batch 是否已经进入 MemTable，而是 WAL bytes 是否可能已经到达 recovery 可见状态。`write()` 返回错误时，部分或全部 bytes 可能已经进入 OS page cache、WAL 文件或实现内部的 WAL buffer；即使 Batch 尚未进入 MemTable，也不能把它当作 definitely failed。除非实现能证明 zero bytes reached WAL state（例如未调用 write，或明确的内部缓冲协议证明没有任何 byte 被提交给 WAL 状态），否则 Batch 内调用方必须返回 `ErrCommitUnknown`，引擎进入 write-stopped 状态。

WAL 写入路径的失败分类如下：

| 失败点 | WAL 副作用 | 客户端结果 | 引擎状态 |
|--------|------------|------------|----------|
| Batch 校验 / MemTable 容量预留 / 内存编码失败，且尚未分配 sequence | 无 | 普通错误 | 可继续写入 |
| Batch 校验 / MemTable 容量预留 / 内存编码失败，但 sequence 已分配且不复用 | 无 | 普通错误 | write-stopped |
| WAL write / append 已尝试后失败，且无法证明 zero bytes reached WAL state | 可能有 | `ErrCommitUnknown` | write-stopped |
| WAL write / append 已成功，但 MemTable pending 写入违反预留保证仍然失败 | 可能有 | `ErrCommitUnknown` | write-stopped |
| WAL bytes 已写入，MemTable pending 写入后 fsync 失败 | 可能有 | `ErrCommitUnknown` | write-stopped |

`ErrCommitUnknown` 的 Batch 在当前运行期仍不发布、不确认成功；如果已经写入 MemTable，其 entries 保持 pending / unpublished 或转为 aborted，仅作为内部状态存在，对普通读不可见。`publishedSequence` 始终保持连续 high-water mark，普通读仍可使用 `sequence <= publishedSequence` 判断可见性。重启后，如果 recovery 在 WAL 中发现该 Batch 完整、CRC 合法且 sequence 连续，可以按正常 WAL 规则重放；如果只留下尾部 partial write，则按 WAL 尾部截断规则处理。

WAL 编码与写入缓冲必须采用 **per-batch private encode buffer + direct write** 模型：

```text
Batch entries → private encoded []byte → split Physical Records → write(fd, records)
```

每个 WAL Batch 在私有内存缓冲区中完成完整编码和 Physical Record 布局；该缓冲区在调用 `write()` 之前不得被
recovery 看到，也不得进入任何跨 Batch 共享的 WAL 写入状态。实现可以用对象池复用底层 `[]byte` 容量，但复用后的
缓冲区在同一时刻只能归属一个 Batch，并且必须在该 Batch 完成写入或失败处理后才能归还池中。

第一版 WAL writer 不使用共享 `bufio.Writer` 作为 WAL append 路径的一部分，也不允许多个 Batch 先写入同一个共享
用户态缓冲区后再统一 flush。共享 buffered writer 会模糊“zero bytes reached WAL state”的边界：一旦 Batch bytes
进入共享缓冲，后续 flush 失败无法可靠判断具体哪个 Batch 已经对 WAL 状态产生副作用。因此失败分类以是否已经尝试
把当前 Batch 的 private buffer 写入 WAL 文件为边界：未调用 `write()` 前失败是普通错误；调用 `write()` 后失败，
除非实现能证明当前 Batch 零字节到达 WAL 状态，否则必须返回 `ErrCommitUnknown` 并进入 write-stopped。

`ErrCommitUnknown` 表示提交结果不确定：调用方不能把它当作“写入一定失败”并盲目重试。恢复完成后，调用方必须通过读取 key 或后续事务层提供的事务 ID / commit record 查询提交状态，再决定是否重试。后续 MVCC / SSI 事务层必须为事务提交提供幂等标识，避免 fsync 不确定结果导致非幂等事务重复提交。

如果进程在 WAL write / append 已尝试之后、调用方收到成功或错误之前崩溃，调用方观察到的是“无返回结果”。该状态在 API 语义上等价于 `ErrCommitUnknown`：不是成功确认，也不是 definitely failed，而是 maybe committed。调用方重启后必须按同一套状态确认约束处理，不得因为没有收到成功返回就假设该写入一定不存在。

#### ErrCommitUnknown 的 API 层确认约束

`ErrCommitUnknown` 在 API 层的含义是 **maybe committed**：该写入可能已经持久化并会在恢复后可见，也可能没有持久化。它不是 rollback 语义，也不是 definitely failed。调用方不得仅凭错误类型盲目重试，除非该逻辑操作本身幂等，且业务允许覆盖后续并发写入。

第一阶段单 key autocommit 没有事务级 `txnID` / commit record，只能提供弱状态确认：恢复完成后读取 key 只能判断当前最终状态，不能证明“这一次尝试”是否提交。不同操作的确认能力如下：

| 操作 | 读取确认能力 | 约束 |
| ---- | ------------ | ---- |
| `Put(key, distinguishableValue)` | 如果恢复后 `Get(key)` 返回该 value，只能说明当前状态符合预期 | 若存在并发写入或旧值相同，不能证明本次 Put 已提交 |
| `Put(key, emptyValue)` | 必须依赖 API 区分 key exists with empty value 与 key not found | `Get` 不能只返回 `[]byte`；必须返回 `found` 或等价状态 |
| `Delete(key)` | 如果恢复后 key not found，只能说明当前 key 不存在 | key 可能原本就不存在，或被其他操作删除；不能证明本次 Delete 已提交 |
| 重复写同一 key | 读取到相同 value 不能区分是哪一次写入提交 | 需要业务层 operation marker，或未来事务层幂等标识 |

因此，第一阶段 API 必须把单 key autocommit 的状态确认标记为弱确认；非幂等业务逻辑不得依赖它实现 exactly-once。后续 MVCC / SSI 事务层必须提供强确认机制：提交请求携带 client-visible `txnID` 或幂等 token，并把 commit record 与 mutation 原子持久化。`Commit(txnID)` 重试或 `CommitStatus(txnID)` 查询必须返回原始提交结果；同一 token 携带不同 mutation payload 必须被判定为冲突，而不是重新执行或静默覆盖。

#### write-stopped 后的 MemTable 生命周期

进入 write-stopped 后，引擎不再接受新写入，但已经存在的 MemTable / Immutable MemTable 可以继续被后台流程处理；处理边界必须以 `publishedSequence` 为准：

- 后台 freeze / flush 可以继续执行，但 flush 只能输出 `sequence <= publishedSequence` 且非 aborted 的 entry
- pending / unpublished / aborted entry 只作为当前运行期内部状态存在；关闭进程后随 MemTable 内存一起丢弃，不写入 SSTable、MANIFEST 或 checkpoint 元数据
- 含 pending / unpublished / aborted entry 的 MemTable 不得作为 checkpoint 推进依据；checkpoint / MANIFEST 只能覆盖已经发布、已刷入 SSTable 且 sequence 连续的 durable high-water mark
- 后台 flush 即使生成了只含已发布 entry 的 SSTable，也不能让 MANIFEST 越过失败 Batch 对应的 sequence 空隙；`recoverySegmentID` 的推进必须保证恢复路径仍能从 WAL 判断失败 Batch 的最终状态
- recovery 不读取运行期遗留的 pending / aborted MemTable entry，只从完整、CRC 合法、sequence 连续的 WAL Batch 恢复，并在恢复完成后把恢复出的数据视为 published

#### 可见性语义

- `publishedSequence` 表示 WAL 物理 mutation sequence 的连续发布边界：普通读可以读取 MemTable，但第一阶段只返回 `sequence <= publishedSequence` 的 entry
- fsync 前，写入可以已经存在于 MemTable 中，但处于 pending / unpublished 状态，仅供内部提交流程使用，对普通读不可见
- pending / unpublished / aborted entry 不仅对普通读不可见，也不得进入 SSTable；MemTable flush 必须遵守 3.3 的 Flush 过滤规则，只刷 `sequence <= publishedSequence` 且非 aborted 的 entry
- `Always` 默认策略下，`publishedSequence` 也是 durable high-water mark；普通读能读到的数据，必须是崩溃恢复后仍可恢复的数据
- `Periodic` / `Never` 策略下，`publishedSequence` 可以领先于 durable high-water mark；普通读能读到当前进程内已发布的数据，但这些数据不保证机器掉电后仍可恢复
- 如果 WAL Entry 引用外部持久化对象（例如 Value Log record），发布 WAL Batch 前，被引用对象也必须满足当前落盘策略对应的持久化要求；在 `Always` 下这意味着该 Batch 引用的 Value Log ranges 必须先通过 3.9 的 durable barrier，避免恢复后出现悬空指针
- 第一阶段单 key autocommit 中，`entry.sequence == commitSequence`，因此 `publishedSequence` 可同时作为普通读可见边界；后续 MVCC / SSI 引入多 key 事务后，`publishedSequence` 不改义为事务提交时间，而是继续表示物理发布 / durable / recovery 边界，事务可见性由独立的 `commitSequence` / `visibleCommitSequence` 控制
- `Always` 下该选择牺牲的是写入进入 MemTable 后到 fsync 发布前的短暂全局可见性延迟，不是已发布数据的读路径性能

#### WAL 落盘策略

| 策略             | 行为                                       | 语义                           |
| ---------------- | ------------------------------------------ | ------------------------------ |
| `Always`（默认） | WAL Batch fsync 后发布 sequence 并返回成功 | 不丢已确认写入                 |
| `Periodic`       | 按固定时间间隔 fsync，可提前发布 sequence  | 崩溃可能丢失最近一段已确认写入 |
| `Never`          | 不主动 fsync，仅依赖 OS page cache         | 吞吐最高，崩溃风险最大         |

非默认落盘策略必须由用户显式开启。内部仍使用同一套 sequence / publish 机制，区别只在于 `publishedSequence` 是在 fsync 后推进，还是在 WAL write 成功后提前推进。`Periodic` / `Never` 下，已经发布并返回成功的写入仍可能在机器掉电后丢失。

系统必须同时维护 `durableSequence`，表示最后一次已经满足当前恢复发现条件且 fsync 成功的连续 WAL 物理
mutation high-water mark：

```text
durableSequence <= publishedSequence
```

`Always` 策略下，Batch 只有 fsync 成功后才发布，因此 `durableSequence == publishedSequence`。`Periodic` 策略下，
WAL write 成功后可以先推进 `publishedSequence` 并返回成功，后台 fsync 成功后再推进 `durableSequence`。`Never`
策略下，引擎不主动推进 `durableSequence`，它只能反映最近一次由启动恢复或显式同步操作确认的 durable 边界。

`Periodic` 后台 fsync 失败后，引擎必须进入 write-stopped：后续写入被拒绝，错误被记录并暴露给调用方的健康检查 /
状态查询接口。已经返回成功但 `sequence > durableSequence` 的 Batch 不回滚、不从 MemTable 删除，也不向原调用方
补发错误；它们在当前进程内仍按 `publishedSequence` 可见，但机器掉电后可能丢失。调用方如果选择 `Periodic` /
`Never`，必须通过 `GetDurableSequence()` 或等价状态接口自行判断哪些已确认写入已经跨越 durable 边界。

`Periodic` 的 fsync 间隔必须是显式配置项；文档中的默认策略仍是 `Always`，第一版可以不提供 `Periodic` 默认值。
若后续提供默认 `Periodic` 间隔，必须同时在用户文档中说明最大时间窗口和最大 pending bytes 窗口，使调用方能估算
`publishedSequence - durableSequence` 覆盖的风险范围。

`durableSequence` 不能只根据“最近一次 fsync 成功”模糊推进。WAL writer 必须为每个完整 Batch 记录
`(segmentID, endOffset, endSequence)`，其中 `endOffset` 是该 Batch 最后一个 Physical Record 结束后的文件偏移，
`endSequence = baseSequence + entryCount - 1`。后台 fsync worker 触发时必须先快照当前 append high-water mark：

```text
fsyncSnapshot = (segmentID, endOffset, endSequence)
```

fsync 成功后，只能把 `durableSequence` 推进到满足以下条件的最大连续 Batch：

```text
batch.segmentID < fsyncSnapshot.segmentID
∨ (batch.segmentID == fsyncSnapshot.segmentID ∧ batch.endOffset <= fsyncSnapshot.endOffset)

∧ batch.segment 已进入 durable-ready 状态
∧ batch.endSequence 连续衔接当前 durableSequence
```

如果 fsync 期间又追加了新的 Batch，或者发生 segment rotation，这些晚于快照的 Batch 不得因为本次 fsync 成功而被
标记为 durable；它们必须等待覆盖自身 `(segmentID, endOffset)` 的下一次 fsync 成功。跨 segment 推进时，目标 segment
还必须满足 WAL 元数据持久化协议：segment header 已 fsync，rename 后目录项已 fsync，并已进入 durable-ready。

#### WAL 元数据持久化协议

`Always` 策略下，WAL Batch 可以返回成功的前提不仅是 WAL bytes 已 fsync，还包括恢复路径能够在掉电后找到这些 bytes。任何承载已确认写入的 WAL segment 都必须先进入 durable-ready 状态。

新 WAL segment 创建流程：

```text
1. create segment-N.wal.tmp
2. write WAL File Header
3. fsync segment-N.wal.tmp
4. rename segment-N.wal.tmp → segment-N.wal
5. fsync WAL directory
6. segment-N enters durable-ready state
7. WAL writer may append batches whose recovery depends on segment-N
```

`Always` 下，一个 WAL Batch 只有同时满足以下条件才能确认成功并发布 sequence：

```text
batch 所在 segment 已进入 durable-ready 状态
∧ WAL bytes 已写到 batch end offset
∧ WAL 文件已 fsync 到 batch end offset
∧ 发现该 segment 所需的目录项已经 fsync
```

如果新 segment 进入 durable-ready 之前任一步失败，该 segment 不得成为 active segment，也不得承载可确认写入。如果此时尚未分配 sequence，可以重试创建或切换到其他 segment；如果 sequence 已分配或已有 WAL Batch 依赖该 segment，则按 WAL write failure 处理，引擎进入 write-stopped 状态。

新 segment 的 durable-ready 协议只能在当前 active segment 已结束于完整 WAL Batch 边界后启动。实现不得预创建未来 segment，也不得让 `segment-N+1.wal` 在 `segment-N.wal` 仍可能存在未完成 Batch 尾部时对 recovery 可见。该约束保证：如果 recovery 看到后续 segment 存在，前一个 segment 必须已经 sealed 在完整 Batch 边界；否则前一个 segment 的尾部异常应被视为 WAL 中间损坏。

CURRENT 更新不属于 durable-ready 协议，也不是 WAL Batch 确认成功的前提。写入侧可以在 segment 进入 durable-ready 后，通过 temp + rename 的方式 best-effort 更新 CURRENT，用于下次启动或运行期快速定位 active segment；CURRENT 更新失败不得影响已经通过目录 fsync 变为可发现的 segment，也不得让 recovery 改以 CURRENT 作为权威来源。

MANIFEST 不在每次 WAL segment 轮转时更新。MANIFEST 表示 recovery 起点 / checkpoint 状态，只在 MemTable flush、SSTable 与 checkpoint 元数据都持久化后推进；旧 WAL segment 何时可删除由后续文件生命周期规则定义。

MANIFEST 只作为 checkpoint / recovery 起点元数据；recovery repair 不更新 MANIFEST，也不通过 MANIFEST 记录恢复终点。

#### 设计决策

| 决策     | 选择                               | 理由                                                                        |
| -------- | ---------------------------------- | --------------------------------------------------------------------------- |
| 批量窗口 | 时间 + 大小双触发（500µs 或 32KB） | 高负载靠大小触发效率最大化，低负载靠时间触发不会卡住                        |
| 日志格式 | 固定 Block（32KB）                 | 恢复时按 block 读取校验，比逐条快；和 SSTable block 设计一致                |
| 文件管理 | 分段日志                           | 精确删除已刷盘的旧 WAL 段；恢复通过 MANIFEST + WAL 目录扫描发现 segment；CURRENT 仅作为 last-known active segment 的 best-effort hint |
| WAL segment 大小 | 默认 64MB，可配置但有最小值约束 | 保证最大合法 WAL Batch 可以写入空 segment，同时避免过小 segment 导致频繁 rotation |

#### WAL Block 格式

WAL 使用分层格式：

```text
WAL Segment File
├── WAL File Header
└── Block (32KB)
    └── Physical Record
        └── WAL Batch
            └── Entry
```

术语定义：

| 名称 | 含义 |
|------|------|
| Entry | 一条用户写操作，例如 Put / Delete |
| WAL Batch | 一次 group commit 的逻辑提交单元，包含多条 Entry |
| Physical Record | WAL 文件中的物理片段，负责承载 WAL Batch 的完整或部分 bytes |
| Block | 固定 32KB 的顺序读写容器，Physical Record 必须完整位于单个 Block 内 |

除 varint 字段外，WAL 中固定宽度整数统一使用 little-endian 编码。Block 区域从 `headerSize` offset 开始，Block offset 相对于 Block 区域计算。

##### WAL File Header

每个 WAL segment 文件开头写入 32 bytes header：

```text
┌────────────────┬──────┐
│ magic          │ u32  │
│ formatVersion  │ u16  │
│ headerSize     │ u16  │
│ blockSize      │ u32  │
│ segmentID      │ u64  │
│ startSequence  │ u64  │
│ headerCRC      │ u32  │
└────────────────┴──────┘
```

字段说明：

| 字段 | 含义 |
|------|------|
| magic | 文件类型标识，用于确认这是 go-kv WAL segment |
| formatVersion | WAL 文件格式版本，第一版为 1 |
| headerSize | Header 长度，第一版为 32；后续扩展时可跳过新增字段 |
| blockSize | WAL Block 大小，第一版为 32KB |
| segmentID | WAL segment 编号，用于恢复时校验文件顺序 |
| startSequence | 当前 segment 理论上的起始全局 sequence |
| headerCRC | 校验 `magic` 到 `startSequence` 字段，不包含 `headerCRC` 自身，避免 header 损坏后误解析 |

##### Physical Record

Block 只是固定大小容器，CRC 放在每个 Physical Record 上，而不是放在 Block 尾部。

```
Physical Record
┌────────┬────────┬──────┬─────────┐
│ crc32c │ length │ type │ payload │
│ u32    │ u16    │ u8   │ bytes   │
└────────┴────────┴──────┴─────────┘
```

字段说明：

| 字段 | 含义 |
|------|------|
| crc32c | 校验 `length + type + payload`，用于识别 torn write、partial write 和数据损坏 |
| length | payload 长度；使用 u16，因为单个 Physical Record 不跨 32KB Block |
| type | fragment 类型 |
| payload | WAL Batch 的完整 bytes 或部分 fragment bytes |

fragment 类型（u8）：

| type | 名称 | 含义 |
|------|------|------|
| 0 | Invalid | 非法值，用于损坏检测 |
| 1 | Full | 一个完整 WAL Batch |
| 2 | First | 跨多个 Physical Record 的 WAL Batch 的第一个 fragment |
| 3 | Middle | 中间 fragment，可重复出现任意多次 |
| 4 | Last | 最后一个 fragment |

完整 WAL Batch 的 Physical Record 组合只有两种合法形式：

```text
Full
First + Middle* + Last
```

Physical Record 的顺序由 WAL 文件的顺序追加和顺序扫描保证，不额外存储 fragment index。

##### Block 边界处理

- Physical Record 必须完整位于单个 Block 内，不能跨 Block
- WAL Batch 可以拆成多个 Physical Record，从而跨多个 Block
- 如果当前 Block 剩余空间 `<= 7 bytes`，写入 zero padding 到 Block 末尾，并从下一个 Block 开始写新的 Physical Record
- 如果当前 Block 剩余空间 `> 7 bytes`，可以写入一个 Physical Record
- Physical Record 的 `length` 必须 `> 0`
- 读取时，Block 尾部 padding 必须全为 0；如果 padding 区出现非 0 bytes，视为 WAL 损坏

##### Segment Rotation 约束

- WAL Batch 不得跨 segment 文件；每个 WAL Batch 的所有 Physical Record 必须位于同一个 WAL segment 内
- Segment rotation 只在 WAL Batch 边界发生：当前 segment 写入完一个完整 WAL Batch 后，如果需要轮转，在下个 Batch 写入前切换到新 segment
- 写入 WAL Batch 前，WAL writer 必须根据编码后的 Batch 大小计算 Physical Records 布局；如果当前 segment 剩余空间不足以容纳整个 Batch，必须先切换到新 segment，再写入该 Batch
- 不允许先写入 Batch 的部分 fragment，再因 segment 空间不足切换 segment
- Recovery 的 fragment 收集状态不跨 segment 携带；每个非最后恢复 segment 扫描结束时必须处于 `Idle`

`maxWalSegmentSize` 是单个 WAL segment 文件的最大字节数，包含 WAL File Header；`maxWalSegmentPayload` 是 header 之后可用于 Block / Physical Record / WAL Batch 的 payload 区大小。Segment rotation 的容量判断必须基于 payload 区剩余空间，而不是整个文件大小。

配置必须满足以下不变量，确保最大合法 WAL Batch 可以写入一个空 segment：

```text
maxWalSegmentPayload = maxWalSegmentSize - walFileHeaderSize

maxWalSegmentPayload >=
    maxEncodedWalBatchSize
  + worstCasePhysicalRecordOverhead
  + worstCaseBlockPadding
```

当前格式下：

```text
walFileHeaderSize = 32 bytes
walBlockSize = 32KB
physicalRecordHeaderSize = 7 bytes
walBatchHeaderSize = 18 bytes
maxWalBatchEntriesSize = 4MB

maxEncodedWalBatchSize =
    walBatchHeaderSize + maxWalBatchEntriesSize
  = 18 + 4,194,304
  = 4,194,322 bytes

maxPhysicalRecordPayload =
    walBlockSize - physicalRecordHeaderSize
  = 32,768 - 7
  = 32,761 bytes

maxPhysicalRecordCount =
    ceil(maxEncodedWalBatchSize / maxPhysicalRecordPayload)
  = 129

worstCasePhysicalRecordOverhead =
    maxPhysicalRecordCount * physicalRecordHeaderSize
  = 129 * 7
  = 903 bytes

worstCaseBlockPadding = 7 bytes

minWalSegmentPayload =
    4,194,322 + 903 + 7
  = 4,195,232 bytes

minWalSegmentSize =
    walFileHeaderSize + minWalSegmentPayload
  = 32 + 4,195,232
  = 4,195,264 bytes
```

因此，在当前默认资源上限下，`maxWalSegmentSize` 必须 `>= 4,195,264 bytes`。默认 `maxWalSegmentSize = 64MB`，满足该约束。`maxWalSegmentSize`、`maxWalBatchEntriesSize`、`walBlockSize` 必须在 DB 创建 / 打开时用 checked arithmetic 联合校验；如果配置无法满足上述不变量，DB 必须拒绝启动或拒绝创建，而不是运行到写入阶段才发现某个合法 Batch 无法写入空 segment。

##### WAL Batch

WAL Batch 是物理持久化单元，对应一次 group commit batch。恢复时必须拼出完整 WAL Batch 后才能重放，不能重放半个 batch。WAL Batch 不是事务边界；一个 WAL Batch 可以包含多个独立 autocommit 写入，后续也可以包含一个或多个事务提交记录。

```text
WAL Batch
┌──────────────┬─────────┐
│ Batch Header │ Entries │
└──────────────┴─────────┘
```

Batch Header：

```text
┌────────┬──────────────┬────────────┬─────────────┐
│ flags  │ baseSequence │ entryCount │ entriesSize │
│ u16    │ u64          │ u32        │ u32         │
└────────┴──────────────┴────────────┴─────────────┘
```

字段说明：

| 字段 | 含义 |
|------|------|
| flags | batch 扩展标记，第一版为 0；后续可用于压缩、加密、事务标记、Value Log pointer 等 |
| baseSequence | 当前 batch 第一条 Entry 的全局 sequence |
| entryCount | 当前 batch 内 Entry 数量 |
| entriesSize | Entries 区域总字节数，不包含 Batch Header，用于解析边界校验 |

Entry 的 sequence 由 batch 内位置推导：

```text
entry[i].sequence = baseSequence + i
```

所有 sequence 运算必须使用 checked arithmetic。写入侧在分配 Batch sequence 前必须确认：

```text
baseSequence + entryCount - 1 不溢出 u64
```

如果剩余 sequence 空间不足以容纳整个 Batch，WAL writer 不得写入部分 Entry，也不得让 sequence wrap；引擎必须进入
terminal `sequence exhausted` 状态，拒绝后续写入并要求用户迁移 / 重建数据库。Recovery 侧解析 WAL Batch 时也必须做
同样的溢出检查；如果 `baseSequence + entryCount - 1` 或 `expectedSequence += entryCount` 发生溢出，视为 WAL 损坏
并报错，而不是按取模后的 sequence 继续恢复。

###### WAL Batch 资源上限

WAL Batch 解析和写入必须使用同一套可配置资源上限。默认上限如下：

| 资源 | 默认上限 | 约束目的 |
|------|----------|----------|
| `entryCount` | 10,000 | 限制单个 batch 的解析循环次数和重放工作量 |
| `entriesSize` | 4MB | 限制 Entries 区域和完整 batch payload 的内存占用 |
| 单个 `keyLen` | 4KB | 防止单 key 分配过大 |
| 单个 Inline `valLen` | 4KB | 超过该大小的 value 必须走 ValueLogPointer |
| fragment buffer | 不超过 Batch Header 长度 + `entriesSize` 上限 | 防止 fragment 拼接阶段无限增长 |
| varint 编码长度 | 最大 5 bytes | 防止畸形 varint 长时间解析或解出超大长度 |

写入侧必须在 sequence 分配、MemTable 预留和 WAL append 之前完成这些校验；超出任一上限时按普通错误拒绝写入，且不得产生 WAL 副作用。Recovery 侧超过任一上限时视为 WAL 损坏，并按“WAL 尾部可截断、中间损坏报错”的规则处理。

###### 事务边界与 commit sequence

WAL 的 `baseSequence + i` 是物理 mutation sequence，用于保持 WAL 重放顺序和 `publishedSequence` 连续推进；它不能直接等同于多 key 事务的逻辑提交时间。

相关 sequence 的职责边界如下：

| 名称 | 范围 | 职责 |
|------|------|------|
| `walSequence` / `entry.sequence` | 单条 WAL 物理 mutation | 保证 WAL append、recovery replay、MemTable flush 与 Value Log 可达性判断的物理顺序 |
| `publishedSequence` | 连续的 WAL 物理 mutation high-water mark | 表示已发布到读路径且满足当前落盘策略的物理边界；也是 recovery / durable high-water mark；MVCC 引入后仍不表示事务可见时间 |
| `commitSequence` | 事务逻辑提交记录 | 表示 MVCC reader 与 SSI 冲突检测使用的逻辑提交 timestamp；同一事务内所有 mutation 共享一个 `commitSequence` |
| `visibleCommitSequence` / reader snapshot | 读事务或普通读的逻辑可见边界 | 事务时代的读可见性 high-water mark；读路径必须同时满足 MVCC 可见性规则和底层物理 mutation 已不超过 `publishedSequence` |

因此系统在 MVCC / SSI 阶段会同时维护两个边界：`publishedSequence` 负责物理持久化与恢复连续性，`visibleCommitSequence` 负责事务逻辑可见性。二者在第一阶段单 key autocommit 中等价，但这是阶段性简化，不是长期语义。

第一阶段单 key autocommit 规则：

```text
每个 Put / Delete Entry 都是独立事务
entry.sequence == commitSequence
```

后续 MVCC / SSI 多 key 事务必须使用事务级提交记录，使同一事务内所有 mutation 共享同一个逻辑提交时间：

```text
TxnCommit Record
┌────────────┬────────────────┬───────────────┬───────────┐
│ txnID      │ commitSequence │ mutationCount │ mutations │
│ bytes/var  │ u64            │ u32           │ ...       │
└────────────┴────────────────┴───────────────┴───────────┘
```

事务语义：

- `commitSequence` 是 MVCC reader 和 SSI 冲突检测使用的逻辑提交 timestamp
- 同一 `TxnCommit` 内所有 mutation 对外必须以同一个 `commitSequence` 原子可见
- 普通 reader 不得观察到同一事务的部分 mutation 已可见、部分 mutation 不可见
- WAL 物理 sequence 只用于恢复顺序、幂等重放和 durable high-water mark，不作为多 key 事务的可见性边界
- recovery 必须完整解析并校验一个 `TxnCommit` 后，才可以按同一个 `commitSequence` 重放其中所有 mutation

因此，单 key autocommit 可以继续使用当前 Entry 编码；多 key 事务在 WAL 格式扩展时必须增加事务提交记录，而不是把同一事务的多个 key 编码成多个独立 autocommit Entry。

##### Entry

Entry 使用 row-based 编码，每条 Entry 自带自己的 key/value 长度。`opType` 表示操作语义，`valueKind` 表示 value 的存储形态。

```text
Entry
┌────────┬───────────┬────────┬────────┬─────┬───────┐
│ opType │ valueKind │ keyLen │ valLen │ key │ value │
│ u8     │ u8        │ varint │ varint │ ... │ ...   │
└────────┴───────────┴────────┴────────┴─────┴───────┘
```

第一版 OpType：

| opType | 含义 |
|--------|------|
| 0 | Invalid，非法值，用于损坏检测 |
| 1 | Put |
| 2 | Delete |

第一版 ValueKind：

| valueKind | 含义 |
|-----------|------|
| 0 | None，用于 Delete |
| 1 | Inline，value 字段存用户 value bytes |
| 2 | ValueLogPointer，value 字段存 encoded Value Log pointer |

合法性规则：

- `keyLen > 0`
- `Put` 要求 `valueKind ∈ {Inline, ValueLogPointer}`
- `Put + Inline` 允许 `valLen = 0`，表示 key 存在且 value 为空 bytes
- 因为空 value 是合法值，API 层 `Get` 必须区分 key exists with empty value 与 key not found；否则 `ErrCommitUnknown` 后无法通过读取做弱状态确认
- `Put + ValueLogPointer` 的 value 字段存 opaque encoded pointer bytes，具体格式由 Value Log 模块定义
- `Delete` 编码为 tombstone，要求 `valueKind = None`、`valLen = 0` 且 value 为空
- Delete 恢复到 MemTable 后写入 tombstone，而不是直接移除 key

##### WAL Recovery 规则

恢复目标是恢复到最后一个完整、CRC 校验通过、sequence 连续的 WAL Batch。恢复过程不能重放半个 batch；尾部半写可以截断，中间损坏必须报错。

###### CURRENT / MANIFEST 权威性

MANIFEST 是 recovery 起点和 checkpoint 状态的权威源，记录最老仍需恢复的 `recoverySegmentID`。
MANIFEST 不记录 recovery repair 终点；尾部截断 repair 的持久化结果由 WAL 文件截断后的 EOF 隐式表达。
CURRENT 只表示写入侧上次尝试记录的 active WAL segment hint，是写入侧快速定位 active segment 的辅助文件；
它可能缺失、落后或与目录扫描结果不一致，不能作为 recovery 起点、终点或排除 segment 的依据。

恢复时：

```text
1. 读取 MANIFEST，取得 recoverySegmentID
2. 从 WAL 目录扫描 segment 文件
3. 从 recoverySegmentID 开始按 segmentID 升序恢复
4. 如果 CURRENT 指向的 segment 与目录扫描结果不一致，以 MANIFEST + 目录中满足 header 校验的 segment 为准
5. 如果 MANIFEST 指定的 recovery segment 缺失，或后续需要恢复的 segment 不连续，报错
```

目录扫描得到的 WAL segment 集合必须先按 `MANIFEST.recoverySegmentID` 过滤：`segmentID < recoverySegmentID` 的旧 segment 已被 MANIFEST 覆盖，不属于 recovery candidate set。Recovery 不校验这些旧 segment 的连续性、header 或 `startSequence`；它们即使仍残留在目录中，也只交由文件生命周期清理流程处理。

该规则保证即使 CURRENT 更新在崩溃前未持久化，恢复仍不会依赖不可靠的 active segment 指针；只要已确认写入所在 segment 已按 WAL 元数据持久化协议进入 durable-ready 状态，恢复就能通过目录扫描发现它。

###### Recovery 扫描流程

```text
1. 从 MANIFEST 指定的 recovery 起点开始，按 segmentID 从小到大打开 WAL segment
2. 校验 WAL File Header：magic、formatVersion、headerSize、blockSize、segmentID、headerCRC
3. 对 recovery 起点 segment，使用 segment.startSequence 初始化 expectedSequence
4. 对后续 segment，校验 segment.startSequence == expectedSequence
5. 从 File Header 之后开始按 Block 顺序扫描
6. 在每个 Block 内按 Physical Record 顺序解析
7. 将 Full 或 First/Middle/Last 拼成完整 WAL Batch
8. 校验并重放 WAL Batch 中的 Entries
9. 更新 expectedSequence，并继续扫描下一个 batch
10. 当前 segment 所有 Block 扫描完毕后，检查 fragment 状态：
    - Idle: 正常结束当前 segment，继续下一个 segment
    - CollectingFragments 且当前 segment 是最后一个需要恢复的 segment: 丢弃 incomplete batch，并截断到最后一个完整 WAL Batch 结束位置
    - CollectingFragments 且当前 segment 不是最后一个需要恢复的 segment: 视为 WAL 中间损坏，报错
```

###### Segment 连续性校验

从 MANIFEST 指定的 recovery 起点开始，多 segment 恢复必须同时校验 `segmentID` 和 `startSequence` 的连续性：

```text
segments = scan WAL directory
recoverySegments = segments where segmentID >= manifest.recoverySegmentID
sort recoverySegments by segmentID ascending

expectedSegmentID = manifest.recoverySegmentID
expectedSequence = recoveryStartSegment.startSequence

for segment in recoverySegments:
    require segment.segmentID == expectedSegmentID
    require segment.startSequence == expectedSequence

    recover all complete batches in segment

    expectedSegmentID += 1
    expectedSequence = next sequence after last recovered batch
```

如果 segmentID 不连续、文件名与 header 中的 segmentID 不匹配，或后续 segment 的 `startSequence` 不等于上一 segment 恢复后的 `expectedSequence`，说明 WAL segment 缺失、乱序或损坏，默认报错。

已被 MANIFEST 证明不再需要的旧 WAL segment 可以删除，不参与连续性校验；连续性要求只适用于 recovery 起点之后仍需恢复的 WAL segment。

如果目录中仍存在 `segmentID < recoverySegmentID` 的旧 segment，recovery 必须忽略它们，不得因旧 segment 的缺失、不连续、header 损坏或 `startSequence` 不匹配而报错。是否删除这些旧文件属于 checkpoint 后的文件生命周期管理，不是 recovery 正确性的前置条件。

首次创建数据库时，`MANIFEST.recoverySegmentID` 通常初始化为 0；但该值不是全局固定起点。随着 checkpoint 推进，`recoverySegmentID` 可以推进到任意后续 `segmentID`，后续 recovery 只从该起点开始要求连续。

###### Physical Record 解析规则

解析器在 Block 内顺序读取 Physical Record：

```text
while blockRemaining >= 7:
    read crc32c, length, type
    if header 全 0:
        要求当前 Block 剩余 bytes 全 0
        跳到下一个 Block

    require length > 0
    require length <= blockRemaining - 7
    read payload
    verify crc32c(length + type + payload)
```

当 `blockRemaining < 7` 时，剩余 bytes 必须全为 0，然后进入下一个 Block。

最后一个需要恢复的 segment 的最后一个 Block 可以是 short Block：如果解析器刚好在一个完整 Physical Record 结束后
遇到 EOF，该 EOF 是正常 WAL 结束条件，不要求文件中不存在的 bytes 补齐到 32KB，也不要求对不存在的 padding 做 0 校验。
Padding 规则只适用于文件中实际存在的 bytes：只要当前 Block 内已经物理存在的剩余 bytes 非 0，就按 padding 损坏处理；
如果剩余 bytes 尚未写入文件而直接 EOF，则按正常尾部结束处理。

错误分类：

这里的 “WAL 尾部” 有严格定义：只指最后一个需要恢复的 WAL segment 的物理 EOF 附近。非最后恢复 segment 中的 header 半写、`length` 越界、CRC 错误、incomplete batch 或非法 padding，即使发生在该 segment 文件尾，也视为 WAL 中间损坏。

| 情况 | 位于 WAL 尾部 | 位于 WAL 中间 |
|------|---------------|---------------|
| header 半写 | 丢弃尾部并截断 | 报错 |
| `length` 越界 | 丢弃尾部并截断 | 报错 |
| CRC 校验失败 | 丢弃尾部并截断 | 报错 |
| padding 区出现非 0 bytes | 丢弃尾部并截断 | 报错 |

尾部损坏通常来自进程崩溃或机器掉电时最后一次写入的 partial write。若后面还有需要恢复的 segment，前一个 segment 的尾部异常不能按尾部损坏截断，否则可能跳过已经持久化历史并破坏 sequence 连续性。中间损坏说明已持久化 WAL 文件被破坏，默认不静默跳过，不默认 repair。

###### Fragment 状态机

恢复器维护两个状态：

```text
Idle
CollectingFragments
```

状态转移：

| 当前状态 | 输入 type | 行为 |
|----------|-----------|------|
| Idle | Full | 解析并重放该完整 WAL Batch |
| Idle | First | 开始收集 fragment，进入 CollectingFragments |
| Idle | Middle | 非法 fragment 顺序 |
| Idle | Last | 非法 fragment 顺序 |
| CollectingFragments | Middle | 追加 payload 到当前 fragment buffer；追加后 buffer 不得超过 Batch Header 长度 + WAL Batch `entriesSize` 上限 |
| CollectingFragments | Last | 追加 payload；追加后 buffer 不得超过 Batch Header 长度 + WAL Batch `entriesSize` 上限，随后拼出完整 WAL Batch，解析重放后回到 Idle |
| CollectingFragments | Full | 非法 fragment 顺序 |
| CollectingFragments | First | 非法 fragment 顺序 |
| Idle | segment 结束 | 正常结束当前 segment |
| CollectingFragments | segment 结束，且是最后恢复 segment | 丢弃 incomplete batch，并截断尾部 |
| CollectingFragments | segment 结束，且不是最后恢复 segment | WAL 中间损坏，报错 |

Segment 边界不是合法的 fragment 边界。`First + Middle* + Last` 必须全部出现在同一个 segment 内。如果扫描到 WAL 尾部时仍处于 `CollectingFragments` 状态，说明最后一个 WAL Batch 未写完整，丢弃该 incomplete batch，并截断到最后一个完整 WAL Batch 结束位置。

###### WAL Batch 校验与重放

拼出完整 WAL Batch 后，先校验 Batch Header：

```text
require flags 合法
require 0 < entryCount <= maxWalBatchEntryCount
require 0 < entriesSize <= maxWalBatchEntriesSize
require entriesSize == 实际 Entries bytes 长度
require batch.baseSequence == expectedSequence
```

然后顺序解析 Entries。解析 `keyLen` / `valLen` varint 时，编码长度不得超过 `maxWalVarintBytes`；在分配 key/value buffer 或切片前，必须先完成长度上限和 Entry 边界校验：

```text
for i in 0..entryCount:
    sequence = batch.baseSequence + i
    parse opType, valueKind, keyLen, valLen, key, value
    require opType ∈ {Put, Delete}
    require 0 < keyLen <= maxWalKeyBytes
    require Entry 不越界

    if opType == Put:
        require valueKind ∈ {Inline, ValueLogPointer}
        if valueKind == Inline:
            require valLen <= maxWalInlineValueBytes
            replay PutInline(key, value, sequence)
        if valueKind == ValueLogPointer:
            require valLen > 0
            require value 可由 Value Log 模块解析为 encoded pointer
            replay PutPointer(key, value, sequence)

    if opType == Delete:
        require valueKind == None
        require valLen == 0
        replay Tombstone(key, sequence)
```

一个 WAL Batch 内所有 Entry 都校验通过后，才算该 batch 可重放。重放成功后：

```text
expectedSequence += entryCount
lastCompleteBatchEnd = 当前 WAL offset
```

如果 Batch Header 或 Entry 解析错误发生在 CRC-valid 的完整 WAL Batch 中，即使该 Batch 位于最后一个需要恢复的
segment 的物理尾部，也必须视为 WAL 损坏并报错；它不再是典型 torn write 证据，不能通过尾部截断静默丢弃。
尾部可截断 repair 只适用于物理不完整或物理校验失败的情况：半个 Physical Record header、`length` 越过 EOF、
CRC 校验失败、非法 tail padding、或 `First + Middle*` fragment 链未出现 `Last`。

###### 尾部截断持久化

当 recovery 在最后一个需要恢复的 segment 发现可截断的 WAL 尾部损坏时，截断本身是 recovery repair 的一部分，必须在恢复完成并接受新写入前持久化。截断目标始终是最后一个完整 WAL Batch 的结束位置 `lastCompleteBatchEnd`；非最后恢复 segment 中的异常仍按 WAL 中间损坏处理，不得通过截断 repair 静默跳过。

尾部截断持久化步骤：

```text
1. ftruncate 当前 active segment 到 lastCompleteBatchEnd
2. fsync 被截断的 segment
3. 删除 startSequence == expectedSequence 且不含任何 complete batch 的后续空 segment
4. fsync WAL directory
```

以上步骤全部成功后，截断后的 WAL 文件状态本身就是 recovery repair 的持久化结果，recovery 才能进入恢复完成状态并允许 WAL writer 接受新写入。
后续再次启动时，recovery 仍从 `MANIFEST.recoverySegmentID` 开始扫描 WAL，并自然在截断后的 EOF 停止；不需要额外记录 `recoveryEndSequence`。

若 ftruncate、segment fsync、空 segment 删除或 WAL directory fsync 任一步失败，recovery 必须报错，DB 不得进入可写状态；
否则再次崩溃后可能重新暴露未持久化的截断尾部，导致重复 repair 或 recovery 边界不一致。

###### 恢复完成状态

恢复完成后：

```text
recoveredSequence = expectedSequence - 1
nextSequence = expectedSequence
publishedSequence = recoveredSequence
```

因为 WAL 中恢复出来的数据都来自已经持久化的完整 batch，所以恢复后可以全部视为 published。
`nextSequence` 与 `publishedSequence` 是本次 recovery 扫描结果导出的内存状态，不写入 MANIFEST；下一次启动会重新从 MANIFEST recovery 起点扫描 WAL 并重新计算。

这里的 published 表示“恢复后对普通读可见”，不表示崩溃前调用方一定已经收到成功确认。进程崩溃但机器未掉电时，已经通过 `write()` 进入 OS page cache、但尚未完成 fsync 或尚未唤醒调用方的完整 WAL Batch，可能在进程退出后被内核刷盘。重启后如果 recovery 发现该 Batch CRC 合法、sequence 连续且完整，就会按正常 WAL 规则重放并标记为 published。这不是数据丢失或 WAL 损坏，而是未确认写入的可见性前移。

因此 `Always` 策略的确认语义是不对称的：

```text
Put 返回成功   => 写入所在 WAL Batch 已 fsync，进程崩溃或机器掉电后必须可恢复
Put 未返回成功 => 写入结果不确定；恢复后可能存在，也可能不存在
```

如果未来需要严格区分“调用方已确认成功”与“未确认但已经存在于 WAL”，必须引入额外的 durable commit marker、confirmed-sequence 元数据，或由后续事务层提供 client-visible `txnID` / 幂等 token 与 commit record。第一版 WAL recovery 不维护该区分；它只以完整性校验和 sequence 连续性决定是否重放。

---

### 3.3 MemTable（内存表）

**职责：** 内存中的有序索引，支持快速读写。

#### 设计决策

| 决策       | 选择              | 理由                                                                                  |
| ---------- | ----------------- | ------------------------------------------------------------------------------------- |
| 数据结构   | 跳表（Skip List） | 实现简单（~200 行核心代码）；节点大小固定，Arena 分配天然适配；BadgerDB/Pebble 已验证 |
| 最大层数   | 20 层             | 容量 ~10 亿条，额外内存开销极小                                                       |
| Arena 大小 | 可配置，默认 64MB | 不同场景灵活调整；64MB 是 BadgerDB 验证过的安全值                                     |
| 并发策略   | Mutex 写 + 无锁读 | 写入已被 WAL 组提交串行化，无锁写优势用不上；读是并发的，无锁读有价值                 |

#### 发布与内存序

MemTable 的“Mutex 写 + 无锁读”只表示写入侧用互斥锁串行化结构修改；它不允许依赖 Mutex 本身向未持锁读者发布内存。所有可能被无锁读遍历到的 skiplist 指针必须用原子操作发布，且发布顺序必须满足：

1. 写入侧在 Mutex 保护下完成节点内容初始化，包括 key、value、sequence 与 pending / aborted 状态。
2. 写入侧用 release 语义的原子 store 发布 skiplist 链接指针，使无锁读者只能看到初始化完整的节点。
3. WAL Batch 达到当前落盘策略的发布条件后，WAL writer 在所有 Batch entry 的 skiplist 节点都已原子发布之后，才用 `atomic.Uint64.Store` 推进 `publishedSequence`。
4. 无锁读者先用 `atomic.Uint64.Load` 读取一次 `publishedSequence`，再遍历 skiplist；遍历中只返回 `sequence <= loadedPublishedSequence` 且非 aborted 的 entry。

因此 `publishedSequence` 必须是 typed atomic（例如 `atomic.Uint64`），不能是普通 `uint64` 字段；skiplist next 指针也必须是 `atomic.Pointer` 或具备等价 release / acquire 语义的实现。该协议把“节点可见”放在“sequence 可见”之前，避免 ARM 等弱内存序平台出现读者看见新 sequence 却看不见对应节点的状态。

#### MemTable 生命周期

```
MemTable (可写, 64MB)
    ↓ 写满
冻结为 Immutable MemTable (只读)
    ↓ 后台线程
刷盘为 SSTable 文件
    ↓ 刷盘完成
释放 Immutable MemTable
```

#### Arena 容量预留

MemTable 必须提供写入前容量预留能力，供 WAL writer 在追加 WAL 之前调用。预留必须覆盖整个 WAL Batch，而不是逐条 entry 边写边试：一旦预留成功，后续把 Batch 内所有 entry 写入 pending / unpublished MemTable 时，不得再因为 Arena 满返回错误。预留应绑定到具体的可写 MemTable generation；如果后续在 sequence 分配和 WAL append 之前发生 Batch 校验或内存编码失败，预留必须可以回滚，或通过丢弃该 generation 的方式保证不会泄漏 Arena 容量。

预留计算至少包含：key bytes、value bytes 或 ValueLogPointer bytes、skiplist node 固定字段、随机层高带来的 next 指针数组、arena 对齐 padding，以及实现需要的 entry metadata。为避免随机层高导致预留不足，预留必须按该 MemTable 最大层高的最坏情况计算，或在预留阶段一次性确定并记住每个 entry 的层高。

写入侧必须先用 checked arithmetic 判断 Batch 最坏情况预留量是否超过空 MemTable 的可用容量上限；这里的上限是扣除 MemTable 固定元数据后的 usable capacity，而不是原始配置的 Arena 字节数。若单个 Batch 本身不可能放入一个空 MemTable，必须在 sequence 分配和 WAL append 之前拒绝该 Batch，不得先等待 flush 或追加 WAL。

当 Batch 可以放入空 MemTable、但当前可写 MemTable 剩余容量不足时，写入侧必须在 WAL write 之前执行 freeze + switch：

1. 如果 Immutable MemTable 队列未满，冻结当前 MemTable，创建新的可写 MemTable，并在新 MemTable 上重新执行预留。
2. 如果 Immutable MemTable 队列已满，阻塞新 Batch，等待后台 flush 释放队列名额；等待期间不得先追加 WAL。

如果实现违反上述预留协议，导致 WAL append 已经成功后 MemTable pending 写入仍因 Arena 满或 generation mismatch 失败，该 Batch 不能按普通错误处理；必须按 `ErrCommitUnknown` 返回并进入 write-stopped，因为 WAL bytes 已可能被 recovery 观察到。

该约束把 Arena 满从“WAL 已有副作用后的不确定提交”前移为“WAL 前的容量/背压决策”，避免出现 WAL 中存在完整 Batch、但当前运行期 MemTable 没有对应 pending entry 的状态。

#### Flush 过滤规则

MemTable 可能包含已写入内存但尚未发布的 pending entry，也可能包含 fsync 失败后保留的 aborted entry。Flush 到 SSTable 时必须过滤这些 entry：

- 只刷 `sequence <= publishedSequence` 且非 aborted 的 entry
- pending / unpublished entry 不进入 SSTable
- aborted entry 不进入 SSTable
- 失败 WAL Batch 中遗留在 MemTable 的 entry 只作为运行期内部状态存在，关闭后随内存丢弃，不会从 WAL 恢复
- 含 pending / unpublished / aborted entry 的 MemTable 即使完成过滤 flush，也不得单独作为 checkpoint / MANIFEST 推进依据；推进规则必须遵守 3.2 的 write-stopped 后生命周期约束

该规则保证失败写入不会因为 MemTable flush 进入持久化 SSTable，也不会因为 checkpoint / MANIFEST 错误推进而被恢复路径当作已经持久化的连续 batch；同时保持无锁读场景下无需原地删除 skiplist / arena 节点。

#### 多 Immutable MemTable 策略

- 上限 2 个 Immutable MemTable 排队等刷盘
- 超过上限时阻塞新写入
- 大部分情况不阻塞，极端情况兜底

---

### 3.4 读路径

#### 嵌入式 Get API

第一版嵌入式 API 的 `Get` 必须显式返回 key 是否存在，不能只返回 `[]byte`：

```go
// GetResult 表示单 key 读取结果。
type GetResult struct {
    // Value 是 key 当前可见版本的 value bytes。
    // 当 Found 为 true 时，Value 可以是长度为 0 的合法空 value。
    Value []byte

    // Found 表示 key 是否存在。
    // Found=false 表示 key 不存在；Found=true 且 len(Value)==0 表示 key 存在但 value 为空 bytes。
    Found bool
}

// Get 读取 key 的当前可见值。
func (db *DB) Get(key []byte) (GetResult, error)
```

该签名服务于两个约束：第一，`Put(key, emptyValue)` 是合法写入，读路径必须能把“存在但 value 为空”与“不存在”
区分开；第二，`ErrCommitUnknown` 后的弱状态确认需要读取当前 key 状态，如果 API 丢失 `Found` 信息，调用方无法
判断空 value 写入是否可能已经提交。

读取时按优先级查，从新到旧，找到第一个就返回：

```
读请求
  ↓
1. 查 MemTable（最新的数据）
  ↓ 没找到
2. 查 Immutable MemTable #1
  ↓ 没找到
3. 查 Immutable MemTable #2
  ↓ 没找到
4. 查 SSTable（L0 → L1 → L2 → ...）
```

SSTable 层级的读取通过布隆过滤器加速，避免无谓的磁盘 I/O。

---

### 3.5 SSTable（Sorted String Table）

**职责：** 磁盘上的有序不可变文件，存储 MemTable 刷盘后的数据。

#### 文件格式

```
┌──────────────┐
│ Data Block 0 │ ← key+value 有序排列，块内前缀压缩
├──────────────┤
│ Data Block 1 │
├──────────────┤
│ ...          │
├──────────────┤
│ Index Block  │ ← 每个 Data Block 的最小 key 和文件偏移量
├──────────────┤
│ Filter Block │ ← 布隆过滤器
├──────────────┤
│ Footer       │ ← 指向 Index Block 和 Filter Block 的指针
└──────────────┘
```

#### 设计决策

| 决策       | 选择                            | 理由                                   |
| ---------- | ------------------------------- | -------------------------------------- |
| Block 大小 | 16KB                            | 读取粒度适中；压缩效率好；缓存命中率高 |
| 刷盘策略   | 不阻塞写入，上限 2 个 Immutable | 大部分情况不阻塞，极端情况兜底         |

---

### 3.6 Compaction（压实合并）

**职责：** 后台合并多个 SSTable，去除重复 key、删除废弃值、整理成有序的新文件。

#### 层级结构

```
L0: 刚刷下来的 SSTable，key 范围可能重叠，最多 8 个文件
L1: Compaction 后，key 范围不重叠，总大小上限约 64MB
L2: 比 L1 大 10 倍，约 640MB
L3: 比 L2 大 10 倍，约 6.4GB
...
每层大小上限 = 上一层的 10 倍
```

#### 触发条件

| 条件               | 阈值            | 行为                  |
| ------------------ | --------------- | --------------------- |
| L0 文件数 ≥ 4      | 触发 Compaction | 后台开始 L0 → L1 合并 |
| L0 文件数 ≥ 8      | 阻塞写入        | 等待 Compaction 赶上  |
| 某层总大小超过上限 | 触发 Compaction | 该层 → 下一层合并     |

#### 限速

- 默认限制 Compaction 使用 50% 磁盘带宽
- 可配置
- 保护前台读写延迟不受 Compaction 影响

---

### 3.7 布隆过滤器（Bloom Filter）

**职责：** 快速判断 key 是否"肯定不在"某个 SSTable 里，跳过不必要的磁盘读取。

#### 设计决策

| 决策     | 选择        | 理由                                           |
| -------- | ----------- | ---------------------------------------------- |
| 哈希函数 | xxhash      | 速度最快（~2 bytes/ns）；RocksDB/Pebble 已验证 |
| 精度     | 10 bits/key | 误判率 ~0.8%，1000 次查询约误判 8 次，可接受   |
| 层级区分 | 所有层统一  | 保持简洁，后续如有需要再分                     |

---

### 3.8 Block Cache（块缓存）

**职责：** 将热点 SSTable 数据块缓存在内存，避免重复磁盘读取。

#### 设计决策

| 决策     | 选择                                    | 理由                                                   |
| -------- | --------------------------------------- | ------------------------------------------------------ |
| 缓存内容 | Data Block + Index Block + Filter Block | 缓存索引和过滤器后，点查可能完全不需要磁盘 I/O         |
| 淘汰策略 | S3FIFO                                  | 抗扫描污染（范围扫描不会挤掉热点数据）；实现复杂度适中 |
| 分片     | 256 个分片                              | 每个分片独立锁，并发度高 256 倍                        |
| 大小     | 可配置，用户必须显式指定                | 根据可用内存灵活分配                                   |

#### S3FIFO 队列结构

```
小队列 (Small, ~10%)
  → 新数据先进这里
  → 短时间被再次访问 → 晋升到主队列
  → 自然被淘汰 → 进入幽灵队列

主队列 (Main, ~80%)
  → 存放经过验证的热点数据
  → 被淘汰时检查幽灵队列历史决定是否保留

幽灵队列 (Ghost, ~10%)
  → 不存数据，只记录被淘汰 key 的指纹
  → 防止被扫描数据反复进入主队列
```

---

### 3.9 Value Log（KV 分离）

**职责：** 大值存储在独立的追加写文件中，SSTable 只存指针，降低 compaction 写放大。

#### 写入流程

```
写入请求（key, value）
      ↓
value > 4KB?
  ├── 是 → value 追加写入 Value Log，得到指针 [文件ID, 偏移, 长度]
  │        默认 Always 策略下，该 WAL Batch 引用的 Value Log ranges 必须先通过 durable barrier
  │        WAL Batch 记录 Put + ValueLogPointer，value 字段存 encoded pointer
  │        MemTable 存：key → [vlog指针]（pending / unpublished）
  │
  └── 否 → WAL Batch 记录 Put + Inline，value 字段存用户 value bytes
           MemTable 存：key → value（pending / unpublished）
      ↓
WAL Batch fsync 成功后发布 sequence，写入对普通读可见并返回成功
```

#### 与 WAL 的崩溃一致性

Value Log 指针只有在被引用的 Value Log record 已满足当前落盘策略后，才能随 WAL Batch 发布。

默认 `Always` 策略下，发布 WAL Batch 前必须先完成 Value Log durable barrier。该规则是有意的 durability-first 取舍：已确认写入恢复后不得出现悬空 Value Log pointer；大值写入吞吐通过 Value Log group commit、WAL group commit 和非默认落盘策略弥补，而不是削弱 `Always` 的恢复语义。

Value Log durable barrier 以 WAL Batch 引用的 Value Log ranges 为单位，而不是要求每条大 value 单独 fsync。实现可以在同一个 group commit 窗口内收集多条大 value append，先对这些 ranges 做一次 Value Log fsync，再写入并 fsync 对应 WAL Batch：

```text
1. 收集当前 group commit 窗口内的大 value 写入
2. append 多条 Value Log record，得到各自的 ValueLogPointer
3. fsync 这些 record 所在 Value Log 文件到 batch 引用的最大 offset
4. 写入 WAL Batch（记录 key + vlog 指针）
5. fsync WAL Batch
6. 发布 sequence
7. 返回写入成功
```

如果 WAL 已持久化但 Value Log record 未持久化，恢复时会得到指向不存在或半写 value 的悬空指针，因此默认模式必须禁止这种状态。`Periodic` / `Never` 策略可以放宽 fsync 时机，但必须同时放宽 WAL 和 Value Log 的崩溃保证，并明确可能丢失最近已发布写入。把大 value 直接放入 WAL 可以避免外部指针依赖，但会破坏 KV 分离目标，增加 WAL 体积、恢复成本和写放大；第一版不作为默认方案。

WAL Entry 通过 `valueKind` 区分内联值和 Value Log 指针：

```text
Put + Inline:
  value = 用户 value bytes

Put + ValueLogPointer:
  value = opaque encoded ValueLogPointer bytes
```

第一版 ValueLogPointer 编码：

```text
ValueLogPointer v1
┌────────┬────────┬────────┬──────────┐
│ fileID │ offset │ length │ valueCRC │
│ u64    │ u64    │ u32    │ u32      │
└────────┴────────┴────────┴──────────┘
```

字段说明：

| 字段 | 含义 |
|------|------|
| fileID | Value Log 文件编号 |
| offset | value record 在文件中的起始偏移 |
| length | value bytes 长度 |
| valueCRC | value bytes 校验值，用于读取时发现悬空、半写或损坏 value |

ValueLogPointer v1 使用 little-endian 固定宽度编码，长度为 24 bytes。WAL 层只把它当作 opaque bytes 承载，具体解析和校验由 Value Log 模块负责。

#### 读取流程

```
读请求
  ↓
MemTable 找到 entry
  ├── 内联 value → 直接返回
  └── vlog 指针 → 去 Value Log 文件读取实际 value
```

#### 设计决策

| 决策       | 选择                            | 理由                                                        |
| ---------- | ------------------------------- | ----------------------------------------------------------- |
| 分离阈值   | 4KB                             | 小值内联保范围扫描性能，大值分离保 compaction 效率          |
| fsync 策略 | group commit + durable barrier  | 摊薄大 value fsync 成本，同时保持 `Always` 下无悬空指针     |
| 文件格式   | 带校验的追加写                  | CRC 校验防止磁盘损坏返回错误数据                            |
| 文件大小   | 固定 128MB                      | GC 粒度固定可预期，足够大不频繁切换                         |
| GC 策略    | 按文件存活率，低于 50% 触发重写 | 精准回收，只处理需要的文件                                  |

#### Value Log 记录格式

```
┌────────┬────────┬────────┬─────┬─────────┬────────┐
│ CRC(4) │key_len │val_len │ key │  value  │ CRC(4) │
│  bytes │  varint│  varint│     │         │  bytes │
└────────┴────────┴────────┴─────┴─────────┴────────┘
```

#### Value Log GC 流程

```
1. 统计每个 Value Log 文件的存活率
   存活率 = 有效 value 数量 / 总 value 数量
      ↓
2. 存活率 < 50% 的文件触发 GC
      ↓
3. 读取该文件中的有效 value，重写到新文件
       ↓
4. 向 LSM 写入新的 pointer 版本
       ↓
5. 等旧 pointer 被 compaction 淘汰后删除旧文件
```

注意：SSTable 不可变，GC 不能原地更新 SSTable 中的旧指针。Value Log GC 通过写入新的 pointer 版本来更新引用，旧 pointer 由 compaction 按 MVCC / snapshot 规则淘汰。

Value Log GC 计算存活率时，只能把已发布且非 aborted 的 LSM entry 视为可达引用：

- `sequence > publishedSequence` 的 pending / unpublished pointer 不计入存活 value
- aborted pointer 不计入存活 value
- 失败 WAL Batch 遗留在 MemTable 中的 pointer 永不进入 SSTable，也不参与 Value Log GC 可达性计算
- GC 判断 value 是否有效时，必须以 LSM 当前已发布版本和后续 MVCC / snapshot 规则为准

---

## 4. 待设计模块

以下模块尚未讨论，将在后续补充：

- **MVCC + 事务（SSI）** — 多版本并发控制，可串行化隔离级别；必须提供 client-visible `txnID` / 幂等 token、durable commit record，以及 `CommitStatus(txnID)` 类提交状态查询能力
- **范围扫描** — 多层 Merge Iterator
- **网络层** — 自定义 TCP 二进制协议
- **嵌入式 API** — Go 库接口设计；`Get` 必须区分 key not found 与 key exists with empty value，写 API 必须明确 `ErrCommitUnknown` 的 maybe committed 语义和禁止盲目重试的约束
- **崩溃恢复** — WAL 重放 + MANIFEST 恢复
- **文件管理** — MANIFEST、CURRENT、文件生命周期
