这次我们来看一个有点“逆天”的演讲主题:在 P99 CONF 2025 上,Glauber Costa 直接抛出一个争议性问题——为什么要把 SQLite 用 Rust 重写一遍。
SQLite 是全球部署量最大的数据库,没有之一。它跑在手机、浏览器、嵌入式设备、桌面软件里,稳定运行了二十多年。这样一个被视为“基础设施级可靠”的项目,突然有人要重写,而且是用 Rust,很多人第一反应是:是不是闲得慌?
但这正是 P99 CONF 这种偏向性能工程的会议上最值得听的技术判断。讲者不是来讨论“Rust 比 C 好”这种口水话题,而是从存储引擎的实际瓶颈出发,讲清楚一个现代数据库在当下硬件环境里到底卡在哪,以及为什么语言层面的选择会直接影响架构演进。
这篇文章会把演讲背后的技术逻辑拆开:SQLite 的设计边界在哪里,Rust 重写解决了哪些实际问题,这个项目用什么方式保持兼容,以及如果你想在本地复现、测试甚至参与这类 Rust 数据库项目,环境和工具链应该怎么搭。
核心看点有三个:
- SQLite 不是“烂”,而是设计决策停在 1990 年代,某些瓶颈无法用打补丁解决。
- Rust 重写不是推翻重来,而是保留文件格式和 SQL 语义,替换掉存储引擎和并发模型。
- 这类项目的实际验证方式,就是本地跑基准测试、观察 p99 延迟和写吞吐。
下面直接进入正文。
1. 演讲核心命题与项目背景
P99 CONF 是一个专攻延迟、性能、可观测性主题的技术会议。主办方做的是 p99 延迟监控工具,所以这个会议上的演讲普遍有一个特点:不讲概念,直接讲数据、架构和真实生产环境里的坑。
Glauber Costa 的演讲标题是“Why We’re Rewriting SQLite in Rust”。如果你熟悉数据库生态,应该知道 Glauber Costa 是 Turso 的创始人,Turso 是基于 libSQL 的托管数据库服务,而 libSQL 是 SQLite 的一个开源分支。他在数据库领域做过不少底层工作,包括之前的 io_uring 相关研究和构建开源缓存服务。这个演讲不是随便找个数据库开发者来聊 Rust,而是一个真的在维护 SQLite 衍生分支、并且已经在做 Rust 重写的人,来讲自己的工程决策。
他推进的重写项目就是Limbo,一个用 Rust 实现的 SQLite 兼容数据库。需要说明的是,Limbo 不是一个“用 Rust 重新写一遍 SQLite 所有代码”的复制型项目,而是一个全新的存储引擎实现,目标是:
- 兼容 SQLite 的数据库文件格式。
- 兼容 SQLite 的 SQL 方言和 C API 使用方式。
- 保留嵌入式、单文件、零配置的优点。
- 在存储层、并发模型和 I/O 调度上采用现代方案。
这决定了整场演讲的核心逻辑:重写不是为了炫技,而是为了解决 SQLite 在当前环境下确实存在的结构性限制。
2. SQLite 的成就与结构性瓶颈
在讨论重写之前,先得说清楚 SQLite 为什么值得被重写。SQLite 本身是一个非常优秀的产品,它解决的问题很明确:提供一个无需独立服务进程、直接嵌入应用的数据库,数据存在一个普通文件里,任何语言都能调用。
这种设计的优点极其突出,这也是它被部署到几十亿设备上的原因:
- 零配置,不需要安装服务、不需要账号权限管理。
- 单文件存储,复制文件即备份。
- 跨平台,Windows、Linux、macOS、Android、iOS 全覆盖。
- 公有领域授权,商业集成没有法律负担。
但这些优点背后,是一系列在 1990 年代合理、在今天却成为瓶颈的设计决策。
2.1 写并发受限
SQLite 的并发模型非常保守。默认情况下,同一时刻只允许一个写事务。即便开启了 WAL 模式,也只是把写阻塞从“整个数据库”降到“读和写之间互斥”,本质上一个时刻仍然只有一个写者。
如果是单机单进程的本地应用,这个限制几乎感觉不到。但一旦出现以下场景,瓶颈就非常明显:
- 多个应用进程同时打开同一个数据库文件。
- Web 服务的多个 Worker 并发写同一份数据。
- 边缘节点上的本地数据库需要高频写入。
当写并发上来之后,SQLite 会把大量时间花在锁等待和文件同步上,而不是真正执行 SQL。
2.2 扩展插件机制的尴尬
SQLite 支持通过sqlite3_create_function注册自定义函数,也能通过虚拟表机制做自定义存储引擎。这个扩展机制很灵活,但它基于 C 函数指针和回调,底层还是共享全局状态。
在现代编程语言里,想安全地把扩展机制和多线程、异步 I/O 结合起来,难度会指数上升。扩展一旦写崩,崩溃的是宿主进程。
2.3 存储引擎的可演进出空间有限
SQLite 的 B+Tree 存储结构本身是很成熟的设计,但在新硬件环境下,它可以优化的点很多:
- 是否可以利用 io_uring 做异步 I/O?
- 是否可以在 WAL 之外做更细粒度的并发控制?
- 是否可以在页缓存层加入更现代的替换策略?
- 是否能够支持列式存储、向量索引等新场景?
这些问题不是说 SQLite 做不到,而是要在 C 代码基础上改动,风险远大于收益。SQLite 二十年积累下来的兼容性包袱太重,任何底层改动都可能影响几十亿设备上的行为。
3. 为什么是 Rust,而不是继续用 C 或者 C++
这是整场演讲的核心。用 Rust 重写一个数据库,需要回答一个问题:Rust 到底带来了什么,是 C 代码做不到的?
3.1 内存安全,尤其是线程安全
数据库存储引擎是内存安全问题的高发区。指针操作、缓冲区管理、页缓存、并发事务,每一个模块都涉及大量 unsafe 的内存访问。在 C 语言里,这些代码靠人肉 review 和长时间测试来保证质量,但依然会有崩溃和内存泄漏。
Rust 的所有权模型把大部分内存错误从运行时搬到了编译期。用 Rust 写存储引擎,编译通过不等于没有逻辑 bug,但至少能消灭整类 use-after-free、double free、缓冲区溢出问题。
更关键的是线程安全。Rust 的类型系统强制要求跨线程共享的数据实现Send和Sync,这直接改变了并发事务模块的编写方式。很多并发 bug 在编译阶段就被拦截,而不是等到生产环境偶现死锁。
3.2 无 GC,性能可预测
Go 也能写数据库,但 Go 的 GC 会带来不可预测的暂停。数据库引擎对延迟极度敏感,尤其 p99 延迟。一个 GC 暂停哪怕只有几毫秒,也会直接拉高尾延迟。
Rust 没有 GC,内存管理靠编译期生成的析构逻辑,运行时不会突然停下来做垃圾回收。这一点让 Rust 在延迟敏感型基础设施里非常有吸引力。
3.3 FFI 安全和生态支持
SQLite 之所以能被所有语言使用,靠的是 C ABI。Rust 可以直接通过extern "C"导出动态库,对接所有支持 C ABI 的语言。
也就是说,用 Rust 重写 SQLite,不需要让所有上层用户改写代码。只要保证 C API 兼容,原有的 Python、Go、Node.js、Java 客户端都能继续工作。
3.4 异步生态成熟
Rust 的异步生态(tokio、async-std)虽然有过一些争议,但已经足够成熟。数据库引擎对 I/O 的调度,用异步模型表示比用阻塞线程模型要自然得多。
尤其是结合 io_uring 或者 Windows 上的 overlapped I/O,Rust 可以做到非常高效的异步文件读写。这也是 Limbo 这类项目比较关注的方向:把存储层的 I/O 完全异步化,降低单次请求的延迟。
4. Limbo:Rust 重写 SQLite 的实践路径
既然要重写,那么具体怎么重写就是关键问题。Limbo 的路线可以归纳为:兼容优先,内核替换,协议延续。
4.1 文件格式兼容
Limbo 选择直接兼容 SQLite 的数据库文件格式。这意味着,你不需要做数据迁移,一个现有 SQLite 数据库文件可以直接被 Limbo 打开。
从用户视角看,这是一个很聪明的策略。数据库重写最大的迁移成本不是 SQL 语法差异,而是数据迁移。如果文件格式兼容,迁移成本可以降低到几乎为零。
文件格式兼容意味着:
- B+Tree 页结构保持一致。
- 页大小、编码方式、表结构存储方式保持兼容。
- WAL 文件机制尽量兼容。
这一层的约束非常多,但也是整个重写项目里最值得关注的部分,因为它是“重写”和“从零开发一个新数据库”的分水岭。
4.2 SQL 方言兼容
SQLite 的 SQL 方言有自己的一些特点,比如动态类型、AUTOINCREMENT关键字、PRAGMA语句体系。Limbo 需要在这些语义上和 SQLite 保持一致,否则上层应用会踩到大量隐性差异。
SQL 层的兼容是长期工作,因为 SQLite 本身也在不断演进。Limbo 更务实的做法是先覆盖高频 SQL 子集,再逐步补齐长尾。
4.3 C API 兼容
SQLite 最广泛的使用方式是通过 C API 调用:sqlite3_open、sqlite3_prepare、sqlite3_step、sqlite3_finalize。Limbo 如果要替换 SQLite,就需要在 C API 层提供兼容实现。
这意味着,一个使用 SQLite C API 的程序,可以通过重新链接到 Limbo 的动态库,切换到底层引擎。对上层应用来说,代码几乎不需要改动。
当然,这只是一种理想化的完整兼容目标。实际 Limbo 目前的 API 覆盖范围还在演进中,不同功能模块的支持程度需要以项目文档为准。
4.4 并发模型的改进方向
Limbo 在并发模型上的改进,是演讲中最核心的技术点。
传统 SQLite 把整个数据库看作一个需要全局锁的资源。Limbo 的目标是实现更细粒度的并发控制,让读操作和写操作能够并行推进,多个写操作之间尽量不互相阻塞。
这个改进的本质是重新设计事务管理器,而不是简单优化锁等待时间。它涉及到:
- 更细粒度的页级锁或版本链。
- 利用 MVCC 思想管理快照。
- 写事务之间通过合理调度减少冲突。
- 充分利用现代文件系统的异步 I/O 能力。
实际能做到什么程度,还需要看 Limbo 发布版本的测试数据。但方向是明确的:解决 SQLite 在写并发上的结构性限制,而不是在原有框架里修修补补。
5. 重写过程中的架构取舍与风险
用 Rust 重写 SQLite,听起来很美好,但工程上有一堆取舍和风险。这里需要泼一点冷水。
5.1 第二系统效应
数据库历史上有一个著名陷阱:重写时想加入所有新想法,结果项目越做越大,永远发不出可用版本。Limbo 的应对方式是保持文件格式兼容,用兼容性约束来控制范围。
这个策略值得参考。重写老系统的第一步,不是创新,而是用新语言把原有行为完整复刻一遍。复刻稳定之后,再考虑引入新特性。
5.2 性能不可能凭空翻倍
用 Rust 重写,性能不一定自动超过 C 版本。C 和 Rust 编译后的机器码在同等优化水平下差异很小。真正的性能提升来自:
- 新的并发模型减少了锁竞争。
- 新的 I/O 调度减少了阻塞。
- 新的缓存策略提升了命中率。
如果只是把 C 代码翻译成 Rust,性能不会有本质变化。这一点在评估任何 Rust 重写项目时都要注意:语言本身不是性能银弹,架构层面的改进才是。
5.3 扩展生态需要重建
SQLite 有极其丰富的扩展生态,包括 FTS5 全文搜索、Spatialite 空间扩展、JSON1、各种虚拟表引擎。Rust 重写版本如果想完全兼容这些扩展,工作量会非常惊人。
更现实的路径是:先保证核心 OLTP 场景稳定,再逐步兼容高频扩展。对多数应用来说,只用 SQLite 的基本 SQL 能力和 WAL 模式,不依赖复杂扩展,这个风险是可控的。
6. 本地验证:Rust 与 SQLite 工具链准备
如果看完演讲,你想自己动手验证一下 SQLite 的基础能力,或者尝试跑一个 Rust 版本的数据库连接,这里给一套本地环境准备方案。
6.1 安装 Rust 工具链
推荐使用rustup安装。Linux 和 macOS 上执行:
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh source "$HOME/.cargo/env" rustc --version cargo --versionWindows 上请下载rustup-init.exe,按提示安装。安装完成后打开新的 PowerShell 窗口即可使用。
6.2 创建 Rust 项目
cargo new sqlite_rust_demo cd sqlite_rust_demo修改Cargo.toml,加入rusqlite依赖。这里的bundled特性可以让 rusqlite 自动编译内置 SQLite C 源码,省去系统级 SQLite 依赖:
[dependencies] rusqlite = { version = "0.31", features = ["bundled"] }6.3 编写最小连接代码
编辑src/main.rs:
use rusqlite::{Connection, Result}; fn main() -> Result<()> { let conn = Connection::open("demo.db")?; conn.execute( "CREATE TABLE IF NOT EXISTS users ( id INTEGER PRIMARY KEY, name TEXT NOT NULL )", [], )?; conn.execute( "INSERT INTO users (name) VALUES (?1)", ["alice"], )?; let count: i64 = conn.query_row( "SELECT COUNT(*) FROM users", [], |row| row.get(0), )?; println!("users count: {}", count); Ok(()) }运行:
cargo run如果没有报错,输出users count: 1,说明 Rust 环境下连接、建表、写入、查询全部打通。
6.4 检查生成的数据库文件
用命令行工具检查数据库文件:
sqlite3 demo.db .tables SELECT * FROM users;如果没有安装 SQLite 命令行工具,也可以使用 DB Browser for SQLite 打开demo.db,直接在图形界面里确认数据。这个工具对排查数据库文件结构问题很方便,也是社区里比较常用的 SQLite 可视化工具。
7. 写性能与并发观察:做一个简单的基准测试
要理解 SQLite 在写并发上的瓶颈,最好的方式是本地做一个简单测试。这里给出一个 Rust 写的批量插入基准,也可以对比开启 WAL 前后的差异。
7.1 单事务批量插入测试
use rusqlite::Connection; use std::time::Instant; fn main() { let conn = Connection::open("bench.db").expect("open failed"); conn.execute_batch( "PRAGMA journal_mode=WAL; PRAGMA synchronous=NORMAL; CREATE TABLE IF NOT EXISTS t ( id INTEGER PRIMARY KEY, val TEXT );" ).expect("init failed"); let start = Instant::now(); { let tx = conn.unchecked_transaction().expect("tx failed"); for i in 0..10_000 { tx.execute( "INSERT INTO t (val) VALUES (?1)", [format!("value-{}", i)], ).expect("insert failed"); } tx.commit().expect("commit failed"); } println!("10k inserts in single tx: {:?}", start.elapsed()); }从数据库实践来看,这种把 10000 条插入放在一个事务里的做法,效率远高于 10000 次独立提交。原因在于单事务减少了大量磁盘同步和事务日志刷盘操作。
如果你把上面的循环改成每条INSERT单独提交,也就是去掉外层事务,每次execute之后自动提交,执行时间会明显变长。这就是fsync带来的成本。在很多 SQLite 调优场景里,合理控制事务粒度是收益最大的一项优化。
7.2 压测时重点观察 p99 延迟
P99 CONF 的核心关注点是 p99 延迟。本地压测时,不应该只看平均值,要关注长尾:
- 平均延迟可能是 1ms,但 p99 可能达到 50ms。
- p99 的变化通常来自锁等待、缓存未命中和磁盘同步抖动。
如果是 Linux 环境,可以用perf或strace观察fsync调用频率。进程阻塞在fsync上的时间往往就是写延迟的主要来源。
如果是高负载压测,建议使用hyperfine这类工具对命令行程序做多次计时,而不是只看单次结果:
cargo build --release hyperfine --runs 10 './target/release/sqlite_rust_demo'Rust 项目的调试构建和发布构建性能差异很大。任何性能对比都应该用--release构建,否则优化目标不同,结果没有参考价值。
8. 从单机嵌入到服务化:API 与批量任务的扩展方向
SQLite 原本是嵌入式数据库,没有网络服务接口。但围绕 SQLite 的生态已经发展出了一系列服务化方案,Turso 做的 libSQL 就是典型代表。
如果你关注的是“能不能直接把 SQLite 接到自己的工具链里”,有几个方向值得关注。
8.1 通过 libSQL 提供 HTTP 访问
libSQL 是 SQLite 的分支,增加了对远程访问的支持。部分服务端版本通过 HTTP 接口暴露 SQL 能力,客户端可以通过 REST API 执行查询。这意味着,一个原本只支持本地文件访问的数据库,可以被暴露成内部服务,多个客户端通过网络共用同一份数据。
实际接口路径和请求格式需要以 libSQL 官方文档为准。这里给出一个通用的 HTTP 调用思路:
import requests url = "http://127.0.0.1:8000/v2/pipeline" payload = { "requests": [ {"type": "execute", "stmt": {"sql": "SELECT COUNT(*) FROM users"}} ] } response = requests.post(url, json=payload, timeout=10) print(response.json())注意,这个示例只是说明 HTTP 访问模式,具体路径和字段必须替换为实际服务的接口定义。
8.2 批量导入数据时的事务粒度设计
如果你有大量数据要导入 SQLite,一个关键的设计点是事务粒度。常见实践:
- 每 1000 到 10000 条记录提交一次事务。
- 开启 WAL 模式。
- 将
synchronous设置为NORMAL。 - 建表、建索引在导入完成后再统一执行。
批量任务的失败重试逻辑也很重要。比如每批任务写入完成后,记录批次 ID。如果中途失败,可以从上一个完成批次继续,而不是从头再来。
8.3 接口服务与批量任务的监控
如果把 SQLite 服务化,必须监控这些指标:
- 连接数:嵌入式数据库转服务化后,连接管理会变成新问题。
- 慢查询:定位哪些 SQL 拖慢了整体吞吐。
- 锁等待:是否出现多个客户端互相阻塞。
- p99 延迟:观察长尾变化。
这些指标不是 SQLite 自带的能力,需要依赖外部监控工具,或者自己在应用层打点。这也是 SQLite 服务化改造里最容易被忽略的一部分。
9. 常见问题与排查方法
不管你是自己用 Rust 写一个 SQLite 封装,还是想尝试 Limbo 这类项目,都可能会遇到下面这些问题。
| 问题现象 | 可能原因 | 排查方式 | 解决方案 |
|---|---|---|---|
| Rust 编译速度很慢 | 依赖过多或未用增量编译 | 检查cargo build输出 | 使用cargo build --release,尽量复用依赖缓存 |
bundled特性编译失败 | 缺少 C 编译工具链 | 检查cc是否可用 | 安装 gcc/clang 或 MSVC Build Tools |
| 数据库文件被锁定 | 多个进程同时写 | 检查lsof输出 | 开启 WAL 模式,或改为单写入进程 |
| 写操作特别慢 | 每次提交都触发 fsync | 观察synchronous设置 | 合理设置事务粒度,评估synchronous=NORMAL |
| 打开数据库后只能读不能写 | 文件权限或目录权限不足 | 检查文件属主和权限 | 修改权限或调整运行用户 |
| 查询结果与预期不一致 | SQLite 动态类型导致隐式转换 | 检查字段实际类型 | 明确字段类型,避免隐式类型转换 |
| Rust FFI 调用崩溃 | 传递了空指针或错误长度 | 开启RUST_BACKTRACE=1运行 | 检查所有外传入参的生命周期和有效性 |
| 大量并发读时仍有写阻塞 | 仍在使用默认 journal mode | 检查PRAGMA journal_mode | 开启 WAL,并允许更多读连接 |
如果只是想快速检查 SQLite 文件是否损坏,可以在命令行执行:
sqlite3 demo.db "PRAGMA integrity_check;"这条命令会走一遍 B+Tree 完整性检查。如果输出只有ok,说明文件结构没有明显问题。
10. 什么样的项目适合用 Rust 重写
回到演讲本身。Glauber Costa 的演讲虽然标题是“重写 SQLite”,但核心方法论适用于所有关键基础软件:
10.1 先测量,再重写
重写一个运行良好的系统,必须有数据支撑。不是“我觉得可以更快”,而是“p99 延迟在双写场景下退化到不可接受”,或者“内存安全审计成本已经超过维护成本”。
如果只有模糊的“感觉 C 代码不好维护”,那大概率不应该重写。
10.2 兼容层优先
优秀的重写不是从零创造一个新系统,而是先划清兼容边界:
- 文件格式兼容,用户数据可无缝迁移。
- API 兼容,上层调用方不用改代码。
- 行为兼容,边界 case 尽量一致。
兼容层做得越扎实,重写项目的存活率越高。
10.3 用新语言解决旧架构的问题
Rust 不是万能药。但如果旧系统的问题集中在内存安全、并发控制和异步 I/O 上,Rust 就是非常合适的替代品。
SQLite 的问题恰恰是这三者的交集:
- C 代码手动管理内存,存储引擎又是内存安全问题高发区。
- 并发模型保守,写入锁粒度过大。
- I/O 调度以阻塞式为主,难以利用现代异步 I/O 能力。
所以用 Rust 重写 SQLite 在技术逻辑上是成立的,它不是在“翻新”,而是在替换一个确实过时的实现层。
10.4 警惕重写的时间线
数据库级别重写,最短也需要数年。Limbo 这类项目能推进,是因为有 Turso 这样的公司在背后持续投入,并且有明确的生产需求驱动。
个人开发者或者小团队想重写一个数据库,一定要想清楚:你有多少时间、多少用户、多少真实场景来验证正确性?
数据库不是普通应用,数据一致性出问题,后果极其严重。任何重写项目都应该把测试放在第一位。
11. 总结与下一步
P99 CONF 2025 这场演讲最有价值的点,不是“Rust 比 C 好”这个结论,而是它展示了一个真实存在的重写路径:如何在不破坏兼容性的前提下,用新的语言和新的并发模型,把一个旧系统里最核心的存储引擎替换掉。
如果你对这个方向感兴趣,可以从这几个方面继续深入:
- 手动跑一个 Rust + SQLite 的最小项目,感受
rusqlite的 API 设计。 - 用
PRAGMA调整 journal mode 和 synchronous 参数,观察写性能变化。 - 关注 Limbo 项目的最新版本,尝试打开一个现有
.db文件验证兼容性。 - 如果生产环境真的有 SQLite 写并发瓶颈,先量化 p99 延迟,再决定是否引入重写方案。
最容易踩的坑是把“用 Rust 重写”理解成“性能一定翻倍”。真正的提升来自并发模型、I/O 调度和缓存策略,语言本身只是工具。
先把本地工具链跑通,再做一个几百行的小项目实测,比空谈重写更有价值。
建议收藏备用。下次再看数据库底层项目,至少知道从哪些维度去评估一次重写值不值得。