前几篇给 bigywater 项目挂了一组信号做文章的"双保险"文件备份,这一篇本机真实验证了一个直接影响这套备份正不正确的问题:post_save 信号是在 save() 内部同步触发的,不会等外层事务真的提交;而 transaction.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 级别,不是"整个事务要么全跑要么全不跑"这么粗糙。
回到 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 记录干干净净地消失了,但已经落盘的文件不会跟着"回滚"——它就是一个普通的文件系统写操作,不受数据库事务保护。
既然 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() 会走前面讲的"没有事务、立刻执行"那条分支——效果和现在的写法完全一样,不会有任何延迟或额外开销。
对应上面"内层 savepoint 回滚、外层提交"的真实实验,把两层事务的生命周期和各自注册的回调可视化。
数据直接对应上面真实实测的嵌套事务实验(外层提交、内层因异常回滚到 savepoint),演示动画只是把"回调注册在哪个 savepoint 集合里、那个集合有没有被回滚"这条规则可视化,最终结果(outer callback 执行、inner callback 被丢弃)和真实输出一致。