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 判断:每次成功都要 “读一次 + 写一次 + 版本比较”,而悲观锁只有一次加锁内的自增。重试少只说明冲突低,不说明路径成本低。真实高并发环境下,重试还会随冲突率非线性上升(见模型表)。