zhaojie.tech/me
MySQL 2026-10-08T00:00:00.000Z

防超卖:一条 UPDATE 条件语句凭什么比 SELECT FOR UPDATE 强

赵杰 · zhaojie.tech

门店盘点后对不上账:系统里的可卖数量比货架上的实物多 3 件。查了两个小时的变更流水,发现是两个收银员同一秒各卖了一瓶同一批次的酒,两次扣减都”成功”了——库存基数被并发改穿。

当时的扣减代码是最直觉的写法:先 SELECT quantity FROM ... FOR UPDATE,Java 里判断够不够,再 UPDATE 扣减。逻辑没写错,错在把判断放在了锁的外面还是里面,全凭调用方自觉——有一次重构,判断逻辑被挪出了事务,check-then-act 的窗口就这么打开了。

两条路的本质差别

先说公道话:SELECT FOR UPDATE 和条件 UPDATE 都加行 X 锁、都持有到事务提交(不是语句结束),单次扣减的锁开销没有高下之分。差别在临界区里装了什么:

  • SELECT FOR UPDATE 是”读—判—写”三步。锁的覆盖范围从 SELECT 开始到事务提交,中间夹着网络往返和应用层判断。判断若没圈进同一个事务,就退化成竞态;就算圈进去了,热点商品行会把每个请求的往返时延都串进临界区,连接全被拖住;
  • 条件 UPDATE 是单语句原子操作。比较发生在存储引擎层,判断与写入之间不存在任何可插入的窗口。

所以我的库存引擎把扣减收敛成了一条六条件 UPDATE:

UPDATE ss_inventory_snapshot
SET quantity   = quantity - #{quantity},
    total_cost = total_cost - (#{quantity} * COALESCE(unit_cost, 0)),
    version    = version + 1
WHERE id = #{id}
  AND quantity >= #{quantity}            -- ①数量够
  AND sellable_quantity >= #{quantity}   -- ②可售口径够
  AND status = 1                         -- ③批次未被召回冻结
  AND version = #{version}               -- ④乐观锁
  AND deleted = 0                        -- ⑤未逻辑删除
                                          -- ⑥主键 id 定位行

六个条件各管一段现实:①防数量扣穿;②的 sellable_quantity 是生成列(quantity − 冻结 − 破损 − 锁定),是唯一可售口径——只查 quantity 会被”锁定中但未扣减”的量骗过,这一条在 SQL 层兜住了 Java 层可能的口径遗漏;③拦被召回的批次;④version 防的是”读—改—写覆盖别人更新”的 ABA 类问题;⑤逻辑删除行不可扣;⑥主键定位保证命中行数封顶 1 行,之前用业务键定位,同键两行的合法状态下会改错商品,这是另一次返工换来的教训。

失败语义与重试

条件更新的失败信号非常干净:affected rows = 0 就是没扣成,没有异常、没有中间态。失败后回读当前行即可区分”库存不足”(可售数量不满足,报业务错)与”版本冲突”(并发修改,进重试)——两种失败两种出路,不混在一个异常里。重试装在一个独立模板里:

public static final int MAX_RETRIES = 5;        // 含首次执行
public static final long INITIAL_BACKOFF_MS = 20;
public static final long JITTER_MS = 15;

// 退避序列:20 → 40 → 80 → 160 → 320ms,每次叠加 [0, 15ms) 随机抖动
long jitter = ThreadLocalRandom.current().nextLong(0, JITTER_MS);
Thread.sleep(backoff + jitter);
backoff *= 2;

两个设计点值得说。其一,只捕获 ConcurrentStockModificationException(version 不匹配),其他异常立即上抛——业务校验失败重试一百次也不会成功,把”可重试”与”不可重试”在异常类型上分家,比在 catch 里猜消息文本可靠得多。其二,抖动不是为了好看:多个线程同一毫秒撞上同一个 version,指数退避若不加随机量,它们会踩着完全相同的节拍再撞一次、再撞一次。加 [0, 15ms) 抖动把重试时刻摊开,5 次机会才真正是 5 次。重试次数从早期版本的 3 提到 5,也是实测中前 3 次连续撞版本冲突的概率比预想高。

什么时候还是得用 FOR UPDATE

条件更新不是万能替身。我的库存引擎里同时存在一条 SELECT ... FOR UPDATE:FEFO(先到期先出)批次分配——需要先扫可售批次、按到期日排序、算出”首选批次不足时续批补足”的分配方案,这是多行协调 + 复杂计算,必须持锁期间完成,一条 UPDATE 表达不了。所以选型口径是:变更逻辑能压进一条 WHERE 的,用 CAS 条件更新 + 退避重试;必须在持锁期间做多行协调或复杂计算的,老实用悲观锁,并把整个判断过程圈进同一事务。怕的从来不是 FOR UPDATE 本身,而是”读出来的判断”漂出了锁的保护范围。

一句收尾

能用一条语句在存储引擎层比完的,就不要把判断带出数据库——并发正确性靠的是把窗口压到零,而不是把锁加得更多。

← 全部文章 赵杰 · me.zhaojie.tech