Python 3.12 sqlite3 autocommit:三种事务模式与迁移边界
升级 Python 以后,最危险的事务变化往往不是立即抛错,而是原来的代码仍然运行,提交边界却换了。sqlite3 从 Python 3.12 起提供 autocommit 参数,但它有三个取值,名字也很容易与 SQLite 引擎底层的自动提交状态混在一起。
不要把所有连接统一补成 autocommit=True 当作迁移。这个值会让 Python 连接对象的 commit()、rollback() 不再执行事务控制,原先依靠 rollback() 撤销一批写入的代码,语义会直接变化。
下面只讨论 Python 3.12 标准库驱动,使用临时数据库和两条连接观察提交结果,不涉及服务端数据库,也不依赖 ORM。
三个取值,对应三种控制方式
autocommit=False 选择符合 DB-API 2.0 风格的事务控制。驱动让事务保持打开:连接建立后有事务,调用 commit() 或 rollback() 结束当前事务后,又会打开下一笔。这里的“保持打开”并不意味着建立连接就持有写锁,更不意味着所有连接被串行化;SQLite 的锁行为还取决于真正执行的语句与事务模式。
autocommit=True 让驱动采用 SQLite 的自动提交模式。每条独立语句可以自行完成提交;Python 方法 commit() 和 rollback() 在这种模式下不起事务控制作用。若业务需要显式 SQL 事务,也可以使用 BEGIN、COMMIT、ROLLBACK,但应该明确由这一层完整管理,别再把两套接口混着调用。
autocommit=sqlite3.LEGACY_TRANSACTION_CONTROL 则保留 Python 3.12 之前的控制方式,由 isolation_level 影响驱动何时隐式开启事务。Python 3.12 默认仍是这个兼容值。新参数的出现,不代表所有旧连接已经自动切换到 False。
还有一个相似名字:connection.in_transaction。它报告底层连接是否处于事务中,不是 autocommit 属性的简单反义词。检查实际状态时可以观察它,配置事务策略时则应该明确设置 autocommit。
用两条连接看清提交和回滚
表结构只有一列业务说明与一个主键,完整建表语句如下:
CREATE TABLE event_log (
id INTEGER PRIMARY KEY,
note TEXT NOT NULL
);
每组实验使用独立文件。准备连接采用 autocommit=True 建好表并关闭,避免把建表事务夹进后续观察。写入连接负责演示,观察连接只读取已经提交的记录。
保存为 sqlite_commit_modes.py,使用 Python 3.12 运行:
import sqlite3
from pathlib import Path
from tempfile import TemporaryDirectory
SCHEMA = """
CREATE TABLE event_log (
id INTEGER PRIMARY KEY,
note TEXT NOT NULL
)
"""
def prepare(path):
connection = sqlite3.connect(path, autocommit=True)
try:
connection.execute(SCHEMA)
finally:
connection.close()
def committed_count(path):
observer = sqlite3.connect(path, autocommit=True)
try:
return observer.execute(
"SELECT COUNT(*) FROM event_log"
).fetchone()[0]
finally:
observer.close()
def manual_driver_mode(path):
prepare(path)
writer = sqlite3.connect(path, autocommit=False)
try:
assert writer.in_transaction
writer.execute("INSERT INTO event_log(note) VALUES (?)", ("first",))
assert committed_count(path) == 0
writer.commit()
assert writer.in_transaction
assert committed_count(path) == 1
writer.execute("INSERT INTO event_log(note) VALUES (?)", ("second",))
writer.rollback()
assert writer.in_transaction
assert committed_count(path) == 1
print("False: before commit=0, after commit=1, after rollback=1")
finally:
writer.close()
def engine_auto_mode(path):
prepare(path)
writer = sqlite3.connect(path, autocommit=True)
try:
assert not writer.in_transaction
writer.execute("INSERT INTO event_log(note) VALUES (?)", ("first",))
assert committed_count(path) == 1
writer.rollback()
assert committed_count(path) == 1
print("True: after insert=1, after rollback=1")
finally:
writer.close()
def legacy_mode(path):
prepare(path)
writer = sqlite3.connect(
path,
autocommit=sqlite3.LEGACY_TRANSACTION_CONTROL,
isolation_level="DEFERRED",
)
try:
assert not writer.in_transaction
writer.execute("SELECT COUNT(*) FROM event_log").fetchone()
assert not writer.in_transaction
writer.execute("INSERT INTO event_log(note) VALUES (?)", ("first",))
assert writer.in_transaction
assert committed_count(path) == 0
writer.rollback()
assert not writer.in_transaction
assert committed_count(path) == 0
print("legacy: SELECT opens no transaction, INSERT rolls back to 0")
finally:
writer.close()
if __name__ == "__main__":
with TemporaryDirectory() as directory:
root = Path(directory)
manual_driver_mode(root / "manual.db")
engine_auto_mode(root / "auto.db")
legacy_mode(root / "legacy.db")
执行:
python3 --version
python3 sqlite_commit_modes.py
正常情况下打印三行模式说明,断言全部通过。第一组提交后 in_transaction 仍为真,恰好体现驱动立即开下一笔事务,而不是提交失败。第二组 rollback() 没有删除记录,是因为那条独立插入已经完成,不是 SQLite 失去了回滚能力。
观察连接每次都新建、读取、关闭,避免自身长期持有一个读取快照,让实验把“看见旧快照”误当成“写入没有提交”。实际程序若复用事务连接,也要把读事务的寿命算进去。
with connection 不等于自动关闭连接
连接对象作为上下文管理器时,正常退出可以提交,异常退出可以回滚,具体效果仍受事务模式影响。它并不顺手关闭连接。
选择 autocommit=False 后,业务写入可以这样组织:
from contextlib import closing
import sqlite3
def append_pair(path, first, second):
with closing(sqlite3.connect(path, autocommit=False)) as connection:
with connection:
connection.execute(
"INSERT INTO event_log(note) VALUES (?)", (first,)
)
connection.execute(
"INSERT INTO event_log(note) VALUES (?)", (second,)
)
外层 closing 负责关连接,内层负责事务。若第二条因为 NOT NULL 约束失败,例如传入 None,异常离开内层后第一条也应回滚。不要在内层捕获异常后不再抛出,又以为上下文管理器能读懂“业务已经失败”的意图;没有异常离开,它会按照正常退出路径处理。
相同代码若把连接参数改成 True,两条插入不再因为这个 with connection 自动组合成一笔事务。第一条已经提交以后,第二条报错,不能再靠外层的 rollback() 把第一条撤销。迁移时必须验证失败后的实际数据,而不是只看错误有没有被捕获。
提交本身失败,也不是一句“已经写完”能带过
插入语句执行成功,只说明当前连接接受了这次修改。提交时仍可能遇到锁等待或其他数据库错误,因此业务成功的返回点应放在提交成功之后。若提交失败,继续复用连接前应明确它处于什么状态,不能在捕获异常后直接把同一连接交给下一批任务。
实验没有设置 WAL,也没有安排并发长事务,目的是把驱动模式的差别单独展示出来。线上程序的锁等待、读取快照和日志模式会改变并发体验,却不会让 Python 方法的合同反过来:True 模式下调用 rollback(),仍不等于驱动替你撤销已经完成的独立语句。
此外,关闭连接不是提交按钮。依赖关闭时“应该会保存”的写法,在事务控制迁移时尤其脆弱。需要保存就显式提交,需要失败回退就明确回滚;连接释放是资源动作,不能代替业务对结果的选择。
旧的 isolation_level=None 要按原意迁移
旧项目常用 isolation_level=None 关闭驱动隐式事务,再自己发送 BEGIN IMMEDIATE 等 SQL。这个参数只有在 LEGACY_TRANSACTION_CONTROL 下才控制兼容行为。连接已经显式设置 autocommit=False 时,再设置 isolation_level=None 并不会把它变回旧模式。
因此,迁移前要先找调用方的真实依赖。有的代码依赖“写操作先累积,最后统一 commit”,有的依赖“每条 SQL 独立提交”,还有的已经完全使用显式 SQL 事务。它们不能用一次全局替换得出同一份配置。
运行中修改 connection.autocommit 也有动作:改成 True 会提交挂起事务,改成 False 会开启事务。这不是一个只影响未来连接的静态标签,不宜在通用工具函数里悄悄切换。
executescript() 也需要单独核查。兼容事务控制模式下,它对挂起事务有隐式提交行为;一段包含多条语句的字符串,不应仅凭“放进了同一个方法”就被认定为原子批次。需要整体成败时,应明确事务开始与结束,并为中间失败保留可执行测试。
验收这类迁移,至少要在独立连接中观察三件事:提交以前看见什么,提交以后看见什么,第二次写入失败后留下什么。事务配置的名字只能说明意图,数据可见性和失败后的状态才是合同是否保持的证据。