KEEL · 龙骨 · A CURRICULUM FOR THE AI ERA
06 · 乐观锁与悲观锁:选型与实现 — keel 龙骨
这一章回答:扣库存到底用 UPDATE ... SET stock = stock - 1 还是版本号?冲突率多高时该换方案?
这一章回答:扣库存到底用 UPDATE ... SET stock = stock - 1 还是版本号?冲突率多高时该换方案?
先修课讲了锁的类型与排查方法,但没解决"我该用哪种锁"。这一章给出基于冲突率的选型判据和三种可直接落地的写法。
一、两种锁的本质差别
| 悲观锁 | 乐观锁 | |
|---|---|---|
| 假设 | 冲突很常见,先占住再说 | 冲突很少,先改再说,撞了重试 |
| 实现 | SELECT ... FOR UPDATE / 数据库隐式加锁 |
版本号 / 条件更新 / CAS |
| 冲突时 | 等待(阻塞) | 失败(需要重试逻辑) |
| 开销 | 锁管理 + 等待,冲突越多越慢 | 无锁开销,冲突时浪费一次计算 |
| 适合 | 冲突率高、临界区大、重试代价高 | 冲突率低、临界区小、可安全重试 |
决策的核心变量是冲突率,不是个人偏好。
二、悲观锁:写法与代价
BEGIN;
SELECT stock FROM sku_stock WHERE sku_id = 1001 FOR UPDATE; -- 加行锁
-- 应用层判断 stock >= 1
UPDATE sku_stock SET stock = stock - 1 WHERE sku_id = 1001;
COMMIT;
代价:
- 锁的持有时间 = 事务时间。如果事务里还夹了网络调用,锁会被持有到事务结束——这是悲观锁最大的风险;
- 冲突时请求排队,排队会吃掉连接池(呼应第 05 章:排队比失败更危险);
FOR UPDATE的WHERE必须走索引,否则锁范围远超预期。
使用前提(三条都满足才用):
- 事务里只有数据库操作,没有任何外部调用;
- 冲突确实频繁(否则白付锁开销);
- 业务上"等待"比"失败重试"更可接受。
三、乐观锁之一:版本号 CAS
-- 读
SELECT stock, version FROM sku_stock WHERE sku_id = 1001;
-- 写:把读到的 version 作为条件
UPDATE sku_stock
SET stock = stock - 1, version = version + 1
WHERE sku_id = 1001 AND version = 7;
-- 影响行数 = 0 → 说明期间有人改过 → 重试或失败
async def deduct(sku_id: int, n: int, retries: int = 3):
for attempt in range(retries):
row = await fetch_one("SELECT stock, version FROM sku_stock WHERE sku_id=?", sku_id)
if row["stock"] < n:
raise InsufficientStock()
affected = await execute(
"UPDATE sku_stock SET stock=stock-?, version=version+1 "
"WHERE sku_id=? AND version=?",
n, sku_id, row["version"],
)
if affected == 1:
return True
# 影响 0 行 → 重读重试,退避放在事务外
await asyncio.sleep(0.02 * (2 ** attempt))
raise ConcurrentConflict()
三个必须注意的点:
- 用"影响行数"判断成功,不要用"再查一次 version 有没有变"——后者在并发下不可靠;
- 重试退避必须放在事务外,重试前重新读取数据(不能在事务里套着重试,那等于持锁重试);
- 重试次数必须有上限,超过就明确失败;无限重试会把冲突放大成雪崩。
四、乐观锁之二:条件更新(最常用,也最容易被写错)
很多场景根本不需要 version 字段——把"业务约束"直接写进 UPDATE 的条件:
UPDATE sku_stock
SET stock = stock - 1
WHERE sku_id = 1001 AND stock >= 1;
-- 影响行数 = 1 → 扣减成功;0 → 库存不足(原子判定,无需先 SELECT)
✅ 这个写法是原子的:判定与更新在同一条语句里完成,不需要事务包裹两条语句,也不存在"查的时候够、扣的时候不够"的窗口。
❌ 常见错误写法:
# 错误:先查后写,两条语句之间有并发窗口
if (await fetch_one("SELECT stock ..."))["stock"] >= 1:
await execute("UPDATE sku_stock SET stock = stock - 1 ...") # 可能被并发扣成负数
条件更新能覆盖的场景比想象的多:
| 场景 | 条件更新写法 |
|---|---|
| 库存扣减 | WHERE stock >= n |
| 余额扣款 | WHERE balance >= amount |
| 状态流转 | WHERE status = 'PENDING'(防止重复推进) |
| 限额计数 | WHERE used < quota |
| 幂等占位 | INSERT ... ON DUPLICATE KEY UPDATE(唯一键兜底) |
判断标准:只要约束能表达为"更新前的某个条件成立",就可以用条件更新,且它总是比"SELECT 然后 UPDATE"更优。
五、如何选:冲突率阈值
冲突率 = 冲突失败次数 / 总尝试次数
| 冲突率 | 方案 | 理由 |
|---|---|---|
| < 5% | 条件更新 / 版本号(乐观) | 重试开销可忽略,省掉锁开销 |
| 5% ~ 20% | 乐观锁 + 有限重试 + 退避 | 仍可接受,但要把重试做成指标 |
| 20% ~ 50% | 悲观锁,或先削峰再乐观 | 重试已显著浪费,等待更划算 |
| > 50% | 不要在这个粒度上竞争——换设计(见第 08 章) | 锁方案解决不了,必须消除热点 |
冲突率必须来自监控,不能靠猜。埋点方式:
metrics.increment("sku.deduct.attempt")
if affected == 0:
metrics.increment("sku.deduct.conflict") # 冲突率 = conflict / attempt
有了这个指标,你才能回答"该不该换方案",而不是在评审会上争论。
六、ABA 问题与为什么它在这里通常不是问题
经典 CAS 的 ABA:值从 A 变 B 又变回 A,CAS 判断"没变"从而误放行。
在数据库场景里:
- 版本号单调递增 → 不会回退 → 天然免疫 ABA;
- 条件更新(stock >= 1) → 判断的是"条件仍成立"而非"值未变",ABA 无意义;
- 真正会出问题的是读到旧快照:在 RR 下,
UPDATE读的是当前读(最新已提交版本),所以条件更新是安全的;但如果用SELECT(快照读)判断再写,就会出错——这正是"先查后写"错误的根源。
记住这条:
SELECT(快照读)+ UPDATE = 有窗口,可能错
UPDATE ... WHERE 条件(当前读 + 原子写)= 无窗口
动手:可观察结果
| 产出 | 判断标准 |
|---|---|
| 三种写法对比 | 条件更新 / 版本号 / FOR UPDATE 在同一压测下的 QPS、冲突率、P99 |
| 一次并发超卖复现 | 用"先查后写"在 100 并发下卖出超过库存的数量,再用条件更新证明不再超卖 |
| 冲突率埋点 | 能输出 attempt / conflict 两个指标,并据此给出选型结论 |
| 重试策略验证 | 退避在事务外、次数有上限;压到高冲突时表现为明确失败而非雪崩 |
| 状态机幂等 | WHERE status='PENDING' 的更新在重复请求下只成功一次 |
完成标志:给你一个并发写场景(库存/余额/状态流转),你能直接给出 SQL 写法 + 重试策略 + 监控指标,并说明为什么不用另外两种方案。
故障注入
| 注入方式 | 观察 |
|---|---|
| 用"先查后写"跑 100 并发扣 10 个库存 | 是否超卖(stock 变负) |
| 改成条件更新再跑同样压测 | 成功次数是否恰好等于库存数 |
| 在事务里做重试(持锁重试) | 锁等待是否激增、吞吐是否下降 |
| 重试次数设为无限 | 高冲突下是否出现雪崩 |
| 冲突率提升到 60% 后仍用乐观锁 | QPS 与重试浪费的比例,验证阈值判断 |
RR 下先 SELECT 判断再 UPDATE |
是否读到旧快照导致错误放行 |
自测题
- 为什么"先 SELECT 判断再 UPDATE"在并发下是错的?正确的写法是什么?
- 版本号 CAS 为什么用"影响行数"判断成功,而不是"再查一次 version"?
- 重试退避为什么要放在事务外?放在事务内会发生什么?
- 冲突率分别为 2%、30%、60% 时,你会怎么选方案?
- 条件更新能表达哪些业务约束?举出三个例子并写出 SQL。