导入程序先写入批次记录,再调用公共函数写明细,最后校验失败抛出异常。代码外面明明套了 with connection,重查数据库却发现批次和明细都还在。遇到这种情况,先沿调用链检查谁执行了提交,尤其是公共函数里的第二层 with connection。

下面是可执行的缩小案例,不对应某个生产事故。适用于 Python 3.12 及以上的标准库 sqlite3;示例显式选择 autocommit=False,不依赖随版本变化的默认模式。本次在 Windows、Python 3.14.6、SQLite 3.50.4 上验证。

现象:异常传播正常,原子性仍然失效

假设一批导入有两个动作:写入 batch 标记,再写入 detail 标记。业务要求全部成功或全部撤销。公共写入函数为了“保证事务”,又包了一层连接上下文:

import sqlite3
from contextlib import closing

with closing(sqlite3.connect(":memory:", autocommit=False)) as connection:
    connection.execute("CREATE TABLE event (name TEXT PRIMARY KEY)")
    connection.commit()
    try:
        with connection:
            connection.execute("INSERT INTO event VALUES (?)", ("batch",))
            with connection:
                connection.execute("INSERT INTO event VALUES (?)", ("detail",))
            raise RuntimeError("批次校验失败")
    except RuntimeError as exc:
        print(str(exc))
    print(connection.execute("SELECT name FROM event ORDER BY name").fetchall())

运行结果是“批次校验失败”,随后输出 [('batch',), ('detail',)]。这不是异常被吃掉:外层确实收到异常并执行了回滚,只是原来的两条写入已经被内层正常退出时提交了。

根因:连接上下文没有嵌套层级

with connection 的退出动作针对连接当前的事务,不会因 Python 缩进自动创建保存点。内层正常退出时提交的是连接事务,其中也包含外层先前写入的 batch。这与 Django 的嵌套 atomic() 语义不同,不能直接照搬使用经验。

在本例的 autocommit=False 模式中,提交后驱动会重新开启事务。因此,只看 connection.in_transaction 一直为真,不能证明最初的事务还没有提交。用 autocommit=True 替换也不是修复:此时连接上下文不会替你提交或回滚,写入的原子性需要另行管理。连接上下文的行为见 Python sqlite3 官方说明。

排查时应把事务边界与业务动作放在同一条时间线上:入口开始批次、写主记录、调用公共函数、公共函数退出、入口失败。检查公共函数是否调用 commit()、rollback()、另开连接或使用连接上下文。不要在生产请求中直接记录 SQL 全文或参数;它们可能含业务敏感数据。

修复:由业务入口统一拥有事务

这个场景要求整批原子性,公共函数只执行参数化语句,让异常向入口传播即可。不需要为每个函数都添加事务管理器。下面的完整文件同时包含故障实现、修复实现和八项测试。保存为 test_transaction_owner.py,执行 python -m unittest -v test_transaction_owner。

import sqlite3
import unittest
from collections.abc import Callable


def write_detail_broken(connection: sqlite3.Connection, name: str) -> None:
    """保留故障实现,用于验证提前提交现象。"""
    with connection:
        connection.execute("INSERT INTO event VALUES (?)", (name,))


def write_detail(connection: sqlite3.Connection, name: str) -> None:
    """加入调用方事务;提交和回滚由业务入口负责。"""
    connection.execute("INSERT INTO event VALUES (?)", (name,))


def import_batch(
    connection: sqlite3.Connection,
    writer: Callable[[sqlite3.Connection, str], None],
    *,
    should_fail: bool = False,
    detail_name: str = "detail",
) -> None:
    """演示批次入口;调用前不应存在其他待提交的业务工作。"""
    with connection:
        connection.execute("INSERT INTO event VALUES (?)", ("batch",))
        writer(connection, detail_name)
        if should_fail:
            raise RuntimeError("批次校验失败")


class TransactionOwnerTests(unittest.TestCase):
    def setUp(self) -> None:
        self.connection = sqlite3.connect(":memory:", autocommit=False)
        self.addCleanup(self.connection.close)
        self.connection.execute("CREATE TABLE event (name TEXT PRIMARY KEY)")
        self.connection.commit()

    def names(self) -> list[str]:
        return [
            row[0]
            for row in self.connection.execute("SELECT name FROM event ORDER BY name")
        ]

    def test_broken_writer_commits_before_outer_failure(self) -> None:
        with self.assertRaisesRegex(RuntimeError, "批次校验失败"):
            import_batch(self.connection, write_detail_broken, should_fail=True)
        self.assertEqual(self.names(), ["batch", "detail"])

    def test_outer_failure_rolls_back_every_write(self) -> None:
        with self.assertRaisesRegex(RuntimeError, "批次校验失败"):
            import_batch(self.connection, write_detail, should_fail=True)
        self.assertEqual(self.names(), [])

    def test_success_survives_later_rollback(self) -> None:
        import_batch(self.connection, write_detail)
        self.connection.rollback()
        self.assertEqual(self.names(), ["batch", "detail"])

    def test_duplicate_detail_rolls_back_batch(self) -> None:
        self.connection.execute("INSERT INTO event VALUES (?)", ("detail",))
        self.connection.commit()
        with self.assertRaises(sqlite3.IntegrityError):
            import_batch(self.connection, write_detail)
        self.assertEqual(self.names(), ["detail"])

    def test_writer_does_not_commit_callers_work(self) -> None:
        self.connection.execute("INSERT INTO event VALUES (?)", ("before",))
        write_detail(self.connection, "detail")
        self.connection.rollback()
        self.assertEqual(self.names(), [])

    def test_value_is_bound_as_data(self) -> None:
        name = "x'); DROP TABLE event; --"
        import_batch(self.connection, write_detail, detail_name=name)
        self.assertEqual(self.names(), ["batch", name])

    def test_released_savepoint_still_rolls_back_with_parent(self) -> None:
        with self.assertRaisesRegex(RuntimeError, "批次校验失败"):
            with self.connection:
                self.connection.execute("INSERT INTO event VALUES (?)", ("batch",))
                self.connection.execute("SAVEPOINT optional_detail")
                write_detail(self.connection, "detail")
                self.connection.execute("RELEASE SAVEPOINT optional_detail")
                raise RuntimeError("批次校验失败")
        self.assertEqual(self.names(), [])

    def test_local_rollback_preserves_parent_work(self) -> None:
        with self.connection:
            self.connection.execute("INSERT INTO event VALUES (?)", ("batch",))
            self.connection.execute("SAVEPOINT optional_detail")
            write_detail(self.connection, "detail")
            self.connection.execute("ROLLBACK TO SAVEPOINT optional_detail")
            self.connection.execute("RELEASE SAVEPOINT optional_detail")
        self.assertEqual(self.names(), ["batch"])


if __name__ == "__main__":
    unittest.main(verbosity=2)

这里让 writer 可替换,是为了用相同入口对比两种事务行为。测试不仅检查成功写入,也检查入口失败时是否一条不留、唯一约束错误是否传播、公共函数是否擅自提交调用方的数据。SQL 参数化不负责业务格式校验;生产入口仍应按实际字段规则校验名称的类型、长度和允许值。

closing() 与事务上下文也承担不同职责:前者关闭连接,后者处理事务。本测试使用 addCleanup() 关闭每条独立连接,避免把资源释放误认为提交策略。

如果业务确实需要局部回滚

例如可选明细失败后允许保留主记录,可以在已经存在的外层事务内建立 SAVEPOINT,按业务约定执行 ROLLBACK TO,再 RELEASE。上面最后两项测试分别证明:释放内层保存点后,外层仍能撤销全部;撤回到保存点后,外层早先的写入仍可提交。

ROLLBACK TO 后保存点还在;RELEASE 用于结束该保存点。若保存点本身就是最外层事务,释放它会提交,因此不能省略对外层事务是否存在的判断。保存点规则见 SQLite 官方文档。示例名字是固定的内部标识符,不接受用户输入;业务值仍通过占位符绑定。生产封装还要处理嵌套命名、释放失败及异常链,不应把这两段测试直接当成通用事务库。

验证结果与上线检查

本次执行上面的八项测试全部通过。随后把修复函数临时改回嵌套 with connection,外层失败全撤销、公共函数不提交上游工作两项回归断言失败;两项保存点测试也因提前提交清除了保存点而报错。恢复后八项再次通过。这证明测试能识别原来的提交缺陷及其对保存点的影响。复现段的残留两行也已实际运行确认。

验证使用独立的内存数据库,检查的是事务逻辑;没有验证生产文件持久化、多进程锁竞争、断电恢复、ORM 或真实导入服务。环境没有 Ruff,未运行格式化和静态检查,也未为文章安装依赖。

上线前先梳理所有写入入口。移除公共函数里的提交行为后,独立调用它的脚本也必须显式拥有事务,否则可能在关闭连接时丢弃尚未提交的工作。不要只改公共函数而漏掉调用方。请求结束前应保持“成功提交、失败回滚”的单一出口,并增加与实际业务表结构对应的失败注入用例。

如果旧代码已留下部分批次,先暂停有问题的入口,以业务批次号核对实际明细与处理状态,再制定补偿方案。不要仅因遇到这类问题就批量删除记录。回退时也应整体回退入口与公共函数的事务约定,避免新旧提交策略混用。

总结

with connection 能处理连接当前事务的退出,却不代表可随意嵌套。整批成功或失败的事务应由业务入口统一管理,底层函数只执行语句;需要局部撤销时再引入明确的保存点。回归测试要在中间步骤之后主动制造失败,并检查最终数据,才能发现“异常抛出了,但数据已经提交”的问题。