乐观锁的两种流派:version 字段 vs 条件更新
两个 SQL 都叫乐观锁,失败语义却完全相反:version 失败是"冲突",条件更新失败是"资源不足"。混用会让重试逻辑变成压垮数据库的重试风暴。这篇讲清两种流派的取舍与边界。
一、一个被"乐观锁"三个字掩盖的分歧
提到数据库乐观锁,几乎所有资料都会贴出这段 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时,数据库仍然会给命中的那一行加排他锁,所以严格说是"借了行锁的力"。
这个细节解释了两件事:
- 为什么它在单条语句里就是原子的——因为行锁保证了"读判断写"不被插入。
- 为什么热点行的性能依然有上限——所有并发请求都要在数据库这一行上排队拿锁,锁竞争本身就是瓶颈。
这也正是"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_* 四个变量后自动开启。