bigywater.tech
Flask · 94

乐观锁

怎么避免两个请求同时改同一行而互相覆盖?乐观锁的 UPDATE 长什么样?冲突了怎么办?这篇在真实的 PostgreSQL 上用多线程把无锁、乐观锁、乐观锁加重试的结果摆在一起。

25 → 160
8 线程各加 20 次(期望 160):无锁与乐观锁加重试的最终值
727 次
乐观锁加重试时的冲突与重试次数
412
带过期的 If-Match 更新时的状态码

丢失更新

实测 · 本机执行输出
  期望 n = 20,实际库里 n = 10
  ORM 发出的 UPDATE: UPDATE plain SET n=%(n)s::INTEGER WHERE plain.id = %(plain_id)s::INTEGER

上一篇:expire_on_commit;下一篇:悲观锁。

version_id_col

实测 · 本机执行输出
  两个会话读到的版本号: 1 1
  先提交的会话发出的 UPDATE: UPDATE versioned SET n=%(n)s::INTEGER, ver=%(ver)s::INTEGER WHERE versioned.id = %(versioned_id)s::INTEGER AND versioned.ver = %(versioned_ver)s::INTEGER
  后提交的会话 → StaleDataError: UPDATE statement on table 'versioned' expected to update 1 row(s); 0 were matched.
  库里 n = 10 | ver = 2

并发下的三种做法

实测 · 本机执行输出
  没有锁(Plain)                       期望 160,最终 n =  25 | 冲突/重试   0 次 | 76 ms
  乐观锁,冲突就放弃(Versioned)             期望 160,最终 n =  25 | 冲突/重试 135 次 | 85 ms
  乐观锁 + 重试(Versioned)              期望 160,最终 n = 160 | 冲突/重试 727 次 | 502 ms

线程里每个都用自己的 Session,sleep(1 ms) 放大读改写之间的窗口。

ETag 与 If-Match

实测 · 本机执行输出
  GET → ETag: "1"
  第一个客户端带 If-Match 更新 → 200
  第二个客户端用同一个旧 ETag 更新 → 412 {'error': '版本已变化'}
  重新 GET 得到的新 ETag: "2"

分步演示

乐观锁
本步的对象

    本文用到的工具与边界

    • 本机 macOS 27 arm64 / Python 3.14.7 / Flask 3.1.3 / Werkzeug 3.1.9 / Jinja2 3.1.6 / SQLAlchemy 2.1.1 / gunicorn 26.2.0(都装在自己的虚拟环境里);大多数实验用 Flask 的 test_client(直接调用 WSGI 应用,不经过网络),需要真实 HTTP 时另起 gunicorn 或开发服务器(端口 9600 以上,本机 127.0.0.1);计时用 time.perf_counter,数字每次运行会有小幅抖动。Flask-SQLAlchemy 3.1.1、psycopg 3.3.6、自己起的 PostgreSQL(非默认端口)
    • 没有验证:version_id_generator 自定义版本号(如时间戳或 UUID)、批量 UPDATE 语句(core update())不经过 version_id_col 的情况、SERIALIZABLE 隔离级别下的另一种解法、多行聚合冲突
    京ICP备2026067502号-1京公网安备11010502063487号