Files
go-kv/docs/design.md
2026-06-06 22:18:48 +08:00

33 KiB
Raw Blame History

go-kv 存储引擎设计文档

1. 项目定位

1.1 目标

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

1.2 聚焦方向

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

1.3 具体数字目标

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

1.4 架构风格

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

1.5 功能范围

包含

  • 有序 KVLSM-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-TreeLog-Structured Merge Tree

选择理由:

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

3.2 WAL预写日志

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

持久化策略

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

写入流程

① 写请求进入 commit queue
② WAL writer 收集当前队列中的多条写入,组成 WAL Batch
③ 等待组提交触发500µs 或 32KB先到者触发
④ 为 Batch 内每条写入分配递增的 sequence number
⑤ 编码 WAL Batch并拆分为一个或多个 Physical Record 写入 WAL 文件
⑥ 写入 MemTable但标记为 pending / unpublished
⑦ WAL Batch fsync 落盘
⑧ 发布 publishedSequence使 Batch 内写入对普通读可见
⑨ 唤醒 Batch 内每个调用方,分别返回客户端"写入成功"

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

如果 WAL write 失败或 WAL bytes 未完整写入,该 WAL Batch 不发布;对应 pending entries 保留在 MemTable 中,但保持 unpublished / aborted 状态不做原地删除Batch 内调用方返回错误。由于 sequence 已经分配且不复用,引擎必须进入 write-stopped 状态,不再接受新写入,直到关闭并完成恢复;否则 publishedSequence 无法跳过失败 Batch 推进,后续成功 Batch 也会因为 sequence 空隙而不可见。这样避免在无锁读和 Arena / SkipList 分配模型下引入并发删除与内存回收复杂度,同时保持 publishedSequence 始终是连续 high-water mark。

如果 WAL bytes 已写入但 fsync 返回错误,该 WAL Batch 在当前运行期仍不发布、不确认成功Batch 内调用方返回 ErrCommitUnknown,而不是普通失败错误;因为它的持久化状态是 unknown而不是 definitely lost。sequence 一旦分配不复用;引擎进入 write-stopped 状态,不再接受新写入,直到关闭并完成恢复。因此 publishedSequence 始终保持连续 high-water mark普通读仍可使用 sequence <= publishedSequence 判断可见性,失败 Batch 的 entries 在当前运行期永远不会对普通读可见。重启后,如果 recovery 在 WAL 中发现该 Batch 完整、CRC 合法且 sequence 连续,可以按正常 WAL 规则重放。

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

可见性语义

  • publishedSequence 表示普通读的逻辑可见 high-water mark普通读可以读取 MemTable但只返回 sequence <= publishedSequence 的 entry
  • fsync 前,写入可以已经存在于 MemTable 中,但处于 pending / unpublished 状态,仅供内部提交流程使用,对普通读不可见
  • Always 默认策略下,publishedSequence 也是 durable high-water mark普通读能读到的数据必须是崩溃恢复后仍可恢复的数据
  • Periodic / Never 策略下,publishedSequence 可以领先于 durable high-water mark普通读能读到当前进程内已发布的数据但这些数据不保证机器掉电后仍可恢复
  • 如果 WAL Entry 引用外部持久化对象(例如 Value Log record发布 WAL Batch 前,被引用对象也必须满足当前落盘策略对应的持久化要求;在 Always 下这意味着被引用对象必须已持久化,避免恢复后出现悬空指针
  • 该模型为后续 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 下,已经发布并返回成功的写入仍可能在机器掉电后丢失。

设计决策

决策 选择 理由
批量窗口 时间 + 大小双触发500µs 或 32KB 高负载靠大小触发效率最大化,低负载靠时间触发不会卡住
日志格式 固定 Block32KB 恢复时按 block 读取校验,比逐条快;和 SSTable block 设计一致
文件管理 分段日志 精确删除已刷盘的旧 WAL 段恢复可并行读多文件CURRENT 文件指向当前活跃 WAL

WAL Block 格式

WAL 使用分层格式:

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

┌────────────────┬──────┐
│ 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 校验 magicstartSequence 字段,不包含 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 组合只有两种合法形式:

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 损坏
WAL Batch

WAL Batch 是逻辑提交单元,对应一次 group commit batch。恢复时必须拼出完整 WAL Batch 后才能重放,不能重放半个 batch。

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

Batch Header

┌────────┬──────────────┬────────────┬─────────────┐
│ 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 内位置推导:

entry[i].sequence = baseSequence + i
Entry

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

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 Inlinevalue 字段存用户 value bytes
2 ValueLogPointervalue 字段存 encoded Value Log pointer

合法性规则:

  • keyLen > 0
  • Put 要求 valueKind ∈ {Inline, ValueLogPointer}
  • Put + Inline 允许 valLen = 0,表示 key 存在且 value 为空 bytes
  • Put + ValueLogPointer 的 value 字段存 opaque encoded pointer bytes具体格式由 Value Log 模块定义
  • Delete 编码为 tombstone要求 valueKind = NonevalLen = 0 且 value 为空
  • Delete 恢复到 MemTable 后写入 tombstone而不是直接移除 key
WAL Recovery 规则

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

Recovery 扫描流程
1. 从 MANIFEST 指定的 recovery 起点开始,按 segmentID 从小到大打开 WAL segment
2. 校验 WAL File Headermagic、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 或下一个 segment
Segment 连续性校验

从 MANIFEST 指定的 recovery 起点开始,多 segment 恢复必须同时校验 segmentIDstartSequence 的连续性:

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

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 状态机

恢复器维护两个状态:

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 顺序

如果扫描到 WAL 尾部时仍处于 CollectingFragments 状态,说明最后一个 WAL Batch 未写完整,丢弃该 incomplete batch并截断到最后一个完整 WAL Batch 结束位置。

WAL Batch 校验与重放

拼出完整 WAL Batch 后,先校验 Batch Header

require flags 合法
require entryCount > 0
require entriesSize > 0
require entriesSize == 实际 Entries bytes 长度
require batch.baseSequence == expectedSequence

然后顺序解析 Entries

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 可重放。重放成功后:

expectedSequence += entryCount
lastCompleteBatchEnd = 当前 WAL offset

如果 Batch Header 或 Entry 解析错误发生在 WAL 尾部,可以丢弃该 incomplete batch如果发生在 WAL 中间,默认报错。

恢复完成状态

恢复完成后:

recoveredSequence = expectedSequence - 1
nextSequence = expectedSequence
publishedSequence = recoveredSequence

因为 WAL 中恢复出来的数据都来自已经持久化的完整 batch所以恢复后可以全部视为 published。


3.3 MemTable内存表

职责: 内存中的有序索引,支持快速读写。

设计决策

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

MemTable 生命周期

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

Flush 过滤规则

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

  • 只刷 sequence <= publishedSequence 且非 aborted 的 entry
  • pending / unpublished entry 不进入 SSTable
  • aborted entry 不进入 SSTable
  • 失败 WAL Batch 中遗留在 MemTable 的 entry 只作为运行期内部状态存在,关闭后不会从 WAL 恢复

该规则保证失败写入不会因为 MemTable flush 进入持久化 SSTable也保持无锁读场景下无需原地删除 skiplist / arena 节点。

多 Immutable MemTable 策略

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

3.4 读路径

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

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

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


3.5 SSTableSorted 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: 刚刷下来的 SSTablekey 范围可能重叠,最多 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/nsRocksDB/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 LogKV 分离)

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

写入流程

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

与 WAL 的崩溃一致性

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

默认 Always 策略下,顺序为:

1. append Value Log record
2. fsync Value Log record 所在文件
3. 写入 WAL Batch记录 key + vlog 指针)
4. fsync WAL Batch
5. 发布 sequence
6. 返回写入成功

如果 WAL 已持久化但 Value Log record 未持久化,恢复时会得到指向不存在或半写 value 的悬空指针,因此默认模式必须禁止这种状态。Periodic / Never 策略可以放宽 fsync 时机,但必须同时放宽 WAL 和 Value Log 的崩溃保证,并明确可能丢失最近已发布写入。

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

Put + Inline:
  value = 用户 value bytes

Put + ValueLogPointer:
  value = opaque encoded ValueLogPointer bytes

第一版 ValueLogPointer 编码:

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 效率
文件格式 带校验的追加写 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 — 多版本并发控制,可串行化隔离级别
  • 范围扫描 — 多层 Merge Iterator
  • 网络层 — 自定义 TCP 二进制协议
  • 嵌入式 API — Go 库接口设计
  • 崩溃恢复 — WAL 重放 + MANIFEST 恢复
  • 文件管理 — MANIFEST、CURRENT、文件生命周期