bigywater.tech
分布式事务 · 18

乐观锁:版本号与 CAS

乐观锁用一个 version 做比较并交换,不加锁就能防住丢失更新。它在什么情况下好用、什么情况下白做、能不能被 ABA 欺骗、冲突严重时怎么重试?这篇用真实的 MySQL,把这几个问题的数字逐一量出来。

697 → 0
8 个线程各加 100 次:不保护丢的更新次数 → version CAS 丢的次数
1.00 → 27.57
同一行上并发线程数从 1 到 32,乐观锁每次成功平均要试的次数
208 / 0
300 次 ABA 试验里,按状态值 CAS 与按 version CAS 被过期的支付单欺骗的次数

丢失更新

实测 · 本机执行输出
  最终余额 103,丢了 697 次更新(87%)

读出后在应用里算再写回,别人的更新会被覆盖,800 次只剩 103。

version CAS

实测 · 本机执行输出
  最终余额 800(应为 800,丢 0 次);一共 5619 次尝试成功 800 次,平均每次成功试 7.02 次,p99 106 次,最多 189 次;4819 次尝试因 version 变了而更新 0 行(白做)
  用时 0.55 s(1444 成功/秒),延迟中位数 0.68 ms,p99 84.93 ms

UPDATE ... WHERE version = ?,0 行就是冲突,重读重试;结果正确,代价是白做的尝试。

冲突越多越白做

实测 · 本机执行输出
  并发线程数 | 乐观锁 成功/秒 | 乐观锁 平均每次成功试几次 | FOR UPDATE 操作/秒 | 原子 UPDATE 操作/秒
  ------+----------+---------------+-----------------+---------------
  1     | 4573     | 1.00          | 5092            | 6122          
  2     | 4162     | 1.71          | 5363            | 8485          
  4     | 3119     | 3.57          | 6359            | 7061          
  8     | 1612     | 7.03          | 6294            | 8118          
  16    | 833      | 13.94         | 5866            | 7805          
  32    | 417      | 27.57         | 5041            | 7188          
  线程越多,同一行上乐观锁的白做越多(每次成功要试的次数涨),吞吐不再增长甚至下降;悲观加锁让冲突的请求排队而不是重做

同一行线程数翻倍,每次成功要试的次数大约翻倍;悲观加锁让请求排队,吞吐稳定。

低冲突

实测 · 本机执行输出
  写法         | 操作/秒 | 平均每次成功试几次
  -----------+------+----------
  乐观锁        | 8958 | 1.00     
  FOR UPDATE | 9958 | 1.00     
  没有冲突时乐观锁一次就成功,不需要任何等待;它的价值就在“冲突少”的场景

没有冲突时乐观锁一次成功,和悲观锁同一量级。

ABA

实测 · 本机执行输出
  写法                               | 试验次数 | 发货时订单上的支付单已不是发货方看到的那张(原单已退款)的次数
  ---------------------------------+------+--------------------------------
  按状态值 CAS(WHERE status = 'PAID')  | 300  | 208                            
  按 version CAS(WHERE version = ?) | 300  | 0                              
  ABA:状态值回到了“PAID”,CAS 看不出中间发生过退款和重新支付,于是按旧的支付单发了货;version 每次改动都会增加,不会“变回去”

按状态值做 CAS 看不出 PAID → REFUNDED → PAID 中间发生过什么;version 每次改动都递增,不会变回去。

重试要退避

实测 · 本机执行输出
  重试策略        | 成功/秒 | 平均每次成功试几次 | 延迟 p99    | 白做的尝试(每轮)
  ------------+------+-----------+-----------+----------
  无退避(失败立刻重试) | 409  | 27.30     | 1484.1 ms | 168318   
  随机指数退避      | 3332 | 2.49      | 169.7 ms  | 9544     

冲突严重时无退避的重试把数据库打满,随机指数退避让总吞吐高 8 倍。

分步演示

乐观锁
本步的对象

    本文用到的工具与边界

    • 本机 macOS 27 arm64 / MySQL 26.7.0 与 PostgreSQL 16.15(Homebrew,每个实验自己 initdb、自己起实例,端口 9150 以上,只监听 127.0.0.1)/ Python 3.14.8 + PyMySQL 1.2.3 + psycopg 3.3.6 / Redis 8.10.2 / Kafka 4.3.1 / RabbitMQ 4.3.6 / etcd 3.7.2(按各篇注明用到哪些);不连任何外部服务,不碰本机已有的数据库实例;故障用 kill -9 进程、让连接断开或在代码里的指定位置抛异常来制造;计时用 time.perf_counter,数字每次运行会有小幅抖动。本篇用一个 MySQL 26.7.0 实例(默认 REPEATABLE-READ),PyMySQL,每个线程一个连接;吞吐是 2 轮取中位数(第三节)或单次(其它);第五节的“A 读到 PAID 之后、更新之前 B 做退款并重新支付”是用线程和随机延迟(0~10 ms 与 0~6 ms)造的交错,不是从真实业务抓来的
    • 没有验证:SERIALIZABLE 或 READ COMMITTED 下的差别;多行 / 多表的乐观锁(只测了单行);ORM(如 JPA 的 @Version)的行为;乐观锁与悲观锁在长事务下的差别;重试次数上限与放弃策略;退避基数 0.2 ms 是我设的,没有调参
    京ICP备2026067502号-1京公网安备11010502063487号