Compare commits

...

4 Commits

Author SHA1 Message Date
dailz
5e9836c931 update MemTable arena reservation design
Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-06-11 19:53:17 +08:00
dailz
b037c1a382 update MemTable publish ordering design
Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-06-11 18:29:47 +08:00
dailz
a98c144c51 update WAL write failure design
Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-06-11 17:36:14 +08:00
dailz
f6a2cd979e update WAL segment boundary design
Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-06-11 17:33:39 +08:00

View File

@@ -104,22 +104,42 @@
① 写请求进入 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 内每个调用方,分别返回客户端"写入成功"
完成 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 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 副作用之前的强制容量关口WAL writer 必须按 Batch 内所有 entry 的最大内存占用key、value 或 ValueLogPointer、skiplist 节点、arena 对齐与层高开销)计算需要预留的 Arena 字节数。若当前可写 MemTable 剩余容量不足,必须先把它冻结为 Immutable MemTable并创建新的可写 MemTable 后再继续。若 Immutable MemTable 队列已达上限,该 Batch 必须在 WAL write 之前等待后台 flush 释放容量;若单个 Batch 的最大可能内存占用超过 MemTable 上限,则该 Batch 在进入 WAL 前按普通错误拒绝。实现不得在 WAL write / append 已尝试之后,再因为 Arena 满而让 MemTable pending 写入失败
如果 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 规则重放
步骤 ⑧ 与 ⑩ 之间存在明确的内存序约束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 尾部截断规则处理。
`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。调用方不得仅凭错误类型盲目重试除非该逻辑操作本身幂等且业务允许覆盖后续并发写入。
@@ -198,6 +218,8 @@ batch 所在 segment 已进入 durable-ready 状态
如果新 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 何时可删除由后续文件生命周期规则定义。
#### 设计决策
@@ -309,6 +331,14 @@ Physical Record 的顺序由 WAL 文件的顺序追加和顺序扫描保证,
- 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 写入,后续也可以包含一个或多个事务提交记录。
@@ -444,7 +474,11 @@ MANIFEST 是 recovery 起点和 checkpoint 状态的权威源,记录最老仍
6. 在每个 Block 内按 Physical Record 顺序解析
7. 将 Full 或 First/Middle/Last 拼成完整 WAL Batch
8. 校验并重放 WAL Batch 中的 Entries
9. 更新 expectedSequence并继续扫描下一个 batch 或下一个 segment
9. 更新 expectedSequence并继续扫描下一个 batch
10. 当前 segment 所有 Block 扫描完毕后,检查 fragment 状态:
- Idle: 正常结束当前 segment继续下一个 segment
- CollectingFragments 且当前 segment 是最后一个需要恢复的 segment: 丢弃 incomplete batch并截断到最后一个完整 WAL Batch 结束位置
- CollectingFragments 且当前 segment 不是最后一个需要恢复的 segment: 视为 WAL 中间损坏,报错
```
###### Segment 连续性校验
@@ -522,8 +556,11 @@ CollectingFragments
| CollectingFragments | Last | 追加 payload拼出完整 WAL Batch解析重放后回到 Idle |
| CollectingFragments | Full | 非法 fragment 顺序 |
| CollectingFragments | First | 非法 fragment 顺序 |
| Idle | segment 结束 | 正常结束当前 segment |
| CollectingFragments | segment 结束,且是最后恢复 segment | 丢弃 incomplete batch并截断尾部 |
| CollectingFragments | segment 结束,且不是最后恢复 segment | WAL 中间损坏,报错 |
如果扫描到 WAL 尾部时仍处于 `CollectingFragments` 状态,说明最后一个 WAL Batch 未写完整,丢弃该 incomplete batch并截断到最后一个完整 WAL Batch 结束位置。
Segment 边界不是合法的 fragment 边界。`First + Middle* + Last` 必须全部出现在同一个 segment 内。如果扫描到 WAL 尾部时仍处于 `CollectingFragments` 状态,说明最后一个 WAL Batch 未写完整,丢弃该 incomplete batch并截断到最后一个完整 WAL Batch 结束位置。
###### WAL Batch 校验与重放
@@ -584,6 +621,17 @@ 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内存表
@@ -599,6 +647,17 @@ publishedSequence = recoveredSequence
| 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 生命周期
```
@@ -611,6 +670,23 @@ MemTable (可写, 64MB)
释放 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