Rivet Actors SQLite 大读取负载深度剖析:prefetch、LRU 缓存与 RTT 预算如何决定大查询延迟
【免费下载链接】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 仓库内部设计文档 workload-large-reads.md 展开,系统讲解"大型读取"这一负载类别在 v1(逐页 KV)与 v2(sharded LTX + prefetch)两代 SQLite VFS 下的行为差异:四类典型场景(整表扫描、冷启动工作集、索引范围扫描、重复扫描)的往返次数(RTT)推演、LRU 缓存在顺序访问下的退化问题,以及由此导出的缓存容量、prefetch 深度、preload hint 与协议包的调优建议。读完后,你将掌握一套"以 RTT 为预算"的存储延迟分析方法,并知道当前仓库中哪些源码(VFS 实现、优化标志、基准测试)印证了这些设计决策。
背景:为什么"大读取"值得单独做一篇负载分析
Rivet 把 SQLite 数据库放在 actor 进程内运行,数据通过 KV 通道持久化到引擎侧。这意味着每一次缓存未命中的页读取都要付出一次"引擎 KV 往返"(round trip)。在设计约束文档 constraints.md 中,C1 要求"热读必须零 RTT",C6 设定了生产环境约 20 ms 的往返延迟假设——这两个约束直接决定了:大读取场景的性能几乎完全由"每 RTT 能捎带多少页"和"缓存命中策略"决定。
本文分析的数字基于本地开发环境的 2.9 ms RTT 基准(对应仓库中真实捕获的基准数据 BENCH_RESULTS.md:1 MiB 插入耗时 832 ms、约 287 次 KV 往返)。按 C6 的 20 ms 生产目标折算,所有 RTT 受限的时延需要乘以约 7 倍。文档明确标注这些是"推演估计而非实测",定性结论成立,最终调优常量需以 bench 为准。
全局分析假设
原文档列出了一组贯穿所有场景的假设,理解它们是读懂后续 RTT 推演 arithmetic 的前提:
- 往返成本:本地开发环境每次引擎 KV 往返约 2.9 ms;无论一次
kv_get携带 1 个还是 128 个键,单次kv_get都按约 3 ms 计,因为延迟由引擎隧道主导而非 UDB 读取本身。 - 页大小:SQLite
PRAGMA page_size = 4096(v1/v2 相同),即 1 MiB 表数据 ≈ 256 页。 - SQLite pager 缓存:v1、v2 均未覆写
PRAGMA cache_size,因此每连接默认为 2000 页(约 8 MiB)的进程内页缓存,位于 VFS 之上;只有未命中这部分缓存的读才会到达 VFS 边界。 - VFS 读缓存:v1 的
read_cache通过RIVETKIT_SQLITE_NATIVE_READ_CACHE显式开启(默认关闭);v2 的 LRU 缓存常驻开启,默认 5000 页 = 20 MiB。对比时统一采用 v1 的出厂默认(不启用读缓存),在关键处单独标注开启读缓存的变体。 - SQLite xRead 粒度:SQLite pager 每未命中一页只向 VFS 索要一片 4 KiB,从不索要 64 KiB 条带。因此 v1 的单次读批处理机会为零页,其唯一的 RTT 摊销手段是启动预加载(与查询中途的读取无关)。
- B-tree 扫描中数据页与索引页的比例:约 50 B/行、4 KiB 页的小行表每叶页约容纳 70–80 行,100 万行约 1.3 万叶页加约 200 内部页;50 MB 表(12800 页)的内部节点仅占数据量的 1–2%。RTT 推演中简化为"几乎全是叶页"。
- LZ4 对 SQLite 页的压缩比:综合公开数据,4 KiB B-tree 叶页的 LZ4 块模式压缩比约 2.0–3.0×,推演采用 2.2×(平均每页 1.8 KiB)。
- v2 预取深度:mvSQLite 移植版默认热步长下每次读预测 8 个后续页,
predictor.multi_predict批次作为一次kv_get携带[target, ...predictions],即顺序扫描时单次 RTT 最多 9 键。 - Materializer 状态:场景 1–4 假设 materializer 已追平,四层读路径在稳态下走第 4 层(PAGE/)。
场景 1:内存装得下、VFS 缓存装不下的整表扫描
SELECT * FROM users,100 万行表(磁盘约 50 MB),计上表 B-tree 叶页、少量内部页与索引根页,共约 12,800 页。
v1 行为
- SQLite pager 缓存持有前 2000 页,之后的页既发生页错误又因严格向前遍历不断驱逐早期叶页(LRU + 线性扫描 = 零复用),扫描结束时缓存里只剩最后 2000 页。
- 12,800 个唯一叶页 × 每次一个
xRead=12,800 次xRead,每次一个 4 KiB 块;没有预取,每次xRead都未命中 VFS 读缓存(默认关闭;即便开启,冷扫描时缓存也是空的),退化为一次仅携带1 个键的batch_get。 - 往返次数约 12,800,聚合时延 12,800 × 3 ms ≈ 38.4 s。
- 若设置
RIVETKIT_SQLITE_SQLITE_NATIVE_READ_CACHE=1(即RIVETKIT_SQLITE_NATIVE_READ_CACHE=1),重复同一扫描几乎免费(无界 HashMap 的内存代价约 60 MB),但冷扫描仍是约 38 s。 - 这就是生产环境里用户在任何"有一定规模的表"上看到的典型病理形态。
v2 行为
- SQLite 侧同样发起 12,800 次
xRead。v2 四层读路径:- 层 1(LRU 缓存)——冷启动为空,边扫边填充;默认 5000 页容量下,扫过第 5000 页后早期页被驱逐,扫描不复用;
- 层 2(写缓冲)——只读查询,为空;
- 层 3(未 materialize 日志)——假设 materializer 已追平,零成本;
- 层 4(已 materialize 的 PAGE/)——读取落在这里。
- 预取预测器在前几次读后观测到 +1 步长,随后每次调用输出
PREFETCH_DEPTH= 8 个预测。每 4 次层 4 查询合并为一次kv_sqlite_preload(或经既有路径的胖kv_get),携带9 个键(目标页 + 8 个预测页);随后 8 次读全部缓存命中,零 RTT。 - 有效 RTT 速率:
12,800 / 9 ≈ 1,422次往返,聚合时延约 4.3 s,相对 v1 提速约 9×。 - 把缓存提升到 2000 页(与 SQLite pager 缓存相等,消除"尾页被驱逐"问题)时首次扫描不变,收益体现在场景 4。
缓存有效性与盈亏点
- 对于长于缓存的严格向前扫描,v1 的 60 MB 可选 HashMap 与 v2 的 20 MB LRU 在扫描过程中都不提供价值;缓存只在扫描之后、且同一批页再次被触碰时(场景 4)才有用。真正在扫描中途起作用的是预取批次。
- 预取预测器评级:(a) 非常有效。+1 步长是步长检测器置信度最高的典型工况,预期 8–16× 的 RTT 降幅(取决于
PREFETCH_DEPTH)。 - 盈亏点:v2 在此场景从不劣于 v1——即使关掉预取,v2 付出的 12,800 RTT 与 v1 相同;预取深度 2 即立刻回本。
场景 2:冷启动时的工作集读取
Actor 启动后立即执行SELECT * FROM users WHERE region = 'us-east',返回 10 万行。region索引约 300 个内部页,匹配约 800 个索引叶页;非覆盖索引下每匹配行需回表取数据页,10 万行平均每数据页约 10 行,需取约10,000 个数据页,大致按 table-rowid 序、偏随机。
v1 行为
- 冷启动:META + 有界预加载最近触碰过的页(未必包含 users 表任何页)。现实估计:启动 1 RTT + 预加载正文约 1 RTT。
- 查询执行:
- B-tree 根 → 索引根 → 索引叶:约 800 索引叶页 + 3–5 个根/内部页 ≈ 805 次
xRead,各为一次 1 键batch_get; - 回表取约 10,000 个数据页,各为一次 1 键
batch_get。
- B-tree 根 → 索引根 → 索引叶:约 800 索引叶页 + 3–5 个根/内部页 ≈ 805 次
- 往返次数:2(启动)+ 805 + 10,000 ≈ 10,807,聚合时延 ≈ 32.4 s。数据页读取呈"近似聚簇的随机",不是严格 +1 步长;v1 即使开启了读缓存,对真正冷启动也无济于事。
v2 行为
- 冷启动路径:一次
kv_sqlite_preload操作取回 META + 页 1 + LOGIDX 扫描;若用户声明的预加载 hint 覆盖了 users 表根页与region索引根,同一次 RTT 内返回。启动共 1 RTT。 - 查询执行分两个子阶段:
- 索引扫描(约 805 页):前几次读训练预测器识别 +1 步长,热身后每 RTT 携带 1 目标 + 8 预测 = 9 键,
805 / 9 ≈ 90RTT; - 数据页回表(约 10,000 页):这是难点。数据页访问顺序与 rowid 序相关但不是 +1 步长(
region='us-east'的行散布在全表),步长检测器停滞,Markov bigram 只在特定 (Δ=+k) 对反复出现时有帮助(取决于负载)。现实估计:数据页读取平均 3 页/调用,10,000 / 3 ≈ 3,333RTT。
- 索引扫描(约 805 页):前几次读训练预测器识别 +1 步长,热身后每 RTT 携带 1 目标 + 8 预测 = 9 键,
- 往返次数:1 + 90 + 3,333 ≈ 3,424,聚合时延 ≈ 10.3 s,相对 v1 提速约 3×,瓶颈在数据页回表阶段。
- 若预加载 hint 覆盖相关 region 的
(region, rowid)索引叶页(用户知道自己热分区),索引扫描子阶段压缩到ceil(index_leaf_bytes / ~1 MiB) ≈ 1–2RTT,省约 270 ms——不是瓶颈。
该场景暴露的 v2 设计缺口
数据页回表是真实成本,而预取预测器只能部分发力。v2 目前没有办法告诉引擎"给我这个索引叶条目集合所引用的全部数据页",候选方向是:
- 大幅加深预取窗口(代价:取回不需要的页,浪费 payload 预算);或
- 在
kv_sqlite_preload中新增"回表"(dereference)hint,接受一个 pgnos 列表并在一次胖批次中取回。
后者可视为把预加载 hint 从"启动时加载"泛化为"查询中途、与 SQLite 无关的加载"。它能把 3,333 RTT 压缩到10,000 / 512 ≈ 20RTT(按每操作约 512 键的信封),总量降到 1 + 90 + 20 ≈ 111 RTT = 333 ms。原文档将其列为开放问题。
缓存有效性与盈亏点
- 冷运行缓存完全无热度;结束时 8 MiB pager 缓存持有约 2000 个数据页,v2 的 5000 页 LRU 可装下半数据页加索引叶页;同 region 重放查询可复用全部装得下的内容。
- 预测器评级:(b) 有一定效果——索引遍历子阶段很好(+1 步长),数据页子阶段中等(对恰好重复的 delta 走 Markov bigram)。这正是预测器"诚实边界"显现的场景。
- 盈亏点:即使预测器完全失效,v2 也优于 v1,因为 preload 把启动折叠进 1 RTT。
场景 3:带预取机会的大索引范围扫描
SELECT * FROM events WHERE ts BETWEEN a AND b ORDER BY ts,时间索引表。索引自上而下严格顺序遍历约 2,000 个索引叶页(约 10 MB 索引数据);events为追加写表,ts 与 rowid 强相关,数据页访问大体按 rowid 顺序、带少量小跳跃,共约 20,000 个数据页。
| 阶段 | v1 RTT | v2 RTT |
|---|---|---|
| 索引遍历(2,000 页) | 2,000 × 1 键/次 | 2,000 / 9 ≈ 222(+1 步长,深度 8) |
| 数据页取回(20,000 页) | 20,000 × 1 键/次 | 20,000 / 7 ≈ 2,857(平均每 RTT 7 页) |
| 合计 | 约 22,000 RTT ≈ 66 s | 约 3,080 RTT ≈ 9.2 s,提速约 7× |
- 数据页阶段:步长检测器在多数时刻保持有效,Markov bigram 填补小跳跃;现实预取效率约7 页/RTT(每 8–9 次预测出现一次步长未命中)。
- 缓存有效性:5000 页 LRU(约 20 MiB)装不下约 90 MiB 的工作集,扫描内部无复用;若仪表盘刷新重复扫描,缓存尾部保留最后约 5,000 页(见场景 4)。
- 预测器评级:(a) 非常有效。这是预测器最友好的真实负载形态——mvSQLite 论文描述预测器时使用的动机示例正是这个形状。
- 盈亏点:v2 明确更优。无预测器时与 v1 持平;有预测器时 6–8×。
- 该负载下的 v2 设计要点:预取饱和后,胖批次
kv_sqlite_preload操作信封成为约束。每调用约 512 键、每次只预取约 8 页,仅用了 512 槽位中的 9 个。若预测器在步长饱和时输出更宽的预测(如"接下来 100 页"),2,857 个数据页 RTT 可压缩到20,000 / 100 = 200RTT,在预测器之上再提速 14×。"步长饱和时可变预取深度"是一个具体可调参数,是 v2 发版候选。
场景 4:重复的整表扫描
报表仪表盘每分钟轮询同一查询(即场景 1 的 12,800 页全扫描)。
v1 行为
- 默认(读缓存关闭):每次扫描重新取回全部 12,800 页,每次 38.4 s,零复用。
- 开启可选读缓存:首次扫描 38.4 s(所有页进入无界 HashMap,约 60 MB);后续扫描 0 RTT,仅受 SQLite 执行 CPU 限制(约 1–3 s)。内存随工作集增长,缓存不驱逐。
- 注意:该可选路径按文件状态 HashMap 持有所有页、无上限,10 GiB 数据库会击穿 actor 内存。它不可作为 v1 的"常开"模式交付。
v2 行为
- 首次扫描:4.3 s(场景 1)。12,800 页中 5000 页留在 LRU 里,且按 LRU 顺序,驻留的是最后 5,000 页。
- 第二次扫描:页 1, 2, 3… 顺序请求;页 1–7,800 已被向前遍历驱逐(未命中),页 7,801–12,800 命中缓存:
- 页 1–7,800 走层 4 + 预取:
7,800 / 9 ≈ 867RTT; - 页 7,801–12,800 全部命中:0 RTT;
- 后续每次扫描:867 RTT × 3 ms ≈ 2.6 s。
- 页 1–7,800 走层 4 + 预取:
- 第三次及以后:形态相同——每次扫描的缓存内容都是"最后 5,000 页"的稳态,不再改善。长于 LRU 的向前扫描收敛于"前 N−cache_size 页未命中、后 cache_size 页命中"的稳态。
核心问题:LRU 在顺序扫描下退化
v2 仅凭"最后 5,000 页被缓存"获得一次性 40%/扫描的提速,但收敛不到可选读缓存 v1 的理想值。根本原因:LRU 缓存在向前遍历访问模式下退化——缓存驱逐的恰恰是即将再次需要的页。
两个修复方向:
- MRU 驱逐或预测器感知驱逐:长顺序扫描时改按"最近使用"驱逐,重复扫描即可命中每轮的前 N 页;步长置信度下降时回退 LRU。mvSQLite 记录了这一权衡但未处理,v2 面临同样选择。
- 查询时
kv_sqlite_preloadhint:应用告知 actor"即将整表扫描,请预加载页[1..12800]",v2 可提前发一两个胖批读。按 512 键/操作,12,800 / 512 = 25RTT ≈ 75 ms 完成预热,之后扫描全在内存中执行。前提:- 缓存大到装得下整个扫描(当前 5,000 页,需 12,800+);
- 提供运行时 preload API,而不只是启动期 hint。 两者都是对当前 v2 设计的扩展。
预测器评级:(b) 有一定效果——预测器只帮未命中阶段,重复访问的关键杠杆是缓存策略而非预取。原文档直言:这是 v2 设计相对 v1"一个大可选缓存"最弱的场景。盈亏点:若用户在内存充足时启用 v1 可选读缓存,首扫之后的重复扫描 v1 获胜;v2 要么把缓存扩大到覆盖工作集,要么在顺序扫描下切换驱逐策略,否则无法反超。
调优建议汇总
原文档 Recommendations 一节给出的都是"可调参数"而非定论,全部继承如下:
缓存容量
- 默认 LRU 缓存从 mvSQLite 继承的 5,000 页提升到10,000 页(40 MiB):Rivet actor 通常同时只跑一个 SQLite 连接,单 actor 内存预算可以吸收;40 MiB 覆盖多数"小型报表数据库"工作集(场景 1 与 4)。
- 缓存容量按 actor 可配置并设合理上限(如每 actor 上限 100 MiB,保证 actor 密度)。
- 当预测器报告步长饱和时考虑MRU 驱逐,规避场景 4 的退化;步长置信度下降时回退 LRU。
预取深度
- 默认
PREFETCH_DEPTH = 8(与 mvSQLite 一致),适合场景 1 与 3。 - 步长检测器饱和时允许深度上调:连续 16+ 次 +1 步长饱和时,把预取信封提到 payload 上限
min(remaining_payload_budget, 256)页/调用。这是大顺序扫描(场景 3 受益最大)的具体提速点。 - 信封受
kv_sqlite_preload的 9 MiB / 512 键限制约束——按 2.2× LZ4,512 页在线上传输完全装得下。
预加载 hint
- 启动期 hint(见 walkthrough.md):对场景 2(冷启动工作集)有用,前提是用户足够了解 schema 能声明索引根与热数据范围。
- 运行时 hint(新能力,当前未定义):暴露逐查询 API,如
c.db.preloadPages(pageno_list)或c.db.preloadTableRange(table_name, low, high)。这是场景 4(重复扫描)的杠杆,也能解决场景 2 的数据页回表阶段——在查询执行前让应用告诉 v2"这些行我会用到"。实现方式是查询前发一次或几次胖kv_sqlite_preload调用。它要求应用理解自己的访问模式,对真正有需求的场景(报表查询、仪表盘)可以接受。
协议调优
- 9 MiB / 512 键每操作信封对预取饱和的场景 3 是恰当的,不要缩小到低于此值。
- 考虑引入独立的"scatter-gather 读"操作
kv_sqlite_fetch_pages(pgno_list),与kv_sqlite_preload区分:线上形态相同,但语义是"在一个 RTT 内给我这份 PAGE/ 键列表"。预测器今天实际上已经通过kv_get在使用它;升级为一等操作后,引擎可假设单事务快照,避免无谓的额外工作。
开放问题 / 设计缺口
- 索引扫描到数据页的回表(场景 2)是预取预测器的最弱点——预测器无法预知索引叶指向哪些数据页。诚实的修复是应用 hint("扫完这段索引范围后,预热被引用的数据页")或 VFS 层在返回索引叶字节前先窥探其内容。两者都不在当前 v2 设计中。
- 顺序扫描的 MRU vs LRU 驱逐(场景 4):没有它,v2 在重复整表扫描上打不过 v1 + 可选缓存;文档倾向"步长感知 MRU"。
- 默认缓存容量需先调研典型 actor 的 RAM 配额再定 10,000 页这个数字。
- 预测器在"索引 → 数据页回表"上的效率没有硬数据——文中的 3×、7× 是按 mvSQLite 文档套用预期负载的经验值,值得专门 bench。
- 运行时 preload hint 不在当前 v2 设计中;加上它是小协议扩展 + 中等规模 VFS 改动,建议 v2.0 范围内能容纳就上,否则放 v2.1。
仓库源码印证:设计推演与当前实现
上述推演是设计期文档(Status: Draft 2026-04-15),但仓库中已有多处源码与文档相互印证,可帮助判断哪些机制已经落地:
1. VFS 页缓存与读缓存已从"可选"变为"默认全开"。在引擎侧 VFS 配置 vfs.rs 中,VfsConfig含retain_read_cache字段,其取值来自优化标志的page_cache_mode.caches_any_pages();can_read_cached_page在每次页读取前做统一裁决。这与 optimization_flags.rs 中SqliteVfsPageCacheMode(Off / Target / Startup / Prefetch / All)的枚举一一对应,默认值为All,且带有vfs_page_cache_capacity_pages、vfs_protected_cache_pages、vfs_staging_cache_ttl_ms、pager_cache_size_kib等容量参数。从源码结构看,这印证了 tuning-parameters.md 中"缓存默认常驻、容量按 actor 可调"的方向,也呼应场景 4 结论中"不要让读缓存成为 opt-in"的立场。
2. 预取 / 预加载机制已作为优化标志存在。optimization_flags.rs 定义了SqliteOptimizationFlags,包含read_ahead_mode(默认Adaptive,即"自适应预读",对应文中"步长饱和时调整深度"的思路)、read_ahead、adaptive_read_ahead、recent_page_hints、cache_hit_predictor_training、preload_hint_flush、preload_hints_on_open、preload_hint_hot_pages、preload_hint_early_pages、preload_hint_scan_ranges、startup_preload_max_bytes等字段,全部默认开启。每项都对应一个RIVETKIT_SQLITE_OPT_*环境变量(如RIVETKIT_SQLITE_OPT_READ_AHEAD_MODE、RIVETKIT_SQLITE_OPT_VFS_PAGE_CACHE_MODE、RIVETKIT_SQLITE_OPT_STARTUP_PRELOAD_MAX_BYTES),并有测试断言非法值会被钳制回默认值。这正是文中"运行时预加载、per-actor 可调"建议的落地形态。
3. 启动预加载有独立的指标与失败语义。actor 侧指标 metrics.rs 定义了rivetkit_actor_sqlite_startup_preload_pages_total计数器(区分requested/loaded),由 database.rs 中的record_startup_preload_pages上报;内联测试 vfs.rs 中的delayed_startup_preload_response_fails_closed_and_reopen_is_clean验证了预加载响应延迟时"失败即关闭、重新打开干净"的语义——对应文中场景 2 冷启动路径对kv_sqlite_preload的依赖必须可靠的前提。
4. RTT 预算的现实基准。文中 2.9 ms/RTT 的本地基准与 BENCH_RESULTS.md 完全对齐(1 MiB 插入 832 ms、287 次 put 往返;10 MiB 插入 9,438 ms),且该文件记录的 debug trace 结论(瓶颈在 SQLite VFS / KV 通道而非 SQLite 本身)正是 v2 立项与本文所有"以 RTT 为预算"推演的事实起点。
5. 约束与决策链。本文所有场景推演引用的约束(C1 热读零 RTT、C6 约 20 ms 生产 RTT)与"Option D(sharded LTX + delta log)"布局决策,分别固化在 constraints.md 与 design-decisions.md 中;SPEC.md 是该设计线的总体规格。阅读顺序建议:先 constraints → 再本文大读取负载分析 → 最后 tuning-parameters。
小结
这篇负载分析的价值在于给出了一套可复用的推理框架:把"大读取"延迟分解为"启动 RTT + 索引遍历 RTT + 数据页回表 RTT + 缓存命中"四项,逐项用 512 键/9 MiB 的胖批次信封和步长预测器去压缩。四个场景的结论可以浓缩为:
- 顺序扫描(场景 1、3):+1 步长预取是决定性杠杆,9 页/RTT 带来 7–9× 提速,且步长饱和时应进一步放大预取深度;
- 冷启动回表(场景 2):预测器只能做到 3×,真正解法是运行时"回表 hint"这一协议扩展;
- 重复扫描(场景 4):预取帮不上忙,LRU 在顺序访问下退化,胜负手是 MRU/步长感知驱逐或把缓存撑到覆盖工作集。
对当前仓库而言,这些结论并非纸面推演:depot-client的 VFS 已具备自适应预读、页缓存模式开关与启动预加载指标,RIVETKIT_SQLITE_OPT_*标志体系提供了逐 actor 验证上述参数的入口。若你在 Rivet actor 上遇到"大查询慢"的问题,值得按本文顺序先定位落在哪个场景,再用对应的环境变量与 preload hint 做针对性调优。
【免费下载链接】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),仅供参考