原创

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() 也需要单独核查。兼容事务控制模式下,它对挂起事务有隐式提交行为;一段包含多条语句的字符串,不应仅凭“放进了同一个方法”就被认定为原子批次。需要整体成败时,应明确事务开始与结束,并为中间失败保留可执行测试。

验收这类迁移,至少要在独立连接中观察三件事:提交以前看见什么,提交以后看见什么,第二次写入失败后留下什么。事务配置的名字只能说明意图,数据可见性和失败后的状态才是合同是否保持的证据。

正文到此结束
评论插件初始化中...
Loading...