3.2 乐观锁:并发下的版本号

库存表压测时发现一个现象:available >= qty 的条件更新在并发 50 时还是会出现零星失败,原因是 MySQL 行锁的选中区间在复合条件下加锁范围偏大,并发请求互相阻塞然后超时。吞吐到了瓶颈之后,我们引入了第二道机制:版本号。

场景:悲观锁的代价与乐观锁的余地

库存扣减最初的并发控制是 for update 悲观行锁。压测数据很尴尬:并发 10 时 QPS 1500,并发 50 时 QPS 反而下降到 800。行锁竞争导致上下文切换,CPU 大量耗在锁等待上。乐观锁的思路反过来:先不加锁,更新时检查别人是否改过,冲突就重试。

订单场景适合乐观锁的理由是「冲突率低」:多用户同时秒杀同一个 SKU 是少数,大多数时候版本号是连续递增的单线程更新。冲突率低于 10% 时乐观锁的吞吐显著优于悲观锁。

方案:版本号与重试策略

乐观锁的标准实现是多一个 version 字段:

元素 实现 失败处理
版本号 version 字段递增 冲突重试
防超卖 available >= qty 条件 不足直接失败
重试上限 3 次 转为异步或限流
退避策略 指数退避 避免活锁

关键设计是重试次数有上限,且重试不是简单循环:每次重试前重新查库存,判断是否真的还有货。重试 3 次仍冲突说明并发已到极限,此时返回「当前排队人数较多」比让用户无脑等更合适。

代码:更新与重试骨架

public boolean deductWithRetry(long skuId, int qty) {
    int version = stockDao.getVersion(skuId);
    for (int i = 0; i < MAX_RETRY; i++) {
        String sql = "UPDATE t_stock SET frozen = frozen - ?, version = version + 1 "
                     + "WHERE sku_id = ? AND version = ? AND frozen >= ?";
        if (jdbcTemplate.update(sql, qty, skuId, version, qty) == 1) {
            return true;
        }
        version = stockDao.getVersion(skuId);
        sleep(backoff(i));
    }
    return false;
}

注意 version 必须带在 where 里,这是乐观锁生效的本质:两个并发请求拿到同一个 version,只有一个能更新成功,另一个影响行数为 0,自知失败后重试。另外 version 的自增不应影响可售数的扣减逻辑,两个字段语义独立。

踩坑:乐观锁的三个坑

第一个是 ABA 问题。虽然说订单场景冲突率低,但 ABA 真实发生过:释放库存、冻结库存、再释放库存,版本号绕回原值,某检测逻辑把旧数据当新数据。严格而言 version 单调递增就能避免 ABA,因为每次操作都加 1 不会绕回。真正出问题的是用「状态字段比对」代替版本号的写法,状态可以绕回。结论:版本号必须只增不减。

第二个是重试放大效应。上游超时重试叠加乐观锁重试,一个用户请求被放大成多次库存更新。理由是上游已经成功了的重试也来更新库存,在秒杀场景这种放大很危险。我们的处理方式是上游重试走幂等键返回,根本不进入扣减逻辑。幂等层在乐观锁层之上,顺序不能颠倒,这一点在 高并发下单防护 的分层讨论里也有强调。

第三个是乐观锁不适合极端并发场景。压测表明并发 500 以上时乐观锁的重试率超过 30%,吞吐反而低于悲观锁。真实秒杀场景我们用「Redis 预减库存 + 异步队列落库」的方案,把瞬并发从数据库挪走。Redis 预减是纯内存操作,原子性由 Lua 脚本保证,这个思路在 秒杀实战 和 云数据库 Redis 实践 中都有展开。

进阶实战:两种锁的混合使用

真实系统里悲观锁与乐观锁不是二选一,而是按路径混用。下单扣库存用乐观锁(冲突率低),而关单回滚与库存修正这类强一致性路径用悲观行锁(不怕等,怕错)。混用的纪律是外围标注,调用方明确知道自己走的是哪条路径,因为这决定了失败时的语义:乐观锁失败是重试或返回库存不足,悲观锁失败是直接报错。

链路 锁选型 失败语义
下单冻结 乐观锁 重试 3 次
支付扣减 乐观锁 冲突转补偿
关单释放 悲观行锁 报错上抛
库存修正 悲观行锁 人工确认后执行

一个有意思的压测结论:把乐观锁的重试间隔从「立即重试」改成「指数退避」后,重试成功率从 62% 提到 91%。立即重试让冲突的请求在下一毫秒继续撞同一个版本号,退避给冲突留出疏散时间。这个小改动零成本,但只有压测看得出来。

另外提醒:乐观锁的 version 字段与业务字段一起更新时,MyBatis 的动态 SQL 容易漏掉 version = version + 1,写了 where version = ? 却没更新 version。我们用了一个笨办法强制防护——单测断言更新 SQL 里 version 出现两次(一次 where 一次 set)。规则简单,但再没漏过。

速查清单

乐观锁的四条护栏:

护栏 说明
版本号只增不减 防状态绕回
重试上限 3 次 超限走限流或异步
指数退避 避免撞同一版本号
幂等层在上 重试不进入扣减逻辑

小结

乐观锁与悲观锁不是信仰之争,是冲突率的函数:冲突率低用乐观,冲突率高用队列缓冲。版本号只增不减、重试有上限、幂等层在上面,这三条是乐观锁不翻车的护栏。下一篇讲每日对账,账上的问题最后都靠它兜出来。

常见误解:锁策略的三个陷阱

第一个陷阱是「乐观锁适用一切」。乐观锁适合写冲突少的场景,如果热点行冲突率超过三成,重试成本就超过锁等待成本。秒杀场景我们最终走的是「Redis 预减库存 + 异步队列落库」,数据库压力直接归零。工具没有鄙视链,合适最重要。

场景 推荐策略
日常下单(冲突低) 乐观锁
秒杀爆款(冲突高) 预减 + 异步
强一致修正 悲观行锁
只读统计 MVCC 快照读

第二个陷阱是「重试无脑循环」。重试是会放大的,三个并发请求互相冲突,各自重试三次,冲突总数是九次。指数退避让后续重试错开,这是必须的。另外重试时要重新查询当前库存,避免拿着过期的版本号空转。

第三个陷阱是「忘了 MVCC 快照读的存在」。统计类查询走快照读不加锁,很多同学以为查库存会阻塞写入,其实 InnoDB 的多版本并发读让读写互不阻塞。把统计查询移到从库或用快照读之后,库存接口高峰期的延迟降了一半。理解 MVCC 的快照语义,很多「看起来需要加锁」的查询其实不用。

团队协作:锁实践的评审清单

锁相关的代码是高风险代码,我们为它单独立了评审清单,比普通 PR 多走一道把关。清单里的问题都很具体:锁的粒度是什么、失败语义是什么、是否有重试、重试会不会放大、事务边界在哪里。看似啰嗦,但是每一条背后都有真实的故障。团队对锁代码的敬畏,是这些故障换来的。

评审问题 回答模板
锁粒度 用户级或商品级
失败语义 重试或拒绝,二选一
重试上限 明确数字,默认 3 次
事务边界 无远程调用
兜底机制 唯一索引或审计脚本

新成员入职时会被要求画出库存扣减的完整时序图,包括锁的获取与释放、事务的开始与提交、失败的所有分支。这个练习能迅速暴露「以为懂了其实没懂」的地方。团队对锁的共同理解越深,代码评审时争论越少,效率反而越高。这张时序图后来被新人评为「入职第一个月最有用的练习」,因为它把分散的并发知识串成了一张网。

延伸阅读