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灾难

执行流程拆解

  1. 索引定位:通过idx_shop_category找到shop_id=10086 AND category_id=10的数据(假设5000条)

  2. 回表灾难SELECT *需要回表5000次取完整数据(因为二级索引不含title/price

  3. 内存排序:在sort_buffer中对5000行按create_time排序(Filesort

  4. 丢弃数据:取前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)

  1. 存储引擎通过索引找到5000个ID

  2. 回表5000次取完整数据,传给Server层

  3. Server层过滤status=1,只剩200条

ICP优化(MySQL 5.6+)

  1. 存储引擎找到5000个ID

  2. 在引擎层直接检查status=1(虽然status不在索引列,但ICP允许将条件下推)

  3. 只回表200次,减少数据传输

识别方法EXPLAINExtra列显示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锁机制全景图

锁类型

触发方式

锁定范围

用途

Record Lock

命中唯一索引

单行记录

防修改

Gap Lock

范围查询/唯一键不存在

索引间隙(开区间)

防幻读(禁止insert)

Next-Key Lock

RR隔离级别默认

记录+间隙(左开右闭)

防修改+防幻读

表锁

无索引字段查询

全表

全表保护

追问**:如果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;

为什么这是终极方案?

  1. 原子性:UPDATE是单条SQL,数据库保证不可分割

  2. 当前读count > 0判断读取的是最新提交数据(非快照),且受X锁保护

  3. 无锁竞争:无需SELECT FOR UPDATE,无需version字段,无锁化实现线程安全

Java代码

@Transactional
public boolean deduct(Long skuId) {
    int rows = stockMapper.decreaseWithCondition(skuId);
    return rows > 0;  // rows=0表示库存不足或并发冲突
}

2.4 快照读 vs 当前读(面试高频)

类型

SQL示例

是否加锁

读取版本

场景

快照读

SELECT ...

历史版本(ReadView)

普通查询,性能高

当前读

SELECT ... FOR UPDATE / UPDATE / DELETE

是(X锁)

最新版本

需要最新数据且防并发

关键认知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(无锁化)

拼多多面试题速查表

问题

关键词

易错点

深分页LIMIT 100000,20如何优化?

延迟关联/游标分页

只说"用覆盖索引"不够,需讲清回表次数差异

SELECT FOR UPDATE没走索引会怎样?

表锁

必须强调"无索引则全表加Next-Key Lock"

乐观锁的ABA问题如何解决?

版本号+时间戳

不能只说version,要讲清version回绕风险

为什么UPDATE WHERE count>0能防超卖?

当前读+原子性

区分快照读(SELECT)和当前读(UPDATE)

唯一索引和普通索引的锁区别?

行锁 vs Next-Key Lock

唯一索引命中是Record Lock,普通索引是Next-Key Lock


参考资料

  • 《MySQL实战45讲》(丁奇)

  • 拼多多技术团队:电商高并发库存扣减实践

  • MySQL 8.0 Reference Manual:InnoDB Locking