Turso 深度拆解:用 Rust 重写 SQLite 之后——从 VDBE 字节码到 BEGIN CONCURRENT,「数据库的 LLVM」到底改了什么
一句话立场:Turso 最值得学的不是「Rust 重写」这个动作,而是它把 SQLite 三个被当成公理的设计——同步 I/O、单写者、私有测试套件——同时当成了可谈判的工程约束。这三件事一旦松动,嵌入式数据库的能力边界就变了。
一、背景:为什么会有人去重写「地球上部署最多的数据库」
先摆事实。SQLite 是那种你几乎不可能替换掉的软件:单文件、零配置、无进程、公有领域授权,测试覆盖率高到变态(TH3 套件的分支覆盖是逐年吹出来的护城河),跑在手机、浏览器、飞机、路由器、你写的每一个小工具里。
按常理,重写它是纯粹的自寻死路。但这几年出现了三个新的现实压力,把「重写」从疯话变成了工程选项:
压力一:本地优先(local-first)与边缘计算把「嵌入式」重新变成主流。
以前 SQLite 主要是「App 内部的存储」。现在它成了「离线优先应用的本地副本」「边缘节点的读副本」「每租户/每 Agent 一个库」的载体。这些场景需要复制、同步、变更捕获、加密这些原本不属于嵌入式数据库范畴的东西。
压力二:AI Agent 场景把数据库的数量级从「一个」推到「几百万个」。
Turso 官网现在的定位就写得很直白:为 AI Agent 时代准备,「Millions of Databases, One Architecture」。当你要为每个用户、每个 Agent 会话开一个独立数据库,数据库的启动成本、内存占用、并发写入模型全都成了第一优先级问题——这跟传统「一个大库 + 连接池」是完全不同的工程。
压力三:同步接口挡住了现代 I/O 栈。sqlite3_step() 是同步的。它一旦需要读一个还不在页缓存里的页,就地阻塞。在 io_uring、DirectIO、NVMe 队列深度动辄上百的今天,这意味着你只能靠线程池去「假装并发」,付出上下文切换和内存放大的代价。
Turso 团队(就是做 libSQL 的那批人)在这条路上走过两步:先 fork(libSQL),后重写(早期叫 Limbo,现在直接叫 Turso)。README 里现在的自我定位是:
An in-process SQL database, compatible with SQLite, and now with a Postgres frontend too. ... Turso is also a virtual machine. Like SQLite, it compiles SQL into bytecode for that machine, the VDBE, and then runs the bytecode. That design is what lets one engine host more than one SQL dialect.
这句话是整篇文章的技术核心,后面第四节我会展开:它把数据库当编译器基础设施来做,所以敢叫 "The LLVM of databases"。
二、fork 还是 rewrite:一个被低估的架构决策
很多人只记住结论,没记住推理链。Turso 团队第一次做选择时选了 fork,第二次选了 rewrite,理由都很具体:
| 维度 | fork(libSQL 路线) | rewrite(Turso 路线) |
|---|---|---|
| 上游收益 | 可以持续 merge SQLite 新特性 | 归零,自己造 |
| 改动自由度 | 受限:核心 C 代码不敢大改 | 高:可以重排架构 |
| 测试信心 | 低:SQLite 的完整测试套件(TH3)是专有的,fork 拿不到 | 需要自建,但可以从第一天就设计成可模拟 |
| 内存安全 | C,靠人和评审 | Rust,编译器兜底 |
| 时间成本 | 小 | 巨大(这是唯一的真实反对理由) |
关键在第三行。fork 一个测试套件不公开的数据库,等于你继承了代码但没继承信心。 你想把 pager 改成异步,改完之后没人能告诉你「掉电时数据还对不对」。这一条比「C 不安全」重要得多,也是我认为整个决策里最硬的技术理由。
所以重写方案里,DST(确定性模拟测试)不是附加项,而是前置条件。这个顺序值得每个做基础设施的人抄下来:先有能证伪自己的测试方法,再谈重写。 第七节我会带你手写一个迷你 DST。
三、核心概念:「SQLite 兼容」其实是三层兼容,别混着谈
聊 Turso 之前必须把「兼容」拆开,否则评估必然出错。我习惯分三层:
第一层:文件格式兼容。
数据库文件的 header、页结构、B-tree 布局、freelist、WAL 格式跟 SQLite 一致。意味着老库能直接打开,sqlite3 命令行也还能读它写出来的文件。这是迁移成本最低的一层,也是最容易验证的一层——拿真实库文件做双向读写对照即可。
第二层:SQL 语义兼容。
类型亲和性(type affinity)那套「弱类型」规则、ROWID 语义、INTEGER PRIMARY KEY 的特殊化、隐式转换、NULL 排序、日期函数、PRAGMA、窗口函数、CTE、UPSERT、触发器、虚表(virtual table)……这层是长尾地狱。任何 SQLite 重写项目的真实进度都体现在这层的覆盖率上,而不是 star 数。
第三层:API/ABI 兼容。
C API 是否可替换(libsqlite3 drop-in)、各语言绑定是否与既有生态一致。这层决定「你的老代码要改多少」。
评估任何一个 SQLite 替代品,问三个问题就够了:老文件能不能读(1)?我的 SQL 有没有落在长尾里(2)?我的 ORM/driver 换不换(3)?
顺带一句实用建议:做兼容性验证不要靠读文档,要靠差分测试(differential testing)。同一批随机 SQL 语句,同时喂给 SQLite 和候选引擎,比对结果集与错误码。三十行脚本能挡住 90% 的坑:
# diff_test.py —— SQLite 与候选引擎的差分测试骨架
import sqlite3, random, itertools, sys
DDL = "CREATE TABLE t(a INTEGER PRIMARY KEY, b TEXT, c REAL)"
SEEDS = [
"INSERT INTO t VALUES(1,'x',1.5)",
"INSERT INTO t VALUES(2,'2',2)", # 类型亲和性陷阱:TEXT 列存数字字面量
"INSERT INTO t VALUES(3,NULL,NULL)",
]
QUERIES = [
"SELECT * FROM t ORDER BY b", # NULL 排序位置
"SELECT b+0 FROM t", # 隐式转换
"SELECT typeof(b), typeof(c) FROM t", # 亲和性落地结果
"SELECT count(*), sum(c), avg(c) FROM t",
"SELECT * FROM t WHERE b = 2", # 字符串列与整数比较
]
def run(conn_factory):
conn = conn_factory()
cur = conn.cursor()
cur.execute(DDL)
for s in SEEDS:
cur.execute(s)
out = []
for q in QUERIES:
try:
out.append((q, cur.execute(q).fetchall()))
except Exception as e:
out.append((q, f"ERR:{type(e).__name__}"))
return out
a = run(lambda: sqlite3.connect(":memory:"))
# b = run(lambda: <候选引擎的 connect>) # 换成待测引擎
for (qa, ra) in a:
print(qa, "=>", ra)
我的经验是:类型亲和性和 NULL 语义是重写型引擎最常翻车的两个地方,因为它们不是「功能」,是「历史包袱形成的隐式契约」。
四、架构分析:为什么它敢自称「数据库的 LLVM」
SQLite 的经典分层是这样的(Turso 保留了这套骨架):
SQL 文本
│
├─ Tokenizer / Parser → AST
├─ Planner / Optimizer → 查询计划
├─ Code Generator → VDBE 字节码(这一步是关键)
│
├─ VDBE(虚拟机) → 执行字节码,产生游标操作
├─ B-tree 层 → 表/索引的有序结构
├─ Pager → 页缓存、事务、WAL、锁
└─ OS 接口层(VFS) → 真正的 read/write/fsync
LLVM 的类比精准落在 "Code Generator → VDBE" 这条缝上。
LLVM 的价值在于:多个前端(C/C++/Rust/Swift)编译到同一个 IR,共享同一套优化和后端。Turso 做的事情结构完全一致:
- 前端可以不止一个:SQLite 方言是第一前端;Postgres 方言 + Postgres 线协议(wire protocol)是第二个(实验性)。
- IR 是 VDBE 字节码:所有方言最终都编译成同一台虚拟机的指令。
- 后端共享:B-tree、Pager、MVCC、I/O 层只写一次。
这个设计的现实收益很直接:你可以用 psql、用任意 Postgres driver 去连一个「本质上是 SQLite 文件」的进程内数据库。对于「本地开发用嵌入式、生产用 Postgres」这种常见割裂,它给出了一条「语言层统一」的解法。当然,实验性就是实验性,Postgres 前端的方言覆盖度短期内不可能追上真 Postgres,别拿它去跑 pg_dump 出来的复杂 schema。
再看被改动最狠的两层:
Pager 层:从同步阻塞变成 completion-based 异步。
SQLite 的 VFS 是 readiness/blocking 语义:xRead 调用返回时数据就该在 buffer 里。Turso 要接 io_uring,就得改成 completion 语义:提交请求 → 拿到 completion → 继续执行。这个改动会一路上翻到最顶层 API,因为 step() 现在可能因为「I/O 还没回来」而返回,而不只是因为「有一行数据」或「结束了」。
并发控制层:从单写者 + WAL,加入 MVCC。
README 明确提到 BEGIN CONCURRENT(借鉴 SQLite 官方长期存在的同名分支思路)+ MVCC 用于提升写吞吐,以及 CDC(变更数据捕获)。这是嵌入式数据库能力上限的一次抬升,第六节细讲。
五、异步 I/O:step() 为什么必须能「返回 I/O 未就绪」
这是全篇最值得程序员理解的一个点,因为它是接口设计如何被 I/O 模型倒逼改变的教科书案例。
5.1 同步模型的真实代价
传统写法(Python 版,人人都写过):
cur = conn.execute("SELECT * FROM big_table WHERE k > ?", (1000,))
for row in cur: # 每次内部 step(),缺页就同步 read()
handle(row)
在 async 服务里,这段代码只能塞进线程池:
# FastAPI / asyncio 里典型的「假异步」
rows = await asyncio.get_running_loop().run_in_executor(pool, blocking_query)
代价是三件事:线程栈内存(每线程 512KB~8MB)、上下文切换、背压失控(线程池满了之后队列无限增长,尾延迟爆炸)。当你「每用户一个库」跑几千个库时,这个模型直接崩。
5.2 completion 模型下的执行循环
Turso 的思路是把「I/O 未就绪」提升为一等状态。概念示意(API 名称随版本变化,请以 crate 文档为准,这里表达的是控制流形状):
// 概念示意:driver 层如何驱动一条语句
loop {
match stmt.step()? {
StepResult::Row(row) => handle(row),
StepResult::Done => break,
StepResult::IO => {
// 引擎说:我需要的页还没回来。控制权交还给调用方。
// 单线程 runtime 可以在这里去干别的活儿,而不是阻塞在 read() 上。
io.wait_for_completions()?; // 或者 .await
}
}
}
对比一下三种 I/O 模型的语义差异,这张表建议记住:
| 模型 | 典型接口 | 语义 | 谁持有 buffer |
|---|---|---|---|
| 阻塞 | read() | 返回即完成 | 调用方 |
| readiness | epoll + 非阻塞 read() | 通知「可以读了」,你再读 | 调用方 |
| completion | io_uring / IOCP | 提交请求,完成后通知「已经读好了」 | 内核持有到完成 |
completion 模型多出来的那条约束——buffer 的生命周期被内核借走——是 Rust 里所有 io_uring 封装难做的根本原因(这也是 tokio-uring 需要 owned buffer API 的原因)。数据库自己管页缓存,正好能满足这个约束:页是长期存活的、由 pager 拥有的内存,天然适合交给内核。这是「数据库 + io_uring」比「通用应用 + io_uring」更容易吃到收益的结构性理由。
5.3 Rust 侧的使用形态
从公开资料看,Rust 绑定的形态是 async-first 的:
use turso::Builder;
#[tokio::main]
async fn main() -> Result<(), anyhow::Error> {
let db = Builder::new_local("app.db").build().await?;
let conn = db.connect()?;
conn.execute(
"CREATE TABLE IF NOT EXISTS users(id INTEGER PRIMARY KEY, name TEXT NOT NULL)",
(),
).await?;
conn.execute("INSERT INTO users(name) VALUES (?1)", ("茄子",)).await?;
let mut rows = conn.query("SELECT id, name FROM users", ()).await?;
while let Some(row) = rows.next().await? {
let id: i64 = row.get(0)?;
let name: String = row.get(1)?;
println!("{id} -> {name}");
}
Ok(())
}
这里有个容易被忽略的工程细节:async 的价值不在单查询延迟,而在「一个线程管很多库」。嵌入式数据库的 async 化,收益主要体现在多租户/多库场景的资源密度上。单进程单库的 CRUD,你大概率测不出差别——甚至因为 future 状态机的开销略微变慢。别拿错场景做选型。
六、BEGIN CONCURRENT 与 MVCC:把 SQLite 最硬的天花板掀开
6.1 SQLite 的单写者约束到底卡在哪
WAL 模式下,SQLite 的并发模型是:多读者 + 单写者。写者要拿到 WRITE 锁,同一时刻只有一个写事务能推进;冲突时你拿到 SQLITE_BUSY。
这套模型在「一个 App 一个库」时非常合适——简单、可预测、没有幻读一类的复杂问题。但在下面这些场景就成了硬伤:
- 多个后台 worker 并发写不同的表(逻辑上毫无冲突,物理上互斥);
- 一个长写事务(比如批量导入)把所有短写事务全部饿死;
- 「每租户一库」时你确实规避了这个问题,但换来了成百上千个文件句柄和 WAL。
6.2 BEGIN CONCURRENT 的语义模型:乐观并发
BEGIN CONCURRENT 的核心思想是乐观并发控制(OCC)+ 页级/行级冲突检测:
- 事务开始时不抢全局写锁,各自在自己的快照上工作;
- 各自把修改记在私有的空间里(配合 MVCC 就是多版本);
- COMMIT 时做冲突检测:如果我读/写的集合与已提交的其他事务有交叠,本事务失败回滚;
- 应用层负责重试。
这带来两个必须写进你代码里的现实:
现实一:COMMIT 变成了会失败的操作。 以前你只在 BEGIN/写语句上防 BUSY,现在必须在 COMMIT 上防冲突。
现实二:重试必须幂等 + 带抖动退避。 否则高并发下会出现「同时重试、同时再冲突」的雷群效应。
正确的写法长这样(Rust,重试包裹整个事务体,而不是单条语句):
use rand::Rng;
use std::time::Duration;
/// 用闭包包裹整个事务体,冲突时整体重放。
/// 关键点:闭包里不要做任何不可重放的副作用(发邮件、扣款外部调用)。
async fn with_concurrent_txn<F, Fut, T>(
conn: &turso::Connection,
max_retry: u32,
mut body: F,
) -> anyhow::Result<T>
where
F: FnMut(&turso::Connection) -> Fut,
Fut: std::future::Future<Output = anyhow::Result<T>>,
{
let mut attempt = 0;
loop {
conn.execute("BEGIN CONCURRENT", ()).await?;
let r = body(conn).await;
match r {
Ok(v) => match conn.execute("COMMIT", ()).await {
Ok(_) => return Ok(v),
Err(e) if is_conflict(&e) && attempt < max_retry => {
let _ = conn.execute("ROLLBACK", ()).await;
backoff(attempt).await;
attempt += 1;
}
Err(e) => {
let _ = conn.execute("ROLLBACK", ()).await;
return Err(e.into());
}
},
Err(e) => {
let _ = conn.execute("ROLLBACK", ()).await;
return Err(e);
}
}
}
}
fn is_conflict(e: &turso::Error) -> bool {
// 具体错误码请对照版本文档;这里只表达「区分冲突与真错误」的必要性
e.to_string().to_lowercase().contains("conflict") || e.to_string().contains("BUSY")
}
async fn backoff(attempt: u32) {
let base = 2u64.pow(attempt.min(6)) * 5; // 5,10,20,40… ms
let jitter = rand::thread_rng().gen_range(0..=base / 2); // 必须有抖动
tokio::time::sleep(Duration::from_millis(base + jitter)).await;
}
6.3 降低冲突率的四个实操手段
乐观并发的性能完全取决于冲突率。冲突率高的时候,OCC 比悲观锁更慢(做完全部工作再丢弃)。四个真正有效的手段:
(1)缩短事务持有时间。 把「读外部 API、算复杂逻辑」全部挪到事务外,事务内只做读写。这是收益最大的一条,没有例外。
(2)避免热点行/热点页。 典型反例:全局计数器。
-- 反例:所有并发事务都撞同一行
UPDATE counters SET n = n + 1 WHERE name = 'visits';
-- 正解:分片计数(sharded counter),读时聚合
CREATE TABLE counters(name TEXT, shard INTEGER, n INTEGER, PRIMARY KEY(name, shard));
UPDATE counters SET n = n + 1 WHERE name='visits' AND shard = :random_0_to_15;
SELECT sum(n) FROM counters WHERE name='visits';
分片数取并发写者数的 2~4 倍,够用了。再多只会让读放大。
(3)用 append-only 表替代原地更新。 事件日志、审计流、消息队列这类负载,追加天然不冲突;需要「当前值」的时候用物化视图或定期 compact。
(4)按 key 做写入亲和(affinity)。 应用层用 hash(key) % N 把同一实体的写路由到同一个 worker,把并发冲突转化为串行队列。这条是把 OCC 冲突从数据库推回应用层的最有效解法,代价是引入了单点顺序依赖。
6.4 CDC:嵌入式数据库为什么也需要变更捕获
README 里 CDC 被列为已有能力:real-time tracking of database changes。放在嵌入式场景下,它解决的是四类需求:
- 本地优先同步:本地写入 → 生成变更流 → 上传合并到云端;
- 失效缓存:变更事件驱动上层缓存/搜索索引更新;
- 审计与回放:谁在什么时候改了什么;
- Agent 记忆:AI Agent 的状态变化需要可观测、可回溯。
如果你要在还没有原生 CDC 的 SQLite 上模拟,触发器 + outbox 表是最朴素但可靠的方案(也是我在生产里用过最多的一招):
CREATE TABLE outbox(
id INTEGER PRIMARY KEY,
tbl TEXT NOT NULL,
op TEXT NOT NULL, -- I/U/D
pk TEXT NOT NULL,
after TEXT, -- JSON 快照
ts INTEGER NOT NULL DEFAULT (unixepoch())
);
CREATE TRIGGER users_ai AFTER INSERT ON users BEGIN
INSERT INTO outbox(tbl,op,pk,after)
VALUES('users','I', CAST(new.id AS TEXT), json_object('id',new.id,'name',new.name));
END;
CREATE TRIGGER users_au AFTER UPDATE ON users BEGIN
INSERT INTO outbox(tbl,op,pk,after)
VALUES('users','U', CAST(new.id AS TEXT), json_object('id',new.id,'name',new.name));
END;
CREATE TRIGGER users_ad AFTER DELETE ON users BEGIN
INSERT INTO outbox(tbl,op,pk,after) VALUES('users','D', CAST(old.id AS TEXT), NULL);
END;
消费端按 id 单调递增游标拉取,记住已消费位点,允许重复投递(at-least-once),下游做幂等。原生 CDC 的价值在于省掉触发器带来的写放大和 schema 侵入,语义上你要处理的问题是一样的。
七、DST:重写数据库最难的部分不是写代码,是证明它是对的
这一节是我认为整个 Turso 项目最有普适价值的部分。任何有状态系统的可靠性,最终都是测试方法论的产物,不是代码质量的产物。
传统测试为什么不够:
- 单测/集成测试只能覆盖你「想到的」交错;
- 掉电、fsync 丢失、部分写入(torn write)、磁盘满、EIO、时钟跳变,这些故障在 CI 里几乎不可复现;
- 并发 bug 的复现依赖调度,而调度是不确定的——测出来了也可能修不了,因为你复现不了第二次。
DST(Deterministic Simulation Testing)的做法是把整个世界变成可控的:
- 所有不确定性来源统一注入:随机数、时间、线程调度、I/O 完成顺序、故障发生点,全部来自一个种子(seed);
- 模拟真实故障:读写延迟、乱序完成、写入部分成功、fsync 静默失败、进程崩溃重启;
- 失败即可复现:拿到 seed 就能 100% 重放;
- 断言不变量:崩溃恢复后数据必须满足持久性/原子性,快照读必须自洽。
Turso 从项目第一天就内置了 DST 框架,并配合 Antithesis(专门做确定性故障注入测试的商业平台)。这不是营销点,这是他们敢动 pager 的底气。
手写一个 60 行的迷你 DST
理解一个方法论最快的方式是自己实现最小版本。下面这段可编译运行(只需 rand),演示了 DST 的四个要素:种子、虚拟时钟、可注入故障的存储、不变量断言。
use rand::{rngs::StdRng, Rng, SeedableRng};
use std::collections::HashMap;
/// 一个会「说谎」的存储:可能延迟、可能丢失未 fsync 的写、可能部分写入
struct FaultyStore {
committed: HashMap<String, String>, // 已落盘
buffer: HashMap<String, String>, // 未 fsync
rng: StdRng,
clock_ms: u64,
}
impl FaultyStore {
fn new(seed: u64) -> Self {
Self { committed: HashMap::new(), buffer: HashMap::new(),
rng: StdRng::seed_from_u64(seed), clock_ms: 0 }
}
fn write(&mut self, k: &str, v: &str) {
self.clock_ms += self.rng.gen_range(1..5); // 虚拟耗时
self.buffer.insert(k.to_string(), v.to_string());
}
fn fsync(&mut self) -> Result<(), &'static str> {
self.clock_ms += self.rng.gen_range(5..50);
if self.rng.gen_bool(0.05) { return Err("EIO"); } // 5% 概率 fsync 失败
// 关键:fsync 成功才把 buffer 提升为 committed
for (k, v) in self.buffer.drain() { self.committed.insert(k, v); }
Ok(())
}
/// 模拟掉电:未 fsync 的写全部消失
fn crash(&mut self) { self.buffer.clear(); }
}
/// 被测「数据库」:write-then-sync 的最小事务
fn txn(store: &mut FaultyStore, pairs: &[(&str, &str)]) -> Result<(), &'static str> {
for (k, v) in pairs { store.write(k, v); }
store.fsync()
}
fn simulate(seed: u64) -> Result<(), String> {
let mut store = FaultyStore::new(seed);
let mut acknowledged: Vec<(String, String)> = vec![]; // 我们向用户承诺过的写
for i in 0..200 {
let k = format!("k{}", store.rng.gen_range(0..10));
let v = format!("v{i}");
match txn(&mut store, &[(&k, &v)]) {
Ok(()) => acknowledged.push((k, v)), // 只有 COMMIT 成功才算承诺
Err(_) => { /* 未承诺,允许丢失 */ }
}
if store.rng.gen_bool(0.1) { store.crash(); } // 10% 概率掉电
}
// 不变量:所有「已承诺」的最新值必须还在(持久性)
let mut expect: HashMap<&str, &str> = HashMap::new();
for (k, v) in &acknowledged { expect.insert(k, v); }
for (k, v) in expect {
match store.committed.get(k) {
Some(got) if got == v => {}
other => return Err(format!(
"seed={seed} 违反持久性: key={k} 期望={v} 实际={:?} @t={}ms",
other, store.clock_ms)),
}
}
Ok(())
}
fn main() {
for seed in 0..2000 {
if let Err(e) = simulate(seed) {
println!("❌ 发现反例,可复现: {e}");
return;
}
}
println!("✅ 2000 个种子全部通过");
}
跑一下你会发现它真的能抓到 bug:上面这个「被测数据库」在 fsync 返回 EIO 之后没有清理 buffer,后续一次成功的 fsync 会把上一轮本该丢弃的脏数据一起提交上去——这正是现实中著名的 fsync EIO 语义坑(Linux 上 fsync 失败后错误可能被清除、脏页被丢弃,重试 fsync 返回成功但数据已丢)。PostgreSQL 当年为此把「fsync 失败直接 panic」写成了铁律。
这就是 DST 的价值:它不是提高覆盖率,而是把「稀有故障」变成「按种子可复现的日常」。 你自己的项目也用得上:把 now()、rand()、网络调用、文件 I/O 全部抽象成 trait / 依赖注入,你就获得了做 DST 的基础设施。这件事的投入产出比,比再写 500 个单测高得多。
八、代码实战:从 SQLite 迁到 Turso 的完整路径
8.1 上手
# 官方给的安装形态(curl | sh 类脚本,生产环境请先审阅脚本内容)
curl -sSL <官方安装脚本地址> | sh
# CLI 交互,跟 sqlite3 手感一致
turso app.db
-- 直接打开一个既有的 SQLite 文件验证文件格式兼容
.open ./legacy.db
.tables
PRAGMA table_info(users);
SELECT count(*) FROM users;
第一步永远是拿你的真实库文件做只读验证,不要拿 hello world 表。真实库里的怪东西(虚表、FTS5 索引、旧版本 schema、WITHOUT ROWID 表)才是兼容性风险的所在。
8.2 多语言接入的现实情况
Turso 提供多语言绑定(Rust/JS/Python/Go 等),但各语言绑定的成熟度并不一致,且 API 在快速演进。我的建议非常直接:
- Rust:一等公民,async API,最完整;
- JS/TS:本地优先应用的主力场景,注意 sync/async API 两套形态;
- Python/Go:先在非核心路径上试,做好回退到 SQLite 的开关。
别在关键路径上硬编码某个引擎。 用一层薄薄的仓储抽象把 SQL 执行圈起来,你就永远保留了 A/B 与回退能力:
// repo.go —— 引擎无关的仓储层,方便在 SQLite / Turso 之间切换与 A/B
package store
import (
"context"
"database/sql"
)
type Store struct{ db *sql.DB }
// 关键:驱动名从配置来,代码不认识具体引擎
func Open(driver, dsn string) (*Store, error) {
db, err := sql.Open(driver, dsn)
if err != nil { return nil, err }
// 嵌入式数据库的连接数策略与 C/S 数据库完全不同
db.SetMaxOpenConns(1) // 单写者引擎:写连接串行化,避免 BUSY 风暴
db.SetMaxIdleConns(1)
return &Store{db: db}, nil
}
func (s *Store) CreateUser(ctx context.Context, name string) (int64, error) {
res, err := s.db.ExecContext(ctx, `INSERT INTO users(name) VALUES(?)`, name)
if err != nil { return 0, err }
return res.LastInsertId()
}
注意那个 SetMaxOpenConns(1):在单写者引擎上开 50 个连接并发写,只会把 BUSY 从数据库层搬到 Go 层,还看不到真正的排队指标。 正确做法是显式串行化写、并单独开只读连接池给读负载(WAL 下读不阻塞写)。
8.3 「每租户一库」的落地骨架
这是 Turso 最想吃的场景,也是嵌入式数据库真正独特的能力。核心难点不是 SQL,是句柄与内存管理:几千个库不能全开着。给一个 LRU + 引用计数的骨架:
type Pool struct {
mu sync.Mutex
lru *list.List // 最近使用顺序
items map[string]*list.Element // tenantID -> node
max int
openFn func(tenant string) (*Store, error)
}
type entry struct {
tenant string
st *Store
refs int32 // 正在使用的请求数,>0 不许淘汰
}
func (p *Pool) Get(tenant string) (*Store, func(), error) {
p.mu.Lock()
if el, ok := p.items[tenant]; ok {
p.lru.MoveToFront(el)
e := el.Value.(*entry)
atomic.AddInt32(&e.refs, 1)
p.mu.Unlock()
return e.st, func() { atomic.AddInt32(&e.refs, -1) }, nil
}
p.mu.Unlock()
st, err := p.openFn(tenant) // 打开/创建该租户的库文件
if err != nil { return nil, nil, err }
p.mu.Lock()
e := &entry{tenant: tenant, st: st, refs: 1}
p.items[tenant] = p.lru.PushFront(e)
p.evictLocked() // 超限时从尾部淘汰 refs==0 的库
p.mu.Unlock()
return st, func() { atomic.AddInt32(&e.refs, -1) }, nil
}
三个必须提前想清楚的坑:
- 每个库都有自己的 WAL 与页缓存,内存是
库数 × cache_size,默认值下几千个库能吃掉几个 GB。上线前把PRAGMA cache_size按租户规模压小(比如-2000,即 2MB)。 - 文件描述符上限:
ulimit -n要按「库数 × 每库 2~3 个 fd」算,容器里默认 1024 会很快撞墙。 - 备份与 schema 迁移变成 N 次操作。迁移脚本必须幂等、可断点续跑、有并发上限,且先在影子副本上跑一遍。
8.4 迁移 checklist(可直接抄进你的技术评审文档)
- 用真实库文件跑只读兼容性验证(含 FTS5/JSON1/
WITHOUT ROWID/虚表/触发器) - 跑差分测试:核心 SQL 集合在两个引擎上结果一致
- 事务语义:把所有写事务包进「可重试 + 幂等」的闭包
-
COMMIT失败路径有独立监控指标(冲突率、重试次数分布) - 崩溃恢复演练:
kill -9+ 断电模拟,验证已 ack 的写不丢 - 配置回退开关:一行配置切回 SQLite(这是上线的前提,不是可选项)
- 备份策略:文件级快照 + WAL,验证「恢复」而不是只验证「备份」
- 性能基线:用你自己的负载测,不要引用别人的 benchmark 数字
九、性能优化:嵌入式数据库真正的瓶颈在哪
先说结论,这是我做过若干 SQLite 性能排查后的经验排序(从收益最大到最小):
1. 事务边界(收益常常是 10~100 倍)
逐条 INSERT 各自开事务,等于每条一次 fsync。批量包进一个事务:
# 慢:10000 次事务 = 10000 次 fsync
for r in rows:
conn.execute("INSERT INTO t VALUES(?,?)", r) # 自动提交
# 快:一次事务
with conn: # BEGIN ... COMMIT
conn.executemany("INSERT INTO t VALUES(?,?)", rows)
2. prepared statement 复用
SQL 文本 → AST → 计划 → 字节码,这条链路在 OLTP 小查询里占比高得惊人。循环里用同一个 prepared statement,只换参数。ORM 默认往往每次重新构造 SQL 文本(因为拼了不同的 IN (...) 长度),这是最常见的隐形损耗——把 IN 列表长度归一化到 2 的幂,命中率立刻上来。
3. PRAGMA 三件套(按持久性要求选)
PRAGMA journal_mode = WAL; -- 读写并发的前提,几乎总要开
PRAGMA synchronous = NORMAL; -- WAL 下常用折中;FULL 最安全,OFF 别在生产用
PRAGMA cache_size = -20000; -- 负数=KB,这里约 20MB;多库场景要调小
PRAGMA busy_timeout = 5000; -- 让引擎替你等,而不是立刻抛 BUSY
PRAGMA temp_store = MEMORY; -- 排序/临时表走内存
PRAGMA mmap_size = 268435456; -- 读多场景可显著减少 syscall(注意与加密/网络文件系统冲突)
synchronous 这一项要跟业务谈清楚:NORMAL 在 WAL 模式下,进程崩溃安全,操作系统崩溃/掉电可能丢最后若干个事务。做支付就别省这一下。
4. WAL checkpoint 策略
WAL 只涨不收会让读放大(每次读要遍历 WAL 索引)。默认自动 checkpoint 阈值是 1000 页;写密集场景要么调大阈值配合手动 checkpoint(在低峰做 PRAGMA wal_checkpoint(TRUNCATE)),要么单独起一个 checkpoint 线程。注意:手动 TRUNCATE 需要没有活跃读者,否则会被推迟——排查「WAL 文件为什么涨到 10GB」时,第一嫌疑人永远是某个忘了关的长读事务。
5. 索引与查询计划EXPLAIN QUERY PLAN 是这类引擎里最便宜的工具,没有之一:
EXPLAIN QUERY PLAN
SELECT u.name, count(o.id) FROM users u
JOIN orders o ON o.user_id = u.id
WHERE u.created_at > ? GROUP BY u.id;
看到 SCAN TABLE 就该警觉,看到 USE TEMP B-TREE FOR ORDER BY 说明排序没走索引。覆盖索引(把 select 列一起放进索引)在嵌入式场景收益比在 C/S 数据库更大,因为省下的是页读取而不是网络往返。
6. 到了 Turso 特有的部分
- io_uring 队列深度:只有在「多库并发 + 高 IOPS 设备」下才有意义。单库顺序扫描,队列深度调大没用。
BEGIN CONCURRENT的冲突率:这是新的一等指标。冲突率超过 5%,你的分片/亲和设计就该改了,继续加重试只会放大浪费。- 异步栈的收益验证:对比指标要选「同一台机器上能撑住的库数量/QPS 密度」,而不是单查询 P99。
怎么测:一个能自证的基准脚手架
benchmark 里最容易犯的错是「测了缓存」。给一个还算严谨的骨架:
# 1) 每次测试前清页缓存(Linux;macOS 用 purge)
sync && echo 3 | sudo tee /proc/sys/vm/drop_caches >/dev/null
# 2) 固定 CPU 频率/关闭省电,避免调频噪声(视平台)
# 3) 用 hyperfine 做多轮 + 预热 + 统计
hyperfine --warmup 3 --runs 20 \
'sqlite3 bench.db < workload.sql' \
'turso bench.db < workload.sql'
// 更细粒度:在进程内测,输出分位数而不是平均值
fn percentile(sorted: &[u128], p: f64) -> u128 {
let idx = ((sorted.len() - 1) as f64 * p).round() as usize;
sorted[idx]
}
let mut lat = Vec::with_capacity(100_000);
for i in 0..100_000u64 {
let t = std::time::Instant::now();
do_one_op(i)?; // 单次操作
lat.push(t.elapsed().as_micros());
}
lat.sort_unstable();
println!("p50={}us p99={}us p999={}us",
percentile(&lat, 0.50), percentile(&lat, 0.99), percentile(&lat, 0.999));
只报平均值的 benchmark 一律不可信,尤其是数据库——p999 才是用户投诉的来源。另外一定要记录:设备型号、文件系统、synchronous 设置、数据集是否超过内存。缺任意一项,这个数字就没法复现,也就没有意义。
十、什么时候别用:一份诚实的风险清单
我不打算给你「赶紧上」的结论。基础设施的选型要看下限。
可以现在就用的场景:
- 内部工具、CLI、离线分析、原型验证;
- 本地优先应用的可替换存储层(有回退开关);
- 你需要的正是 SQLite 给不了的能力:并发写吞吐、原生 CDC、异步 I/O 密度。
建议再等等的场景:
- 单点、无备份、丢数据就出事的系统(金融账务、医疗记录);
- 重度依赖 SQLite 长尾特性的项目(复杂虚表、自定义 collation/扩展、FTS 深度使用);
- 团队没有能力做崩溃恢复演练与差分测试的项目——没有验证能力就不要碰新存储引擎,这句话适用于所有数据库。
Postgres 前端目前只当「有意思的实验」看:能用 psql 连一个进程内引擎很酷,但方言与协议覆盖度、扩展生态、以及行为一致性都需要时间。
还有一个容易被忽略的点:成熟度不等于 bug 数,而等于「已知失效模式的完备程度」。 SQLite 的价值不只在代码正确,还在于二十多年里全世界踩过的坑都变成了文档和 StackOverflow 答案。新引擎哪怕代码更干净,你遇到问题时的可搜索性也差一个数量级。这个成本要提前算进去。
十一、总结与展望:数据库的下一轮 unbundling
把这篇拆解收成几条能带走的判断:
1. 「Rust 重写 X」的真实价值往往不在内存安全,而在于重写给了你重排架构的许可。
Turso 最重要的改动是异步 I/O 和 MVCC,这两件事在原有 C 代码里不是不能做,而是做不动——没有测试信心,谁也不敢碰 pager。语言只是顺带的收益。
2. 「数据库的 LLVM」是一个值得抄的架构范式。
把 SQL 前端、IR(字节码)、执行后端解耦,一套存储引擎可以说多种方言、接多种协议。你在做任何 DSL、规则引擎、查询层时都可以借这个形状:先定义 IR,再让前端和后端各自演进。
3. 可靠性是测试方法论的函数。
DST 是这篇文章里最值得你今天就动手的东西。把时间、随机、I/O 全部注入化,然后用种子暴力搜索反例。哪怕你只是写业务系统,这套方法也能把「偶发 bug」变成「可复现 bug」。
4. 嵌入式数据库正在从「App 的存储」变成「一等分发单元」。
每用户一库、每 Agent 一库,这个模式会把「数据库数量」从个位数推到百万级。那时候瓶颈不是 SQL 性能,而是启动成本、内存密度、句柄管理、批量 schema 迁移——这些恰好是传统数据库工程从不关心的问题,也正是新引擎的机会窗口。
5. 但请永远保留回退路径。
SQLite 不会消失,它是这个行业少见的「几乎不会错」的选择。新引擎的正确用法是:在明确的收益点上引入,用抽象层隔离,用差分测试守住语义,用配置开关守住退路。
最后说句实在话。这波「用 Rust/Zig 重写基础设施」的浪潮里,真正稀缺的从来不是重写的勇气,而是证明重写后的东西没有变得更脆弱的能力。Turso 值得关注,恰恰是因为它把大部分精力花在了后者上。你在自己的项目里做重构时,也该问同一个问题:我拿什么证明新版本没有变差?
答不上来的话,先别动手。