CHAPTER 05 · Storage
接入 RocksDB,做一个 KV 数据库
第四章的房间和消息仍然只在内存。本章加入 RocksDB,并向 JavaScript 提供 env.KV。数据库调用在工作线程执行,V8 handle 永远留在 Runtime 线程。
本章新增:
KvStore。 它唯一拥有 RocksDB DB,负责物理 key、大小限制、CRUD、前缀 list 和错误转换。这个类保持同步,因为 RocksDB API 本身就是同步的。
本章新增:
StorageExecutor。 固定线程在有界队列中执行阻塞操作,使用 KJ cross-thread fulfiller 把结果送回创建 Promise 的 event loop。
本章新增:
KvBinding。 它把get()、put()、delete()、list()安装到env.KV,并且只在 V8 线程创建、resolve 或 reject JavaScript Promise。
1. 用同一工具链构建 RocksDB
RocksDB v9.10.0 的官方 Release 没有 Linux 静态库资产;系统包又使用 libstdc++,不能与本教程的 Chromium libc++ ABI 混用。因此固定 commit ae8fb3e…,自行构建一次静态库。
压缩库、jemalloc、gflags、benchmark 和命令行工具都不是 KV 教学所需,全部关闭。这既避免系统动态依赖,也缩小需要理解的依赖面。
完整文件 · ch05/install-rocksdb.sh
正在加载源码...export LAB_ROOT=/home/zq/v8v8
bash "$LAB_ROOT/v8-mod-tutor/src/code/ch05/install-rocksdb.sh"
- 预期输出
- 完成 344 个对象后出现
Linking CXX static library librocksdb.a,脚本末行是RocksDB 9.10.0:OK。 - 原因
- 安装结果来自锁定 Clang 21/libc++,不是系统
librocksdb-dev;所有可选压缩库均关闭。
2. 建立 Chapter 05 工程
本章复用第三章 Runtime,并链接刚安装的 RocksDB::rocksdb CMake target。
完整文件 · ch05/CMakeLists.txt
正在加载源码...CMAKE="$LAB_ROOT/.deps/cmake-3.31.8/bin/cmake"
SOURCE="$LAB_ROOT/v8-mod-tutor/src/code/ch05"
BUILD="$SOURCE/build-v137"
"$CMAKE" -S "$SOURCE" -B "$BUILD" -GNinja \
-DCMAKE_TOOLCHAIN_FILE="$LAB_ROOT/v8-mod-tutor/toolchain/v8-pinned.cmake" \
-DCMAKE_BUILD_TYPE=Debug \
-DCMAKE_PREFIX_PATH="$LAB_ROOT/.deps/v137"
"$CMAKE" --build "$BUILD"
- 预期输出
- 最后链接
libstorage_runtime.a、storage-test和v8-kv。 - 原因
- V8、KJ、RocksDB 和 storage binding 已在同一套 libc++ ABI 下组成可执行程序。
3. 先定义物理 key
逻辑 namespace 和用户 key 编码为:
<namespace> \0 <user-key>
NUL 是明确边界,所以 one/x 与 two/x 永远不会碰撞。namespace 最长 128 字节且不能含 NUL,key 最长 1 KiB,value 最长 1 MiB。
完整文件 · ch05/src/kv-store.h
正在加载源码...完整文件 · ch05/src/kv-store.c++
正在加载源码...KvStore 的 get 用 std::optional 区分“不存在”和空字符串;list 从编码后的 prefix Seek,离开前缀后立即停止。单次写默认启用 WAL,但 sync=false;需要强制刷盘时显式传 true。
- 预期输出
[PASS] physical keys isolate namespaces和[PASS] KvStore supports put get delete and prefix list。- 原因
- 第一个 case 检查 NUL 的准确位置;第二个写入两个前缀 key、读取、列举、删除,再确认 deleted key 返回空 optional。
4. 建立有界工作队列
同步 RocksDB 调用不能直接放在 V8/KJ 线程,否则一次磁盘抖动会同时冻结 HTTP、timer 和 WebSocket。StorageExecutor 的队列只保存拥有所有权的字符串与回调:
V8 / KJ thread storage thread
-------------- --------------
创建 KJ Promise
复制 key/value ────────────────→ RocksDB::Get/Put
复制结果
cross-thread fulfiller ─────────→ 唤醒原 event loop
进入 Isolate
resolve JavaScript Promise
完整文件 · ch05/src/storage-executor.h
正在加载源码...完整文件 · ch05/src/storage-executor.c++
正在加载源码...线程硬规则。 worker lambda 只能携带字符串、数字和 CrossThreadPromiseFulfiller。
v8::Local、v8::Global、Context、Isolate 与 cppgc 对象都不能进入 RocksDB 线程。
- 预期输出
[PASS] StorageExecutor rejects work beyond its bound。- 原因
- 测试暂停单个 worker,以容量 2 提交三项;前两项留在队列,第三项立即以
StorageBusyErrorreject,随后恢复 worker 并正常 drain。
5. 安装 env.KV
第三章 Runtime 增加一个可选 environment Global;没有 binding 时仍传空对象,有 KvBinding 时传入包含 KV 的同一个 env。
KvBinding 自身由 C++ Runtime 生命周期持有,JS 方法的 v8::External 只保存它的非拥有指针。关闭顺序固定为:先销毁 binding TaskSet,再停止并 join StorageExecutor,随后关闭 RocksDB,最后才销毁 Runtime/Isolate。
完整文件 · ch05/src/kv-binding.h
正在加载源码...完整文件 · ch05/src/kv-binding.c++
正在加载源码...JavaScript API 保持最小:
await env.KV.put("room:lobby:topic", "V8 internals")
const topic = await env.KV.get("room:lobby:topic")
const page = await env.KV.list("room:", 100)
await env.KV.delete("room:lobby:topic")
get 缺失时 resolve 为 null;list 返回 { keys, complete }。本章不加入 cursor,避免在还没有分页需求时伪造不稳定协议。
- 预期输出
[PASS] env KV resolves JavaScript promises on the runtime thread。- 原因
- worker 依次 await put、get、delete、get,最终 Response 是
V8:null。V8 对象只在 KJ continuation 回到 Runtime 后创建。
6. 完整程序与示例 worker
完整文件 · ch05/src/main.c++
正在加载源码...完整文件 · ch05/worker/index.js
正在加载源码...完整测试文件:
完整文件 · ch05/test/storage-test.c++
正在加载源码..."$BUILD/storage-test"
"$LAB_ROOT/.deps/cmake-3.31.8/bin/ctest" --test-dir "$BUILD" --output-on-failure
- 预期输出
- 五行
[PASS],汇总5 test(s) passed;CTest 为1/1 ... Passed。 - 原因
- KJ runner 分别覆盖编码、同步数据库、跨线程背压、JS binding 和重开恢复;CTest 将整个 runner 视为一个测试程序。
7. 重开验收
选择一个不会与现有数据库冲突的新目录,连续运行两次:
DB=/tmp/v8-kv-ch05
mkdir -p "$DB"
"$BUILD/v8-kv" "$SOURCE/worker/index.js" "$DB"
"$BUILD/v8-kv" "$SOURCE/worker/index.js" "$DB"
- 预期输出
- 两次都输出
V8 internals; keys=1。 - 原因
- 第一次创建 DB 并写入 topic,第二次打开同一目录后覆盖同一 key;前缀 list 始终只看见一个持久化物理 key。
当前逐项写 WAL、日志逐条 flush 的策略优先保证清晰和可靠。下一章会加入 trace 与负载测试,用数据决定怎样批量写入,而不是先猜优化参数。