Skip to content
Open
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
239 changes: 239 additions & 0 deletions docs/zh/design/io_uring_async.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,239 @@
# ParaKV io_uring 读写异步改造方案

> 目标:把 ParaKV 的 SSD 数据面从同步 `pread`/`pwrite`(+ 每条 fsync 的 WAL)改造为
> 基于 io_uring 的异步、批量、可控持久性的 IO 引擎。面向 LLM KVCache 场景:
> value 为 MB 级、访问是大块批量、写为非关键路径。

## 0. 现状事实(改造的出发点,均已核对代码)

| 位置 | 现状 | 问题 |
|---|---|---|
| `segment_base.h:59-72` | `Read/Insert/BatchInsert/Delete` 全同步返回 `Status` | 提交-完成无法分离,占用调用线程(brpc bthread)干等磁盘 |
| `segment_file.cc` `PRead/PWrite` | 同步循环 syscall | 一次 BatchRead N 个 chunk = N 次独立 syscall,吃不满 NVMe 队列深度 |
| `segment_base.cc:44` | `slot_size_ = key_size + value_size`(默认 528B) | 非 4K 对齐,O_DIRECT 不可用 |
| `segment_file.cc:126-138` | `Insert` = `PWrite(key)`→`PWrite(value)`→`FlushBitmap`,均无 fsync | 数据/bitmap 顺序靠代码顺序,crash 后可能 bitmap 说 live 但数据是垃圾 |
| `wal.cc:245` | `Append` 每条 `pwrite` + `fsync`,一把全局 mutex | 每个写阻塞在同步 fsync,串行,写延迟高(对 cache 语义过重) |

## 1. 领域特性给出的三个简化(为什么 ParaKV 比通用 DB 好做)

1. **chunk 不可变、append-only**:slot 数据写彼此**无依赖**,可完全乱序并发提交;只有"数据 → bitmap"一步有序。
2. **cache 语义**:丢数据只掉性能不掉正确性。⇒ 持久性可放宽为"异步组提交",崩溃丢最近窗口即可(那些 chunk 下次 miss 重算)。
3. **写是非关键路径**:吞吐优先、延迟不敏感 ⇒ 可用大攒批窗口、可容忍 SQPOLL 的批控制粒度。

## 2. io_uring 使用的三条基本律(约束本方案的正确性)

1. **不保证执行顺序**:同批 SQE 可能乱序/并行。要顺序用 `IOSQE_IO_LINK`(链)或应用层排队。
2. **write 返回 ≠ 落盘**:需 `IORING_OP_FSYNC`(`IORING_FSYNC_DATASYNC` = fdatasync)或 FUA。
3. **buffered write 掉 io-wq**:page cache 写常不能 nowait,内核丢给 io-wq 线程池,几十 µs 抖动。延迟敏感写要 O_DIRECT。
4. **link 语义陷阱**:`IO_LINK` 是"前一个**完成**"非"成功";short write / 出错会把后续链节点 `-ECANCELED`。⇒ **必须检查 write 那节 CQE 的 `res == 预期长度`,不能只看 fsync 的 CQE**。

## 3. 核心组件:IoUringEngine(新增)

一个进程级(或 per-NUMA)的 io_uring 引擎,封装 SQ/CQ + 收割线程 + completion 唤醒。
brpc/bthread 的收割与唤醒接入由使用方提供(团队已有成熟方案),本引擎只暴露提交接口。

```cpp
// parakv/core/io/io_uring_engine.h (新增)
struct IoReq {
int fd;
void* buf;
uint64_t offset;
uint32_t len;
enum { kRead, kWrite, kFsync } op;
};

// 一组请求作为一个「提交单元」;done 在这一组全部 CQE 收割后触发一次。
// 组内可选顺序约束(用于 数据→bitmap 的 IO_LINK)。
using Completion = std::function<void(Status /*agg*/, std::vector<int32_t> /*res*/)>;

class IoUringEngine {
public:
// reqs 内无序并发提交;若 link_last=true,最后一个 req 用 IO_LINK 挂在前面之后。
void Submit(std::vector<IoReq> reqs, bool link_last, Completion done);
// 便捷:一组独立读,全部完成回调。
void SubmitReads(std::vector<IoReq> reads, Completion done);
};
```

要点:
- **`res` 逐项回传**,调用方按基本律④检查每项 `res == len`。
- completion 的唤醒机制(eventfd / bthread resume)在引擎内留接入点,不在本方案范围。
- fixed file / fixed buffer(`IOSQE_FIXED_FILE` / `write_fixed`)为可选优化,第二阶段再上(也为 RDMA MR 注册铺路)。

### 3.1 线程模型:ring 钉在 brpc user_pthread(定稿)

**IoUringEngine 跑在固定的 brpc user_pthread(`bthread::usercode_in_pthread`)上,每线程一个 ring 混读写;ring 的提交/收割全在其 owner user_pthread,brpc bthread 仅通过队列投递 IoReq 并挂起等唤醒,不触碰 ring。**

为什么走 user_pthread 而不是 bthread:
- io_uring 要求**线程稳定**(SINGLE_ISSUER 要求同一 ring 只由同一线程提交 SQE;SQPOLL、`register_files`/`register_buffers` 都有线程归属假设)。
- bthread 是 M:N 可迁移的,work-stealing 会让挂起-唤醒后落到不同 pthread ⇒ 在错误线程碰 ring,轻则性能塌,重则违反 SINGLE_ISSUER 出错。
- user_pthread 把执行钉死在固定 pthread,ring↔线程绑定稳定,上述亲和假设天然满足。

形态:
```
brpc worker bthread (handler) user_pthread io 线程 (钉住, 持有 ring)
│ BatchRead handler ┌─ 提交 SQE(SINGLE_ISSUER 安全)
│ 组装 IoReq[] ──── 无锁队列/eventfd ───────→│ 收割 CQE
│ 挂起等 completion │ 同线程再提交后续(link/fsync)
│ ←──────── 唤醒(bthread resume) ─────┤
▼ └─ 全程线程稳定
```
- 跨线程只传 **IoReq 描述符 + completion 句柄**(轻量),bthread 绝不碰 ring 操作。
- 提交只发生在 owner user_pthread ⇒ SINGLE_ISSUER "提交必须回 owner 线程" 的约束自动满足。
- io 线程数量固定(如 per-NUMA 几个),不需要 bthread 的海量并发——ParaKV 的 IO 提交/收割本就是少数固定线程。

### 3.2 ring 归属:每 io 线程一个 ring 混用 + 两个演化钩子

业界主流:**ring 的归属轴是线程,不是操作类型**;读/Segment 写/WAL 混在同一 ring 是常态(Seastar/ScyllaDB、TigerBeetle 皆如此)。拆 ring 的理由从不是"读写语义不同",而是 **flags 不兼容** 或 **故障域/预算隔离**。

P1/P2 阶段:**每个 io 线程一个 ring,读、Segment 写、WAL group commit 全混用**。共享一次 `submit_and_wait` 的摊薄收益。

留两个演化钩子(`Options` 里加 flag,触发任一才拆成双 ring,仍由同一 io 线程驱动):
1. **`sqpoll`(更可能先撞)**:读要低延迟、写/WAL 要高吞吐省提交 syscall,两个 profile 分化时。SQPOLL 与非 SQPOLL 不能共存于一个 ring ⇒ 拆:读 ring(普通)+ 写/WAL ring(SQPOLL)。user_pthread 的线程稳定性正好给 SQPOLL 配一个稳定提交线程。
2. **`iopoll`(后撞)**:读要榨极限延迟。IOPOLL ring 只能放 O_DIRECT 可轮询读,fsync/buffered/timeout 全不支持 ⇒ 拆:读 IOPOLL ring + 写/fsync 普通 ring。
拆开后两个 ring 仍在同一 user_pthread 里轮流 peek,架构不变。

## 4. 读路径改造

### 4.1 分界线(异步推到哪一层)

**阶段一(推荐先做)——异步只到 SegmentBase,上层包成同步:**

给 `SegmentBase` 加批量异步读虚接口:
```cpp
struct ReadReq { uint32_t slot_id; void* key_buf; void* val_buf; };
virtual void SubmitReads(const std::vector<ReadReq>& reqs, Completion done) = 0;
```
`SegmentFile::SubmitReads`:对每个 slot 算 `GetSlotOffset(sid)`,生成 read IoReq(key、value 可合并为一个连续读,因为盘上 `[key][value]` 相邻),一把提交给 IoUringEngine,CQE 全回来后 `done`。

上层 `Index` / backend 暂时"提交后等 completion"包成同步:`index.cc` / `backend.cc` **签名不变**。
- **拿到**:一次 syscall 提交整个 BatchRead、NVMe 深队列并行(读吞吐的大头)。
- **暂不拿到**:释放 bthread(仍阻塞等待,但等的是"一批"而非"逐个")。

**阶段二(可选)——异步推到 Index/backend:**
`Index::BatchGet` 返回 future;backend 的 `BatchRead` handler 挂起 bthread,CQE 唤醒后回填 response。拿到 bthread 不被磁盘阻塞。回归面广,量出阶段一收益后再决定。

### 4.2 新增聚合入口

`Index` 加 `BatchGet(const std::vector<KeyT>& keys, ...)`:
1. 对每个 key 走 map lookup 拿 encoded offset(纯内存,快);
2. 按 (segment_id) 分组;
3. 每组一次 `SegmentFile::SubmitReads`;
4. 全部组 completion 后回填。

backend 的 `BatchRead` handler 从"循环单个 `Get`"改为一次 `BatchGet`。

## 5. 写路径改造:WAL + Segment 合并的有序异步提交

一个 KVCache 写涉及**三处盘上写**:Segment slot 数据、Segment bitmap、WAL 记录。
它们不是三条独立路径各自提交/各自 fsync,而是**对同一批写,组织进同一个 io_uring
提交单元,用统一的 link 链表达顺序、共享一次 fsync**。整体异步(不阻塞提交方),
但盘上落盘顺序被 link 严格保序。

### 5.1 为什么 WAL 必须最后落(与数据库直觉相反)

ParaKV 的 WAL 存的是**索引指针**(`key → 编码 offset`),不是数据本身;数据在 Segment slot 里。
所以持久化顺序必须是:

```
Segment slot 数据 → Segment bitmap → WAL 记录(commit record,最后落)
```

- 若 **WAL 先落、数据后落** 而崩在中间:replay 会装回一个指向**尚未落盘/垃圾 slot** 的索引项 → 脏读。
- 因此 WAL 在这里扮演 **commit record** 角色:它落盘 ⟺ "这条写已提交、数据已在盘上、索引可信"。
数据库通常 WAL 先行,是因为其 WAL 含数据 redo;ParaKV WAL 不含数据,故顺序相反。

### 5.2 合并提交的 link 编排

对一批(一个 tick 内到达的)写,单个 io_uring 提交单元:

```
[write slot_0][write slot_1]...[write slot_{n-1}] ← N 个 slot 数据, 乱序并发(append-only 无依赖)
│ (全部完成后, IO_LINK)
[write bitmap] ← 数据全落后, 落 bitmap
│ (IO_LINK)
[write wal_batch @ wal_tail] ← bitmap 后, append WAL 批(索引指针)
│ (IO_LINK)
[fsync DATASYNC] ← 一次 fsync 覆盖以上全部
→ fsync CQE 到达后, 统一 resume 这批等 commit 的写方
```

- **摊薄**:一批 N 条写共享一次 fsync(fsync 百 µs~ms 级,是大头);slot 数据段内乱序并发打满 NVMe 队列。
- **保序**:三段之间用 `IOSQE_IO_LINK` 串成 `数据 → bitmap → WAL → fsync`;段内(N 个 slot 数据)无 link,乱序。
- **正确性检查(基本律④)**:link 是"前一节完成"非"成功",short write 会把后续 `-ECANCELED`。
⇒ 收割时必须逐节校验 `res == len`;任一节短写/失败,则**整批视为未提交**(不 resume 成功、不认 WAL)。
- **可见性(§5.4)**:index map 何时对外可见,取决于选"立即可见"还是"fsync 后可见"。

### 5.3 崩溃恢复(recover)语义 —— 合并方案的核心

保序异步下,任意崩溃点落在 link 链的某一节。逐点分析:

| 崩溃点 | 盘上状态 | recover 结果 | 一致? |
|---|---|---|---|
| **A** slot 数据写了一半 / bitmap 未落 / WAL 未落 | bitmap 标记该 slot 空;WAL 无记录 | slot 视为未写,index 无此项 | ✅ 该 chunk 下次 miss 重算 |
| **B** slot 数据 + bitmap 已落,**WAL 未落** | slot 在盘上 live 且数据完整,但 index 无指针 | **孤儿 slot**:live 但无 index 指向 | ⚠️ 见下 |
| **C** 全部落盘(含 WAL) | 数据完整 + WAL 有指针 | WAL replay 装回 index,指向完整 slot | ✅ |

**只有 B 需要显式处理**,且它不是错误——数据是好的,只是索引没登记。因为 WAL 是 commit
record 且最后落,B 意味着"这条写未提交";cache 语义下有两种策略:

- **策略 1(默认,最简)**:忽略孤儿 slot。它占着空间,等 compaction 扫到时(该 slot 无
index 指向 ⇒ 视为已删)顺手回收。零额外 recover 逻辑,完全符合 cache 语义。
- **策略 2(可选,主动回收/补登记)**:recover 时对每个 segment 扫 bitmap,凡"bitmap live
但 WAL replay 后 index 未指向"的 slot,主动清 bitmap 位回收(或读出 key 反向补登记 index)。
代价是一次全 segment 扫描。

recover 总流程(与 §0 现状的 `Index::Open` 对齐):
1. Load snapshot(index 基线);
2. Replay WAL,遇 magic/CRC mismatch 停(torn-write),`LastGoodOffset` 截断——即"最后一批
未完成的合并提交"被整体丢弃(对应 A/B 的 WAL 那节未落);
3. (策略 2 才有)扫 segment 处理孤儿 slot。

**关键不变式**:index 里的每个 key,其指向的 slot 必然 (a) bitmap live 且 (b) 数据完整——
因为 WAL 记录只在数据+bitmap 已落之后才落。反向不成立(存在孤儿 slot),由上面策略兜底。

### 5.4 bitmap 覆写的注意

`FlushBitmap` 现在每次 `PWrite` 整个 bitmap 区(一个对齐块反复原地改写)。O_DIRECT 下这是
高频对齐块覆写,是写路径 O_DIRECT 化最麻烦处 → 阶段一保持 buffered,O_DIRECT 留阶段二。
合并提交里 bitmap 写是 link 链的固定一节,不受此影响;仅 O_DIRECT 化时需额外处理其对齐覆写。

### 5.5 持久性语义(cache 场景放宽)

合并提交把 WAL+Segment 的 fsync 统一为 link 链末端的**一次** `IORING_FSYNC_DATASYNC`,
覆盖 slot 数据 + bitmap + WAL。可见性(index map 何时对外可见)二选一:

- **立即可见(推荐)**:写入内存 map 后立即返回可见,合并批在后台异步 fsync。崩溃丢最近一个
提交窗口的写(那些 chunk 下次 miss 重算)。写延迟最低,最符合 cache 语义。对应 §5.3 表中
A/B:未 fsync 的整批被 `LastGoodOffset` 截断,index 与 segment 保持一致(或留孤儿 slot 由策略兜底)。
- **fsync 后可见(保守)**:提交方阻塞等本批 fsync 的 CQE 返回,再把项装进 map。保留"已 ack
必不丢"的耐久契约,写延迟 = 组提交周期。
- **fsync 频率**:两种模式下 fsync 都是**每合并批一次**(非每条);进一步可周期性合并多批共享
一次 fsync,吞吐换更大的崩溃窗口——cache 语义下可放宽。

## 6. O_DIRECT 与对齐(阶段二,连锁 §3.2)

阶段一用 **io_uring + buffered IO**:不碰 slot 布局,现有 528B slot 直接提交,先拿 batching + 深队列。

阶段二上 O_DIRECT,必须:
- `CalculateLayout`(`segment_base.cc:44`)改为 `slot_size_ = align_up(key_size + value_size, 4096)`,或分离 key/value 区、value 单独 4K 对齐(= §3.2 layer-major)。
- 所有 buffer `posix_memalign(4096)`(也为 RDMA MR 注册铺路)。
- **破坏磁盘格式兼容**(旧 528B 文件读不了)⇒ 必须与 §3.2 布局重构一起做,单独立项。
- 权衡:O_DIRECT slot 定长 4K,小 value 浪费大 ⇒ 仅对大 value(MB 级 chunk)划算,正是目标场景。

## 7. 分阶段落地

| 阶段 | 内容 | 布局改动 | 接口签名改动 | 风险 |
|---|---|---|---|---|
| **P1** | IoUringEngine + `SegmentBase::SubmitReads` + `Index::BatchGet`;读路径 batch(buffered) | 无 | 无(上层包同步) | 低 |
| **P2** | Segment+WAL 合并有序异步提交(数据→bitmap→WAL→fsync 单 link 链)+ recover 语义 | 无 | 无 | 中(顺序/持久性/recover 正确性) |
| **P3** | 异步推到 Index/backend,释放 bthread | 无 | `Index::BatchGet` future 化 + handler | 中高(回归面) |
| **P4** | O_DIRECT + slot 4K 对齐(合并 §3.2 layer-major)+ fixed buf | **是(破坏兼容)** | 无 | 高 |

## 8. 建议起点

**P1(读路径 batch,buffered,同步包装)**:零布局、零签名改动,拿到 io_uring 读吞吐大头,风险最低。
其次 **P2 的 Segment 数据→bitmap IO_LINK 写**:顺序约束明确、契合 append-only、且修复当前"数据/bitmap 无 fsync 保序"的正确性短板,收益比 WAL 更实。