update inventory set stock = stock - ? where sku_id = ? and stock >= ? 是最简安全起点,原子执行、自动加行锁、where条件加锁后重评估,只需检查row_count()是否为1即可防超卖;sku_id必须有唯一索引,避免锁升级。

UPDATE WHERE 是最简安全起点,不是“不够用才换”
别被“高并发”三个字吓住——对大多数业务,UPDATE inventory SET stock = stock - ? WHERE sku_id = ? AND stock >= ? 这条语句本身就能扛住 QPS 2000 以内抢购。它原子执行、自动加行锁、WHERE 条件在加锁后重新评估,只要应用层检查 ROW_COUNT() 返回值是否为 1,就不可能超卖。
常见错误是只看 SQL 执行是否报错,不查影响行数。比如扣减 5 件,库存只剩 3,语句不报错但 ROW_COUNT() 返回 0,此时必须拒绝下单,而不是继续走后续逻辑。
- WHERE 中的
sku_id字段必须有唯一索引,否则可能升级为间隙锁甚至表锁 - 不要在 WHERE 中拼接函数或表达式(如
WHERE UPPER(sku_id) = ?),会失效索引导致锁表 - 该语句在 MySQL RR 隔离级别下表现稳定,无需额外 BEGIN/COMMIT 包裹(单条 DML 自动成事务)
SELECT FOR UPDATE 仅在需要多步校验时启用
当你必须在扣减前做复合判断(比如“库存 ≥ 需求数 + 预占数”“用户等级决定折扣后可扣数量”“跨 SKU 组合库存校验”),才值得上 SELECT ... FOR UPDATE。但它不是更“高级”的方案,而是更重、更易阻塞的方案。
真实踩坑点在于:有人把 SELECT stock FROM inventory WHERE sku_id = ? 和 UPDATE ... 拆成两步,中间没锁;或者在 FOR UPDATE 后调用外部服务、sleep、复杂计算,导致锁持有时间长达几百毫秒,排队雪崩。
- 必须在同一个事务中完成:
BEGIN→SELECT ... FOR UPDATE→ 校验逻辑 →UPDATE→COMMIT -
FOR UPDATE的 WHERE 条件字段必须命中唯一索引,否则锁范围不可控 - 存储过程中不能依赖应用层传入“校验结果”,所有判断必须在数据库内完成
失败必须显式终止事务,不能靠“返回提示”蒙混
很多存储过程写成这样:IF @cur_stock —— 这毫无意义。事务仍开着,后续语句照常执行,最后 <code>COMMIT 会把没扣减却写了日志、生成了订单号的脏状态落库。
真正有效的失败路径只有两条:SIGNAL SQLSTATE '45000' 强制中断,或 ROLLBACK + LEAVE proc_label 跳出。且 SIGNAL 必须在事务活跃时触发,否则 MySQL 不识别。
- 使用
SIGNAL时,应用层需捕获 MySQL 错误码 1644(SQLSTATE '45000')而非通用异常 - 若存储过程内还写了日志表(如
INSERT INTO stock_log),失败时这条日志也必须回滚,不能“先记再撤” - 不要在失败分支里写
INSERT INTO error_log,这属于副作用,违背事务原子性
分表不是银弹,拆分粒度错了反而更慢
当单 SKU 热点 QPS 突破 3000,UPDATE WHERE 开始排队等待行锁,这时才考虑分表。但直接按 SKU 哈希分 16 张表?错。真正的瓶颈不在“多少张表”,而在“每张表里有多少热点行”。
比如一个爆款手机分到表 A,其他 15 张表空转,压力一点没分担。正确做法是:对超高热度 SKU 单独切分(如 sku_1001_shard_0 到 _9),冷门 SKU 仍走主表;分表路由必须基于业务键(如订单号、用户 ID)而非 SKU,避免热点集中。
- 分表后,
SELECT FOR UPDATE仍需保证跨分表事务一致性,实际往往退化为应用层分布式锁 - 每张子表的自增主键必须独立配置,否则
LAST_INSERT_ID()在分表间不可靠 - 分表方案上线前,务必压测“同一分表内多 SKU 并发”和“不同分表间同频请求”两种场景
最常被忽略的一点:无论用哪种方案,库存字段必须定义为 INT 或 BIGINT 无符号类型,并设 CHECK (stock >= 0)(MySQL 8.0.16+)。这不是性能优化,是兜底——哪怕事务逻辑漏掉校验,数据库层面也能拦住负数写入。











