SQLite 生产级实战:从 WAL 并发控制到 LiteFS 分布式复制的全链路工程解析
过去两年,"把数据库换回 SQLite"从一种反直觉的极客行为,变成了严肃的工程选项:local-first 应用、边缘节点、AI Agent 的会话状态持久化、Durable Execution 框架(Temporal 风格的确定性重放)、以及 Turso / libSQL / Cloudflare D1 这类"SQLite 即服务"产品,共同把这门 25 岁的嵌入式数据库重新推到了聚光灯下。
但 SQLite 的坑从来不在 SQL 语法,而在并发模型与持久化语义。绝大多数生产事故都源于同一件事:开发者把它当成"小号 Postgres",用 ORM 的默认事务,然后在线上撞上 SQLITE_BUSY 和写长尾。本文从写路径一路讲到分布式复制,给出一份可以直接落到配置里的实战清单。
一、写路径解剖:rollback journal 与 WAL 的本质差异
1.1 rollback journal:写时先拷贝旧页
默认模式下,写事务会先把被修改页的原始内容写入 *-journal 文件,再就地修改主数据库文件。崩溃恢复时,用 journal 里的内容回滚未提交事务。
代价有两个:一是每个写事务至少两次 fsync(journal 落盘 + 数据库落盘);二是写事务持有排他锁期间,读者必须等待——因为主库文件此刻处于半修改状态。
1.2 WAL:追加写 + 多版本读
WAL(Write-Ahead Log)把修改追加到 *-wal 文件,而不动主库。文件结构是 32 字节 header + 若干 frame(24 字节 frame header + 一页数据)。提交时只需追加并在 WAL 尾部写入 commit 标记。
共享内存里维护的 wal-index(*-shm 文件)记录了"每一页的最新已提交版本在 WAL 中的偏移"。读者拿着自己事务开始时的快照去 wal-index 里查,因此:
- 读不阻塞写,写不阻塞读;
- 写事务只需一次顺序 append,fsync 成本远低于随机写主库;
- 主库文件保持长期一致,"只读副本"可以直接分发。
开启方式(这是持久属性,写一次存进文件头即可):
PRAGMA journal_mode = WAL; -- 持久化,执行一次即可
PRAGMA synchronous = NORMAL; -- WAL 模式下的黄金组合
PRAGMA wal_autocheckpoint = 1000; -- 触发 checkpoint 的 WAL 页数,默认 1000
PRAGMA busy_timeout = 5000; -- 遇到锁等待 5 秒再报 BUSY
synchronous 在 WAL 下的取舍必须讲清楚:
| 取值 | WAL 模式行为 | 崩溃后果 | 吞吐 |
|---|---|---|---|
| FULL | 每次 commit fsync WAL | 零丢失 | 低 |
| NORMAL | commit 不 fsync,checkpoint 时才 fsync | 断电丢失最后若干已提交事务 | 高 |
| OFF | 完全交给操作系统 | 可能结构性损坏 | 最高(禁用) |
关键认知:synchronous=NORMAL 在 WAL 模式下不会损坏数据库,最多丢掉最后几个事务。这对内容、缓存、Agent 会话这类可重放数据是合理的;涉及资金与订单落库,请老实用 FULL。
1.3 checkpoint:真正制造长尾的地方
WAL 不会无限增长,需要把 frame 回写进主库,这个过程叫 checkpoint。默认的 auto-checkpoint 在写入线程上同步执行,当你一次大批量写入把 WAL 撑过阈值时,那条写入语句会突然卡住几百毫秒甚至数秒——这是线上 p99 抖动最常见的来源,而且极难从应用日志里看出。
工程做法:关闭自动 checkpoint,交给后台线程异步执行。
PRAGMA wal_autocheckpoint = 0; -- 关闭自动
PRAGMA wal_checkpoint(PASSIVE); -- 后台定期调用:能拿多少锁回写多少,不阻塞
PRAGMA wal_checkpoint(TRUNCATE); -- 低峰期调用:回写并截断 WAL 到 0
四种模式:PASSIVE 不阻塞读写但可能回写不完;FULL 阻塞新写直到完成;RESTART 额外重置 wal-index;TRUNCATE 在 RESTART 基础上把 WAL 文件截断为 0。生产环境建议"高频 PASSIVE + 低峰 TRUNCATE"。
二、并发:单写者模型与 SQLITE_BUSY 的真实来源
WAL 允许 1 个写者 + N 个读者,"单写者"是硬约束,不是可以调优的参数。SQLITE_BUSY 主要有三个来源:
- 写-写竞争:两个连接同时想拿写锁;
- 读→写升级失败:事务以 deferred 模式开始,先读再写,中途需要升级为写锁,但此时快照已被别人推进,返回
SQLITE_BUSY_SNAPSHOT; - checkpoint 期间的排他瞬间:后台 checkpoint 拿到排他锁的窗口内,新写者被挡。
第 2 种最隐蔽,也最常见——因为几乎所有 ORM 的默认事务都是 deferred。解决方案只有一个正确的姿势:确定要写的事务,从一开始就声明 IMMEDIATE。
Python 示例:
import sqlite3
conn = sqlite3.connect("app.db", timeout=5.0, isolation_level=None)
conn.execute("PRAGMA journal_mode=WAL")
conn.execute("PRAGMA synchronous=NORMAL")
conn.execute("PRAGMA busy_timeout=5000")
conn.execute("BEGIN IMMEDIATE") # 立即获取写锁,杜绝升级失败
try:
cur = conn.execute("SELECT balance FROM accounts WHERE id=?", (uid,))
balance = cur.fetchone()[0]
if balance < amount:
conn.execute("ROLLBACK")
raise ValueError("余额不足")
conn.execute("UPDATE accounts SET balance=? WHERE id=?", (balance - amount, uid))
conn.execute("COMMIT")
except Exception:
conn.execute("ROLLBACK")
raise
Go(mattn/go-sqlite3)侧还有一个额外建议:写连接池设为 1,把写操作天然串行化,读操作再开独立连接池。这比分多个连接抢锁要快,因为省掉了锁竞争与重试。
// 写连接:串行 + IMMEDIATE
w, _ := sql.Open("sqlite3",
"file:app.db?_journal_mode=WAL&_synchronous=NORMAL&_busy_timeout=5000&_txlock=immediate")
w.SetMaxOpenConns(1)
// 读连接:只读 + 长连接
r, _ := sql.Open("sqlite3",
"file:app.db?mode=ro&_journal_mode=WAL&_query_only=on")
r.SetMaxOpenConns(runtime.NumCPU())
还有一个容易忽略的点:WAL 文件过大会拖慢读。当 WAL 里堆积了几万甚至几十万 frame,每次读都要在 wal-index 里做更多查找,读延迟随 WAL 体积单调上升。所以 checkpoint 不只是磁盘回收,更是读性能治理。
三、性能调优:从批量事务到查询计划
3.1 批量写必须包进事务
这是量级差异,不是百分比差异。autocommit 模式下每条 INSERT 都是一次独立事务、一次 fsync;包进一个事务后,一万条插入在 NVMe + synchronous=NORMAL 下通常从数十秒降到亚秒级。任何批量导入、事件回放、缓存预热,都必须显式包事务,并复用到 prepared statement:
conn.execute("BEGIN IMMEDIATE")
stmt = "INSERT INTO events(ts, user_id, kind, payload) VALUES(?,?,?,?)"
conn.executemany(stmt, rows) # executemany 内部复用 prepared statement
conn.execute("COMMIT")
3.2 值得调整的 PRAGMA
PRAGMA mmap_size = 268435456; -- 256MB 只读映射,减少 read syscall
PRAGMA cache_size = -65536; -- 负值表示 KB,即 64MB page cache
PRAGMA temp_store = MEMORY; -- 临时表/排序走内存
PRAGMA foreign_keys = ON; -- 默认是 OFF!且必须在事务外执行
PRAGMA optimize; -- 应用关闭前调用,等价于条件触发的 ANALYZE
page_size 默认 4096,通常匹配文件系统页与 SSD 内部页,除非你的查询是以大范围扫描为主(可考虑 8192),否则不必动;它只能在 VACUUM 时变更。
3.3 索引要看查询计划,不要凭感觉
EXPLAIN QUERY PLAN
SELECT user_id, amount FROM orders
WHERE status = 'paid' AND created_at > '2026-01-01';
-- 期望输出:
-- SEARCH orders USING INDEX idx_orders_status_created (status=? AND created_at>?)
如果输出里出现 SCAN orders,说明全表扫描。更进一步,把查询需要的列全部塞进索引,做成覆盖索引,可以彻底消除回表:
CREATE INDEX idx_orders_cover ON orders(status, created_at, user_id, amount);
-- 再次 EXPLAIN 应看到:SEARCH orders USING COVERING INDEX ...
第三个习惯:定期 ANALYZE(或 PRAGMA optimize)。没有统计信息的 planner 只能瞎猜索引选择性,这在小表上看不出问题,在千万行表上会选错索引选到怀疑人生。
四、数据安全:备份、完整性与迁移
不要用 cp 复制正在写入的 db 文件——至少必须同时复制 -wal,否则拿到的是一个没有最新提交的旧快照(运气差的话是损坏的)。正确方式是使用官方 Backup API,它做的是页级在线拷贝,且允许增量:
sqlite3_backup *b = sqlite3_backup_init(dst_db, "main", src_db, "main");
while ((rc = sqlite3_backup_step(b, 100)) == SQLITE_OK) {} /* 每轮 100 页,不长时间持锁 */
rc = sqlite3_backup_finish(b);
Python 里 conn.backup(dest) 就是它的封装。另外,把这几件事排进运维日历:
- 定期
PRAGMA quick_check(比integrity_check快得多,覆盖绝大多数问题),重大变更后跑一次完整的integrity_check; - 迁移不要用"大表 ALTER"的幻想:SQLite 的
ALTER TABLE只支持有限的几种操作,加带约束的列、改类型、删列都要走"建影子表 → 拷贝数据 → 重命名"的套路,务必在事务里做; - 打开外键约束后,删除与导入的顺序会变严格,这是好事,但要在测试环境先跑一遍。
五、从单机到分布式:Litestream / LiteFS / libSQL
SQLite 单写者的约束在单机内可以靠队列解决,跨机器就得靠复制方案。目前三条主流路线:
5.1 Litestream:WAL 帧级持续复制
Litestream 的思路很聪明——它不复制整个数据库文件,而是持续监控 -wal,把新产生的 frame 以"generation"为单位增量上传到 S3,并周期性做一次完整 snapshot。RPO 是秒级,恢复时按 generation 顺序重放。
dbs:
- path: /data/app.db
replicas:
- type: s3
bucket: my-backups
path: app
retention: 720h
sync-interval: 1s
snapshot-interval: 24h
litestream restore -o /data/app.db s3://my-backups/app
代价要说清楚:Litestream 是备份与恢复,不是高可用。它没有自动故障切换,RTO 取决于 snapshot 与 generation 数量(generation 太多会让恢复变慢,需要依赖 snapshot-interval 与保留策略控制)。适合"能容忍分钟级恢复"的中小应用。
5.2 LiteFS:FUSE 拦截 + 主节点租约
LiteFS 用 FUSE 挂一个伪文件系统,拦截 SQLite 对数据库文件的 POSIX 调用,从而做到:只有被选举为 primary 的节点能写,副本节点通过 HTTP 拉取主节点产生的 LTX 变更包(一组页面的事务级增量)来追赶。主节点由 Consul 租约决定,写入请求则由内置 proxy 转发到当前主节点。
fuse:
dir: "/litefs"
data:
dir: "/var/lib/litefs"
lease:
type: "consul"
candidate: true
promote: true
url: "http://consul:8500"
advertise-url: "http://${HOSTNAME}:20202"
proxy:
addr: ":8080"
target: "localhost:8081"
db: "app.db"
应用侧只需判断自己是不是主节点(读 /litefs/.primary 或请求 /litefs/primary),并把写请求交给 proxy。实践中踩过的坑:FUSE 在容器里需要 /dev/fuse 设备与相应权限,某些托管 K8s 环境直接不可用;租约 TTL 设太长会拉长故障切换与"双主"风险窗口,设太短又容易被网络抖动误切换——这是纯粹的 CAP 权衡,没有银弹。
libSQL / D1 走的是第三条路:直接 fork SQLite,增强 ALTER 能力,并原生支持嵌入式副本与 HTTP 访问协议。如果你的团队不想碰 FUSE 和租约,这是更省心的托管路线。
六、什么时候不该用 SQLite
诚实地划边界,比无脑吹捧更有价值:
- 持续写并发高:单写者是物理约束,所有写最终都要排成一队。写吞吐超过单核能消化的量,就该换数据库;
- 数据量超出单机:SQLite 理论上限 281TB,但实践上超过几百 GB 后,备份、checkpoint、VACUUM 的运维成本会陡增;
- 需要跨地域强一致写入:LiteFS 这类方案本质上仍是单点写,跨洲写延迟直接等于应用延迟;
- 需要复杂权限模型与在线 DDL:这些是客户端-服务器数据库的长项。
七、落地清单(可直接抄)
journal_mode=WAL+synchronous=NORMAL(或 FULL)+busy_timeout=5000;- 写路径一律
BEGIN IMMEDIATE,写连接池设为 1,读连接池独立; - 关掉 auto-checkpoint,后台线程跑
PASSIVE、低峰跑TRUNCATE; - 批量写必须包事务 + prepared statement 复用;
PRAGMA foreign_keys=ON(事务外执行)、PRAGMA optimize定期跑;- 备份用 Backup API 或 Litestream,禁止裸拷 db 文件;
- 先明确"单写者边界",再讨论要不要上分布式复制。
结语
SQLite 的优势不在功能多,而在可预测:没有网络跳数、没有连接池抖动、没有版本升级带来的计划缓存失效。它的代价同样可预测——一个写者、一台机器、一份需要你自己设计好 checkpoint 与备份策略的文件。
把这六七个 PRAGMA 和两条事务规则落实到位,SQLite 足以支撑绝大多数中小规模生产负载;而当你确实需要跨机器写入时,先问一句"能不能把写收敛到一个地方",再决定是 Litestream、LiteFS 还是托管 libSQL——这个顺序反了,才是大多数 SQLite 生产事故的开端。

发表评论 取消回复