select ... for update 必须在显式事务中使用,否则锁立即释放;可靠防超卖应采用原子update语句(如update ... where id=? and stock>=1),配合唯一索引防重、乐观锁适配读多写少场景,并确保where条件走索引避免表锁。

SELECT ... FOR UPDATE 必须在显式事务里用
单独执行 SELECT ... FOR UPDATE 没用,InnoDB 只在事务中才真正加锁。如果 autocommit=1(默认),那这条语句一执行完就自动提交、锁立刻释放,后续的 UPDATE 完全不受保护。
必须显式开启事务:
START TRANSACTION; SELECT stock FROM goods WHERE id = 123 FOR UPDATE; -- 应用层判断 stock > 0 UPDATE goods SET stock = stock - 1 WHERE id = 123; COMMIT;
常见错误现象:日志里看到“查到 stock=1”,但最终库存变成 -2;本质是查完就释放锁,多个事务都读到了旧值。
- 连接池配置要关掉自动提交(
autocommit=false) -
FOR UPDATE只对 InnoDB 有效,MyISAM 不支持 - 若 WHERE 条件不走索引,会升级为表锁,直接拖垮并发能力
UPDATE 带条件才是防超卖的兜底写法
依赖 SELECT ... FOR UPDATE + 应用层判断,逻辑链太长,中间任意环节出错(比如网络延迟、服务重启、代码分支遗漏)都会漏掉检查。真正可靠的是把“判断”和“更新”压进一条 SQL:
UPDATE goods SET stock = stock - 1 WHERE id = 123 AND stock >= 1;
执行后看 ROW_COUNT():
- 返回
1→ 扣减成功 - 返回
0→ 库存不足或记录不存在,没做任何修改
这条语句天然具备三重保障:InnoDB 自动对匹配行加 X 锁、WHERE 和 SET 原子执行、不依赖应用层读取值。即使 5000 个请求并发打进来,也只有一个能拿到锁并满足条件,其余全部静默失败。
注意:AND stock >= 1 不能写成 AND stock > 0 —— 虽然语义等价,但部分 MySQL 版本对 > 0 的索引优化略差,>= 1 更稳妥。
唯一索引 + INSERT 先判重,比锁更轻量
防重复下单和防超卖可以拆开处理。比如用户秒杀资格,用 (uid, goods_id) 唯一索引比加锁查 buy_record 表快得多:
INSERT INTO buy_record (uid, goods_id) VALUES (1001, 2001);
如果报错 Duplicate entry '1001-2001' for key 'uk_uid_goods',说明已参与过,直接拒绝。
接着再扣库存:
UPDATE goods SET stock = stock - 1 WHERE id = 2001 AND stock >= 1;
这种组合把“查是否买过”从锁操作降级为索引冲突检测,避免了大量 SELECT ... FOR UPDATE 堵塞。适用于秒杀类场景,且要求业务能接受“先抢资格、再扣库存”的两段式语义。
- 唯一索引必须是
UNIQUE类型,普通INDEX不拦截重复插入 - MySQL 返回的错误码是
1062,应用层要捕获这个特定错误,而不是泛化地 catch SQLException
乐观锁 version 字段适合读多写少场景
如果库存变更不频繁(比如后台运营调价、补货),但查询极多(商品详情页 PV 高),用 version 字段比行锁更省资源:
UPDATE goods SET stock = stock - 1, version = version + 1 WHERE id = 123 AND version = 10;
返回影响行数为 0,说明在你查完 version 后,别人已经改过数据,此时应重试或提示“库存变化,请刷新”。
但它不适合秒杀:1000 人同时查到 version=10,然后一起发 UPDATE,999 个会失败,重试风暴可能压垮数据库。
- version 字段类型推荐
INT UNSIGNED或BIGINT,避免溢出 - 不能只靠 version 判断库存是否足够,仍需
stock >= 1条件,否则 version 对得上但库存已为 0,也会扣成负数
最易被忽略的一点:所有这些方案都假设 goods.id 是主键或有唯一索引。如果 WHERE 条件走不了索引,InnoDB 会锁整张表——高并发下等于直接拒绝服务。











