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零丢失低
NORMALcommit 不 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 主要有三个来源:

  1. 写-写竞争:两个连接同时想拿写锁;
  2. 读→写升级失败:事务以 deferred 模式开始,先读再写,中途需要升级为写锁,但此时快照已被别人推进,返回 SQLITE_BUSY_SNAPSHOT;
  3. 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:这些是客户端-服务器数据库的长项。

七、落地清单(可直接抄)

  1. journal_mode=WAL + synchronous=NORMAL(或 FULL)+ busy_timeout=5000;
  2. 写路径一律 BEGIN IMMEDIATE,写连接池设为 1,读连接池独立;
  3. 关掉 auto-checkpoint,后台线程跑 PASSIVE、低峰跑 TRUNCATE;
  4. 批量写必须包事务 + prepared statement 复用;
  5. PRAGMA foreign_keys=ON(事务外执行)、PRAGMA optimize 定期跑;
  6. 备份用 Backup API 或 Litestream,禁止裸拷 db 文件;
  7. 先明确"单写者边界",再讨论要不要上分布式复制。

结语

SQLite 的优势不在功能多,而在可预测:没有网络跳数、没有连接池抖动、没有版本升级带来的计划缓存失效。它的代价同样可预测——一个写者、一台机器、一份需要你自己设计好 checkpoint 与备份策略的文件。

把这六七个 PRAGMA 和两条事务规则落实到位,SQLite 足以支撑绝大多数中小规模生产负载;而当你确实需要跨机器写入时,先问一句"能不能把写收敛到一个地方",再决定是 Litestream、LiteFS 还是托管 libSQL——这个顺序反了,才是大多数 SQLite 生产事故的开端。

点赞(0) 打赏

评论列表 共有 0 条评论

暂无评论
立即
投稿

微信公众账号

微信扫一扫加关注

发表
评论
返回
顶部