最稳的Django乐观锁方案是用F表达式配合整数version字段:更新时filter(id=obj.id, version=obj.version).update(..., version=F('version')+1),返回1成功,0需重试或报错。

用 django.db.models.F 和版本字段做乐观锁最稳
Django 本身不内置“乐观锁”概念,但靠 F 表达式 + 显式版本字段(比如 version 或 updated_at)能可靠实现。核心逻辑是:更新时带上当前版本条件,数据库只在版本匹配时才执行更新,并返回影响行数 —— 如果为 0,说明已被别人改过。
常见错误是直接读出对象、改完再 save(),这本质是覆盖写,毫无并发保护。也有人试图用 select_for_update(),那是悲观锁,会阻塞,和乐观锁目标相悖。
- 必须在模型里加一个可递增的字段,推荐
models.IntegerField(default=0),叫version - 更新时不用
obj.save(),改用Model.objects.filter(id=obj.id, version=obj.version).update(..., version=F('version') + 1) - 检查
update()返回值:等于 1 才算更新成功;等于 0 就得重试或报错 - 别用
DateTimeField(auto_now=True)做版本依据 —— 多次快速更新可能时间戳相同,导致漏判冲突
用 updated_at 做乐观锁?小心精度和时区陷阱
有人图省事想复用 updated_at 字段,但实际容易翻车。PostgreSQL 的 timestamp without time zone 默认精度是微秒,而 Django 的 auto_now 在某些数据库驱动下可能只到秒级;MySQL 5.6 及更早版本甚至不支持毫秒精度。
更麻烦的是时区:如果 updated_at 是 DateTimeField 且 settings.USE_TZ = True,那么 Python 层拿到的时间是 timezone-aware,但数据库存储可能被转换,比对时容易因时区换算偏差导致条件失效。
- 若坚持用时间戳,务必用
models.DateTimeField()+ 手动赋值(如timezone.now()),避开auto_now - 更新语句中用
F('updated_at')配合update(),但要确认数据库实际存储精度 ≥ 应用层操作间隔 - 本地开发用 SQLite 时尤其注意:它不存时区,也不支持微秒,
updated_at做乐观锁基本不可靠
update_or_create 不能替代乐观锁
update_or_create() 看似能“按条件更新”,但它底层是先 get() 再 save() 或 create(),全程没原子性保障,两个请求同时进来,完全可能都查到旧数据、都走更新分支,最终后写者覆盖前写者 —— 这正是乐观锁要防的事。
它适合“根据唯一键确保存在”,比如同步第三方 ID,但绝不适合“我读到的是 A 版本,我就要基于 A 版本改”。真正需要并发安全的场景,必须把判断和更新压进一条 SQL 的 WHERE 条件里。
-
update_or_create()的defaults参数不参与 WHERE 判断,只用于 SET - 即使加上
defaults={'version': F('version') + 1},也没法保证 WHERE 中的version是你读出来的那个值 - 真要简化逻辑,可以封装一个
safe_update()方法,内部调用filter().update()并检查返回值
重试逻辑别写死在视图里
乐观锁失败后最常见的做法是在视图里循环重试,比如 while 循环读-改-更新,但这样会把数据库压力和超时风险直接暴露给 HTTP 请求,一次卡顿可能拖垮整个接口。
更合理的方式是把冲突当作业务异常抛出,在上层统一处理:比如返回 409 Conflict 让前端刷新再试,或者进任务队列异步重试。硬编码重试 3 次看似简单,但掩盖了真实问题 —— 是用户操作太慢?还是业务流程本身就不该高并发修改同一记录?
- 别在
save()方法里自动重试 —— 模型层不该感知并发策略 - 重试时记得重新读取最新数据,否则拿缓存里的旧
version再试,还是失败 - 日志里记下
affected_rows == 0的 case,这是发现热点数据的信号


















