本质是索引结构与自增主键导致锁竞争放大;须用(product_id, shard_id)复合主键替代自增ID,删除product_id二级索引,使同商品更新分散至多行,冲突下降90%+。

MySQL 在电商秒杀场景下,单商品 ID 更新导致的索引热点问题,本质不是“要不要加索引”,而是“索引结构和主键设计放大了锁竞争”。直接优化 product_id 上的普通索引或联合索引,治标不治本;必须配合主键/聚簇索引层的分散策略。
为什么 idx_product_id 会加剧热点?
很多人给 product_stock 表加 KEY idx_product_id (product_id),以为能加速 WHERE 查询。但问题在于:
- InnoDB 的聚簇索引以主键组织物理存储,而默认主键是自增
id—— 所有新插入或更新都挤在 B+ 树最右叶节点,引发页分裂和写入争用 - 即使查询走
idx_product_id,UPDATE 语句仍需回表定位到聚簇索引行,最终锁住的是同一行(product_id = 1001对应的那条记录) - 更隐蔽的是:二级索引更新本身也要写 redo log 和 buffer pool,并发高时
idx_product_id的页也成热点(尤其是product_id值高度重复时)
改用哈希分片主键替代自增 ID
目标是让同商品的库存操作落到不同物理位置,天然分散锁和 IO 压力。不依赖应用层随机选桶,而是从主键设计上根治:
- 将主键改为
(product_id, shard_id)复合主键,其中shard_id是 0–9 的整数(对应 10 个逻辑分片) - 建表示例:
CREATETABLEproduct_stock_shard ( product_idBIGINTNOTNULL, shard_idTINYINTNOTNULL, stockINTNOTNULLDEFAULT0, versionINTNOTNULLDEFAULT0, PRIMARYKEY(product_id, shard_id) ) ENGINE=InnoDB;
- 扣减时不再 UPDATE 单行,而是随机选择一个
shard_id执行:UPDATE product_stock_shard SET stock = stock - 1 WHERE product_id = 1001 AND shard_id = FLOOR(RAND() * 10); - 查总库存用
SELECT SUM(stock) FROM product_stock_shard WHERE product_id = 1001;—— 虽然要扫 10 行,但并发压力已从“1 行锁死”变为“10 行轮转”,冲突下降 90%+
禁止在 product_id 上建普通二级索引
这是最容易被忽略的坑。一旦存在 KEY idx_product_id (product_id),InnoDB 会在该索引页上产生额外的锁竞争:
- 每个 UPDATE 都要先定位二级索引项,再回表 → 二级索引页本身也会被频繁修改
- 若多个事务同时更新不同
shard_id但相同product_id,它们在idx_product_id页上可能命中同一索引页(因product_id值相同),触发页级锁 - 实测表明:删掉这个二级索引后,在 8000 QPS 下,
SHOW ENGINE INNODB STATUS中的waiting for index latch消失,P99 延迟从 4.2s 降至 120ms
复合主键下如何高效查单个分片?
有人担心 WHERE product_id = ? AND shard_id = ? 无法利用索引 —— 其实完全不必担心,因为 (product_id, shard_id) 就是主键,B+ 树搜索直接 O(log n) 定位:
- 执行
EXPLAIN SELECT * FROM product_stock_shard WHERE product_id = 1001 AND shard_id = 3;,type字段必为const或eq_ref - 注意:不能只查
WHERE product_id = 1001(会全索引扫描),所有业务代码必须显式传入shard_id - 推荐在 DAO 层封装:根据
product_idhash 后取模生成shard_id,确保同商品请求始终落在固定分片(避免跨分片读不一致)
真正难的不是分片逻辑,而是说服团队接受“放弃自增主键”——因为这意味着所有关联表的外键、日志追踪、分页排序都要适配复合主键。一旦跨过这道坎,索引热点就从数据库层消失了。


















