直接UPDATE扣减必致高并发锁争抢,因所有请求挤在同一行物理位置强制串行化;应改用INSERT ON DUPLICATE KEY UPDATE分片扣减,避免临键锁且锁粒度限于单shard。

为什么直接 UPDATE ... SET stock = stock - 1 WHERE id = ? 在秒杀中必死锁
不是语句本身错,而是它在高并发下触发了死锁的全部四个条件:互斥(X锁)、持有并等待(事务里还有其他操作)、不可剥夺(InnoDB不支持强制释放行锁)、循环等待(多个事务随机竞争同一行)。MySQL 5.7 默认 REPEATABLE READ 隔离级别,WHERE id = ? 走主键索引时加的是临键锁(Next-Key Lock),但若事务中混入范围查询或间隙操作,锁范围会意外扩大;更致命的是——所有请求都挤在同一个 id 上,物理位置固定、锁争抢串行化,等于把并发压成单线程。
必须绕开行锁竞争:用 INSERT ... ON DUPLICATE KEY UPDATE 替代 UPDATE
核心思路是把“更新一行”变成“尝试插入一条唯一记录”,靠唯一约束触发原子性判断,避免显式加 X 锁。适用于已预分片的库存表:
CREATE TABLE `goods_stock_shard` ( `goods_id` INT NOT NULL, `shard_id` TINYINT NOT NULL, `stock` INT NOT NULL DEFAULT 0, PRIMARY KEY (`goods_id`, `shard_id`) ) ENGINE=InnoDB;
扣减时随机选一个 shard_id(如 shard_id = FLOOR(RAND() * 10)),执行:
INSERT INTO goods_stock_shard (goods_id, shard_id, stock) VALUES (123, 7, -1) ON DUPLICATE KEY UPDATE stock = stock - 1;
注意三点:
-
INSERT不会加临键锁,只在插入冲突时对已有记录加 X 锁,且锁粒度仅限该(goods_id, shard_id)组合 - 必须确保
stock初始值 ≥ 0,否则stock - 1可能为负——业务层需用SELECT stock FROM ... WHERE goods_id = ? AND shard_id = ?做兜底校验 - 不能依赖
ROW_COUNT()判断是否扣减成功:冲突时返回 2(insert + update),无冲突时返回 1(纯 insert),需结合SELECT确认最终值
为什么不能只靠 SELECT ... FOR UPDATE 加锁顺序统一
统一加锁顺序(比如总先查 users 再查 orders)对多表事务有效,但秒杀本质是单表单行热点,所有事务天然锁同一行——顺序再统一也没用。更危险的是:SELECT ... FOR UPDATE 在 REPEATABLE READ 下会加 Next-Key Lock,若索引扫描范围稍大(比如用了非唯一二级索引),就会带上 Gap Lock,两个事务分别锁定 (100, 200) 和 (200, 300) 区间,再插入 200 就可能互相等待。
安装、修复、更新或打包一个基于 Python 和 PyMySQL 实现的 Windows `mysql` 命令。适用于用户希望在 PowerShell/CMD 中输入 `mysql` 时重定向到 Python 实现、需要交互式输入 MySQL 连接信息、需要同步到当前用户本地 bin 目录,或需要将该能力封装成可公开复用的 Codex skill。
真实踩坑点:
- 开发曾用
SELECT stock FROM goods WHERE id = 123 FOR UPDATE后判断再UPDATE,结果因网络延迟导致锁持有超 2 秒,QPS 一过 300 就开始报Deadlock found when trying to get lock - ORM 框架(如 MyBatis)自动生成的 SQL 可能隐式带
ORDER BY或LIMIT,改变执行计划,使本该走主键的查询走了全表扫描,锁升级为表级 -
innodb_lock_wait_timeout默认 50 秒,但秒杀场景要求失败响应
真正零死锁的关键:彻底剥离数据库的“判断-执行”耦合
MySQL 5.7 的 InnoDB 不可能在单行高并发写上做到绝对零死锁,所谓“零死锁”是指:死锁发生率趋近于 0,且一旦发生不影响业务正确性。这只能靠架构解耦实现:
- 前置用 Redis
DECRBY做库存快照:初始化时SET goods:123:stock 1000,扣减前DECRBY goods:123:stock 1,返回值 - Redis 扣减成功后,异步发消息到 Kafka,由消费者按固定速率(如 300 QPS)批量写入 MySQL 分片表,此时数据库压力已降两个数量级
- MySQL 层只承担最终一致性落库,不再参与实时决策;即使某次消费失败,靠消息重试+幂等
INSERT ... ON DUPLICATE KEY UPDATE保证不超卖
最容易被忽略的一点:分片后仍要避免 shard_id 被人为固定(比如永远用 0),否则又回到单行热点。必须用服务端真随机(非客户端 RAND()),且分片数建议 ≥ 16,让锁竞争分散到不同页(Page)上。










