bigywater.tech
Django · 31

F() 表达式实现并发安全计数

F() 表达式被说成「并发安全的计数」。到底安全在哪?不用它会丢多少?这篇在真实的 PostgreSQL 上用 8 个线程同时加 1600 次,把丢失的更新数出来。

335 / 1600
读-改-写:8 线程 × 200 次 +1 的最终值(PostgreSQL)
1600 / 1600
update(n=F("n") + 1) 的最终值
RETURNING
对象上 c.n = F("n") + 1; c.save() 在 PostgreSQL 上用 RETURNING 取回新值

两种写法,两种 SQL

实测 · 本机执行输出
读出来再 +1 再 save():
   UPDATE "lab_counter" SET "name" = 'c', "n" = 1 WHERE "lab_counter"."id" = 1
update(n=F("n") + 1):
   UPDATE "lab_counter" SET "n" = ("lab_counter"."n" + 1) WHERE "lab_counter"."id" = 1
对象上 c.n = F("n") + 1; c.save():
   UPDATE "lab_counter" SET "name" = 'c', "n" = ("lab_counter"."n" + 1) WHERE "lab_counter"."id" = 1 RETURNING "lab_counter"."n"
save 之后 c.n 的类型: int  → refresh_from_db() 之后: 3

F() 的本质是把「加一」写进 SQL 里由数据库执行,而不是先读出值再写回具体数值。数据库在执行 UPDATE … SET n = n + 1 时对这行加锁,并发的更新会排队,每次都基于最新值。

8 个线程实测

实测 · 本机执行输出
读-改-写(c.n + 1; save)         最终 n =   335(期望 1600),丢失  1265 次,用时 0.63 s,线程错误 0
update(n=F("n") + 1)         最终 n =  1600(期望 1600),丢失     0 次,用时 0.33 s,线程错误 0

用 threading.Barrier 让 8 个线程同时起跑。读-改-写丢了绝大多数更新——每个线程读到旧值,各自写回,互相覆盖。

每轮都丢

实测 · 本机执行输出
每轮丢失: [1260, 1263, 1273, 1263, 1260]

丢失数量在 5 轮里几乎一样,不是偶发。(丢失的具体数字和机器速度、线程数有关,别当作通用值。)

F() 的边界

实测 · 本机执行输出
update() 返回受影响行数 = 1
更新不存在的行,返回 = 0
库里现在的值 = 5
n = n*2 + n → 15

update() 返回的是受影响行数,不是新值。想要新值:PostgreSQL 上对象 save() 用了 RETURNING(本次实测),其它数据库没有测;通用做法是 refresh_from_db()。F() 只解决「在数据库里做运算」,不解决「基于旧值做判断」(比如「库存够才扣」需要 select_for_update 或带条件的 update,见第 46 篇)。

分步演示:丢失的更新

F() 并发计数
本步的对象

    本文用到的工具与边界

    • 本机 macOS 27 / Python 3.14.7 / Django 6.1.1(默认用 SQLite,其它数据库会在文中注明),用 django.test.Client 或真实的本机服务器进程实测;计时用 time.perf_counter,数字每次运行会有小幅抖动。PostgreSQL 16.15(本机临时实例,端口 9401,fsync 关闭),默认 READ COMMITTED,8 个线程各自独立连接
    • 没有验证:SQLite / MySQL 上的表现、REPEATABLE READ / SERIALIZABLE 隔离级别下的行为、线程数与迭代数改变时丢失比例的变化、F() 与 select_for_update 组合、save() 的 RETURNING 在其它数据库上的行为
    京ICP备2026067502号-1京公网安备11010502063487号