update design doc

Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
This commit is contained in:
dailz
2026-06-09 11:57:19 +08:00
parent 5c3adb3390
commit 0047d52541

View File

@@ -114,19 +114,45 @@
默认模式下,写入成功发生在 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 encode / write 在步骤 ⑤ 失败,且 Batch 尚未进入步骤 ⑥ 写入 MemTable,该 WAL Batch 不发布,也不会产生 pending / aborted MemTable entryBatch 内调用方返回错误。由于 sequence 已经分配且不复用,引擎必须进入 write-stopped 状态,不再接受新写入,直到关闭并完成恢复;否则 `publishedSequence` 无法跳过失败 Batch 推进,后续成功 Batch 也会因为 sequence 空隙而不可见。这样把纯 WAL failure 与已进入 MemTable 的未知提交状态分开处理,同时保持 `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 规则重放。
如果 Batch 已经完成步骤 ⑥ 写入 MemTable 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 不确定结果导致非幂等事务重复提交。
#### 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 不仅对普通读不可见,也不得进入 SSTableMemTable 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` 下这意味着被引用对象必须已持久化,避免恢复后出现悬空指针
- 如果 WAL Entry 引用外部持久化对象(例如 Value Log record发布 WAL Batch 前,被引用对象也必须满足当前落盘策略对应的持久化要求;在 `Always` 下这意味着该 Batch 引用的 Value Log ranges 必须先通过 3.9 的 durable barrier,避免恢复后出现悬空指针
- 该模型为后续 MVCC / 事务层提供统一的 read timestamp / commit sequence 基础
- `Always` 下该选择牺牲的是写入进入 MemTable 后到 fsync 发布前的短暂全局可见性延迟,不是已发布数据的读路径性能
@@ -140,6 +166,40 @@
非默认落盘策略必须由用户显式开启。内部仍使用同一套 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 状态。
MANIFEST 不在每次 WAL segment 轮转时更新。MANIFEST 表示 recovery 起点 / checkpoint 状态,只在 MemTable flush、SSTable 与 checkpoint 元数据都持久化后推进;旧 WAL segment 何时可删除由后续文件生命周期规则定义。
#### 设计决策
| 决策 | 选择 | 理由 |
@@ -251,7 +311,7 @@ Physical Record 的顺序由 WAL 文件的顺序追加和顺序扫描保证,
##### WAL Batch
WAL Batch 是逻辑提交单元,对应一次 group commit batch。恢复时必须拼出完整 WAL Batch 后才能重放,不能重放半个 batch。
WAL Batch 是物理持久化单元,对应一次 group commit batch。恢复时必须拼出完整 WAL Batch 后才能重放,不能重放半个 batch。WAL Batch 不是事务边界;一个 WAL Batch 可以包含多个独立 autocommit 写入,后续也可以包含一个或多个事务提交记录。
```text
WAL Batch
@@ -284,6 +344,37 @@ Entry 的 sequence 由 batch 内位置推导:
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 的存储形态。
@@ -317,6 +408,7 @@ Entry
- `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
@@ -325,6 +417,22 @@ Entry
恢复目标是恢复到最后一个完整、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
@@ -505,14 +613,15 @@ MemTable (可写, 64MB)
#### Flush 过滤规则
MemTable 可能包含已写入内存但尚未发布的 pending entry也可能包含 WAL write / fsync 失败后保留的 aborted entry。Flush 到 SSTable 时必须过滤这些 entry
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 恢复
- 失败 WAL Batch 中遗留在 MemTable 的 entry 只作为运行期内部状态存在,关闭后随内存丢弃,不会从 WAL 恢复
- 含 pending / unpublished / aborted entry 的 MemTable 即使完成过滤 flush也不得单独作为 checkpoint / MANIFEST 推进依据;推进规则必须遵守 3.2 的 write-stopped 后生命周期约束
该规则保证失败写入不会因为 MemTable flush 进入持久化 SSTable也保持无锁读场景下无需原地删除 skiplist / arena 节点。
该规则保证失败写入不会因为 MemTable flush 进入持久化 SSTable不会因为 checkpoint / MANIFEST 错误推进而被恢复路径当作已经持久化的连续 batch同时保持无锁读场景下无需原地删除 skiplist / arena 节点。
#### 多 Immutable MemTable 策略
@@ -661,7 +770,7 @@ L3: 比 L2 大 10 倍,约 6.4GB
value > 4KB?
├── 是 → value 追加写入 Value Log得到指针 [文件ID, 偏移, 长度]
│ 默认 Always 策略下,Value Log record 必须先 fsync
│ 默认 Always 策略下,该 WAL Batch 引用的 Value Log ranges 必须先通过 durable barrier
│ WAL Batch 记录 Put + ValueLogPointervalue 字段存 encoded pointer
│ MemTable 存key → [vlog指针]pending / unpublished
@@ -675,18 +784,21 @@ WAL Batch fsync 成功后发布 sequence写入对普通读可见并返回成
Value Log 指针只有在被引用的 Value Log record 已满足当前落盘策略后,才能随 WAL Batch 发布。
默认 `Always` 策略下,顺序为:
默认 `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. append Value Log record
2. fsync Value Log record 所在文件
3. 写入 WAL Batch记录 key + vlog 指针)
4. fsync WAL Batch
5. 发布 sequence
6. 返回写入成功
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 的崩溃保证,并明确可能丢失最近已发布写入。
如果 WAL 已持久化但 Value Log record 未持久化,恢复时会得到指向不存在或半写 value 的悬空指针,因此默认模式必须禁止这种状态。`Periodic` / `Never` 策略可以放宽 fsync 时机,但必须同时放宽 WAL 和 Value Log 的崩溃保证,并明确可能丢失最近已发布写入。把大 value 直接放入 WAL 可以避免外部指针依赖,但会破坏 KV 分离目标,增加 WAL 体积、恢复成本和写放大;第一版不作为默认方案。
WAL Entry 通过 `valueKind` 区分内联值和 Value Log 指针:
@@ -731,12 +843,13 @@ MemTable 找到 entry
#### 设计决策
| 决策 | 选择 | 理由 |
| -------- | ------------------------------- | -------------------------------------------------- |
| 分离阈值 | 4KB | 小值内联保范围扫描性能,大值分离保 compaction 效率 |
| 文件格式 | 带校验的追加写 | CRC 校验防止磁盘损坏返回错误数据 |
| 文件大小 | 固定 128MB | GC 粒度固定可预期,足够大不频繁切换 |
| GC 策略 | 按文件存活率,低于 50% 触发重写 | 精准回收,只处理需要的文件 |
| 决策 | 选择 | 理由 |
| ---------- | ------------------------------- | ----------------------------------------------------------- |
| 分离阈值 | 4KB | 小值内联保范围扫描性能,大值分离保 compaction 效率 |
| fsync 策略 | group commit + durable barrier | 摊薄大 value fsync 成本,同时保持 `Always` 下无悬空指针 |
| 文件格式 | 带校验的追加写 | CRC 校验防止磁盘损坏返回错误数据 |
| 文件大小 | 固定 128MB | GC 粒度固定可预期,足够大不频繁切换 |
| GC 策略 | 按文件存活率,低于 50% 触发重写 | 精准回收,只处理需要的文件 |
#### Value Log 记录格式
@@ -777,9 +890,9 @@ Value Log GC 计算存活率时,只能把已发布且非 aborted 的 LSM entry
以下模块尚未讨论,将在后续补充:
- **MVCC + 事务SSI** — 多版本并发控制,可串行化隔离级别
- **MVCC + 事务SSI** — 多版本并发控制,可串行化隔离级别;必须提供 client-visible `txnID` / 幂等 token、durable commit record以及 `CommitStatus(txnID)` 类提交状态查询能力
- **范围扫描** — 多层 Merge Iterator
- **网络层** — 自定义 TCP 二进制协议
- **嵌入式 API** — Go 库接口设计
- **嵌入式 API** — Go 库接口设计`Get` 必须区分 key not found 与 key exists with empty value写 API 必须明确 `ErrCommitUnknown` 的 maybe committed 语义和禁止盲目重试的约束
- **崩溃恢复** — WAL 重放 + MANIFEST 恢复
- **文件管理** — MANIFEST、CURRENT、文件生命周期