Part 1:索引优化与查询性能治理
1.1 场景还原:商家后台商品查询卡顿
表结构:
CREATE TABLE `product` (
`id` bigint unsigned NOT NULL AUTO_INCREMENT,
`shop_id` bigint unsigned NOT NULL,
`category_id` int unsigned NOT NULL,
`status` tinyint unsigned NOT NULL COMMENT '1-上架 2-下架',
`create_time` datetime NOT NULL,
`title` varchar(200) NOT NULL,
`price` decimal(10,2) NOT NULL,
PRIMARY KEY (`id`),
KEY `idx_shop_category` (`shop_id`, `category_id`), -- 现有索引
KEY `idx_create_time` (`create_time`)
) ENGINE=InnoDB;
问题SQL:
SELECT * FROM product
WHERE shop_id = 10086
AND category_id = 10
AND status = 1
ORDER BY create_time DESC
LIMIT 20;现象:查询时快时慢,偶发CPU飙高(Using filesort)。
1.2 核心陷阱:ORDER BY引发的Filesort灾难
执行流程拆解:
索引定位:通过
idx_shop_category找到shop_id=10086 AND category_id=10的数据(假设5000条)回表灾难:
SELECT *需要回表5000次取完整数据(因为二级索引不含title/price)内存排序:在
sort_buffer中对5000行按create_time排序(Filesort)丢弃数据:取前20条,废弃4980条
痛点:大商家(shop_id)可能有上万条记录参与排序,瞬间打满CPU。
1.3 优化方案演进
✅ 方案A:覆盖索引(最佳实践)
ALTER TABLE product ADD INDEX `idx_cover`
(shop_id, category_id, status, create_time, title, price);原理:索引即数据,无需回表;且(shop_id,category_id,status)相等的情况下,create_time天然有序,彻底消除Filesort。
✅ 方案B:延迟关联(Deep Pagination优化)
当无法使用覆盖索引时(字段过多),通过子查询减少回表次数:
SELECT t.* FROM product t
INNER JOIN (
SELECT id FROM product
WHERE shop_id = 10086 AND category_id = 10 AND status = 1
ORDER BY create_time DESC
LIMIT 10000, 20 -- 只在索引层扫描,不回表
) tmp ON t.id = tmp.id;关键差异:
子查询只取
id(索引能覆盖),扫描10020行但不回表外层只回表20次取完整数据
避免将10020行的完整数据加载到内存排序
✅ 方案C:游标分页(Keyset Pagination)
拼多多APP同款方案,拒绝深分页:
-- 第一页
SELECT * FROM product
WHERE shop_id = 10086
ORDER BY create_time DESC, id DESC
LIMIT 20;
-- 第二页(记住上一页最后一条的时间戳)
SELECT * FROM product
WHERE shop_id = 10086
AND (create_time < '2024-03-20 10:00:00'
OR (create_time = '2024-03-20 10:00:00' AND id < 12345))
ORDER BY create_time DESC, id DESC
LIMIT 20;优势:无论翻多少页,扫描行数恒定为20(B+树范围扫描),时间复杂度O(logN) + O(1)。
1.4 隐藏考点:ICP(索引下推)
场景:只有(shop_id, category_id)索引,查询status=1。
传统流程(无ICP):
存储引擎通过索引找到5000个ID
回表5000次取完整数据,传给Server层
Server层过滤
status=1,只剩200条
ICP优化(MySQL 5.6+):
存储引擎找到5000个ID
在引擎层直接检查
status=1(虽然status不在索引列,但ICP允许将条件下推)只回表200次,减少数据传输
识别方法:EXPLAIN中Extra列显示Using index condition(有ICP)vs Using where(无ICP)。
Part 2:事务与锁——秒杀库存扣减的生死局
2.1 超卖陷阱:自以为是的Java代码
错误示范:
@Transactional
public void deduct(Long skuId) {
Stock stock = stockMapper.selectBySkuId(skuId); // 快照读,无锁!
if (stock.getCount() > 0) {
stockMapper.decreaseCount(skuId); // UPDATE有锁,但判断逻辑已错过
}
}并发场景(库存=1,两人同时购买):
t1: 线程A SELECT → count=1(快照读)
t1: 线程B SELECT → count=1(快照读,同时成功)
t2: 线程A 判断>0 → 准备扣减
t2: 线程B 判断>0 → 准备扣减(都认为自己能买)
t3: 线程A UPDATE → count=0
t4: 线程B UPDATE → count=-1(超卖!)根本原因:SELECT是快照读(Snapshot Read),读取的是事务开始时的数据版本;而业务判断逻辑(if count>0)在Java内存中执行,不在数据库保护范围内。
2.2 InnoDB锁机制全景图
追问**:如果sku_id没有索引,UPDATE stock SET count=count-1 WHERE sku_id=1001会锁什么?
答案:全表所有记录+所有间隙(因为无索引无法定位行,只能全表扫描并加Next-Key Lock,相当于锁表)。
2.3 超卖治理的三重境界
青铜:悲观锁(SELECT FOR UPDATE)
@Transactional
public void deduct(Long skuId) {
// 当前读 + 排他锁(X锁),阻塞其他事务
Stock stock = stockMapper.selectForUpdate(skuId);
if (stock.getCount() > 0) {
stockMapper.decreaseCount(skuId);
}
}缺陷:高并发下大量线程阻塞,数据库连接池耗尽,TPS骤降。
白银:乐观锁(版本号CAS)
UPDATE stock
SET count = count - 1, version = version + 1
WHERE sku_id = 1001 AND version = #{oldVersion};原理:利用原子性和影响行数判断。如果version被其他事务修改,UPDATE影响行数为0,业务层捕获异常重试。
ABA问题:若version从1→2→1,无法感知中间变化。解决方案:version + timestamp双字段,或直接使用库存数作为version(见黄金方案)。
黄金:数据库原子CAS
UPDATE stock
SET count = count - 1
WHERE sku_id = 1001 AND count > 0;为什么这是终极方案?
原子性:UPDATE是单条SQL,数据库保证不可分割
当前读:
count > 0判断读取的是最新提交数据(非快照),且受X锁保护无锁竞争:无需SELECT FOR UPDATE,无需version字段,无锁化实现线程安全
Java代码:
@Transactional
public boolean deduct(Long skuId) {
int rows = stockMapper.decreaseWithCondition(skuId);
return rows > 0; // rows=0表示库存不足或并发冲突
}2.4 快照读 vs 当前读(面试高频)
关键认知:UPDATE stock SET count=count-1在执行时,会先当前读获取最新count值,再执行减1操作。这也是为什么黄金方案能防超卖——它把"判断+扣减"封装在了一个原子操作中。
思维导图:知识脉络速记
MySQL优化体系
├── 查询优化
│ ├── 索引设计
│ │ ├── 联合索引:等值查询列在前,排序列在后
│ │ └── 覆盖索引:避免回表(Using index)
│ ├── 排序优化
│ │ ├── 消除Filesort:利用索引天然有序性
│ │ └── 降序索引:MySQL 8.0支持DESC
│ ├── 分页优化
│ │ ├── 延迟关联:子查询先limit再回表
│ │ └── 游标分页:Keyset(时间戳+ID)替代OFFSET
│ └── 引擎优化
│ └── ICP:条件下推至存储引擎层过滤
└── 事务与锁
├── 隔离级别(RR)
│ ├── MVCC:快照读实现一致性非锁定读
│ └── Next-Key Lock:防幻读(Record+Gap)
├── 锁类型
│ ├── 行锁:命中索引
│ └── 表锁:未命中索引(全表扫描)
└── 并发控制
├── 悲观锁:SELECT FOR UPDATE(阻塞)
├── 乐观锁:Version CAS(重试)
└── 数据库原子性:UPDATE WHERE(无锁化)拼多多面试题速查表
参考资料:
《MySQL实战45讲》(丁奇)
拼多多技术团队:电商高并发库存扣减实践
MySQL 8.0 Reference Manual:InnoDB Locking