bigywater.tech
Django · 118

Django 中的并发计数问题

「读出来加一再存回去」是最经典的并发 bug。这篇在 PostgreSQL 上用 10 个线程做真正的并发计数,量出丢了多少,再对比几种正确写法的结果和耗时。

347 / 2000
get → n += 1 → save() 的最终值,丢失 1653 次
250 / 2000
外面包 atomic() 后的最终值——事务本身不解决丢失更新
2000 / 2000
F() + update()、select_for_update、save() + F() 三种写法

五种写法的结果

实测 · 本机执行输出
  get → n += 1 → save()                        最终 n =   347 / 期望 2000   丢失 1653      745 ms
  同上,外面包 atomic()                              最终 n =   250 / 期望 2000   丢失 1750      809 ms
  filter().update(n=F("n") + 1)                最终 n =  2000 / 期望 2000   丢失    0      414 ms
  atomic + select_for_update().get → +1 → save 最终 n =  2000 / 期望 2000   丢失    0      810 ms
  get → c.n = F("n") + 1 → save()              最终 n =  2000 / 期望 2000   丢失    0      850 ms

丢失更新的原因:两个线程各自读到同一个旧值 n,各自加一再写回,后写的覆盖先写的。atomic() 只保证「一组语句要么都提交要么都回滚」,PostgreSQL 的默认隔离级别 read committed 下并不会让另一个事务等你。F() 把计算放到数据库里做,select_for_update() 先把行锁住再读。

F() 赋给实例之后

实测 · 本机执行输出
  save() 之后 c.n 的类型: int  库里的值: 6
  refresh_from_db() 之后 c.n = 6

这一段的结果只在本环境(PostgreSQL、Django 6.1.1)观察到:save() 之后内存里的值已经是整数。别的后端不一定同样,稳妥的做法还是 refresh_from_db()。

耗时的解读

单条 update(F) 最快(414 ms),因为只需要一次往返;select_for_update 与 save() + F() 都要至少两次往返(810 / 850 ms)。这些数字来自 fsync=off 的本机临时 PostgreSQL,线程数与次数都很小,只用来比较相对关系。

分步演示

并发计数
本步的对象

    本文用到的工具与边界

    • 本机 macOS 27 / Python 3.14.7 / Django 6.1.1(默认用 SQLite,其它数据库会在文中注明),用 django.test.Client 或真实的本机服务器进程实测;计时用 time.perf_counter,数字每次运行会有小幅抖动。自己起的临时 PostgreSQL 16(fsync=off、synchronous_commit=off);10 线程 × 200 次;每种写法只跑了一次,丢失的具体数字每次会不同
    • 没有验证:repeatable read / serializable 隔离级别下的表现、MySQL、SQLite 的行为、批量 bulk_update 的并发、乐观锁(版本号)写法、更多线程时的耗时
    京ICP备2026067502号-1京公网安备11010502063487号