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;

代价:

  1. 锁的持有时间 = 事务时间。如果事务里还夹了网络调用,锁会被持有到事务结束——这是悲观锁最大的风险;
  2. 冲突时请求排队,排队会吃掉连接池(呼应第 05 章:排队比失败更危险);
  3. 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()

三个必须注意的点:

  1. 用"影响行数"判断成功,不要用"再查一次 version 有没有变"——后者在并发下不可靠;
  2. 重试退避必须放在事务外,重试前重新读取数据(不能在事务里套着重试,那等于持锁重试);
  3. 重试次数必须有上限,超过就明确失败;无限重试会把冲突放大成雪崩。

四、乐观锁之二:条件更新(最常用,也最容易被写错)

很多场景根本不需要 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 判断"没变"从而误放行。

在数据库场景里:

记住这条:
  SELECT(快照读)+ UPDATE = 有窗口,可能错
  UPDATE ... WHERE 条件(当前读 + 原子写)= 无窗口

动手:可观察结果

产出 判断标准
三种写法对比 条件更新 / 版本号 / FOR UPDATE 在同一压测下的 QPS、冲突率、P99
一次并发超卖复现 用"先查后写"在 100 并发下卖出超过库存的数量,再用条件更新证明不再超卖
冲突率埋点 能输出 attempt / conflict 两个指标,并据此给出选型结论
重试策略验证 退避在事务外、次数有上限;压到高冲突时表现为明确失败而非雪崩
状态机幂等 WHERE status='PENDING' 的更新在重复请求下只成功一次

完成标志:给你一个并发写场景(库存/余额/状态流转),你能直接给出 SQL 写法 + 重试策略 + 监控指标,并说明为什么不用另外两种方案。

故障注入

注入方式 观察
用"先查后写"跑 100 并发扣 10 个库存 是否超卖(stock 变负)
改成条件更新再跑同样压测 成功次数是否恰好等于库存数
在事务里做重试(持锁重试) 锁等待是否激增、吞吐是否下降
重试次数设为无限 高冲突下是否出现雪崩
冲突率提升到 60% 后仍用乐观锁 QPS 与重试浪费的比例,验证阈值判断
RR 下先 SELECT 判断再 UPDATE 是否读到旧快照导致错误放行

自测题

  1. 为什么"先 SELECT 判断再 UPDATE"在并发下是错的?正确的写法是什么?
  2. 版本号 CAS 为什么用"影响行数"判断成功,而不是"再查一次 version"?
  3. 重试退避为什么要放在事务外?放在事务内会发生什么?
  4. 冲突率分别为 2%、30%、60% 时,你会怎么选方案?
  5. 条件更新能表达哪些业务约束?举出三个例子并写出 SQL。

进入 keel 阅读