SQLite WAL 写争用:busy_timeout 与 checkpoint 阈值实测
SQLite 的 WAL 只解决读写互斥,写与写仍要抢锁。8 并发写压测显示:busy_timeout=5000ms、wal_autocheckpoint=4000 页可把写失败率从 63.5% 压到 0.24%,p99 约 0.96 秒;继续加大只会拉长排队。
- busy_timeout 取 5000ms 是甜点,再大只增加尾延迟
- wal_autocheckpoint 从 1000 页提到 4000 页,WAL 峰值约 16MB
- 写事务一律 BEGIN IMMEDIATE,绕开 BUSY_SNAPSHOT
结论先行:单机 8 个并发写者的场景,把 busy_timeout 设为 5000ms、wal_autocheckpoint 设为 4000 页(16MB),写失败率从 63.5% 降到 0.24%,p99 约 0.96 秒。这是起点,不是万能值。
先说人话。SQLite 的预写日志(WAL,Write-Ahead Logging)像一本流水账:写操作不直接改主账本,而是先追加到 app.db-wal 这本流水账的末尾,读的人继续读旧的快照;过一段时间再把流水账合并回主账本,这个合并动作叫检查点(Checkpoint)。问题在于,流水账只允许一个人动笔——多个写者同时来,就得排队抢那把写锁(Write Lock)。busy_timeout 决定你愿意排多久,checkpoint 阈值决定流水账多久合并一次、合并时停顿多长。这两件事凑在一起,才决定了并发写到底顺不顺。
为什么 WAL 模式允许多读,写却还是要排队?#
WAL 换掉的是回滚日志(Rollback Journal)时代的「读写互斥」。在 WAL 下,读者拿到一个快照(Snapshot)就不受写者影响,写者也不需要等待读者退出。但写者之间依然是严格串行的:要改数据,必须先拿到 WAL_WRITE_LOCK,追加帧(Frame),更新 wal-index 头里的 mxFrame,然后释放。任何时刻只有一个连接持有它。
所以并发写失败的根源不是「磁盘慢」,而是「等锁」。等锁的结果有三种,处理方式完全不同:
对比表:SQLITE_BUSY 的几种来源与应对
| 结果码 | 触发条件 | busy handler 是否生效 | 正确处理 |
|---|---|---|---|
| SQLITE_BUSY(5) | 另一个写者正持锁,或 checkpoint 正在恢复 WAL | 生效,按 busy_timeout 轮询重试 | 等待即可,超时后回滚重试 |
| SQLITE_BUSY_SNAPSHOT(517) | 连接先开了读事务,再试图升级为写事务,期间 WAL 已前进 | 不生效 | 回滚整个事务、重新 BEGIN IMMEDIATE 再跑一遍 |
| SQLITE_BUSY_TIMEOUT(773) | SQLite 3.44.0 起,忙等超时单独返回扩展码 | 已经是超时结果 | 计入失败指标,不要与普通 BUSY 混在一起 |
| SQLITE_LOCKED(6) | 同一连接内的表锁冲突,共享缓存模式 | 不生效 | 检查是否共用连接句柄 |
第二行是大多数人踩的坑。SQLite 官方文档在 SQLite Result Codes 里写得很清楚:这种情况下忙等处理器不会被调用,因为等待解决不了问题——快照已经过期了。你唯一能做的是重跑。
规避手段只有一个:写事务一律用 BEGIN IMMEDIATE 显式开场,进来就把写锁拿住,不要用默认的 BEGIN DEFERRED 先读后写。我在 8 写者 + 1 个 60 秒长读者的混合负载上对比过:DEFERRED 模式下约 1 成事务死在 517 上,而代码里配了 5 秒 busy_timeout 依然立刻失败,日志里看着像「超时没生效」,实际是错误码根本不同。改成 IMMEDIATE 后这类失败归零,代价是事务开启时就占用写锁,事务粒度必须小。
顺带一句:写事务里别夹外部 HTTP 调用,这是最容易被忽略的锁持有时间放大器。后面第三节的 curl 示例会给出超时预算的算法。
busy_timeout 设多少合适?8 写者实测数据#
先交代环境,免得数字被误用:4 vCPU / 8GB 内存 / 本地 NVMe + ext4,Linux 6.8,SQLite 3.46.1,page_size=4096,cache_size=-64000(即 64MB),journal_mode=WAL,synchronous=NORMAL。表结构是一张配额表 quota(k TEXT UNIQUE, n INTEGER, ts INTEGER),8 个进程各做 5000 次单行更新,每次一个事务,共 40000 次提交尝试。全部使用 BEGIN IMMEDIATE。
对比表:busy_timeout 与写成功率的实测关系(8 写者,40000 次提交)
| busy_timeout | 成功提交 | 失败率 | 吞吐(tps) | p50 | p99 |
|---|---|---|---|---|---|
| 0(默认) | 14588 | 63.5% | 1176 | 0.3ms | — |
| 200ms | 36140 | 9.6% | 3941 | 1.1ms | 208ms |
| 1000ms | 38880 | 2.8% | 4320 | 1.4ms | 462ms |
| 5000ms | 39904 | 0.24% | 4509 | 1.6ms | 962ms |
| 15000ms | 39979 | 0.05% | 4478 | 1.6ms | 3910ms |
读法:0 到 5000ms 这一段,吞吐涨了 3.8 倍,失败率掉了两个数量级,收益实打实。5000ms 到 15000ms 这一段,失败率只从 0.24% 降到 0.05%,吞吐还略降,但 p99 从 0.96 秒涨到 3.9 秒。换句话说,多出来的等待时间没有换来更多成功,只是把失败改成了慢请求。对同步 API 来说,一个卡 4 秒的请求比一个快速失败的请求更难受,它会占着 PHP-FPM 或 Gunicorn 的工作进程,把上游一起拖下水。
我的取值建议:busy_timeout 设成「你的接口 SLA 减去外部依赖耗时」后再留一半余量。接口 2 秒超时、外部调用占 200ms,那 busy_timeout 给 5000ms 已经超预算,应该给 1000~2000ms 并配合应用层重试。别把数据库排队时间当成可以无限透支的额度。
一个真实踩过的坑:PDO 的 ATTR_TIMEOUT 单位是秒
PHP 8.3 的 PDO_SQLITE 会把 PDO::ATTR_TIMEOUT 按秒换算成 sqlite3_busy_timeout,而且不设时默认给到 60 秒。有人照着文档写 PDO::ATTR_TIMEOUT => 5000,本意是 5 秒,实际变成 5000 秒——请求会挂在那儿等 83 分钟。我的做法是:连接参数里显式写秒,连接后再用 PRAGMA 覆盖一次毫秒,两处对齐。
可执行脚本:PHP PDO 连接与带退避的写重试
<?php
// 示例仅用于逻辑验证,生产环境需补充日志、监控与降级
declare(strict_types=1);
function db(): PDO {
$pdo = new PDO('sqlite:/var/lib/app/quota.db', null, null, [
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
PDO::ATTR_TIMEOUT => 5, // 单位是秒,PDO_SQLITE 默认 60
PDO::ATTR_PERSISTENT => false, // 长连接会放大锁持有时间
]);
$pdo->exec('PRAGMA journal_mode=WAL'); // 持久化到库头,只需设一次
$pdo->exec('PRAGMA synchronous=NORMAL'); // WAL 下只在 checkpoint 时 fsync
$pdo->exec('PRAGMA wal_autocheckpoint=4000'); // 单位是页,4000*4096≈16MB
$pdo->exec('PRAGMA busy_timeout=5000'); // 单位是毫秒,覆盖上面的秒级设置
return $pdo;
}
function bump(PDO $pdo, string $k, int $retries = 4): int {
$sql = 'UPDATE quota SET n = n + 1, ts = :ts WHERE k = :k';
for ($i = 0; $i <= $retries; $i++) {
try {
$pdo->exec('BEGIN IMMEDIATE'); // 先拿写锁,避免锁升级
$st = $pdo->prepare($sql);
$st->execute([':ts' => time(), ':k' => $k]);
$n = $st->rowCount();
$pdo->exec('COMMIT');
return $n;
} catch (PDOException $e) {
try { $pdo->exec('ROLLBACK'); } catch (PDOException $ignored) {}
$raw = (int)($e->errorInfo[1] ?? 0);
$base = $raw & 0xff; // 5=BUSY 6=LOCKED,517/773 取低字节后仍是 5
if (!in_array($base, [5, 6], true) || $i === $retries) {
throw $e; // 非锁冲突或重试用尽,交给上层
}
usleep(random_int(2000, 8000) * (2 ** $i)); // 2ms 起指数退避 + 抖动
}
}
return 0;
}
参数逐个说:ATTR_TIMEOUT => 5 让 PDO 在连接阶段设 5 秒忙等;PRAGMA busy_timeout=5000 是幂等覆盖,保证即使有人改回默认值也能纠正;wal_autocheckpoint=4000 对应 16MB 的 WAL 水位;ATTR_PERSISTENT => false 是因为持久连接会被复用到不确定的进程上,长事务残留的锁你排查不到。bump() 里的 $raw & 0xff 是关键——扩展结果码 517、773 低字节都是 5,直接比较 517 会漏判。
用 Python 把并发写压出来
可执行脚本:多进程写压测,统计成功/忙/尾延迟
#!/usr/bin/env python3
# 示例仅用于逻辑验证,生产环境需补充日志、监控与降级
import os, sqlite3, sys, time
from concurrent.futures import ProcessPoolExecutor
DB = "/var/lib/app/quota.db"
BUSY_MS = int(os.environ.get("BUSY_TIMEOUT_MS", "5000"))
CKPT = int(os.environ.get("WAL_AUTOCHECKPOINT", "4000"))
def connect():
conn = sqlite3.connect(DB, timeout=BUSY_MS / 1000.0, isolation_level=None)
conn.execute(f"PRAGMA busy_timeout={BUSY_MS}") # 秒与毫秒两处对齐
conn.execute("PRAGMA synchronous=NORMAL")
conn.execute(f"PRAGMA wal_autocheckpoint={CKPT}")
return conn
def worker(args):
wid, n = args
conn = connect()
ok = busy = 0
lat = []
for _ in range(n):
t0 = time.perf_counter()
try:
conn.execute("BEGIN IMMEDIATE")
conn.execute("UPDATE quota SET n=n+1, ts=? WHERE k=?", (int(time.time()), f"key-{wid}"))
conn.execute("COMMIT")
ok += 1
lat.append(time.perf_counter() - t0)
except sqlite3.OperationalError as e:
try: conn.execute("ROLLBACK")
except sqlite3.Error: pass
if "locked" in str(e) or "busy" in str(e):
busy += 1 # 计入指标,不吞掉
else:
raise # 磁盘满、库损坏等必须冒泡
lat.sort()
p99 = lat[int(len(lat) * 0.99)] if lat else 0.0
return wid, ok, busy, p99
if __name__ == "__main__":
W, N = int(sys.argv[1]), int(sys.argv[2])
t0 = time.perf_counter()
with ProcessPoolExecutor(max_workers=W) as ex:
res = list(ex.map(worker, [(i, N) for i in range(W)]))
dur = time.perf_counter() - t0
ok, busy = sum(r[1] for r in res), sum(r[2] for r in res)
print(f"writers={W} attempts={W*N} ok={ok} busy={busy} dur={dur:.2f}s tps={ok/dur:.0f}")
for r in res[:3]:
print(f"worker={r[0]} ok={r[1]} busy={r[2]} p99={r[3]*1000:.1f}ms")
运行与输出(对应上表第三行,busy_timeout=5000):
$ BUSY_TIMEOUT_MS=5000 WAL_AUTOCHECKPOINT=4000 python3 bench_wal.py 8 5000
writers=8 attempts=40000 ok=39904 busy=96 dur=8.85s tps=4509
worker=0 ok=4988 busy=12 p99=962.4ms
worker=1 ok=4991 busy=9 p99=948.7ms
worker=2 ok=4985 busy=15 p99=1004.2ms
注意 isolation_level=None:它关掉 Python 的隐式事务管理,让 BEGIN IMMEDIATE 真正生效。Python 3.12 起 sqlite3.connect 新增了 autocommit 参数,两种写法别混用,否则会出现「明明写了 BEGIN 却没开事务」的假象。另外 timeout 参数单位是秒,和 PRAGMA 的毫秒要手动对齐,这是第二个单位坑。
checkpoint 阈值调大,是优化还是埋雷?#
默认 wal_autocheckpoint=1000 页,按 4KB 页算就是 4MB 水位。WAL 累计到这个量,下一次提交时会顺带触发一次被动检查点(PASSIVE Checkpoint),把能合并的帧写回主库。阈值的取舍很直白:调小 → checkpoint 更频繁、单次更短、fsync 更多;调大 → batch 更大、fsync 更少、单次停顿更长、WAL 峰值更高、崩溃后恢复更慢。
对比表:wal_autocheckpoint 取值与 WAL 水位(单写者 40000 次提交)
| wal_autocheckpoint | WAL 峰值大小 | checkpoint 次数 | 单次均值 | 单写者提交 p99 |
|---|---|---|---|---|
| 1000(默认) | 4.1MB | 40 | 0.9ms | 3.6ms |
| 2000 | 8.2MB | 20 | 1.4ms | 3.1ms |
| 4000 | 16.3MB | 10 | 2.7ms | 3.4ms |
| 8000 | 32.6MB | 5 | 4.9ms | 5.8ms |
| 20000 | 81MB | 2 | 11.2ms | 13.1ms |
| 0(关自动,每 2000 次提交手动 PASSIVE) | 18MB | 20 | 6.4ms | 7.2ms |
单写者下这几行的差距只有毫秒级,别指望调阈值能带来数量级提升。真正拉开差距的是「读者不放手」的场景。WAL 的被动检查点只能推进到最老的活跃读者快照为止——只要有一个读事务开着不提交,后面的帧就永远合并不回去,WAL 只涨不落。
可执行命令:观察被读者挡住的 checkpoint
-- 会话 A:开一个读事务然后挂住 90 秒不提交
BEGIN;
SELECT count(*) FROM quota;
-- 此处不执行 COMMIT
-- 会话 B:查 checkpoint 推进情况
PRAGMA wal_checkpoint(PASSIVE);
-- 返回:1|1204|312
-- 三个字段依次是 busy、WAL 总帧数、已检查点帧数
-- busy=1 说明有读者挡住,1204 帧里只合并了 312 帧
这个返回我遇到过最难看的一次:一个导出任务开了游标慢慢吐数据,PASSIVE 一直返回 busy=1,WAL 从 4MB 涨到 1.2GB,磁盘告警先响。修复办法不是调阈值,而是把导出改成「分段查询 + 每段提交」,单次读事务控制在 2 秒内。另外 PRAGMA wal_checkpoint(TRUNCATE) 会等到所有读者退出,期间写者被堵住;它适合维护窗口,千万别塞进业务路径。
外部依赖的超时预算也要一起算。写库前如果还要调用一次归属地查询做富化,那 200ms 的外部耗时是叠加在锁排队之上的。用 curl 量一下真实分阶段耗时:
可执行命令:量外部接口的 TTFB,确定超时上限
$ curl -sS --max-time 2 -o /dev/null \
-w 'http=%{http_code} ttfb=%{time_starttransfer}s total=%{time_total}s\n' \
'https://www.api.plus/api/get?apikey=YOUR_KEY&ip=8.8.8.8'
http=200 ttfb=0.183s total=0.184s
--max-time 2 是整条请求的硬上限,-o /dev/null 丢掉响应体,-w 里的 time_starttransfer 就是首字节时间。单次 183ms 意味着:如果接口 SLA 是 2 秒,留给数据库排队的预算只剩 1.8 秒,busy_timeout 给 5000ms 就是自欺欺人。响应字段与参数说明见 GET 示例接口 文档,压测时只看状态码和耗时即可。
再往下压到应用层,用 curl 打自己的写接口,看降级是否生效:
可执行命令:16 并发打本地写接口,统计状态码分布
$ seq 1 200 | xargs -P 16 -I{} curl -sS --max-time 3 -o /dev/null \
-w '%{http_code} %{time_total}\n' 'http://127.0.0.1:8080/quota?k=ip-8.8.8.8' \
| awk '{c[$1]++; t+=$2; n++} END {for (k in c) printf "status=%s count=%d\n", k, c[k]; printf "avg=%.3fs\n", t/n}'
status=200 count=197
status=503 count=3
avg=0.142s
3 个 503 是服务端在 busy_timeout 用尽后主动返回的降级响应,不是 curl 自己失败。-P 16 控制并发度,--max-time 3 防止个别请求把整个循环拖死。这里有个坑:curl 的 --retry 默认只对幂等请求生效,POST 想重试必须显式加 --retry-all-errors,而写接口盲目重试会重复扣配额——重试放在应用层、带幂等键做,比交给 curl 靠谱。如果服务本身还代理了读接口,比如 随机动漫壁纸接口 这类读多写少的路径,要单独观察它在 checkpoint 高峰期的 TTFB,别把读写混在一个健康检查里。更多同类压测与排障记录整理在 API 文章中心。
监控与告警清单#
调参不能靠拍脑袋,得有指标兜底。下面这 6 条我按「能提前 10 分钟发现问题」的标准筛过:
监控清单:SQLite WAL 并发写必备指标
| 指标 | 采集方式 | 建议阈值 | 告警动作 |
|---|---|---|---|
| 写事务忙等失败率 | 应用层捕获结果码,code & 0xff == 5 计数 / 总写事务 | 5 分钟窗口 > 1% | 查长事务与写并发度,必要时降级为异步写 |
| 写事务 p99 延迟 | 应用埋点,单位毫秒 | 持续 10 分钟 > 500ms | 拉 checkpoint 日志,看是否有读者阻塞 |
| WAL 文件大小 | Linux 下 stat -c %s app.db-wal,10 秒一次 | > 256MB,或 10 分钟增长 > 64MB | 定位长读事务,维护窗口执行 TRUNCATE checkpoint |
| checkpoint 返回 busy=1 | 定时执行 PRAGMA wal_checkpoint(PASSIVE),取第一列 | 连续 3 次 busy=1 | 查 -shm 上的读锁持有者 |
| 单事务执行时长超 1 秒的占比 | 应用埋点 | > 0.5% | 拆分批量写,把外部调用移出事务 |
| 数据库所在盘可用空间 | node_exporter | < 15% | 停止非关键写入,先做 WAL 截断 |
风险清单#
风险清单:WAL 并发写常见配置陷阱
| 风险 | 影响 | 规避方式 |
|---|---|---|
| busy_timeout 用默认 0 | 并发一上来写失败率过半,业务侧表现为随机丢写 | 至少设 2000ms,配指数退避重试 |
| 写事务用 BEGIN DEFERRED 先读后写 | 返回 SQLITE_BUSY_SNAPSHOT,忙等不生效,事务直接失败 | 写路径统一 BEGIN IMMEDIATE,事务内不夹外部调用 |
| 长读事务不提交 | checkpoint 无法推进,WAL 无限增长直到撑爆磁盘 | 读事务限时,分段查询,禁止交互式长游标 |
| wal_autocheckpoint=0 且无手动兜底 | WAL 只涨不落,读路径变慢,崩溃恢复变长 | 定时 PASSIVE checkpoint,大流量后补一次 TRUNCATE |
| 数据库放在 NFS / SMB 共享盘 | -shm 跨主机协调失效,锁语义不可靠,可能损坏文件 | 只放本地盘,容器场景用 volume 绑定本地目录 |
| synchronous=OFF 换性能 | 断电或内核崩溃可能损坏数据库 | WAL 下用 NORMAL,这是安全与性能的平衡点 |
总结#
我的落地配置就这五行,放在连接建立之后立刻执行:
PRAGMA journal_mode=WAL;
PRAGMA synchronous=NORMAL;
PRAGMA busy_timeout=5000; -- 毫秒,按接口 SLA 往下调
PRAGMA wal_autocheckpoint=4000;-- 页,约 16MB 水位
PRAGMA cache_size=-64000; -- 负数单位 KB,即 64MB
配套两条纪律:写事务一律 BEGIN IMMEDIATE,事务内不做网络 IO。做到这三点,8 并发写的失败率能从 63.5% 压到 0.24% 这一档;做不到,把 busy_timeout 调到 60 秒也没用,只是把失败延迟成超时。
适用范围
- 单机、本地块设备(ext4 / XFS / NVMe)上的 SQLite,写并发在 4~16 个连接之间。
- 事务短小、单次写入行数在百行以内、没有跨表大批量更新的场景。
- 读多写少,或读写比例在 5:1 以上,且读事务能在秒级内结束。
不适用范围
- 写并发超过 32 个连接、或需要跨主机共享同一份数据库。此时该换 PostgreSQL 或 MySQL,WAL 调参救不了架构。
- 事务里必须等待用户输入、外部回调或大文件上传的场景,锁持有时间不可控。
- 放在 NFS / SMB / 部分网络块存储上的数据库文件,WAL 的共享内存协调不成立。
- 单表数据量超过几十 GB 且需要复杂分析查询,checkpoint 停顿会随主库变大而变长。
本站实测数据(近 24 小时)#
以下数据来自 API.PLUS 线上调用日志(统计时间 2026-10-30 08:07,口径:仅统计已上线接口,不含扫描与无效路径),可作为容量规划与稳定性判断的现实参考。