Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
954 lines
50 KiB
Markdown
954 lines
50 KiB
Markdown
# 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 编码前置准备
|
||
⑤ 为 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 持久化后才算成功。
|
||
|
||
步骤 ⑦ 与 ⑨ 之间存在明确的内存序约束: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 校验或内存编码在步骤 ⑤ 失败,且尚未分配 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 校验 / 内存编码失败,且尚未分配 sequence | 无 | 普通错误 | 可继续写入 |
|
||
| Batch 校验 / 内存编码失败,但 sequence 已分配且不复用 | 无 | 普通错误 | write-stopped |
|
||
| WAL write / append 已尝试后失败,且无法证明 zero bytes reached WAL state | 可能有 | `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 尾部截断规则处理。
|
||
|
||
`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` 表示普通读的逻辑可见 high-water mark;普通读可以读取 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,避免恢复后出现悬空指针
|
||
- 该模型为后续 MVCC / 事务层提供统一的 read timestamp / commit sequence 基础
|
||
- `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` 下,已经发布并返回成功的写入仍可能在机器掉电后丢失。
|
||
|
||
#### 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. update CURRENT via temp + rename:
|
||
a. write CURRENT.tmp = segment-N.wal
|
||
b. fsync CURRENT.tmp
|
||
c. rename CURRENT.tmp → CURRENT
|
||
d. fsync metadata directory
|
||
7. segment-N enters durable-ready state
|
||
8. 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 中间损坏。
|
||
|
||
MANIFEST 不在每次 WAL segment 轮转时更新。MANIFEST 表示 recovery 起点 / checkpoint 状态,只在 MemTable flush、SSTable 与 checkpoint 元数据都持久化后推进;旧 WAL segment 何时可删除由后续文件生命周期规则定义。
|
||
|
||
#### 设计决策
|
||
|
||
| 决策 | 选择 | 理由 |
|
||
| -------- | ---------------------------------- | --------------------------------------------------------------------------- |
|
||
| 批量窗口 | 时间 + 大小双触发(500µs 或 32KB) | 高负载靠大小触发效率最大化,低负载靠时间触发不会卡住 |
|
||
| 日志格式 | 固定 Block(32KB) | 恢复时按 block 读取校验,比逐条快;和 SSTable block 设计一致 |
|
||
| 文件管理 | 分段日志 | 精确删除已刷盘的旧 WAL 段;恢复可并行读多文件;CURRENT 文件指向当前活跃 WAL |
|
||
|
||
#### 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`
|
||
|
||
##### 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
|
||
```
|
||
|
||
###### 事务边界与 commit sequence
|
||
|
||
WAL 的 `baseSequence + i` 是物理 mutation sequence,用于保持 WAL 重放顺序和 `publishedSequence` 连续推进;它不能直接等同于多 key 事务的逻辑提交时间。
|
||
|
||
第一阶段单 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`。CURRENT 只表示当前活跃 WAL segment,是写入侧快速定位 active segment 的辅助文件,不作为 recovery 起点的权威来源。
|
||
|
||
恢复时:
|
||
|
||
```text
|
||
1. 读取 MANIFEST,取得 recoverySegmentID
|
||
2. 从 WAL 目录扫描 segment 文件
|
||
3. 从 recoverySegmentID 开始按 segmentID 升序恢复
|
||
4. 如果 CURRENT 指向的 segment 与目录扫描结果不一致,以 MANIFEST + 目录中满足 header 校验的 segment 为准
|
||
5. 如果 MANIFEST 指定的 recovery segment 缺失,或后续需要恢复的 segment 不连续,报错
|
||
```
|
||
|
||
该规则保证即使 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
|
||
expectedSegmentID = manifest.recoverySegmentID
|
||
expectedSequence = recoveryStartSegment.startSequence
|
||
|
||
for segment in segmentID ascending order:
|
||
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。
|
||
|
||
###### 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。
|
||
|
||
错误分类:
|
||
|
||
这里的 “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 |
|
||
| CollectingFragments | Last | 追加 payload,拼出完整 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 entryCount > 0
|
||
require entriesSize > 0
|
||
require entriesSize == 实际 Entries bytes 长度
|
||
require batch.baseSequence == expectedSequence
|
||
```
|
||
|
||
然后顺序解析 Entries:
|
||
|
||
```text
|
||
for i in 0..entryCount:
|
||
sequence = batch.baseSequence + i
|
||
parse opType, valueKind, keyLen, valLen, key, value
|
||
require opType ∈ {Put, Delete}
|
||
require keyLen > 0
|
||
require Entry 不越界
|
||
|
||
if opType == Put:
|
||
require valueKind ∈ {Inline, ValueLogPointer}
|
||
if valueKind == Inline:
|
||
valLen 可以为 0
|
||
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 解析错误发生在 WAL 尾部,可以丢弃该 incomplete batch;如果发生在 WAL 中间,默认报错。
|
||
|
||
###### 恢复完成状态
|
||
|
||
恢复完成后:
|
||
|
||
```text
|
||
recoveredSequence = expectedSequence - 1
|
||
nextSequence = expectedSequence
|
||
publishedSequence = recoveredSequence
|
||
```
|
||
|
||
因为 WAL 中恢复出来的数据都来自已经持久化的完整 batch,所以恢复后可以全部视为 published。
|
||
|
||
这里的 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
|
||
```
|
||
|
||
#### 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 读路径
|
||
|
||
读取时按优先级查,从新到旧,找到第一个就返回:
|
||
|
||
```
|
||
读请求
|
||
↓
|
||
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、文件生命周期
|