正确做法是用SELECT ... FOR UPDATE在可重复读隔离级别下锁定库存行,配合CHECK(stock>=0)约束和分表写入,确保扣减原子性与数据一致性。

用 SELECT ... FOR UPDATE 锁住库存行,但只在事务里做
抢购预约本质是“多个请求争一个有限资源”,MySQL 里最直接的手段就是加行锁。但很多人一上来就写 SELECT * FROM goods WHERE id = 123,再判断库存、再 UPDATE——这中间有竞态窗口,根本挡不住并发超卖。
正确做法是把查库存和扣减包进同一个事务,并用 SELECT ... FOR UPDATE 提前锁定目标行:
START TRANSACTION; SELECT stock FROM goods WHERE id = 123 FOR UPDATE; -- 检查 stock > 0,如果是,执行: UPDATE goods SET stock = stock - 1 WHERE id = 123; COMMIT;
注意:FOR UPDATE 只在可重复读(REPEATABLE READ)隔离级别下才锁住间隙(防止幻读),所以必须确认你的事务隔离级别不是 READ COMMITTED;否则可能漏锁新插入的同 ID 行(虽然少见,但线上出过)。
库存字段必须是 INT 且带 CHECK (stock >= 0)
光靠应用层判断库存是否充足远远不够。MySQL 层面没约束,一旦并发写入失控,stock 就可能变成负数——后面所有“判断 > 0”都失效了。
建表时务必加上约束:
ALTER TABLE goods ADD CONSTRAINT chk_stock_nonnegative CHECK (stock >= 0);
这个约束会在 UPDATE 时触发校验,违反则报错 Check constraint 'chk_stock_nonnegative' is violated.。别嫌麻烦,这是兜底防线。
另外,stock 字段类型必须是 INT 或 BIGINT,不能是 DECIMAL 或 VARCHAR——前者影响原子性(比如 stock - 1 在非整型上可能精度丢失),后者会让 FOR UPDATE 失效(锁不到实际数据行)。
预约表要拆分写入,避免单表自增 ID 和索引争抢
抢购成功后,用户预约记录要快速落库。如果所有预约都往一张 order_pre 表里狂插,主键 auto_increment 会成为热点,InnoDB 会频繁更新 dict_sys 系统页,导致大量 LOCK_LOG_SYS 等待,QPS 上不去。
更稳的做法是按时间或用户 ID 哈希分表:
-
order_pre_202409存当月预约,用DATE(created_at)分区 - 或按
user_id % 16落到order_pre_0~order_pre_15
同时,INSERT 语句必须带明确字段列表,不要用 INSERT INTO order_pre VALUES (...)——列顺序一旦变,就容易插错字段,尤其是加了新字段之后。
别依赖 INSERT IGNORE 或 ON DUPLICATE KEY UPDATE 做库存控制
有人想绕过事务锁,用唯一键限制预约人数,比如给 (goods_id, user_id) 加联合唯一索引,然后 INSERT IGNORE 插预约记录,再单独减库存。这看起来简单,但隐患极大:
- 预约成功 ≠ 库存扣减成功:两个操作不在同一事务,网络中断或进程崩溃会导致“预约已记、库存未减”
-
INSERT IGNORE不触发FOR UPDATE,无法阻止并发重复插入(除非你先显式查再插,又回到竞态问题) - 唯一索引冲突时返回的是警告而非错误,很多 ORM 默认不抛异常,业务逻辑直接跳过失败分支
真正需要的是“原子性 + 一致性”,不是“看起来没报错”。宁可慢一点走事务,也不要为省几毫秒引入数据不一致风险。
高并发下最难的不是怎么快,是怎么让每一次成功都真实可靠。锁、约束、分表、事务边界——这些不是性能拖累,而是确定性的代价。少一个,线上就多一分不可控。


















