有一类 bug 在测试环境几乎撞不上,上线后却反复出现:库存变成负数、同一个用户产生两条重复订单、计数器的值总比实际少几次。翻日志一切正常,每条 SQL 都执行成功,数据库也没报错。
这不是代码写错了,而是写入顺序里留了一个竞态窗口:两个请求几乎同时读到同一个值,各自算完再写回去,后写的那个把前一个的结果覆盖掉了。这篇把这类问题拆成几个典型场景,给出三层修法,并说清每一层什么时候不够用。
一、先认清问题:竞态藏在读、算、写之间
Django 官方文档在讲 F 表达式时把这件事写得很直白:两个线程并发执行「取出字段值 → 在 Python 里加一 → 保存」,第二个线程保存的值基于它自己读到的原始值,第一个线程的工作就丢了。
拿库存扣减举例,两个请求交错执行的过程可以画成这样:
| 时刻 | 请求 A | 请求 B | 数据库里的库存 |
|---|---|---|---|
| t1 | 读到库存 1 | — | 1 |
| t2 | — | 读到库存 1 | 1 |
| t3 | 判断 1 > 0 通过 | — | 1 |
| t4 | — | 判断 1 > 0 通过 | 1 |
| t5 | 写入库存 0 | — | 0 |
| t6 | — | 写入库存 0 | 0 |
两个请求都通过了「库存够」的判断,都认为自己扣减成功,实际只扣掉一次。单条 SQL 全都没问题,出错的是两条 SQL 之间的空隙。
所以先立三条约束,它们决定了后面怎么打:
- 不能只靠加事务:事务保证的是原子性,全部成功或者全部回滚,它并不阻止两个并发事务互相覆盖。
- 不能只靠测试兜底:竞态窗口常常只有毫秒级,单进程本地调试基本撞不上,正确性得写进代码结构里。
- 不能靠全局串行:把接口锁成单线程,正确性有了,并发能力也没了。冲突的处理范围要尽可能小。
二、第一层:能交给数据库算的,就别取回 Python
只要更新动作能用 SQL 表达——自增、自减、乘系数——就不要把它拆成「读出来、算一遍、写回去」。
from django.db.models import F
# 判断条件和扣减动作合进同一条 UPDATE
updated = Product.objects.filter(pk=pid, stock__gte=qty).update(
stock=F("stock") - qty,
)
if updated == 0:
raise SoldOut("库存不足") # 一行都没更新,说明条件不成立
为什么这么写:filter(stock__gte=qty) 和 update(stock=F("stock") - qty) 最终是一条 UPDATE ... WHERE stock >= ?,判断和扣减在同一句里完成,中间不存在能被别人插进来的窗口。返回值是受影响行数,0 行就等价于「库存不足」,不需要再查一次。
两个边角要留意。F() 赋给实例字段之后,那个字段上留的是表达式而不是数值,要取真实值必须先 refresh_from_db()。另外 F() 赋值在每次 save() 时都会重新生效,官方文档给的例子是同一实例连存两次,计数会被加两次。
这一层的边界也很清楚:如果「能不能扣」不是单纯的数值比较,而是要看多张表、多个条件,SQL 表达不了,就得进第二层。
三、第二层:把判断搬进事务,并锁住那一行
业务判断写不进 SQL 时,正确姿势是在同一个事务里锁住目标行,重新读,再判断、再写。
from django.db import transaction
with transaction.atomic():
# 读的瞬间就锁住这一行,别的事务要改它得等本事务结束
product = Product.objects.select_for_update().get(pk=pid)
if product.stock < qty:
raise SoldOut("库存不足")
product.stock -= qty
product.save(update_fields=["stock"])
select_for_update() 生成 SELECT ... FOR UPDATE,锁一直持到事务提交,所以这一次读拿到的是不会被别人改动的值。它有几条边界,踩过才知道疼:
| 后端 | 是否生效 | nowait / skip_locked |
|---|---|---|
| PostgreSQL、Oracle、MySQL | 生效 | PG 与 Oracle 全支持;MySQL 支持 nowait、skip_locked、of |
| SQLite | 不生效 | 不支持,传参会抛 NotSupportedError |
- 必须在事务里。在自动提交模式下求值会抛
TransactionManagementError,官方给的理由是此时行并没有真正被锁住,放过去等于允许数据损坏。 - SQLite 上是静默降级。官方文档明确写着:在不支持
SELECT ... FOR UPDATE的后端上,select_for_update()不会生效,FOR UPDATE不会加进查询,而且在自动提交模式下也不会报错。也就是说本地用 SQLite 开发时这行代码看起来一切正常,实际一行都没锁上。 - 锁的范围跟着索引走。MySQL 官方文档写得很直接:如果语句没有合适的索引、必须扫全表,表里每一行都会被锁住,反过来阻塞其他所有写入。所以 WHERE 条件必须落在索引上。
- 持锁事务里别做外部调用。锁从查询求值一直持到事务提交,事务里发邮件、调第三方接口、跑长循环,都会把锁按在那里几百毫秒甚至几秒,并发一上来就是连接池被打满。
- 关联表会一起被锁。
select_related()配合加锁默认会把关联表的行也锁上,多表加锁顺序不一致就是死锁。要么用of=("self",)把锁范围收回来(只有 PostgreSQL 支持),要么统一按主键顺序加锁。
四、第三层:乐观锁与唯一约束兜底
悲观锁解决的是「我改的时候别人别碰」。还有一类冲突是「两个人同时在改同一条记录,谁都不该静默覆盖谁」,这种情况用乐观锁更轻。
# 版本号一起放进 WHERE:只有版本没变才更新
rows = Order.objects.filter(pk=oid, version=old_version).update(
status="paid",
version=F("version") + 1,
)
if rows == 0:
raise ConcurrentUpdate("订单已被其他操作修改,请刷新后重试")
注意不要写成「先 get 出来比一下 version 再 save」。Python 里的比较和随后的保存之间同样有窗口,等于没加锁;版本比对必须和更新放进同一条 SQL,靠受影响行数判断是否冲突。版本字段用整数最省心,别复用精度不保证的时间戳。
再往下还有一道兜底:唯一约束。重复提交、并发注册这类问题,最终要靠数据库的唯一索引拦住,而不是靠应用层先查一遍「有没有」。Django 文档对 get_or_create() 的说明是:它「在正确使用、正确配置数据库、数据库行为正确的前提下才是原子的」,并且如果查询用到的字段没有在数据库层强制唯一,这个方法就有竞态风险,可能同时插入多行相同记录。换句话说,get_or_create() 的原子性不是白送的,前提是那条唯一约束真实存在。用 MySQL 时官方还额外提醒:隔离级别建议用 READ COMMITTED 而不是默认的 REPEATABLE READ,否则可能出现抛了 IntegrityError、随后 get 却又找不到这条记录的情况。
三层怎么选,按冲突的形态对号入座:
| 冲突形态 | 推荐做法 | 主要代价 |
|---|---|---|
| 同一行做算术:计数器、库存、余额 | 原子 UPDATE 配合 F | 承载不了业务判断 |
| 要基于当前状态做业务判断 | 事务内 select_for_update | 阻塞,要求走索引、事务够短 |
| 并发编辑同一条记录,希望显式报冲突 | 版本号乐观锁,冲突交给用户重试 | 需要重试路径或提示 |
| 防止重复创建、重复提交 | 数据库唯一约束,捕获 IntegrityError | 前提是先有约束 |
五、几个更隐蔽的坑
坑一:加了 transaction.atomic() 还是丢更新。atomic 保证的是原子性——要么全成功要么全回滚,它不排他。两个事务各读各写,「覆盖」这件事它管不着。修法是按上面的三层对号入座,而不是给事务再套一层。
坑二:在 atomic 里 try / except IntegrityError,后面的查询抛 TransactionManagementError。这是官方明写的一条纪律——避免在 atomic 内部捕获异常。一旦发生 DatabaseError 这类错误,事务就已经坏了,Django 会在块结束时回滚,此时再执行查询就会报错。正确做法是把捕获放在 atomic 外面,或者给可能失败的那一段单独套一层内层 atomic 形成保存点。
坑三:线上抛 select_for_update cannot be used outside of a transaction,本地测试却是全绿的。原因是 TestCase 会把每个测试包在事务里,于是它在测试中「碰巧」能跑通。要测这类逻辑得用 TransactionTestCase,否则测了等于没测。
坑四:并发一高,接口大面积超时。根因通常是锁的范围远超预期——WHERE 没走索引导致全表加锁,或者事务里混进了外部调用。把 WHERE 条件对齐到索引上,事务里只留数据库操作,副作用用 transaction.on_commit() 推到提交之后再做。
结尾
并发正确性的麻烦在于它不出错的时候完全看不出来:每条 SQL 都成功,日志干净,测试全绿。所以它没法靠「出问题再修」,只能靠写代码时把顺序摆对——能交给数据库算的算术就别取回 Python,需要业务判断就用最短的事务锁住那一行,需要显式暴露冲突就用版本号,最后用唯一约束把兜底交还给数据库。
如果你身边有人正在查「偶发的数据对不上」,可以把这篇转给他。
参考文章
- Django 官方文档:查询表达式(F 表达式与避免竞争条件)
- Django 官方文档:QuerySet API 参考(select_for_update 与 get_or_create 的原子性)
- Django 官方文档:数据库事务(atomic、避免在 atomic 内部捕获异常、autocommit)
- Django 官方文档:模型实例参考(save 的 update_fields)
- PostgreSQL 官方文档:事务隔离(Read Committed 下 UPDATE 的等待与条件重评估)
- MySQL 官方文档:InnoDB 中不同 SQL 语句设置的锁(无索引时全表加锁)