SQLite 默认配置是 1990 年代为软盘 + 单进程优化的——保守、慢、易撞 “database is locked”。直接拿默认配置上生产,性能能差出一个数量级。
这篇拆解 6 个必调 PRAGMA、WAL checkpoint 机制、以及 “database is locked” 的根因和解法。
必调的 6 个 PRAGMA
第一次连接数据库后跑:
PRAGMA journal_mode = WAL; -- 持久化,只需一次
PRAGMA synchronous = NORMAL; -- 每次连接
PRAGMA cache_size = -65536; -- 64 MB,每次连接
PRAGMA mmap_size = 268435456; -- 256 MB,每次连接
PRAGMA temp_store = MEMORY; -- 临时表放内存
PRAGMA busy_timeout = 5000; -- 5 秒锁等待
| PRAGMA | 默认 | 推荐 | 收益 |
|---|---|---|---|
| journal_mode | DELETE | WAL | 写 TPS 3-10 倍,读写并发能力提升一个量级 |
| synchronous | FULL | NORMAL | 写 TPS 2-5 倍(SSD),断电仅丢未 checkpoint 事务 |
| cache_size | -2000(8 MB) | -65536(64 MB) | 缓存命中查询快 10-100 倍 |
| mmap_size | 0 | 256 MB+ | 减少 read 系统调用,OS 自动管理缓存 |
| temp_store | DEFAULT | MEMORY | 临时表 / 排序在内存,避免磁盘 IO |
| busy_timeout | 0 | 5000 | 短锁冲突自动重试,少很多 SQLITE_BUSY |
注意:journal_mode 是持久化的(写到数据库文件里),其他都是连接级别——每开一个新连接都要重设。
WAL 模式的工作原理
WAL(Write-Ahead Log)是 SQLite 3.7(2010)引入的并发模型。
默认 DELETE 模式:
- 写者拿到独占锁
- 把要改的页拷到 rollback journal 文件
- 改主库
- commit 时删 journal
- 期间读者全部阻塞
WAL 模式:
- 写者把修改追加到 -wal 文件(顺序写,快)
- 读者直接读主库 + 检查 WAL 里有没有更新版本(MVCC 类似机制)
- 读不阻塞写、写不阻塞读
- 写者还是只能 1 个,但读者无数
仅有的代价:
- 多两个伴生文件
-wal和-shm - 不能在 NFS 上用(锁机制不可靠)
- 跨进程访问要在同一台机器
生产环境必开 WAL——除非数据库在网络共享。
synchronous 的三档权衡
| 模式 | 性能 | 安全 | 风险 |
|---|---|---|---|
| FULL(默认) | 最慢 | 最安全 | 无 |
| NORMAL(推荐) | 中等 | 高 | 断电丢未 checkpoint 事务,库不损坏 |
| OFF | 最快 | 危险 | 断电可能损坏库(DELETE 模式) |
WAL + NORMAL 的安全保证:
- 主库永远一致——checkpoint 前 WAL 数据在 -wal 文件,断电只是丢未 checkpoint 的事务,主库结构完整
- 已 commit 但未 fsync 的事务可能丢——如果 OS 还没刷盘就断电,这部分丢失,但库不损坏
- 实际损失通常是最近几秒的事务
对比 rollback journal + NORMAL 那个组合真的会损坏库——因为 journal 可能没完整写入。所以 NORMAL 必须配 WAL。
金融账务等不可丢任何事务的场景,用 FULL,接受性能下降;其他场景一律 NORMAL。
cache_size 和 mmap_size 的关系
两个都是缓存,但层级不同:
- cache_size:SQLite 自己的页缓存(精准但 per-connection)
- mmap_size:让 OS 把数据库文件 mmap 到地址空间(粗放但便宜)
推荐配置:
| 库大小 | cache_size | mmap_size |
|---|---|---|
| < 100 MB | -32000(32 MB) | 设到库大小 |
| 100 MB - 10 GB | -65536 ~ -262144(64-256 MB) | 1-4 GB |
| > 10 GB | 按热数据估 | 内存的 50% |
陷阱:
- cache_size 是每连接的——多连接 × 大 cache 会爆内存
- mmap 在 32 位系统受地址空间限制
- 不要无脑设大——测一下 hit rate,不命中再加
监控命中率:sqlite3_db_status(SQLITE_DBSTATUS_CACHE_HIT)。
“database is locked” 根因和解决
这是 SQLite 最常见的报错。根因永远是写竞争:
- 多写者同时 BEGIN——只有 1 个拿到锁,其余 SQLITE_BUSY
- 长读事务持有共享锁阻塞写
- WAL checkpoint 和写者冲突
- 跨进程 + busy_timeout=0 立刻报错
解决步骤:
PRAGMA busy_timeout = 5000——拿不到锁时等 5 秒再报错,多数短冲突自动消化- 缩短事务——BEGIN 到 COMMIT 之间不要做网络请求 / 长计算
- 批量写入用单事务——
BEGIN; INSERT; INSERT; ...; COMMIT;比逐条快 10-100 倍且锁次数少 - WAL 模式必开——默认 DELETE 读写互斥,冲突频繁
- 应用层重试——大多数 sqlite 模块不会自动重试,要包一层 retry
- 避免 BEGIN EXCLUSIVE——除非真需要排他锁,DEFERRED(默认)够用
典型 bug:
- Python sqlite3 默认 isolation_level="" 自动 BEGIN/COMMIT,多线程下容易互锁;建议 isolation_level=None 自己控制事务
- Web 框架 请求处理函数里嵌套写事务,一个请求长占锁阻塞其他
with conn:嵌套——内层 commit 时外层还持锁
WAL checkpoint:合并回主库
WAL 不会无限增长——SQLite 定期把 -wal 文件内容合并回主库,叫 checkpoint。
自动 checkpoint:默认 WAL 累积到 1000 页(约 4 MB)时,下一个写者执行完触发 PASSIVE checkpoint,正常情况下 -wal 文件稳定在 4-50 MB。
四种手动模式:
| 模式 | 作用 |
|---|---|
| PASSIVE | 不阻塞,能 checkpoint 多少算多少 |
| FULL | 等所有读完成后完整 checkpoint |
| RESTART | FULL + 重置 WAL 指针 |
| TRUNCATE | FULL + 把 WAL 文件截到 0 字节(释放磁盘) |
什么时候手动:
- WAL 涨到几百 MB / 几 GB(长事务或 PASSIVE 没机会做完)→ TRUNCATE
- 夜间维护窗口定时跑 → TRUNCATE
- 备份前 → 把 WAL 合并回主库再复制
- 关闭应用前 → 清理 WAL 让下次启动更快
自动限制:PRAGMA journal_size_limit = 67108864(64 MB)超过自动 truncate。
索引设计的 SQLite 特有坑
- type affinity —— 列是动态类型,存进去的 “3” 和 3 在没有明确类型时可能不匹配索引;建表时指定 INTEGER / TEXT / REAL
- WITHOUT ROWID —— 默认每表隐藏 rowid + 主键单独索引;主键就是查询路径时加
WITHOUT ROWID,主键变聚簇,省一份索引 - 复合索引顺序 ——
(a, b, c)能加速a=?、a=? AND b=?、a=? AND b=? AND c=?,加速不了b=?或c=?;高基数列放前面、等值列放前面、范围列放后面 - partial index ——
CREATE INDEX idx ON t(a) WHERE b=1只索引满足条件的行,省空间又快;适合”软删除”(WHERE deleted=0) - expression index ——
CREATE INDEX idx ON t(LOWER(email))配合WHERE LOWER(email)=?用 - EXPLAIN QUERY PLAN —— 必看工具,
USING INDEX表示命中,SCAN TABLE表示全表扫 - ANALYZE —— 周期性跑
ANALYZE更新统计信息
容易踩的查询坑:
WHERE a=? OR b=?即使 a、b 都有索引也可能 SCAN;改写成 UNIONLIKE '%xxx%'走不了索引;前缀匹配LIKE 'xxx%'才能WHERE a=NULL永远 false;要用IS NULL(能命中索引)- JSON 字段:3.38+ 支持
CREATE INDEX idx ON t(json_extract(data, '$.key'))
调优顺序速查
按这个顺序调,多数性能问题能解:
| 优先级 | 调什么 | 预期收益 |
|---|---|---|
| 1 | journal_mode = WAL | 写 TPS 3-10 倍 + 锁冲突大幅减少 |
| 2 | synchronous = NORMAL | 写 TPS 2-5 倍 |
| 3 | busy_timeout = 5000 | 消除大部分 SQLITE_BUSY |
| 4 | 批量写入用单事务 | 写 TPS 10-100 倍 |
| 5 | 加合适的索引 + EXPLAIN 校验 | 查询 10-100 倍 |
| 6 | cache_size / mmap_size 加大 | 命中率上去后再快 1-3 倍 |
| 7 | 还不行 → 检查是不是真超 SQLite 能力 | 迁 Postgres |
上生产前的检查清单
-
PRAGMA journal_mode=WAL已写入文件(一次性持久化) - 每次连接设 synchronous=NORMAL + cache_size + mmap_size + busy_timeout
- 关键查询用
EXPLAIN QUERY PLAN验证走索引 - 批量写入用
BEGIN; ... COMMIT;包,不要逐条 commit - 监控 -wal 文件大小(< 100 MB 健康)
- 监控 SQLITE_BUSY 错误率,频繁出现意味着写并发到瓶颈
- 配 Litestream 或定期
.backup,不要直接 cp 文件 - 定期
ANALYZE更新统计 +PRAGMA integrity_check验证完整性 - 32 位系统不要设 mmap_size > 1 GB
- 不在 NFS 上用 WAL 模式
默认 SQLite 是给”个人本地存储”优化的;生产场景必须显式配置 WAL 和缓存。这 6 个 PRAGMA 是分水岭,过了之后 SQLite 能撑住中等并发的真实业务。