Python SQLite 报 database is locked:有限等待、短事务与故障测试
定时任务把结果写进 SQLite,偶尔却报 sqlite3.OperationalError: database is locked。把等待时间调大以后,错误可能减少,但任务也可能只是等得更久。
本文面向初学者到中级读者,提供一个短事务写入工具,并用两个独立进程复现锁冲突、验证失败退出码和解锁后的恢复。只操作新建的演示库,不需要安装依赖。
1. 方法:先缩短事务,再设置有限等待
SQLite 同时只允许一个写事务;第二个写入者必须等前一个提交或回滚。BEGIN IMMEDIATE 在事务开始时申请写入资格,因此能把竞争暴露在业务写入之前,见 SQLite 事务说明。它不会增加并行写入能力。
排查时先问三个问题:
- 是否有第二轮定时任务、交互终端或数据库工具还在写同一个文件?
- 是否把 HTTP 请求、模型推理或文件转换放在写事务里面?
- 失败后是否仍保留未结束的事务或长时间打开的查询?
对于可恢复的 SQLite 批处理,先在事务外准备结果,再用短事务提交一条或一小批记录。下面按 job 键更新结果;它只保证数据库中的这次更新,不保证外部工作只执行一次。
2. 准备:创建独立演示库
需要 Python 3.11 或更新版本和本地可写目录。以下示例已在 Linux、Python 3.11.4、SQLite 3.41.2 下测试。重复实验时使用新的目录名,后续命令在同一个目录执行:
python3 --version
python3 -c 'import sqlite3; print(sqlite3.sqlite_version)'
mkdir sqlite-lock-demo
cd sqlite-lock-demo
python3 - <<'PY_SETUP'
import sqlite3
from contextlib import closing
from pathlib import Path
db = Path("demo.sqlite")
if db.exists():
raise SystemExit("Use a fresh demo directory")
with closing(sqlite3.connect(db, isolation_level=None)) as con:
con.execute("CREATE TABLE results (job TEXT PRIMARY KEY, value TEXT NOT NULL)")
print(db.resolve())
PY_SETUP
第一条版本检查确认 Python 版本,第二条显示它实际链接的 SQLite 版本。演示默认使用新数据库的回滚日志模式,不启用 WAL,也不调整服务器配置。
3. 实现:准备好结果后再进入事务
把下面代码保存为当前目录的 save_result.py:
"""Save one prepared result in an existing SQLite database. Python 3.11+."""
import argparse
from contextlib import closing
import math
from pathlib import Path
import sqlite3
import sys
def wait_seconds(text):
value = float(text)
if not math.isfinite(value) or not 0 <= value <= 30:
raise argparse.ArgumentTypeError("timeout must be finite and in 0..30 seconds")
return value
def save_result(database, job, value, *, timeout=1.0):
path = Path(database).resolve(strict=True)
# mode=rw prevents accidental creation if the path disappears before opening.
with closing(sqlite3.connect(
path.as_uri() + "?mode=rw", uri=True,
timeout=timeout, isolation_level=None,
)) as con:
try:
con.execute("BEGIN IMMEDIATE")
con.execute(
"INSERT INTO results(job, value) VALUES (?, ?) "
"ON CONFLICT(job) DO UPDATE SET value=excluded.value",
(job, value),
)
con.execute("COMMIT")
except BaseException:
if con.in_transaction:
con.execute("ROLLBACK")
raise
def main():
parser = argparse.ArgumentParser(description=__doc__, allow_abbrev=False)
parser.add_argument("database", type=Path)
parser.add_argument("job")
parser.add_argument("value")
parser.add_argument("--timeout", type=wait_seconds, default=1.0)
args = parser.parse_args()
try:
save_result(args.database, args.job, args.value, timeout=args.timeout)
except (OSError, sqlite3.Error) as exc:
name = getattr(exc, "sqlite_errorname", type(exc).__name__)
print(f"ERROR: {name}: {exc}; db={args.database.resolve()}", file=sys.stderr)
return 1
print(f"OK: job={args.job}")
return 0
if __name__ == "__main__":
raise SystemExit(main())
isolation_level=None 让这里由显式 SQL 控制事务;不要把这段代码混入另一个已经开始的事务。closing() 确保连接关闭。Python 事务控制文档说明了这种用法。
timeout 单位是秒,控制遇到锁竞争时的等待;它不是整个脚本的截止时间,也不是 SQL 执行时长限制。SQLite 的 busy timeout 说明解释了等待重试的行为。这里不额外叠加无限重试,锁一直存在就返回失败。
调用时先完成耗时计算,再把准备好的结果传给 save_result()。写入成功返回 0,数据库或文件错误返回 1,命令行参数错误返回 2;--timeout 只接受 0 到 30 秒内的有限值。mode=rw 和严格路径检查避免将拼错的路径静默创建成空库。
4. 验证正常写入与重复更新
python3 save_result.py --help
python3 save_result.py demo.sqlite daily ready --timeout 1
python3 save_result.py demo.sqlite daily updated --timeout 1
python3 - <<'PY_CHECK'
import sqlite3
from contextlib import closing
with closing(sqlite3.connect("file:demo.sqlite?mode=ro", uri=True)) as con:
print(con.execute("SELECT job, value FROM results ORDER BY job").fetchall())
PY_CHECK
末尾应看到:
OK: job=daily
OK: job=daily
[('daily', 'updated')]
两次使用相同 job,只保留一条记录。SQL 参数用 ? 绑定,值里的引号或中文不需要手工拼进 SQL。
5. 复现锁冲突,验证释放与回滚
把下面代码保存为 test_lock.py。它会修改演示库,将最终的 daily 值改为 recovered;只在前面创建的演示目录运行:
"""Hold a real write lock while a separate process attempts to write."""
from contextlib import closing
from pathlib import Path
import sqlite3
import subprocess
import sys
from save_result import save_result
db = Path("demo.sqlite").resolve(strict=True)
command = [sys.executable, "save_result.py", str(db), "daily", "after-lock",
"--timeout", "0.2"]
with closing(sqlite3.connect(db, isolation_level=None)) as holder:
holder.execute("BEGIN IMMEDIATE")
try:
holder.execute("INSERT INTO results VALUES ('uncommitted', 'discard-me')")
blocked = subprocess.run(command, capture_output=True, text=True, timeout=10)
assert blocked.returncode == 1, blocked
assert "SQLITE_BUSY" in blocked.stderr, blocked.stderr
assert blocked.stdout == "", blocked.stdout
print("PASS: competing writer returned 1 with SQLITE_BUSY")
finally:
holder.execute("ROLLBACK")
released = subprocess.run(command, capture_output=True, text=True, timeout=10)
assert released.returncode == 0, released.stderr
assert released.stdout.strip() == "OK: job=daily", released.stdout
with closing(sqlite3.connect(db, isolation_level=None)) as con:
assert con.execute("SELECT value FROM results WHERE job='daily'").fetchone() == (
"after-lock",
)
assert con.execute("SELECT count(*) FROM results WHERE job='uncommitted'").fetchone() == (0,)
try:
save_result(db, "daily", None)
except sqlite3.IntegrityError:
pass
else:
raise AssertionError("NOT NULL constraint should fail")
save_result(db, "daily", "recovered")
with closing(sqlite3.connect(db)) as con:
assert con.execute("SELECT job, value FROM results").fetchall() == [("daily", "recovered")]
print("PASS: release, rollback, repeat update, and final rows verified")
运行:
python3 test_lock.py
预期输出:
PASS: competing writer returned 1 with SQLITE_BUSY
PASS: release, rollback, repeat update, and final rows verified
父进程一直持锁,直到子进程的写入失败并返回;这样不依赖两个终端碰巧同时运行。子进程应在约 0.2 秒的锁等待后失败,调度开销会影响实际耗时,不应断言精确毫秒数。实验还检查未提交记录已回滚、约束错误后的连接已释放,以及最终行值正确。
测试脚本返回 0 表示这些断言通过;其中一次被测写入故意返回 1。正式任务遇到这个失败时应保留非零退出码,不能打印成功后继续。
6. 常见问题与修复
| 现象 | 具体操作 |
|---|---|
总是 SQLITE_BUSY |
检查重叠任务和未提交事务;把网络请求、sleep 和耗时计算移到 BEGIN 之前。 |
| 增大 timeout 仍失败 | 找出长期占锁的工作;先缩短事务或将写入排队给单个写入者,再按任务时限选择等待值。 |
cannot start a transaction within a transaction |
检查外层是否已经开启事务;本文函数自行创建连接,不要直接复制其 BEGIN 到已在事务中的连接。 |
no such table: results |
确认使用第 2 节初始化的演示库;正式工具应使用自己的表结构和迁移流程。 |
| 只读或目录权限错误 | 核对报错中的绝对路径及运行账号,确保数据库和需要写入日志的父目录可写;不要直接全目录 chmod 777。 |
WAL 能改善读写并发,但仍然只有一个写入者,见 SQLite WAL 并发说明。因此本文不把切换 WAL 当作锁冲突的通用修复,也不使用 check_same_thread=False 解决跨进程竞争。
不要删除 -journal、-wal 或 -shm 文件来“解锁”。需要保存现场时,参考SQLite 备份与恢复验证,避免只复制一个正在使用的主文件。整个外部命令还需要独立运行时限时,可参考子进程超时与日志。
总结
先把耗时准备工作移出写事务,再设置有限的锁等待,并在异常时回滚、关闭连接。用真实持锁进程检查失败退出码,再验证锁释放后能写入、最终行值正确。若写入持续拥挤,就需要单写入者队列或重新评估存储方案,单纯延长 timeout 不能增加 SQLite 的写入并发能力。
- 原文作者:春江暮客
- 原文链接:https://www.bobobk.com/python-sqlite-database-locked.html
- 版权声明:本作品采用 知识共享署名-非商业性使用-禁止演绎 4.0 国际许可协议 进行许可,非商业转载请注明出处(作者,原文链接),商业转载请联系作者获得授权。