MySQL无法根治秒杀超卖和行锁热点,必须用Redis预扣减过滤80%无效请求,再通过分段更新(shard by slot)将单行X锁压力分散到多行,并保留MySQL最终校验防止网络分区超卖。

MySQL 本身无法“彻底”解决秒杀超卖和行锁热点竞争——这不是配置或 SQL 写法能根治的问题,而是高并发写同一行数据时 InnoDB 的固有行为。真正可行的路径是:用 Redis 预扣减过滤掉 80%+ 无效请求,把 MySQL 降级为最终校验层;再配合分段更新(shard by slot)把单行 X 锁压力打散,让剩余真实请求在多个物理行上并发执行。
为什么单行 UPDATE product SET stock = stock - 1 WHERE id = 123 必然排队
不是索引没建好,也不是 buffer pool 小,更不是事务没提交快——根本原因是 InnoDB 对同一行记录加 X 锁时必须串行等待。聚簇索引决定了这行数据物理位置固定,所有请求全挤在同一个磁盘页、同一个内存页、同一个锁对象上。EXPLAIN 显示 type=const、rows=1 只说明你锁得准,不说明你锁得少。
常见错误现象包括:
-
SHOW ENGINE INNODB STATUS里反复出现waiting for trx id -
information_schema.INNODB_TRX中大量事务状态为RUNNING但trx_state = 'LOCK WAIT' - CPU 拉满,磁盘 IO 却很低——锁在内存里空转,根本没到磁盘
分段更新(shard by slot)必须满足的三个硬前提
把 goods_stock 表从单行扩展为 10 行(shard_id 0~9),实测可降低锁冲突 80%+,但漏掉任一条件,效果归零甚至更糟:
-
WHERE条件必须命中联合主键:例如UPDATE goods_stock SET stock = stock - 1 WHERE product_id = 123 AND shard_id = 7,且EXPLAIN中key字段显示PRIMARY、rows ≤ 1;若shard_id没进 WHERE 或联合索引顺序错(比如建了(shard_id, product_id)),就会全表扫,锁住全部 10 行 - 事务只包这一条
UPDATE+ROW_COUNT()判断:里面不能有日志、HTTP 调用、SLEEP(),否则锁滞留时间指数增长 - 隔离级别必须设为
READ COMMITTED:它禁用间隙锁,避免因shard_id范围查询意外锁住相邻槽位;默认的REPEATABLE READ会让锁范围扩大
Redis 预扣减不是可选项,是必须前置的流量闸门
真实压测中,80% 的请求是库存已空还狂点——这些请求根本不该碰 MySQL。用 Redis 原子操作提前拦截,MySQL 只处理真正需要落库的请求:
- Key 设计按
goods_id % 10分片,如goods:123:stock:0到goods:123:stock:9,分散单 key 压力 - 扣减逻辑用
DECRBY goods:123:stock:0 1,返回值 ≥ 0 才放行;否则直接拒绝,不走 DB - 异步落库节奏要可控:用 Redis Streams 或带速率限制的消费者(如每秒最多 500 条),避免 DB 写入突增
- MySQL 最终校验仍需保留:
UPDATE goods SET stock = stock - ? WHERE id = 123 AND stock >= ?,防止 Redis 和 DB 之间因网络分区导致超卖
最容易被忽略的是:分段更新后查总数必须接受短暂不一致。SELECT SUM(stock) FROM goods_stock WHERE product_id = 123 是最终一致性语义,不是强实时。如果业务要求“实时显示精确库存”,那说明你还没把 Redis 缓存层真正用起来,或者前端不该直连数据库查总和。


















