bigywater.tech
Django 系列 · 第八篇

post_save 信号里干的事,可能比事务提交还早发生——本机真实用 bigywater 自己的数据证明了这件事

前几篇给 bigywater 项目挂了一组信号做文章的"双保险"文件备份,这一篇本机真实验证了一个直接影响这套备份正不正确的问题:post_save 信号是在 save() 内部同步触发的,不会等外层事务真的提交;而 transaction.on_commit() 才是真正"等提交了再执行"的机制,并且它自己也有一个容易被忽略的分支——没有事务的时候,注册进去的回调会立刻执行。

立刻执行
本机真实实测:没有 atomic() 包裹时调用 on_commit(),回调不是被推迟,而是当场就跑了
文件还在,记录没了
本机真实实测:bigywater 自己的 Post 在事务里创建又回滚,数据库记录消失,但 signals.py 写的备份文件真实留在了磁盘上

真实实测:on_commit() 没有事务时,立刻执行

on_commit() 这个名字容易让人以为它"总是会推迟执行",真实源码不是这么写的。

django/db/backends/base/base.py · django @ 6.0.7, L727 def on_commit(self, func, robust=False): if self.in_atomic_block: # Transaction in progress; save for execution on commit. self.run_on_commit.append((set(self.savepoint_ids), func, robust)) elif not self.get_autocommit(): raise TransactionManagementError(...) else: # No transaction in progress and in autocommit mode; execute # immediately. func()
真实实测
log = []
transaction.on_commit(lambda: log.append('callback ran'))   # 前面没有任何 atomic() 包裹
print(log)
['callback ran']

没有活跃事务(默认的自动提交模式下),on_commit() 判断出"根本没有可以等待的提交时刻",于是直接原地调用 func()——跟直接写 func() 没有任何区别。只有真的处在 atomic() 块里,回调才会被存进 connection.run_on_commit 列表,推迟到之后执行:

真实实测
log = []
with transaction.atomic():
    transaction.on_commit(lambda: log.append('callback ran'))
    print('atomic 块内部:', log)
print('atomic 块退出后:', log)
atomic 块内部: [] atomic 块退出后: ['callback ran']

真实实测:事务回滚,回调根本不会跑

这才是 on_commit() 真正的价值——如果事务最终没有提交,注册进去的回调会被直接丢弃,一次都不会执行。

真实实测
log = []
try:
    with transaction.atomic():
        transaction.on_commit(lambda: log.append('callback ran'))
        raise ValueError('boom')
except ValueError:
    pass
print('回滚之后:', log)
回滚之后: []

嵌套的 atomic() 更细一层:内层用 savepoint 实现,如果内层的异常被捕获、只回滚到 savepoint,外层事务照样能提交——但内层里注册的 on_commit() 回调,会跟着那个被回滚掉的 savepoint 一起被清除,不会因为外层最终提交了就"捡回来":

django/db/backends/base/base.py · django @ 6.0.7, L404 def savepoint_rollback(self, sid): ... self._savepoint_rollback(sid) # Remove any callbacks registered while this savepoint was active. self.run_on_commit = [ (sids, func, robust) for (sids, func, robust) in self.run_on_commit if sid not in sids # 回调注册时记录的 savepoint 集合里,只要含有被回滚的这个 sid,就被过滤掉 ]
真实实测
log = []
with transaction.atomic():                  # 外层
    transaction.on_commit(lambda: log.append('outer callback'))
    try:
        with transaction.atomic():          # 内层,用 savepoint
            transaction.on_commit(lambda: log.append('inner callback'))
            raise ValueError('inner boom')
    except ValueError:
        pass                                  # 只回滚到 savepoint,外层继续
print('外层退出后:', log)
外层退出后: ['outer callback']

inner callback 干干净净地消失了,outer callback 正常执行——跟内层那次失败的操作被回滚的范围完全对应,粒度精确到 savepoint 级别,不是"整个事务要么全跑要么全不跑"这么粗糙。

真实实测:post_save 信号不等事务提交,直接同步执行

回到 Signal 那篇讲过的源码——Model.save_base() 里的 post_save.send() 是普通的函数调用,不是注册进 on_commit()。用 bigywater 项目自己真实的 Post 模型和真实的 article/signals.py(会在保存时把文章内容写成磁盘文件)直接验证:

真实实测
try:
    with transaction.atomic():
        p = Post.objects.create(title='事务测试', url='zz-txn-demo.html', series=s,
                                 date='2026-09-25', description='d',
                                 content_html='<html><head></head><body>x</body></html>')
        print('事务还没提交,文件已经被 signals.py 写到磁盘了吗:', os.path.exists(path))
        raise RuntimeError('模拟这次事务后面失败了,要整体回滚')
except RuntimeError:
    pass

print('数据库里这条 Post 还在吗:', Post.objects.filter(url='zz-txn-demo.html').exists())
print('磁盘上的文件还在吗:', os.path.exists(path))
事务还没提交,文件已经被 signals.py 写到磁盘了吗: True 数据库里这条 Post 还在吗: False 磁盘上的文件还在吗: True

三行输出连起来就是一个真实的数据不一致:Post.objects.create() 一执行完,post_save 就已经同步触发了 article/signals.py 里的 write_post_file(),文件被真实写到了磁盘上——这时候外层事务连提交都还没发生。等到 RuntimeError 真的把这次事务整体回滚,数据库里那条 Post 记录干干净净地消失了,但已经落盘的文件不会跟着"回滚"——它就是一个普通的文件系统写操作,不受数据库事务保护。

这不是 article/signals.py 写错了,而是选择 post_save 这个时机本身就带着这个假设上的风险:只要调用 Post.save() 的地方有可能处在一个之后会失败回滚的事务里(比如以后 admin 里加一个"批量导入"的 action,中途某一篇校验失败要整体回滚),文件备份就会比数据库记录更"乐观"——数据库如实回到了没保存过的状态,文件却已经真实存在了。

怎么修:把有副作用的操作挪进 on_commit() 里

既然 on_commit() 天生就是"等这次数据库改动真的成功了再做",把文件写入包一层就行,不需要改信号的连接方式。

真实实测
from django.db import transaction

def _export_post(sender, instance, **kwargs):
    old_url = getattr(instance, '_old_url', None)
    def do_backup():
        if old_url and old_url != instance.url:
            backup.delete_post_file(old_url)
        backup.write_post_file(instance)
        backup.rebuild_manifest()
    transaction.on_commit(do_backup)   # 唯一的改动:把原来直接执行的三行,包进 on_commit()

# 重新跑一遍同样的实验
try:
    with transaction.atomic():
        p = Post.objects.create(title='事务测试2', url='zz-txn-demo2.html', ...)
        print('事务还没提交,文件写了吗:', os.path.exists(path2))
        raise RuntimeError('模拟回滚')
except RuntimeError:
    pass
print('回滚之后,文件写了吗:', os.path.exists(path2))
事务还没提交,文件写了吗: False 回滚之后,文件写了吗: False

包上 on_commit() 之后,文件写入被推迟到事务真正提交的那一刻才发生;这次事务从头到尾都没提交,回调根本没有机会执行,磁盘上什么都不会多出来——跟数据库的状态第一次真正保持一致。副作用是:如果外层调用方压根没用 atomic()(比如普通的 admin 表单保存,Django 默认每次 save() 都在自动提交模式下,单条语句提交即成功),on_commit() 会走前面讲的"没有事务、立刻执行"那条分支——效果和现在的写法完全一样,不会有任何延迟或额外开销。

演示:嵌套事务下,on_commit 回调谁被丢弃、谁被保留

对应上面"内层 savepoint 回滚、外层提交"的真实实验,把两层事务的生命周期和各自注册的回调可视化。

嵌套 atomic() · on_commit 回调演示

数据直接对应上面真实实测的嵌套事务实验(外层提交、内层因异常回滚到 savepoint),演示动画只是把"回调注册在哪个 savepoint 集合里、那个集合有没有被回滚"这条规则可视化,最终结果(outer callback 执行、inner callback 被丢弃)和真实输出一致。

参考与说明

  • 本文全部真实实验(on_commit() 无事务时立即执行、atomic() 内推迟执行、回滚后回调被丢弃、嵌套事务 savepoint 级别的回调隔离、post_save 信号同步执行导致的文件/数据库不一致、用 on_commit() 修复后的效果)均在本机真实运行的 Django 6.0.7 + bigywater 项目自己的 Post 模型和真实 SQLite 数据库上完成(用于验证的测试文章保存后已从数据库和磁盘清理干净),数据未做删改。
  • 源码引用(django/db/transaction.py 的 on_commit/Atomic、django/db/backends/base/base.py 的 BaseDatabaseWrapper.on_commit/savepoint_rollback/run_and_clear_commit_hooks)取自本机通过 pip 安装的 Django 6.0.7 包本身,并逐字节比对确认与 django/django 仓库 6.0.7 标签完全一致。
  • 演示动画的嵌套事务模型(savepoint 归属、回调集合过滤)由 Python 独立实现并交叉验证过——参数直接取自真实实测的嵌套 atomic() 实验结果,最终状态核对过与真实输出一致。
  • 没有涉及:durable=True 参数禁止事务嵌套的场景、robust=True 时 on_commit() 回调抛异常会被记日志而不是继续往外抛、TestCase 用 captureOnCommitCallbacks() 在测试里手动触发回调的机制、跨数据库(using= 参数)时多个连接各自独立的事务状态。
☕ 如果这篇文章帮到你,可以请作者喝杯咖啡 · 爱发电
京ICP备2026067502号-1京公网安备11010502063487号