SQLite WAL 读快照:别人已经提交,为什么我仍读到旧值且不能直接写入
同一份数据库,两个连接读到不同值
一个页面读取库存后停留片刻,另一个连接把数量改为二十并提交,第一个连接再次查询却仍看到十。这在保持读事务的 WAL 模式下可能是正常快照行为。更值得注意的是,第一个连接若接着写入,不一定只是等一等就能成功:它可能正拿着已经过时的快照,不能直接升级成写事务。
本例在 Python 3.12.14、SQLite 3.53.1 上执行。保存为 demo.py 后运行 python demo.py,会在本机临时目录创建独立数据库。两个连接都关闭 Python 隐式开启事务的行为,用显式 SQL 控制起止;没有线程、随机延迟或真实业务数据,因此执行顺序可以逐句核对。
先真正读取,再让另一个连接提交
代码首先确认 journal_mode 返回 wal。连接 a 执行 BEGIN 后读取十,建立本次读快照;连接 b 在没有显式事务包围的情况下更新到二十,语句结束即完成它的自动事务。a 仍保留原来的事务,再次读取依旧是十。单独执行 BEGIN 不应被理解成所有数据都已在那个时刻读进内存,示例用实际 SELECT 明确建立观察点。
AI概念示意图:读者保留旧快照,另一连接已经提交新变化,旧快照通往写入的路径被挡住。图片不是数据库运行截图。
import sqlite3
import tempfile
from pathlib import Path
with tempfile.TemporaryDirectory() as folder:
db = Path(folder) / "snapshot.db"
a = sqlite3.connect(db, isolation_level=None, timeout=0)
b = sqlite3.connect(db, isolation_level=None, timeout=0)
try:
assert a.execute("PRAGMA journal_mode=WAL").fetchone()[0] == "wal"
a.execute("CREATE TABLE stock(n INTEGER NOT NULL)")
a.execute("INSERT INTO stock VALUES(10)")
a.execute("BEGIN")
before = a.execute("SELECT n FROM stock").fetchone()[0]
assert before == 10
b.execute("UPDATE stock SET n=20")
assert not b.in_transaction
snapshot = a.execute("SELECT n FROM stock").fetchone()[0]
assert snapshot == 10
print("before:", before)
print("snapshot:", snapshot)
try:
a.execute("UPDATE stock SET n=n+1")
except sqlite3.OperationalError as exc:
assert exc.sqlite_errorcode == sqlite3.SQLITE_BUSY_SNAPSHOT
assert exc.sqlite_errorname == "SQLITE_BUSY_SNAPSHOT"
assert a.in_transaction
print("upgrade:", exc.sqlite_errorcode, exc.sqlite_errorname)
else:
raise AssertionError("old snapshot unexpectedly became writable")
a.execute("ROLLBACK")
assert not a.in_transaction
a.execute("BEGIN IMMEDIATE")
fresh = a.execute("SELECT n FROM stock").fetchone()[0]
assert fresh == 20
a.execute("UPDATE stock SET n=?", (fresh + 1,))
a.execute("COMMIT")
final = b.execute("SELECT n FROM stock").fetchone()[0]
assert final == 21
print("final:", final)
finally:
for connection in (a, b):
if connection.in_transaction:
connection.rollback()
connection.close()看错误码,不只看“database is locked”
前三项输出依次确认 before: 10、snapshot: 10 和 upgrade: 517 SQLITE_BUSY_SNAPSHOT。此处异常属于 Python 的 OperationalError,扩展错误码指出问题是旧快照升级,而不是所有数据库锁冲突都一样。失败后 a 仍处在事务中,所以代码显式 ROLLBACK 释放它,再开始新的写事务。最后输出 final: 21。
重新开始之后必须重新读取业务依赖的数据。本例读到二十再加一;若仍使用失败前缓存的十去计算,就可能把错误的业务决定带入新事务。所谓重试整个操作,既包括重新拿到数据库视图,也包括重新执行以它为依据的判断,不能只把末尾那条 UPDATE 再发一次。
为了区分数据库内容与连接视图,可以同时核对写入连接已经结束事务,以及读取连接仍在事务中这两项状态。不要一看旧值就先清应用缓存。本例每次查询都重新执行 SQL,却仍读到旧值,问题并不是上一条查询的 Python 返回对象被重复使用,而是同一读事务保留了它所看到的数据库版本。
提前开启写事务也有代价
已知事务稍后需要写入时,可以考虑 BEGIN IMMEDIATE,先取得写事务再读。它能避免这个实验中的读后升级情形,但开始时仍可能遇到其他写者占用,必须处理忙碌结果。WAL 允许读者和写者并行,并不让多个写事务同时随意提交;把网络请求或长时间交互放进写事务,会延长其他写者的等待。
本实验把 timeout 设为零,是为了立即观察错误,而不是推荐线上统一关闭等待。延长等待也不会把已有的过时快照变成最新快照。实际排查时记录事务何时开始、何时第一次读取、何时提交或回滚,再结合错误码区分原因。finally 会回滚未结束事务并关闭两个连接,确保临时数据库删除前没有遗留句柄。不要把这个 WAL 结论直接套到其他日志模式或共享缓存配置。


