update MemTable publish ordering design

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-11 18:29:47 +08:00
parent a98c144c51
commit b037c1a382

View File

@@ -115,6 +115,8 @@
默认模式下,写入成功发生在 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 状态。
@@ -132,6 +134,8 @@ 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。调用方不得仅凭错误类型盲目重试除非该逻辑操作本身幂等且业务允许覆盖后续并发写入。
@@ -613,6 +617,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内存表
@@ -628,6 +643,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 生命周期
```