Python 后台任务频繁 database is locked:SQLite WAL 模式排查实录
问题不是“数据库坏了”
最近维护一个 Python 采集服务时,日志里偶尔出现 sqlite3.OperationalError: database is locked。任务运行在 4 个线程里,日常写入量很小,所以第一反应是 SQLite 不适合并发。但复现后发现问题并不是“数据库坏了”,而是默认配置、事务边界和重试策略一起放大了锁等待。
服务原来的写法是:每个任务先查询远程接口,拿到结果后再写数据库;一次批次包含数百条记录,全部放在同一个事务里。接口偶尔变慢时,写事务就会持续十几秒。另一个线程此时尝试写入,只能立刻报错,因为连接没有设置合理的等待时间。
三个容易忽略的点
1. WAL 模式需要配合连接参数
PRAGMA journal_mode=WAL 能让读写并发更顺畅,但它是数据库级设置;而 busy_timeout 是连接级设置。只切换 WAL 并不能解决所有锁冲突。每个新连接都应该设置超时、同步模式和需要的约束。
import sqlite3
DB_PATH = "tasks.db"
def connect():
conn = sqlite3.connect(
DB_PATH,
timeout=5,
isolation_level=None,
check_same_thread=True,
)
conn.execute("PRAGMA journal_mode=WAL")
conn.execute("PRAGMA synchronous=NORMAL")
conn.execute("PRAGMA busy_timeout=5000")
conn.execute("PRAGMA foreign_keys=ON")
return conn
这里把 isolation_level 设为 None,后续由业务代码明确控制事务。这样不会因为忘记提交,让连接长期占用写锁。
2. 不要在事务里做网络请求
把“请求远程接口”和“写数据库”拆开,是最有效的改造。先把网络结果放入内存队列,再开启短事务批量写入。写事务只包含 SQL,不包含 sleep、HTTP 调用和文件上传。
def save_batch(conn, rows):
with conn:
conn.execute("BEGIN IMMEDIATE")
conn.executemany(
"INSERT INTO task_result(batch_id, payload) VALUES (?, ?)",
rows,
)
BEGIN IMMEDIATE 会在事务开始时尝试取得写预留锁。多个写入者因此排队,而不是先做一堆计算,最后提交时才发现锁被占用。配合 busy_timeout,短时间的竞争会等待,而不是马上失败。
3. 不要盲目增加线程数
SQLite 适合单机、低运维成本的工作负载,但同一时刻仍只有一个写入者。把线程从 4 个提高到 32 个,只会让更多请求争抢同一把锁。更稳妥的做法是“并行采集、单队列顺序写入”:采集线程可以并发,数据库写入由一个专用 worker 消费队列。
WAL 的适用边界
WAL 适合本地磁盘上的读多写少场景,不适合把数据库文件放到 NFS、SMB 等网络共享目录。备份时也不要只复制主文件,建议使用 SQLite 的在线备份 API,或者确保主文件、-wal、-shm 状态一致。长时间高频写入后,可以观察 WAL 文件大小,在低峰期执行一次 checkpoint。
改造后,我加了两个指标:写入事务耗时,以及 database is locked 的出现次数。压测时仍会出现排队,但请求不再直接失败;单条事务耗时也稳定在几十毫秒内。对这类小服务来说,正确的连接参数、短事务和明确的生产者消费者边界,往往比直接更换数据库更划算。
- 点赞
- 收藏
- 关注作者
评论(0)