索引下推(Index Condition Pushdown)
一、从最基础的概念开始:数据库是如何查数据的?
在正式讲索引下推之前,我们需要先建立一些基础认知。
1.1 数据库中的数据存在哪里?
数据库里的数据最终都存储在磁盘上。你可以把磁盘想象成一个巨大的仓库,数据一行一行地存放在里面。当你执行一条 SQL 查询语句时,数据库需要从这个仓库里把符合条件的数据找出来,返回给你。
最笨的方式就是:从头到尾把仓库里的每一行数据都取出来看一看,符合条件的留下,不符合的扔掉。这种方式叫做全表扫描。
全表扫描的问题很明显:如果仓库里有一亿行数据,而你只需要其中十行,数据库却要把一亿行数据全部看一遍,效率极低。
1.2 索引是什么?
为了解决全表扫描效率低的问题,数据库引入了索引这个概念。
索引就像是一本书的目录。如果你想在一本 500 页的书里找"索引下推"这个词,你不会从第 1 页翻到第 500 页,而是直接查目录,目录告诉你这个词在第 238 页,你直接翻到第 238 页就行了。
数据库的索引也是一样的道理。它是一种特殊的数据结构(MySQL 中最常用的是 B+ 树 ),这个数据结构存储了某一列(或某几列)的值,以及这些值对应的数据行在磁盘上的位置(我们叫它主键值 或者行指针)。
有了索引,数据库就可以:
- 先去索引里快速找到满足部分条件的行的位置;
- 再根据这个位置去磁盘(或者主表)里取出完整的数据行。
1.3 两种常见的索引类型
在 MySQL InnoDB 引擎中,索引主要分为两种:
主键索引(聚簇索引):
- 索引的叶子节点直接存储了整行数据。
- 通过主键查询时,找到索引节点,就找到了完整数据,不需要额外的步骤。
非主键索引(二级索引 / 辅助索引):
- 索引的叶子节点存储的是索引列的值 + 主键值,并不存储完整数据。
- 通过二级索引查询时,找到索引节点后,只能得到主键值,还需要拿着这个主键值再去主键索引中查一次,才能得到完整的行数据。
- 这个"拿着主键再去查一次"的过程,有一个专业名词叫做回表(Back to Table)。
二、什么是联合索引?
索引下推主要发生在联合索引的场景下,所以我们必须先搞清楚联合索引是什么。
2.1 联合索引的定义
联合索引,就是把多个列组合在一起建立的索引。
比如我们有一张用户表 users:
sql
CREATE TABLE users (
id INT PRIMARY KEY,
name VARCHAR(50),
age INT,
city VARCHAR(50),
email VARCHAR(100)
);
我们在 name 和 age 两个列上建立联合索引:
sql
CREATE INDEX idx_name_age ON users(name, age);
2.2 联合索引是如何存储数据的?
联合索引在 B+ 树中,是按照索引列的顺序依次排序的。
对于 (name, age) 这个联合索引,排序规则是:
- 先按
name排序; name相同时,再按age排序。
假设表里有以下数据:
| id | name | age | city |
|---|---|---|---|
| 1 | 张三 | 25 | 北京 |
| 2 | 张三 | 28 | 上海 |
| 3 | 李四 | 22 | 广州 |
| 4 | 王五 | 30 | 北京 |
| 5 | 张三 | 32 | 深圳 |
那么 idx_name_age 这个联合索引中,叶子节点按照如下顺序存储:
(李四, 22) -> id=3
(王五, 30) -> id=4
(张三, 25) -> id=1
(张三, 28) -> id=2
(张三, 32) -> id=5
可以看到:所有"张三"被放在一起,然后在"张三"内部,按 age 从小到大排列。
2.3 最左前缀原则
联合索引有一个重要的使用规则叫最左前缀原则:查询条件必须从联合索引最左边的列开始,才能使用到这个索引。
WHERE name = '张三'------ 可以用索引(使用了最左列 name)WHERE name = '张三' AND age = 25------ 可以用索引(使用了 name 和 age)WHERE age = 25------ 不能用这个索引(跳过了最左列 name)
三、没有索引下推时,查询是怎么执行的?
现在我们有了足够的背景知识,来看一个具体的查询:
sql
SELECT * FROM users WHERE name LIKE '张%' AND age = 28;
这条 SQL 的意思是:查找所有姓名以"张"开头、并且年龄等于 28 的用户。
3.1 执行流程分析(不使用索引下推)
在没有索引下推的情况下,MySQL 的执行过程分为两个角色:存储引擎层 和Server 层。
- 存储引擎层:负责实际的数据存储和索引的维护,是真正和磁盘打交道的地方。
- Server 层:负责 SQL 解析、优化、以及执行查询逻辑(包括 WHERE 条件的过滤)。
在旧的执行方式下,两层的分工是这样的:
第一步:存储引擎层使用索引定位数据
存储引擎接到查询请求,使用 idx_name_age 联合索引,根据 name LIKE '张%' 这个条件,在 B+ 树中找到所有 name 以"张"开头的索引项:
(张三, 25) -> id=1
(张三, 28) -> id=2
(张三, 32) -> id=5
找到了 3 个匹配的索引项。
第二步:逐条回表
存储引擎对找到的每一个索引项,都要拿着主键值去主键索引(完整数据)里查一次,取出完整的行数据,然后返回给 Server 层。
- 拿着 id=1,回表查询,得到
{id:1, name:'张三', age:25, city:'北京', email:'...'} - 拿着 id=2,回表查询,得到
{id:2, name:'张三', age:28, city:'上海', email:'...'} - 拿着 id=5,回表查询,得到
{id:5, name:'张三', age:32, city:'深圳', email:'...'}
第三步:Server 层过滤
Server 层拿到这 3 行完整数据后,再用 age = 28 这个条件进行过滤,只保留 age 等于 28 的行:
最终结果:{id:2, name:'张三', age:28, city:'上海', email:'...'}
3.2 问题在哪里?
你有没有发现一个浪费?
在第二步,存储引擎回表了 3 次(id=1, id=2, id=5 各一次),取出了 3 条完整数据。但其中 id=1 和 id=5 的数据,在第三步被 Server 层丢弃了,因为它们的 age 不等于 28。
也就是说,id=1 和 id=5 的回表操作是多余的、浪费的。
关键点来了:age 这个条件,实际上在索引里就已经有了 !你看索引里存的是 (张三, 25)、(张三, 28)、(张三, 32),age 的值就在索引节点里。
既然索引里就能看出 age=25 和 age=32 都不等于 28,为什么不在回表之前就把它们排除掉,只回表查 age=28 的那一条呢?
这就是索引下推要解决的问题。
四、索引下推:把条件"下推"到存储引擎层
4.1 索引下推的核心思想
索引下推(Index Condition Pushdown,简称 ICP)的核心思想是:
把原本由 Server 层负责的、可以在索引层面就判断的 WHERE 条件,提前下沉到存储引擎层去执行,从而减少不必要的回表次数。
"下推"这个词,说的就是把条件从上层(Server 层)推到下层(存储引擎层)。
4.2 使用索引下推时,查询如何执行?
还是同样的查询:
sql
SELECT * FROM users WHERE name LIKE '张%' AND age = 28;
第一步:存储引擎层使用索引定位候选项
同样地,存储引擎用 idx_name_age 索引,找到所有 name 以"张"开头的索引项:
(张三, 25) -> id=1
(张三, 28) -> id=2
(张三, 32) -> id=5
第二步:在索引层面直接判断 age 条件(这是关键!)
开启索引下推后,存储引擎不急着回表 ,而是先拿着手头的索引数据,继续判断剩余的 WHERE 条件 age = 28。
(张三, 25):age=25,不等于 28,直接跳过,不回表(张三, 28):age=28,等于 28,需要回表(张三, 32):age=32,不等于 28,直接跳过,不回表
第三步:只对满足全部索引条件的行进行回表
只有 id=2 满足条件,存储引擎只回表一次,取出完整数据:
{id:2, name:'张三', age:28, city:'上海', email:'...'}
第四步:Server 层接收数据
Server 层接到存储引擎返回的数据,已经是过滤后的结果了,直接返回给用户。
4.3 前后对比总结
| 步骤 | 无索引下推 | 有索引下推 |
|---|---|---|
| 索引扫描 | 扫描 3 条索引项 | 扫描 3 条索引项 |
| 在索引层过滤 age | ❌ 不过滤 | ✅ 过滤掉 2 条 |
| 回表次数 | 3 次 | 1 次 |
| 返回给 Server 层 | 3 行完整数据 | 1 行完整数据 |
| Server 层过滤 | 过滤后剩 1 行 | 直接就是 1 行 |
回表次数从 3 次降低到 1 次,节省了 2 次磁盘 I/O 操作。
在实际业务中,如果符合第一个索引列条件(name LIKE '张%')的数据有 10 万条,但同时满足 age = 28 的只有 1000 条,那么索引下推可以减少 99000 次回表操作,性能提升是极其显著的。
五、索引下推的适用条件
索引下推并不是在所有情况下都会生效,它有一些具体的适用条件。
5.1 必须使用二级索引(非主键索引)
索引下推的价值在于减少回表。如果查询走的是主键索引(聚簇索引),索引节点本身就包含了完整的行数据,根本不需要回表,自然也就不需要索引下推了。
所以,索引下推只对二级索引有意义。
5.2 WHERE 条件中必须有可以在索引上判断的列
如果 WHERE 条件里的列根本不在索引里,存储引擎就没有办法提前判断,条件只能由 Server 层来处理。
以我们的 idx_name_age (name, age) 为例:
age = 28:在索引里,可以下推 ✅city = '上海':不在索引里,不能下推 ❌
5.3 索引下推对覆盖索引无效(也不需要)
覆盖索引是指:查询所需要的所有列,都在索引中包含了,不需要回表。
比如:
sql
SELECT name, age FROM users WHERE name LIKE '张%' AND age = 28;
这条 SQL 只需要 name 和 age 两列,而这两列都在 idx_name_age 索引里,根本不需要回表。既然不需要回表,索引下推也就没有意义了(当然,对于覆盖索引,存储引擎仍然会在索引上做条件判断,但这属于"索引过滤",不涉及减少回表的优化)。
5.4 联合索引中,只有"非前导列"的条件才需要下推
在联合索引 (name, age) 中:
name是前导列(第一列)age是非前导列(第二列)
name LIKE '张%' 这个条件,存储引擎在 B+ 树中定位数据时就已经用到了,这是索引的导航作用。
age = 28 这个条件,在 B+ 树的导航过程中没有被用到(因为 name LIKE '张%' 是范围查询,确定范围后,age 的全局排序就没有意义了),只能作为"额外的过滤条件"------索引下推就负责在这里把它提前处理掉。
六、如何验证索引下推是否生效?
6.1 使用 EXPLAIN 查看执行计划
执行 EXPLAIN 可以查看 MySQL 对一条 SQL 的执行计划:
sql
EXPLAIN SELECT * FROM users WHERE name LIKE '张%' AND age = 28;
在输出结果中,有一列叫 Extra,如果索引下推生效,你会看到:
Using index condition
这就是索引下推生效的标志。
相比之下,如果是普通的索引扫描 + 回表(无下推),Extra 列通常是空的或者显示 Using where。
6.2 使用 optimizer_switch 手动开关索引下推
MySQL 5.6 及以上版本默认开启索引下推。你也可以手动控制它:
sql
-- 关闭索引下推
SET optimizer_switch = 'index_condition_pushdown=off';
-- 开启索引下推(默认)
SET optimizer_switch = 'index_condition_pushdown=on';
通过对比开关前后的执行计划和实际查询耗时,可以直观地感受索引下推带来的性能提升。
七、一个完整的例子,把所有内容串起来
让我们用一个更完整的例子,把上面所有内容串联起来理解。
场景设定
假设有一张电商平台的商品表:
sql
CREATE TABLE products (
id INT PRIMARY KEY AUTO_INCREMENT,
brand VARCHAR(50), -- 品牌
price DECIMAL(10,2), -- 价格
stock INT, -- 库存
description TEXT -- 商品描述
);
-- 在 brand 和 price 上建立联合索引
CREATE INDEX idx_brand_price ON products(brand, price);
表中有 100 万条商品数据。
查询需求
查找所有品牌名以"华"开头、价格在 1000 到 2000 之间的商品:
sql
SELECT * FROM products WHERE brand LIKE '华%' AND price BETWEEN 1000 AND 2000;
执行过程分析
联合索引 idx_brand_price 中,数据排列方式:
先按 brand 排序,brand 相同时按 price 排序:
...
(华为, 899.00) -> id=10234
(华为, 1299.00) -> id=10235
(华为, 1999.00) -> id=10236
(华为, 2599.00) -> id=10237
(华硕, 1099.00) -> id=20100
(华硕, 1599.00) -> id=20101
(华硕, 3200.00) -> id=20102
...
不使用索引下推的过程:
- 存储引擎通过
brand LIKE '华%'找到所有品牌以"华"开头的索引项,假设有 5 万条 - 对这 5 万条索引项全部回表,取出完整数据,共 5 万次 I/O
- Server 层对 5 万条数据再用
price BETWEEN 1000 AND 2000过滤,假设最终只剩 8000 条 - 其中 4.2 万次回表是无意义的浪费
使用索引下推的过程:
- 存储引擎通过
brand LIKE '华%'找到所有品牌以"华"开头的索引项,假设有 5 万条 - 在索引层面 直接判断
price BETWEEN 1000 AND 2000,5 万条中有 4.2 万条价格不在范围内,直接排除 - 只剩 8000 条满足两个条件,只对这 8000 条进行回表,共 8000 次 I/O
- 取出 8000 条完整数据返回给 Server 层
效果:回表次数从 5 万次降低到 8000 次,减少了 84% 的磁盘 I/O!
八、索引下推的本质是什么?
现在我们可以从更本质的角度来理解索引下推。
8.1 本质是职责的重新划分
在没有索引下推之前,MySQL 的分层架构中存在一个"浪费":
- 存储引擎层只负责"根据索引导航,找到可能的数据行,逐一回表";
- Server 层负责"拿到完整数据后,用 WHERE 条件过滤"。
这种分工方式,导致很多"其实在索引层面就可以判断为不符合条件的行",仍然被存储引擎层老老实实地回表取出来,再由 Server 层丢掉。
索引下推的出现,重新定义了存储引擎层的职责:存储引擎层不再是"只会导航"的底层工人,而是具备了"在索引上提前过滤"的能力。
8.2 本质是减少磁盘 I/O
回表操作之所以昂贵,是因为它意味着随机的磁盘 I/O。每次回表,都要根据主键值,去磁盘上的某个位置读取数据,而这些位置可能分散在磁盘的各个地方(随机 I/O 比顺序 I/O 慢得多)。
索引下推通过减少回表次数,直接减少了随机磁盘 I/O 的次数,这是它能显著提升查询性能的根本原因。
8.3 它适用于"索引列有剩余过滤能力"的场景
当你的联合索引中,第一列用于范围查询 (如 LIKE '张%'、BETWEEN、>、< 等),此时第二列的排序在索引内部是局部有序而非全局有序的,B+ 树的导航能力对第二列的过滤作用有限。索引下推正是在这种场景下,充分利用了索引中存储的第二列(及以后的列)的值,进行额外的过滤,避免了大量无意义的回表。
九、总结
让我们用最简洁的语言,把索引下推的全部要点总结一遍:
是什么:
索引下推(ICP)是 MySQL 5.6 引入的一种查询优化技术,允许存储引擎在扫描索引时,就对 WHERE 条件中涉及索引列的条件进行判断,提前过滤掉不满足条件的索引项,从而减少回表次数。
解决什么问题:
解决了联合索引场景下,多余的回表操作带来的性能浪费问题。
核心机制:
将原本由 Server 层执行的、可以在索引层面判断的条件,"下推"到存储引擎层,在回表之前就完成过滤。
什么时候最有用:
- 使用了联合索引;
- WHERE 条件中第一列是范围条件(导致第二列在全局上无序);
- 后续列的条件能过滤掉大量数据(选择性高);
- 最终需要回表获取完整数据(非覆盖索引查询)。
如何验证:
使用 EXPLAIN 查看执行计划,Extra 列出现 Using index condition 表示索引下推已生效。
记住这一句话:
索引下推,就是让存储引擎在"拿着号码牌去仓库取货"之前,先把手里的号码牌过滤一遍,把明显不符合要求的号码牌直接扔掉,只拿着真正需要的号码牌去取货。
这样,跑腿取货的次数更少,查询自然就更快了。