25.1 乐观并发与悲观并发

一句话

悲观并发 “先占坑再干活”(锁住再改),乐观并发 “先干活提交时验版本”(冲突就重试);选型只看一个数——冲突率,因为乐观锁的成本是 “重试”,悲观锁的成本是 “等待”。

两种机制

维度 悲观(锁) 乐观(版本/CAS)
典型写法 SELECT ... FOR UPDATE UPDATE ... WHERE version = ?
何时阻塞 读/写时就加锁 只在提交冲突时失败
冲突代价 排队等待(可能死锁) 重试(浪费已做的工作)
适合 冲突高、临界区短 冲突低、读多写少
风险 锁粒度大、长事务 重试风暴、ABA

冲突率决定选择

冲突率(同一行的并发写比例) 建议
< 10% 乐观锁:重试少,无锁开销
10%–30% 两者皆可;优先乐观 + 限次重试
> 30% 悲观锁:乐观重试会放大 CPU 与延迟

数字直觉:冲突率 50% 时,乐观锁平均要重试 2 次才成功;10 个并发写同一行时,重试次数接近线性上升——比排队更糟。

实现手段

手段 例子 说明
数据库行锁 FOR UPDATE、FOR UPDATE SKIP LOCKED SKIP LOCKED 适合 “抢任务”
版本号 CAS UPDATE t SET v=v+1 WHERE id=? AND v=? 影响行数 0 = 冲突
分布式锁 Redis SET NX PX、etcd 租约 跨进程互斥(见 48.3 分布式锁)
原子指令 compare_and_swap、atomic 单机内存级

实测:在本机 Python 下比一次(含重要局限)

8 线程 × 4 万次自增,同一计数器(CPython 3.14):

方案 耗时 计数 重试 吞吐
悲观锁 67.0ms 320,000 0 4.77M ops/s
乐观 CAS 84.1ms 320,000 7 3.81M ops/s

必须说明的局限:CPython 有 GIL,锁内操作几乎不会真正并行,所以 “冲突” 只发生了 7 次——这个实验证明了实现正确,不能用来论证 “悲观更快”。真实结论要用无 GIL 的环境测(Go/Java),或用下面的模型推算。

冲突率 p 下的期望尝试次数(模型,可自行验算):

p 期望尝试 有效吞吐(相对无冲突)
5% 1.05 95%
30% 1.43 70%
50% 2.00 50%
# 乐观锁的正确写法:限次 + 退避 + 影响行数检查
for attempt in range(3):
    row = db.get(id)                       # 读版本
    if db.exec("UPDATE t SET v=?, ver=ver+1 WHERE id=? AND ver=?", v, id, row.ver):
        return True                        # 影响行数 1 = 成功
    time.sleep(0.001 * (2 ** attempt))     # 退避后重试
raise Conflict("三次仍失败,交给上层")

反模式

反模式 后果
乐观锁失败不重试,直接报错 用户看到莫名失败
无限重试 高冲突下 CPU 打满
悲观锁包住 RPC 锁持有时间从 μs 变成 ms,吞吐崩塌
忘了 ABA 版本号被 “改回去”,CAS 误成功
用 “读-改-写” 而不加版本 并发下丢更新(lost update)

三条铁律

🔑 冲突率低用乐观,高用悲观;别用 “感觉” 选。

🔑 乐观锁必须限次重试 + 退避;否则冲突会变成雪崩。

🔑 悲观锁的临界区必须极短;里面不许有 IO/RPC。

自检

Q1 两个用户同时改同一订单,用的是 UPDATE ... WHERE version=?,会发生什么?

▸ 答案:先提交的成功(version 匹配并被 +1),后提交的影响行数为 0(版本已被改)。应用必须检查影响行数,为 0 时重新读取最新版本、重算后再提交;否则会静默丢失一次更新。

Q2 为什么 “冲突率 50% 时乐观锁更差”?

▸ 答案:因为每次失败都要重做业务计算(读、校验、写),而冲突概率高意味着平均重试次数接近 2 次以上;CPU 与延迟都被放大。悲观锁虽然排队,但只做一次工作,整体更省。

Q3 SKIP LOCKED 适合什么场景?

▸ 答案:适合 “多消费者抢任务”:每个 worker 用 SELECT ... FOR UPDATE SKIP LOCKED LIMIT 1 取一条未被锁的任务,天然避免互相等待。它不保证 FIFO,但吞吐高,常用于任务队列(数据库版)。

Q4 分布式锁与数据库行锁怎么选?

▸ 答案:能在一个库内解决就用行锁(无额外组件、事务语义完整);跨服务/跨库才用分布式锁,并配租约与 fencing(见 20.2 集中式互斥 (协调者))。分布式锁的每一次加锁都是一次网络往返,成本更高。

Q5 乐观锁的 “ABA 问题” 是什么?

▸ 答案:值从 A 改成 B 又改回 A,版本号若只比较 “值相等” 就会误判 “没变过”。解法是用单调递增的版本号(而不是值本身)做比较,这样 “改回原值” 也会让版本变化。

Q7 为什么本机实验里 “重试只有 7 次” 却仍然更慢?

▸ 答案:因为慢的不是重试,而是乐观路径的额外读取与 CAS 判断:每次成功都要 “读一次 + 写一次 + 版本比较”,而悲观锁只有一次加锁内的自增。重试少只说明冲突低,不说明路径成本低。真实高并发环境下,重试还会随冲突率非线性上升(见模型表)。

关联