select_for_update()不能直接套在get()外面,因为get()会先执行无锁查询检查存在性,再执行带锁查询,中间存在竞态窗口;且若对象不存在,get()抛出异常时锁已失效,破坏原子性。

为什么 select_for_update() 不能直接套在 get() 外面就完事
很多人一看到并发更新问题,第一反应是加 select_for_update(),然后写成 MyModel.objects.select_for_update().get(id=123)。这看起来很自然,但实际会触发 PostgreSQL(或 MySQL InnoDB)的“快照读 + 行锁分离”机制:Django 先执行一次无锁 SELECT 去查主键是否存在,再执行带 FOR UPDATE 的查询——中间存在竞态窗口。更糟的是,如果对象不存在,get() 抛出 DoesNotExist,但锁早已失效,后续逻辑可能误判状态。
正确做法是把查询和锁绑定在同一事务中,且必须用 filter() + select_for_update() + first() 或显式 exists() 判断:
from django.db import transaction
<p>with transaction.atomic():
obj = MyModel.objects.select_for_update().filter(id=123).first()
if obj is None:
raise ValueError("not found")
obj.counter += 1
obj.save()
- 必须用
filter().first(),不是get();get()在锁获取前就尝试查数据,破坏原子性 - MySQL 用户注意:默认
READ COMMITTED隔离级别下,select_for_update()只锁匹配行,不锁间隙;如需防插入(防止幻读),得配合select_for_update(of=('id',))或升级到REPEATABLE READ - PostgreSQL 用户注意:它默认支持间隙锁模拟,但若 WHERE 条件未命中索引(比如用函数包裹字段),会退化为全表扫描锁,极易引发死锁
死锁不是运气差,而是锁顺序不一致
两个请求同时更新用户余额和订单状态,一个先锁用户再锁订单,另一个反过来——这就是典型死锁。Django 不会自动帮你统一锁序,靠代码逻辑保证。
解决的关键是「所有涉及多模型更新的路径,必须按固定字段顺序获取锁」。例如约定:总是先锁 User(按 id 升序),再锁 Order(按 created_at 升序):
立即学习“Python免费学习笔记(深入)”;
user_ids = [u1.id, u2.id]
ordered_users = User.objects.select_for_update().filter(id__in=user_ids).order_by('id')
# 然后处理 orders,也必须 order_by('id') 或其他确定字段
- 避免在同一个事务里用不同顺序调用
select_for_update():比如 A 事务先查Order.objects.filter(user_id=1).select_for_update(),B 事务先查User.objects.filter(id=1).select_for_update(),就埋了雷 - 用
select_for_update(nowait=True)主动失败比等死锁好:捕获DatabaseError(PostgreSQL 是OperationalError,MySQL 是DatabaseError),然后重试或降级 - 别在循环里逐个
select_for_update():比如 for item in items: item.refresh_from_db() → 这会产生 N 次锁请求,顺序难控,应改用单次filter(id__in=ids).select_for_update().order_by('id')
脏读没消失,只是被你忽略的 transaction.atomic() 范围骗了
你写了 @transaction.atomic,但视图里混着缓存读、信号触发、异步任务——这些都在事务外执行。比如用户刚提交付款,信号里发消息通知风控,而风控服务去查数据库时,看到的仍是旧余额,因为 Django 默认事务隔离级别不保证跨服务可见性。
真正防脏读,要分三层看:
- 同一事务内:用
refresh_from_db()强制重读,别信实例属性缓存。尤其在save()后又需要最新值时,必须刷新 - 同数据库会话间:确保所有读操作也在
atomic块内,或者明确用select_for_update()(它隐式开启读已提交语义) - 跨服务/进程:不能只靠数据库隔离。该加 Redis 分布式锁的地方加,该用最终一致性补偿的地方做补偿。例如余额变更后发 Kafka 消息,下游监听并更新本地缓存
一个常见陷阱:Model.save(update_fields=[...]) 不会触发 post_save 信号里的 refresh_from_db(),导致信号处理拿到过期数据。
测试死锁不能靠手动点两次提交
本地开发环境并发低,手动刷新很难复现死锁。必须用可编程方式压测:
- 用
concurrent.futures.ThreadPoolExecutor启 10+ 线程,每个线程执行相同更新逻辑,观察是否抛出DatabaseError(MySQL)或OperationalError(PostgreSQL)且args[0]含"deadlock" - Django 测试用例里禁用自动事务(
transaction=False),自己控制atomic块,再用patch模拟延迟,人为制造竞争窗口 - PostgreSQL 查死锁日志:开
log_lock_waits = on,配合pg_stat_activity查waiting = true的会话;MySQL 看SHOW ENGINE INNODB STATUS里的 LATEST DETECTED DEADLOCK
最常被漏掉的是:没在测试里验证锁释放时机。事务一退出,锁就释放——哪怕你 save() 成功了,但后续还有耗时计算或 HTTP 调用,此时别人已经能读到新值,却还没完成业务闭环。这种“伪成功”才是线上最难 debug 的脏读来源。


















