嵌入式数据库不是玩具。一旦 QPS 从几十涨到几百,database is locked 会成批出现,P99 从 8ms 跳到 300ms。根因通常不是 SQL 写得差,而是 WAL 模式下 checkpoint 抢写锁、busy_timeout 设得太短、以及读事务拖住 WAL 文件无法截断。
本文按生产排查顺序讲:先确认是锁等待还是 checkpoint 风暴,再给出可落地的参数组合和错误写法对照。
先搞清楚 WAL 在锁什么
读多写少时为什么还会卡住
WAL 把写入追加到 -wal 文件,读请求走快照,写请求不再直接改主库页。听起来读写互不阻塞,但有两个例外:
- checkpoint 要把 WAL 页刷回主库时,需要短暂持有写锁。
- 长事务一直拿着旧快照,WAL 就不能截断,文件膨胀后每次 checkpoint 更慢,形成正反馈。
错误写法是把所有写入塞进一个长事务,再在事务里做网络调用:
python
import sqlite3
conn = sqlite3.connect("orders.db")
conn.execute("BEGIN")
conn.execute("INSERT INTO orders(id, status) VALUES (?, ?)", (oid, "created"))
# 错误:事务期间去调支付网关,持锁时间被网络放大
pay(oid)
conn.execute("UPDATE orders SET status=? WHERE id=?", ("paid", oid))
conn.commit()
正确写法是把外部 IO 放到事务外,数据库事务只覆盖本地状态机:
python
import sqlite3
conn = sqlite3.connect("orders.db", timeout=5.0)
conn.execute("PRAGMA journal_mode=WAL")
conn.execute("PRAGMA synchronous=NORMAL")
conn.execute("PRAGMA busy_timeout=5000")
conn.execute("PRAGMA wal_autocheckpoint=1000")
conn.execute("INSERT INTO orders(id, status) VALUES (?, ?)", (oid, "created"))
conn.commit()
pay(oid)
conn.execute("UPDATE orders SET status=? WHERE id=?", ("paid", oid))
conn.commit()
timeout=5.0 只影响 Python 驱动层;真正决定锁等待的是 busy_timeout,单位是毫秒。两边都要设,否则驱动先抛错,SQLite 还没来得及重试。
busy_timeout 不是越大越好
5000ms 和 30000ms 的取舍
短超时会把瞬时锁冲突变成业务错误,调用方开始无脑重试,冲突更密。长超时会把线程钉死在等待上,线程池被写请求填满。经验值:
| 场景 | busy_timeout | 说明 |
|---|---|---|
| 单进程本地缓存 | 1000ms | 冲突少,失败应快速暴露 |
| 多线程 API 进程 | 5000ms | 覆盖一次 checkpoint |
| 多进程共享同一文件 | 15000ms | 进程切换会拉长锁等待 |
不要设成 0。busy_timeout=0 等于第一次拿不到锁立刻失败,压力测试里错误率会虚高。
checkpoint 策略决定写延迟形态
PASSIVE、FULL、RESTART、TRUNCATE
PRAGMA wal_checkpoint(PASSIVE) 不抢写锁,适合请求路径上顺手做。FULL 会尽量刷完 WAL,可能卡住写入。RESTART 还会阻断新写者进入 WAL,TRUNCATE 进一步把 WAL 文件截成 0 字节。
生产上请求路径只用 PASSIVE,后台线程定时 FULL:
python
import sqlite3
import threading
import time
def checkpoint_loop(db_path: str):
conn = sqlite3.connect(db_path, timeout=10.0)
conn.execute("PRAGMA busy_timeout=15000")
while True:
mode, wal_pages, checkpointed = conn.execute("PRAGMA wal_checkpoint(FULL)").fetchone()
if wal_pages > 5000:
conn.execute("PRAGMA wal_checkpoint(TRUNCATE)")
time.sleep(30)
threading.Thread(target=checkpoint_loop, args=("orders.db",), daemon=True).start()
wal_autocheckpoint=1000 表示大约 1000 页(默认页 4KB,约 4MB)触发自动 checkpoint。写入突发时 1000 太密,P99 会出现周期性尖刺;改成 4000~8000,尖刺间隔变长、单次耗时增加,整体更稳。
实测一组对照(单文件、8 写线程、每条 200 字节):
- autocheckpoint=1000,P99 写入 42ms,WAL 稳定在 6MB。
- autocheckpoint=8000,P99 写入 18ms,WAL 峰值 28MB。
- 关闭自动 checkpoint 只靠后台 FULL,P99 写入 11ms,但进程崩溃后恢复时间从 0.4s 升到 2.1s。
选 2 作为默认,磁盘紧张再降回 1。
多连接必须共享同一套 PRAGMA
每个连接的 journal_mode、busy_timeout、synchronous 都是连接级的。连接池里有一个连接忘了开 WAL,它会退化成 DELETE 模式,把整库拖回互斥锁。
启动时用一个引导连接设置库级项,再让池内连接只设连接级项:
python
def init_db(path: str):
boot = sqlite3.connect(path)
boot.execute("PRAGMA journal_mode=WAL")
boot.close()
def new_conn(path: str):
conn = sqlite3.connect(path, check_same_thread=False, timeout=5.0)
conn.execute("PRAGMA busy_timeout=5000")
conn.execute("PRAGMA synchronous=NORMAL")
conn.row_factory = sqlite3.Row
return conn
synchronous=FULL 每次提交都等磁盘,嵌入式设备上写入会掉一个数量级。NORMAL 在 WAL 下已经能在进程崩溃后恢复,机器掉电才可能丢最后一次 checkpoint 之后的数据。边缘网关可以接受;金融流水不行。
总结
SQLite 高并发写入的核心不是换数据库,而是管住锁等待和 WAL 膨胀。事务里禁止外部 IO,busy_timeout 按进程模型选 5 到 15 秒,自动 checkpoint 放宽到 4000 页以上,重活放到后台 FULL/TRUNCATE。连接池必须统一 WAL。按这组约束,单文件写 QPS 上到几百时,P99 可以从数百毫秒压回二十毫秒内。