用户登录

技术文章

外部链接将在新标签页打开。

SQLite WAL 写争用:busy_timeout 与 checkpoint 阈值实测

一句话回答

SQLite 的 WAL 只解决读写互斥,写与写仍要抢锁。8 并发写压测显示:busy_timeout=5000ms、wal_autocheckpoint=4000 页可把写失败率从 63.5% 压到 0.24%,p99 约 0.96 秒;继续加大只会拉长排队。

核心要点 TL;DR
  • 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)p50p99
0(默认)1458863.5%11760.3ms—
200ms361409.6%39411.1ms208ms
1000ms388802.8%43201.4ms462ms
5000ms399040.24%45091.6ms962ms
15000ms399790.05%44781.6ms3910ms

读法: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_autocheckpointWAL 峰值大小checkpoint 次数单次均值单写者提交 p99
1000(默认)4.1MB400.9ms3.6ms
20008.2MB201.4ms3.1ms
400016.3MB102.7ms3.4ms
800032.6MB54.9ms5.8ms
2000081MB211.2ms13.1ms
0(关自动,每 2000 次提交手动 PASSIVE)18MB206.4ms7.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,口径:仅统计已上线接口,不含扫描与无效路径),可作为容量规划与稳定性判断的现实参考。

常见问题(FAQ)

busy_timeout 设成 15000 毫秒能彻底消灭 SQLITE_BUSY 吗?
不能。忙等只把立即失败换成排队,超过上限照样返回 SQLITE_BUSY;而且先读后写的锁升级会直接返回 SQLITE_BUSY_SNAPSHOT,忙等处理器根本不会被调用。设长只是把失败转成慢请求。
wal_autocheckpoint 调大有什么代价?
WAL 文件变大、崩溃恢复变慢、单次 checkpoint 停顿变长,磁盘占用随之上升。收益是减少 fsync 次数、降低写抖动。单机多写者建议 2000~4000 页,超过 8000 页要配合监控。
WAL 模式的 SQLite 能放在 NFS 或 SMB 共享盘上吗?
不能跨主机用。WAL 依赖同目录的 -shm 共享内存文件做读者与写者的协调,SQLite 要求打开同一数据库的所有进程位于同一台主机。放到网络盘上锁语义失效,可能损坏数据库。

作者:沐小悠

本文约 29 分钟读完 · 关键词:SQLite WAL、busy_timeout、wal_checkpoint 阈值、SQLITE_BUSY、并发写优化

更多文章 GET 示例接口