SQLite WAL 高并发写入:busy_timeout 与 checkpoint 调优实战

嵌入式数据库不是玩具。一旦 QPS 从几十涨到几百,database is locked 会成批出现,P99 从 8ms 跳到 300ms。根因通常不是 SQL 写得差,而是 WAL 模式下 checkpoint 抢写锁、busy_timeout 设得太短、以及读事务拖住 WAL 文件无法截断。

本文按生产排查顺序讲:先确认是锁等待还是 checkpoint 风暴,再给出可落地的参数组合和错误写法对照。

先搞清楚 WAL 在锁什么

读多写少时为什么还会卡住

WAL 把写入追加到 -wal 文件,读请求走快照,写请求不再直接改主库页。听起来读写互不阻塞,但有两个例外:

  1. checkpoint 要把 WAL 页刷回主库时,需要短暂持有写锁。
  2. 长事务一直拿着旧快照,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 字节):

  1. autocheckpoint=1000,P99 写入 42ms,WAL 稳定在 6MB。
  2. autocheckpoint=8000,P99 写入 18ms,WAL 峰值 28MB。
  3. 关闭自动 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 可以从数百毫秒压回二十毫秒内。

相关推荐
从零数据分析20 小时前
零基础学SQL 23:和上月比总对不上?LAG/LEAD 一步取出上一次(附 4 个模板)
java·数据库·sql·mysql·oracle·sqlite·database
ycydynq1 天前
django利用不涉及数据库form自定义表单 表单元素验证
数据库·django·sqlite
Web3&Basketball2 天前
TorchTPU 迁移实战:3 个 CUDA 卡点
pytorch·性能调优·cuda·tpu·模型迁移·torchtpu
专注仿真2 天前
从「能跑通」到「能交付」:一套可直接落地的电商用户行为分析平台(Python + SQLite/MySQL + Streamlit 全栈源码与部署文档)
python·mysql·sqlite
用户0510122572962 天前
SQLite 学习博客(一):从零建库,理解 SQLite 的角色与第一张表
数据库·sqlite
姜鱼问生3 天前
Kuma SQLite 备份坑
数据库·sqlite
橙光成长4 天前
Context Mode 深度解析:用沙盒、SQLite 与钩子路由,把 AI 编码 Agent 的上下文消耗削减 98%
数据库·人工智能·sqlite
阿星_4 天前
Linux Ubuntu 查看 SQLite 工具
linux·ubuntu·sqlite
jiushidt5 天前
在 DBeaver 中为 SQLite 启用 SpatiaLite
数据库·sqlite