Rivet Actors SQLite VFS v2 的设计约束全景:C1–C8 如何推导出分片 LTX + Delta Log 架构
【免费下载链接】actorsRivet Actors are the primitive for stateful workloads. Built for AI agents, collaborative apps, and durable execution.项目地址: https://gitcode.com/GitHub_Trending/riv/actors
本文以 Rivet Actors 仓库内部设计文档 constraints.md(状态:2026-04-15 已锁定)为主体,完整解读 SQLite VFS v2 的八条承重约束(C1–C8)、被这些约束直接排除的方案清单、基于 20 ms RTT 假设的性能数学推导,以及从 A/B/C/D 四种存储布局中选出"分片 LTX + delta log"(Option D)的完整论证。结合仓库中的基准测试 BENCH_RESULTS.md 与后续落地的规范 SPEC.md,读完后你将理解:为什么 Rivet 的 actor 内 SQLite 必须跑在进程内、为什么写入是首要优化目标、以及"一次 round trip 值 20 ms"这一前提如何决定了整个存储格式。
1. 问题起点:v1 的每页 KV 布局在真实 RTT 下不可用
Rivet 的 actor 是有状态工作负载的基元(项目描述:面向 AI agent、协作应用与持久执行),actor 内嵌 SQLite 时,v1 的实现是把 SQLite 页面按 4 KiB 一页拆成独立 KV key,经 VFS 读写隧道访问引擎侧 KV。仓库中的基准测试直接量化了这条路径的代价。BENCH_RESULTS.md 记录了 2026-04-15 在本地开发环境(约 2.9 ms RTT、端点http://127.0.0.1:6420)的插入基准:
| Payload | Actor DB 插入 | 原生 SQLite 插入 | Actor DB 对比原生 |
|---|---|---|---|
| 1 MiB | 832.2 ms | 1.8 ms | 461× |
| 5 MiB | 4199.6 ms | 25.3 ms | 166× |
| 10 MiB | 9438.2 ms | 45.5 ms | 207× |
其中 1 MiB 用例的 debug trace 显示 317 次 KV round-trip(30 次get+ 287 次put,累计 577 个 key 写入)。测试文档给出的结论是:瓶颈"明显不是 SQLite 本身,而是 SQLite VFS / KV 通道 / 引擎路径",即把数据库按 4 KiB 页面切块、为一次大插入发出海量 KV 读写。生产环境 10 MiB 插入实测 26.2 s(本地 19.2 s),比本地更差——这正是 constraints.md 选择20 ms 作为 C6 典型 RTT 假设的现实背景。
v1 的另一处实现证据在 depot-client 的 VFS 实现 与 engine/sqlite-vfs.md 中:4 KiB chunk 页面布局、打开时固定的 pragma(journal_mode=DELETE、locking_mode=EXCLUSIVE、auto_vacuum=NONE)等。constraints.md 的全部推导,都是要回答"在 20 ms RTT 的世界里,这套布局该怎么改"。
2. 八条承重约束(C1–C8)逐条解读
constraints.md 开篇声明:所有后续设计文档(protocol-and-vfs.md、compaction-design.md、key-decisions.md 及各类 workload 分析)都从本文档推导而来;任何一条约束一旦改变,设计必须重新评估。这是它被称为"canonical source of truth"的原因。
C1 — 热读必须零 round trip
Rivet SQLite actor 的主导场景是"稳定的工作集 + 对该工作集的重复查询",这个场景必须以内存速度执行。唯一实现方式是:SQLite 本体连同它的页缓存都运行在 actor 进程内。
该约束明确排除了三类架构:
- 引擎托管 SQLite("Model A"):每条查询都要付一次到引擎的 RTT,即使数据是热的,违反 C1;
- 混合设计(Model C:规范库在引擎侧、actor 侧只读缓存):跨边界的缓存失效是独立的设计泥潭,且热读仍会频繁命中 cache-miss RTT;
- 拦截在 SQLite pager 之上的架构(在 actor 内解析 SQL、把操作分发到远端):绕过了 SQLite 自己的页缓存,丢掉"免费热读"这一性质。
C2 — 写入是首要优化目标
在 C1 允许的范围内,VFS 首先为写入优化:大原子提交信封、分片存储(一个 KV value 装很多页)、压缩、以及用 prefetch/preload 把冷读拉到可接受。读是次要问题,因为 C1 已经免费解决了热读。
这是对 v1 调优方向的刻意反转:v1 用逐页 KV key 换取了冷读简单性;v2 则接受"稍贵的冷读(取回一个分片、切出目标页)",换取便宜得多的写入路径。
C3 — 冷读付 round trip,可接受
冷读(缓存未命中)不可能零 RTT,数据总得从某处来。设计会优化它——分片摊薄每 key 开销、prefetch 合并顺序访问、preload hint 在冷启动时预热缓存——但不试图将其降为零。无法容忍冷读延迟的负载(大随机访问、工作集放不进缓存)超出范围,需要另做运行时。
C4 — 无本地磁盘,KV 是唯一持久存储
所有状态字节都住在 actor 的 KV 子空间中。页缓存和脏缓冲是临时的,随 actor 进程消失;重启后的恢复完全来自 KV,而非任何本地文件。
C5 — 单写入者(故障转移窗口内用 fencing 防护)
Rivet 同一时刻最多调度一个 actor 进程。但引擎的 runner-id 检查是尽力而为的,在 runner 重新分配时存在一个短暂窗口,两个进程可能都认为自己拥有该 actor。v2 用代际令牌 fencing防御:每个提交操作携带(generation, expected_head_txid),引擎执行 CAS,不匹配则失败关闭(fail closed);每次冷启动都递增 generation。这让"head 指针提交模式"在并发写入者下是安全的。
这一点在后续 SPEC.md 中落到协议层:每个sqlite_*op 都带 fencing 字段,收到SqliteFenceMismatch即标记 actor 死亡、拒绝后续操作并由 Rivet 干净重启——与分布式系统中 leader 丢失租约同构。key-decisions.md 还记录了动机:对抗性评审发现的 4 个正确性 bug 全部追溯到缺失的 fence,一次 CAS 即可全部修复。
C6 — VFS 到 KV 的 RTT 高(典型约 20 ms)
这是承重参数:批量大小、本地缓存 vs 远端取回的取舍、"用 CPU 换 round trip"的决策全都基于它。文档特别强调了参数的鲁棒性方向:如果生产实际更低,设计仍然成立(只是没那么关键);如果更高,设计的价值只会上升——每个省下 round trip 的架构决策都按比例回报。
C7 — v1/v2 分发复用引擎既有 schema-version 标志
v2 引擎已有在 v1/v2 actor 实现间路由的 schema-version 机制,v2 SQLite VFS 直接搭车:不新增分发字节、不探测 key、不在 SQLite 子空间里放独立版本标签。引擎对该 actor schema 版本的说法直接决定它拿到哪个 VFS 实现。
C8 — 允许 SQLite v2 破坏性 API 兼容
v1 actor 永远留在 v1,v2 是新世界:没有 Drizzle 兼容 shim、没有要保留的 v1 trait 面、没有要维持的磁盘格式兼容。v2 可以自由更改:
- runner 协议上的线格式(自由加新 op);
- KV 磁盘布局(分片、压缩、索引,随需随改);
- Rust 侧
SqliteKvtrait 面(新方法、新错误变体); - 面向用户的 JS/TS SQL API 面(如果确实显著改善设计)。
v2 唯一不能做的是损坏 v1 actor 的数据。由于分发发生在引擎 schema-version 层(C7),两者不共享 key 空间,没有交叉污染风险。(仓库中 SPEC.md 把这一点具体化为:v1 用 UDB 前缀0x08,v2 用前缀0x02,两套 key 永不交集。)
3. 约束直接排除的方案清单
constraints.md 用一张表把"哪些想法被哪条约束杀死"固定下来,这是防止设计空间回流的关键护栏:
| 被排除的想法 | 排除依据 |
|---|---|
| 引擎托管 SQLite(Model A:引擎跑 SQLite,actor 发 SQL 字符串) | C1(热读永远 ≥1 RTT) |
| 本地 + 远端混合 SQLite(Model C:引擎为规范库,actor 做缓存) | C1(跨边界缓存失效、热读回退) |
| v2 用逐页 KV 布局(一页一个 KV key) | C2+C6(每 key 开销 × 20 ms × 页数 = 冷读与批量写代价不可接受) |
| v1→v2 迁移 | C7+C8(schema-version 标志隔离两者;同一 actor 内不共存,无需迁移) |
| Drizzle 兼容 shim 或任何 v1 API 保留 | C8 |
| 维护 LTX 滚动校验和 | (隐式)v2 不复刻第三方 LTX 工具链,字节保真由 SQLite + UDB 保证 |
| 把 LTX log 物化成逐页 KV key(最初 v2 草案的 LOG→PAGE materializer) | C2(进入时付 LTX 编码成本、出去时付逐页成本,两头收益都没拿到) |
值得注意的是最后两项:v2 最终只借用 LTX 的格式(LZ4 压缩页 + 页索引),而明确放弃了 LTX 的滚动校验和维护工具链;最初的"actor 内把 LTX log 物化回逐页 KV"方案也被 C2 否决——这正是 delta log 方案的前身教训。
4. 约束的数学含义:v1 数字在 20 ms 下放大 7 倍
constraints.md 以 BENCH_RESULTS.md 中约 2.9 ms RTT 的实测为基线,按 C6 的 20 ms 假设把 v1 所有冷路径数字放大约 7 倍:
| 工作负载 | v1 @ 3 ms(今日基准) | v1 @ 20 ms(生产目标) | v2-shards @ 20 ms |
|---|---|---|---|
| 1 MiB 插入 | 832 ms(287 RTT) | 约 5.7 s | 约 3 RTT × 20 ms =60 ms |
| 10 MiB 插入 | 9438 ms(约 2k RTT) | 约 65 s | 约 5 RTT × 20 ms =100 ms |
| 100 页冷读 | 约 290 ms(100 RTT) | 约 2 s | 约 2 RTT × 20 ms =40 ms |
| 缓存内页热读 | 约 5 µs(0 RTT) | 约 5 µs | 约 5 µs |
结论原文非常直接:"在 C6 之下,v1 对任何非平凡写入负载都濒临不可用,v2 不是可选项——它是 Rivet SQLite 在生产 RTT 下服务严肃负载的唯一路径。" 需要说明的是,表中 v2 列是按分片 RTT 数估算的设计值(v2 尚未实现时的推导),而非实测;v1 列则是实测基线线性外推。
5. 架构决策:A/B/C/D 四种布局的诚实对比
在 C1+C2+C6 的包围盒内,设计空间很小。constraints.md 列出四个选项:
| 选项 | 布局 | 优点 | 缺点 |
|---|---|---|---|
| A | 逐 key(v1 现状) | 简单;读 miss 每页 1 RTT | 每 key 开销 × 20 ms = 任何冷负载灾难 |
| B | 分片原始字节(每 KV value 约 64 页,裸拼接) | 每 key 开销约降 1000×;格式简单 | 每次提交都需读改写受影响的分片;小提交付全分片代价;无压缩 |
| C | 分片 LTX(分片内 LZ4) | B 的收益 + 约 2× 压缩 | 与 B 同样的每次提交 RMW 问题;读解压有 CPU 成本(很小) |
| D | 分片 LTX + delta log(DELTA 层装小的近期 LTX 文件,SHARD 层装大的压实后 LTX 文件) | 小提交 1 RTT 落 DELTA、无需改写分片;后台压实把 DELTA 折叠进 SHARD;大小提交写延迟都最优 | 机制最多:内存 delta 页索引、后台压实任务、fencing 保护的物化 op |
推荐:Option D(分片 LTX + delta log)。文档特别说明:早期版本曾夸大 D 在大提交上的优势,下表是经过修正的"诚实对比"。假设分片约 64 页 = 256 KiB 原始 / 128 KiB 压缩;kv_sqlite_*op 信封约 9 MiB。
逐场景推演(20 ms RTT):
- 4 页小提交(OLTP 主导场景):B 冷路径 1 RTT 读分片 + 1 RTT 写分片 = 40 ms,传输 256 KiB;C 同 RTT 模式但传 128 KiB;D 直接写 delta、无需读分片 = 20 ms,仅传约 8 KiB。
- 5,000 页提交(约 80 个分片、约 10 MiB 压缩):B 与 C 都是 2 RTT 读 + 2 RTT 写 = 80 ms;D 把全部页编码为单个 LTX delta(约 10 MiB 压缩),分两个信封 = 2 RTT = 40 ms。
- 热页重写(同样 4 页更新 100 次):B/C 约 200 RTT(缓存后约 100),约 4 s、传输 25/12.5 MiB;D 100 次 delta 追加 + 1 次压实,约 2 s、仅 1 MiB——2× RTT 优势、约 25× 带宽优势。
- 冷单页读:三者都是 1 RTT = 20 ms,平手。
- 热缓存命中:任何选项都是 0 RTT(C1 的意义所在)。
汇总对比表:
| 工作负载 | A(逐 key v1) | B(原始分片) | C(LTX 分片) | D(LTX + delta) |
|---|---|---|---|---|
| 4 页提交、冷分片 | 1 RTT(20 ms) | 2 RTT(40 ms)、256 KiB | 2 RTT(40 ms)、128 KiB | 1 RTT(20 ms)、8 KiB |
| 4 页提交、热分片 | 1 RTT(20 ms) | 1 RTT(20 ms)、256 KiB | 1 RTT(20 ms)、128 KiB | 1 RTT(20 ms)、8 KiB |
| 5,000 页提交 | 约 2k RTT journal 回退(40 s) | 4 RTT(80 ms)、20 MiB | 4 RTT(80 ms)、10 MiB | 2 RTT(40 ms)、10 MiB |
| 热 4 页重写 × 100 | 100 RTT(2 s) | 约 200 RTT(4 s)、25 MiB | 约 200 RTT(4 s)、12 MiB | 约 100 RTT(2 s)、1 MiB |
| 冷单页读 | 1 RTT、4 KiB | 1 RTT、256 KiB | 1 RTT、128 KiB | 1 RTT、128 KiB |
| 热读 | 0 | 0 | 0 | 0 |
D 在每一类负载上获胜或平手,最大赢面在:小提交(对 B/C 2× RTT + 32× 带宽,因为写入不必先读分片)、热页重写(2× RTT + 约 25× 带宽,delta 极小且压实惰性折叠)、任意大小的冷分片提交(D 跳过 B/C 必须付的"先读后写"罚金)。而 D 在 5,000 页大提交上只有 2× 的小优势,因为那个量级上两个方案都是信封受限而非 RTT 受限——文档坦承 D 的戏剧性优势在提交大小分布的小端,不在大端。D 几乎不赢的场景:受影响分片已热的超大提交(RTT 打平、仅带宽赢)、冷单页读(平手)。
D 相对 B/C 的成本是实现复杂度:内存dirty_pgnos_in_logmap(让读知道哪些页在 delta 里)、后台压实任务、fencing 保护的原子 op(写新分片 + 删已折叠 delta + 推进 META)、崩溃后孤儿 delta 的恢复逻辑。文档评估这些代价"真实但都已充分理解",且分片+delta 变体把压实单元收敛到单个分片 key(而非多 key 范围删除 + 逐页写序列),避开了早期设计对抗性评审发现的多数正确性隐患。
6. LZ4 压缩:进还是不进?
独立于布局选择,分片/delta 内的字节要选 LZ4(LTX 风格)还是裸字节。推荐:进。论证很简单:LZ4 解压约 1 GB/s,256 KiB 分片解压约 250 µs,完全被 20 ms RTT 掩盖;压缩省下约 50% 冷读传输字节和约 50% KV 存储成本,在 20 ms RTT 下净收益为正。文档还留了退路:如果实现成本成为问题,可以先发 D-裸字节版,后续再上 LZ4——因为格式是 v2 内部的,可自由变更(C8)。
这个"分片给约 1000×、LTX 压缩在其上再给约 2×"的分层量化,在 key-decisions.md 中被再次确认为两个可分离的决策;SPEC 层面则确认 LTX 滚动校验和显式弃用(V3 格式允许置零),字节保真由 UDB + SQLite 保证。
7. 遗留开放问题(不影响架构决策)
constraints.md 区分了"约束问题"与"实现调优问题",后者不阻塞上面的架构决策,完整继承如下:
- 分片大小:约 64 页(256 KiB 原始 / 128 KiB 压缩)是工作假设,需实测在"每分片取回成本 vs 点查过取"间平衡;
- 默认页缓存大小:mvSQLite 用 5,000 页(约 20 MiB);workload 分析提示分析型 actor 想要 50,000 页(约 200 MiB);倾向做成 per-actor 可配置,默认值靠经验确定;
- 压实触发:按时间、delta 数还是 delta 大小?倾向 delta 大小为主、delta 数为上限;
- 压实并发:常驻后台 vs 空闲时运行 vs 仅受压时运行,各有尾延迟含义;
- preload hint API 面:配置期列表、运行时可变、还是两者都要?按 key、按范围还是带标签?
- 20 ms RTT 是典型值还是最坏值:若为典型值,v2 是团队最高优先级项目;若为最坏值(多数用户 <5 ms、少数跨区),紧迫性是"按它设计"而非"明天上线"。两种情况下架构相同。
这些开放问题在后续文档中有部分收敛:SPEC.md 第 14 节给出了带默认值与测量计划的调优参数表(如shard_size=64建库后不可变、N_count=64delta 压实阈值、B_hard=200 MiB背压阈值、T_idle=5 s空闲定时器),并注明策略是"按默认值上线、第一天起埋全量指标、用基准逐个 sweep 后定生产默认值"。
8. 约束在仓库中的落地与后续演化
constraints.md 是"约束层",仓库里可以看到它向下推导出的三层产物,值得作为延伸阅读:
- 协议与 VFS 层:protocol-and-vfs.md 把 C1–C8 展开为
sqlite_*op 家族(takeover/get_pages/commit/commit_stage/commit_finalize/preload)、actor 侧三层读路径(写缓冲 → LRU 页缓存 → 引擎取回,每次引擎取回一个 RTT)以及 fencing 失败即死(fail-dead)语义。C5 的"head 指针提交模式在并发写入者下安全"在此落成"每个 op 携带(generation, expected_head_txid),引擎 CAS,fence mismatch 无重试、进程退出"。 - 决策层:key-decisions.md 对每条决策记录"决策 / 为什么 / 被否掉的替代 / 何时替代会赢 / 具体收益",并补了一条 constraints.md 未展开的重要决策——压实跑在引擎侧而非 actor 侧:actor 侧每轮压实要付 3+ RTT,引擎侧因 UDB 本地而约 0 成本;同时 actor 侧
dirty_pgnos_in_logmap 和一层读路径逻辑整个消失。 - 实现层:SPEC.md 是后续合并出的规范文档,包含
DBHead(META)结构、0x02前缀的 key 格式(SHARD/<shard_id_be32>、DELTA/<txid_be64>、PIDX/delta/<pgno_be32>等)、FDB 硬上限适配(100 KB value 限制下的 10 KB 分块、10 MB 事务上限决定MAX_DELTA_BYTES = 8 MiB)、独立存储配额(默认 10 GiB)等工程细节。仓库实现侧的证据见 engine/sqlite-vfs.md 与 SQLITE_OPTIMIZATIONS.md:v2 存储 key 使用0x02子空间 + 大端数字后缀以维持scan_prefix的数值序;slow-path staging 直接把编码后的 LTX 写在 DELTA chunk key 下(测试与恢复代码不应期待/STAGEkey);优化通过RIVETKIT_SQLITE_OPT_*环境/特性标志门控(如读前模式、staging 缓存 TTL),指标暴露在 actor 的 Prometheus 端点上。这体现了 C8 承诺的演化空间——约束锁定后,具体格式与开关可以在 v2 内部自由调整而不破坏架构。
9. 小结
constraints.md 的方法论价值在于:它先冻结八个不可协商的前提(进程内 SQLite 换零 RTT 热读、写入优先、冷读可付 RTT、KV 唯一持久层、fencing 单写入者、20 ms 承重 RTT、schema-version 分发、允许破坏性变更),再让一切方案在这条约束链上做可计算的取舍——被排除表让方案空间只进不出,A/B/C/D 对比表用统一假设(64 页分片、9 MiB 信封、20 ms RTT)逐工作负载算账,最终选出在所有负载类别上获胜或打平的分片 LTX + delta log 布局。对于任何"把本地文件数据库搬到远端 KV"的系统设计,这套"先写约束、再做数学、后选布局"的推导路径本身就是可直接复用的工程方法;而 BENCH_RESULTS.md 中 461×–207× 的实测差距,则持续提醒着 C6 这条承重参数的分量。
【免费下载链接】actorsRivet Actors are the primitive for stateful workloads. Built for AI agents, collaborative apps, and durable execution.项目地址: https://gitcode.com/GitHub_Trending/riv/actors
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考