数据库

乐观锁的两种流派:version 字段 vs 条件更新

两个 SQL 都叫乐观锁,失败语义却完全相反:version 失败是"冲突",条件更新失败是"资源不足"。混用会让重试逻辑变成压垮数据库的重试风暴。这篇讲清两种流派的取舍与边界。

2026-03-05 约 11 分钟 4248 字 MySQL 乐观锁 库存 并发控制

一、一个被"乐观锁"三个字掩盖的分歧

提到数据库乐观锁,几乎所有资料都会贴出这段 SQL:

UPDATE stock SET available = available - 1, version = version + 1
WHERE sku_id = 1001 AND version = 5;

但生产代码里更常见的其实是另一段:

UPDATE stock SET available = available - 1
WHERE sku_id = 1001 AND available >= 1;

两段 SQL 都被叫做"乐观锁",写法差别不大,失败时的含义却完全相反。第一种失败要重读重试,第二种失败绝对不能重试。把它们混为一谈,是很多线上事故的起点。

二、数据库层扣减的两种写法

先从更基础的层面分一次。在数据库里扣库存,有两种写法。

写法一:不带判断的盲扣

UPDATE stock SET available = available - 1 WHERE sku_id = 1001;

思路是:"缓存层(比如 Redis)已经把过关了,数据库只是同步数据。"

这种写法在生产环境里非常常见,因为它的吞吐最好——只更新一行,不额外做条件判断。

但它的前提是上游必须真的把住了关。一旦缓存层被绕过(管理后台直接改、脚本直连数据库、缓存击穿导致漏判),盲扣会毫不犹豫地把库存扣成负数,而且数据库层不会发出任何警报。

写法二:带判断的扣减

UPDATE stock SET available = available - 1
WHERE sku_id = 1001 AND available >= 1;

这才叫乐观锁:把业务判断(够不够扣)写进 WHERE 里,由数据库在一条语句内完成"判断 + 更新"。

执行后看影响行数:

  • 返回 1 → 扣减成功
  • 返回 0 → 没扣成功
int affected = stockMapper.decrease(skuId);
if (affected == 0) {
    throw new BizException("库存不足");
}

关键在于这条 UPDATE 是原子的:数据库在执行时会对 sku_id = 1001 这一行加行级排他锁,"读当前值 → 判断 ≥ 1 → 减去 1 → 写回"这一整串动作在锁内完成,中间不会被别的会话插进来。

三、"乐观锁"的两种流派

现在回到开头那两段 SQL。

流派一:version 字段

表里额外加一列 version,每次更新都递增。判断依据是"这一行在我读完之后有没有被别人改过"。

-- 第一步:读出当前值和版本号
SELECT available, version FROM stock WHERE sku_id = 1001;
-- 结果:available = 10, version = 5

-- 第二步:带版本号更新
UPDATE stock SET available = available - 1, version = version + 1
WHERE sku_id = 1001 AND version = 5;
-- 影响 1 行 → 更新成功,version 变成 6
-- 影响 0 行 → 读完之后有人改过这行 → 冲突 → 重读重试

注意它需要两条语句,中间存在窗口。这个窗口正是 version 字段存在的意义——用来检测窗口期内是否发生了并发修改。

// 典型的重试写法
int retry = 0;
while (retry++ < 3) {
    Stock s = stockMapper.selectBySkuId(skuId);
    if (s.getAvailable() < 1) {
        throw new BizException("库存不足");
    }
    int affected = stockMapper.updateWithVersion(
            skuId, s.getVersion());
    if (affected == 1) {
        return;   // 成功
    }
    // affected == 0:说明被别人抢先改了,循环重来
}
throw new BizException("系统繁忙,请重试");

流派二:条件更新(CAS by condition)

不改表结构,直接把业务条件本身当作判断依据。

UPDATE stock SET available = available - 1
WHERE sku_id = 1001 AND available >= 1;
-- 影响 1 行 → 扣减成功
-- 影响 0 行 → 库存不足 → 直接返回售罄

只有一条语句,没有窗口,不需要先读后写。

int affected = stockMapper.decreaseIfEnough(skuId);
if (affected == 0) {
    throw new BizException("库存不足");   // 注意:直接返回,不重试
}

四、两种流派对比

对比维度 version 字段版 条件更新版
表结构 要多一列 version 不用改表
判断依据 版本号有没有变 业务条件(够不够扣)
语句数量 两条(先查后更) 一条
失败语义 冲突,数据被别人改过 资源不足,库存真的不够
失败后动作 需要重读重试 直接返回,不该重试
适合场景 低冲突的通用更新(余额、配置项、用户资料) 库存、名额这类资源争抢
额外成本 多一列存储 + 重试逻辑 无

为什么失败语义的差异是本质的

这是全文最重要的一点。

version 版的 UPDATE 返回 0 行,可能意味着两件不同的事:

  • 库存不够了
  • 库存够,但有人在 1 毫秒前改了这行(哪怕只是改了一个不相关的字段)

version 无法区分这两者,所以唯一安全的处理方式就是重读、重判、重试。

而条件更新版的 UPDATE 返回 0 行,含义是唯一的:available >= 1 这个条件不成立,也就是库存真的不足。此时重试一万次结果也一样,纯属浪费数据库资源。

version 版的失败是"我运气不好",条件更新版的失败是"没货了"。前者值得再试一次,后者再试也没用。

五、库存场景该用哪个

结论很明确:库存几乎一律用条件更新版。

原因在于热点 SKU 上的冲突率。设想一万人同时抢同一款商品,库存 100 件:

version 字段版 条件更新版
前 100 个请求 命中版本号则成功 成功
第 101~10000 个请求 读到 version = 5 → 更新失败 → 重读 → 又失败 → ... 一次判断返回 0 行,立刻结束
数据库压力 每个失败请求产生 2 次以上 IO,重试风暴 每个请求 1 次 UPDATE
结果 绝大多数更新都在失败和重试中空转 一次判断定生死

version 版的问题在于:失败者不会"放弃",而会"重来"。一万人抢 100 件,意味着 9900 个请求都在反复重读重试,每次重试都要加锁、判断、回滚。这些 IO 和锁竞争最终会把数据库压垮——而它们全都是无效功。

条件更新版则是"一次判断定生死":没有重试开销,失败的请求直接把"售罄"返回给用户,链路更短,用户体验也更好(立刻得到确定结果,而不是转圈等待)。

六、一个容易被忽视的细节:UPDATE 底层也加锁

有一种说法是"乐观锁不加锁"。严格来说,这个表述只在应用层的语义上成立。

条件更新版的 SQL 本身没有显式加锁,靠条件判断避免覆盖,思路是乐观的;但底层执行这条 UPDATE 时,数据库仍然会给命中的那一行加排他锁,所以严格说是"借了行锁的力"。

这个细节解释了两件事:

  1. 为什么它在单条语句里就是原子的——因为行锁保证了"读判断写"不被插入。
  2. 为什么热点行的性能依然有上限——所有并发请求都要在数据库这一行上排队拿锁,锁竞争本身就是瓶颈。

这也正是"Redis 预扣 + 数据库兜底"两层架构的由来:

层 职责 特性
Redis 预扣 挡住绝大部分无效流量,快速给出"售罄" 内存操作,几十万 QPS
数据库扣减 最终库存账本,保证不超卖 强一致,但吞吐有限

第一层用空间换吞吐,第二层用锁换正确性。两层都做同一件事(防超卖),但目的不同:Redis 是为了扛住流量,数据库是为了"账不能错"。

所以"既然数据库能兜底,Redis 预扣能省掉吗"这个问题有明确答案:能省掉,但省掉之后数据库会直接面对全部流量,热点行的锁竞争会让系统在大促时崩掉。Redis 预扣不是正确性的必需品,而是性能的必要品。

七、边界:这到底算乐观锁还是悲观锁

这个问题常被较真,值得理清。

视角 判断
应用层 乐观:不加显式锁,先改再说,靠条件判断避免覆盖
数据库层 悲观:UPDATE 执行时对该行加排他锁

所以准确的表述是:它在应用层是乐观的,在存储层是悲观的。

严格意义上的"纯乐观锁"(比如 CAS 无锁指令)在冲突时完全不阻塞任何线程;而数据库条件更新在行锁上仍然会阻塞。理解这个区别,就不会在"它到底算哪种锁"上纠结——概念是用来指导取舍的,不是用来分门别类的。

八、小结

  • 数据库扣减有两种写法:盲扣(依赖上游把关)和带判断的扣减(业务判断进 WHERE)
  • 乐观锁分两个流派:version 字段(判断"有没有被改过")和条件更新(判断"业务条件成不成立")
  • 两种流派的失败语义相反:version 失败 = 冲突,需重读重试;条件更新失败 = 资源不足,不该重试
  • 库存场景一律用条件更新:热点 SKU 上 version 冲突率极高,重试风暴会把数据库压垮;条件更新"一次判断定生死",没有重试开销
  • 条件更新不加显式锁,但底层 UPDATE 仍然加行级排他锁,所以它是"应用层乐观、存储层悲观"
  • 完整方案是两层:Redis 预扣挡流量,数据库作为最终库存账本——前者保性能,后者保正确
  • 落库时把影响行数当作业务结果来读:0 行就是"售罄",不是"异常"

一句话记住:看影响行数的时候,先问自己"0 行代表什么"。如果答案是"冲突",就去重试;如果答案是"没货了",就立刻返回。这个判断做对了,乐观锁才算用对了。

留下点什么

评论基于 GitHub Discussions(giscus)。在项目根目录配置.env.local填入 NEXT_PUBLIC_GISCUS_* 四个变量后自动开启。