有一次帮同事看一条 SQL,4 秒。
我只做了一件事:把 select * 换成了具体的三个列,然后加了一个联合索引。EXPLAIN 的 Extra 从 Using where 变成了 Using index,时间从 4 秒掉到 60 毫秒。
他问我,这是为什么。
这个问题的答案,要从"两棵树"讲起。

一、一张表,两棵树
InnoDB 里,每一张表都有一个聚簇索引 (clustered index),可以有任意多个二级索引(secondary index)。它们都是 B+ 树,长得几乎一样,唯一的区别在叶子上。

聚簇索引的叶子,挂的是完整的一行。
text
叶子节点上:id | name | age | city | created_at | ...
所以聚簇索引的叶子,就是数据文件本身。你按主键查一行,走三层 B+ 树,到叶子就直接拿到全部列,没有任何额外动作。
二级索引的叶子,只挂索引列和主键。
假设在 name 上建了索引:
text
叶子节点上:name | id
注意,这里没有 age、没有 city。因为索引只承诺"我能快速找到 name 对应的主键",至于这一行长什么样,它不管。
这个设计是刻意的,理由很实际:
- 二级索引要小。只存两列,一页能放几百条,树矮、占内存少。
- 数据只有一份。如果每个二级索引都存整行,10 个索引就是 10 份数据,写入放大无法接受。
代价也很直接:按二级索引查到主键后,你还得再去聚簇索引取一次完整数据 。这个动作叫回表。
三个必须记住的事实
① 一张表只有一个聚簇索引,就是主键索引。
如果你没显式定义主键,InnoDB 会找一个非空的唯一索引当主键;如果找不到,它会隐式生成一个 6 字节的 row_id。所以"没有主键的表"其实是有主键的,只是你看不见------而这个看不见的主键是全局递增的,你没法控制它,也就没法利用它做范围查询。
② 二级索引一定包含主键。
即使你写 KEY idx_name (name),实际存的也是 (name, id)。这一点在算索引大小时容易被忽略。
③ 主键越短,所有二级索引都跟着变小。
主键从 bigint(8 字节)换成 char(36) 的 UUID,每个二级索引的每条记录都多 28 字节。10 个索引、1000 万行,就是 2.8GB 的额外开销。这也是为什么"别用长字符串当主键"------不是为了省主键那一棵树,而是为了省所有树。
二、回表:查到 id 只是拿到一把钥匙

回表的完整过程是这样:
sql
SELECT age FROM users WHERE name = '许涛';
- 在
idx_name上找到name = '许涛',拿到主键id = 1。 - 拿着
id = 1去聚簇索引,从根页往下走三层。 - 到叶子取出整行,拿到
age。 - 返回。
如果只匹配一行,这个代价可以接受:idx_name 走三层 + 聚簇索引走三层 = 6 次页访问。
问题是匹配多行的时候。
假设 name like '张%' 匹配 1000 行:
text
1000 次回表 × 3 次页访问 ≈ 3000 次页访问
而且这 1000 个主键在聚簇索引里不一定连续。如果它们是分散的,每次回表都是一次随机 IO:
- SSD 每次 0.1ms → 约 300ms
- 机械盘每次 8ms → 约 24 秒
这就是回表最痛的地方:它不是"多读一点数据",而是"多读很多次,而且每次都是随机的"。
对比一下,如果这 1000 行本来就在主键上连续,一次范围扫描只需要读 63 个叶子页(1000 行 × 1KB ÷ 16KB),而且是顺序读,可以被预读(read-ahead)摊薄。同样是 1000 行,代价差几十到几百倍。
什么情况下回表不痛
回表不是原罪。三种情况下它几乎不花钱:
第一,匹配行数很少。 where id = 1 这种唯一索引查询,回表就一次,完全无所谓。
第二,回表的数据已经被缓存。 如果这些页正好在 Buffer Pool 里,回表就是内存访问,0.1 微秒。
第三,主键是有序的 。InnoDB 有个优化叫 MRR(Multi-Range Read),可以先把一批主键排序,再按顺序批量回表,把随机 IO 变成近似顺序 IO。
sql
-- 打开 MRR(默认开启)
SET optimizer_switch = 'mrr=on,mrr_cost_based=off';
-- EXPLAIN 里会看到 Using MRR
EXPLAIN SELECT * FROM users WHERE age BETWEEN 20 AND 30;
所以真正该警惕的是:匹配行数多 + 数据分散 + 缓存不命中。三个条件同时成立,回表就会变成灾难。
三、覆盖索引:要的列索引里全有
既然回表的根本原因是"索引里没有我要的列",那最直接的办法就是------把列加进索引里。

回到开头那个例子:
sql
-- 索引只有 name
KEY idx_name (name)
-- 查询要 age,索引里没有 → 必须回表
SELECT age FROM users WHERE name = '许涛';
如果把索引改成 (name, age):
sql
ALTER TABLE users ADD INDEX idx_name_age (name, age);
SELECT age FROM users WHERE name = '许涛';
这时 age 已经在索引项里了,找到 name = '许涛' 就能直接读走 age,完全不需要回表。
这叫覆盖索引(covering index):查询需要的所有列,索引里都有,索引"覆盖"了这个查询。
EXPLAIN 里怎么认:
sql
mysql> EXPLAIN SELECT age FROM users WHERE name = '许涛'\G
*************************** 1. row ***************************
type: ref
key: idx_name_age
key_len: 202
rows: 1
Extra: Using index ← 关键在这一行
Extra: Using index 就是覆盖索引生效的标志。注意它和 Using where、Using index condition 是三个不同的东西:
| Extra | 含义 | 回表了吗 |
|---|---|---|
Using where |
Server 层逐行过滤 | 回了 |
Using index condition |
索引下推,部分条件在引擎层过滤 | 回了(但次数少了) |
Using index |
覆盖索引,索引里拿全了数据 | 没回 |
这三个词的区分,基本能覆盖日常 80% 的索引优化判断。
覆盖索引的代价
天下没有免费的午餐。覆盖索引的代价有三个:
第一,索引变大。 每多一列,每条索引记录就多一段。(name, age) 比 (name) 大 30% 到 50%,树可能变高一层。
第二,写变慢。 每次 insert、update,所有相关索引都要维护。5 个索引的表,写入成本大概是 1 个索引的 3 到 5 倍。
第三,可能影响其他查询。 索引列的顺序决定它能服务哪些查询。为了一个查询加的列,可能让另一个查询用不上最左前缀。
所以覆盖索引的正确用法是:为高频、列少、结果集小的查询专门定制。
反例是把整张表塞进索引:
sql
-- 这是灾难
ALTER TABLE orders ADD INDEX idx_bad (shop_id, order_no, amount, status, remark, address, ...);
索引比表还大,写入慢一倍,缓存全部被冲掉。
一个更轻的替代:延迟关联
如果查询要的列实在太多,做不了覆盖索引,还有一个折中办法------延迟关联(deferred join)。
思路是:先用覆盖索引拿到主键,再用主键回表取详情。因为主键只有少量(比如 20 个),回表次数就从"全量"降到"分页大小"。
sql
-- 优化前:60 万行全部回表,然后排序取 20 条
SELECT id, order_no, amount, address, remark
FROM orders
WHERE shop_id = 10241 AND status = 1
ORDER BY created_at DESC
LIMIT 20;
-- 优化后:先在索引里拿到 20 个 id,再回表 20 次
SELECT o.id, o.order_no, o.amount, o.address, o.remark
FROM (
SELECT id FROM orders
WHERE shop_id = 10241 AND status = 1
ORDER BY created_at DESC
LIMIT 20
) t
JOIN orders o ON o.id = t.id;
子查询里的 SELECT id 可以被 (shop_id, status, created_at) 覆盖,走 Using index,不回表;外层的 JOIN 按主键查,20 次回表,每次都是 eq_ref。
这个写法在分页场景特别有用,尤其是 limit 100000, 20 这种深分页。
四、索引下推:把过滤提前到索引里
覆盖索引解决的是"不要回表",索引下推解决的是"少回表"。

场景:
sql
SELECT * FROM users WHERE name LIKE '张%' AND age = 25;
索引是 (name, age),但查询要 select *,做不了覆盖索引,必须回表。
MySQL 5.5 及更早(没有索引下推):
- 在
idx_name_age上按name LIKE '张%'找到 1000 条候选。 - 1000 条全部回表,把整行取出来。
- Server 层逐行判断
age = 25,剩下 10 条。
1000 次回表,990 次是白干的。
MySQL 5.6 之后(默认开启索引下推):
- 在索引上按
name LIKE '张%'找到 1000 条候选。 age就在这条索引项上 ,直接在索引里判断age = 25,筛到 10 条。- 只对 10 条回表。
回表次数从 1000 降到 10。省下的 990 次,就是索引下推(Index Condition Pushdown,ICP)的全部价值。
EXPLAIN 里看这一行:
sql
mysql> EXPLAIN SELECT * FROM users WHERE name LIKE '张%' AND age = 25\G
*************************** 1. row ***************************
key: idx_name_age
rows: 1000
filtered: 1.00
Extra: Using index condition
Using index condition 就是 ICP 生效的标志。
注意一个容易搞混的点:ICP 显示的是 Using index condition,不是 Using index。
Using index= 索引覆盖了查询,完全不回表。Using index condition= 索引帮忙过滤了条件,但还是要回表。
前者是"不用去",后者是"少去几趟"。完全是两件事。
ICP 的边界
三个限制要知道:
第一,条件必须落在索引列上。 age 不在索引里,就推不下去。这也是为什么联合索引的列顺序这么重要------它决定了哪些条件能被推到引擎层。
第二,不支持子查询和存储函数。 像 where age = (select ...) 这种,ICP 无能为力。
第三,只对二级索引有效。 聚簇索引不需要下推,因为它的叶子就是整行,所有条件本来就能在引擎层判断。
可以手动验证:
sql
-- 关掉 ICP,对比 rows 和实际耗时
SET optimizer_switch = 'index_condition_pushdown=off';
EXPLAIN SELECT * FROM users WHERE name LIKE '张%' AND age = 25;
-- 再打开
SET optimizer_switch = 'index_condition_pushdown=on';
关掉之后 Extra 会变成 Using where,表示过滤回到了 Server 层。
一个真实的对比数据
我在测试库上做过一组对比,表 100 万行,(name, age) 索引,name LIKE '张%' 命中约 8000 行,age = 25 剩 37 行:
| 配置 | Extra | 回表次数 | 耗时 |
|---|---|---|---|
| ICP 关闭 | Using where | 8000 | 1.42 s |
| ICP 开启 | Using index condition | 37 | 0.011 s |
差了 129 倍。而这中间没有任何代码改动,只是版本升级后默认开了这个优化。
五、联合索引的顺序,决定谁能用上
讲回表和 ICP 的时候,反复出现"索引里有这一列吗"。而"索引里有哪些列、按什么顺序",就是联合索引要解决的问题。

索引 idx_abc (a, b, c) 在页里的排列规则是:
- 先按
a排序。 a相同的,按b排序。b相同的,按c排序。
所以:
text
(1,1,1) < (1,1,5) < (1,2,3) < (1,3,2) < (2,1,4) < (2,2,1) < (3,1,9)
注意 (1,3,2) 排在 (2,1,4) 前面------a 的优先级高于一切。
这个排序规则直接决定了四种查询的成败:
sql
-- ✅ 能用:a 和 b 连续
WHERE a = 1 AND b = 2
-- ❌ 用不上:跳过了最左的 a
WHERE b = 2
-- ✅ 能用,且省掉 filesort:a 固定后 b 天然有序
WHERE a = 1 ORDER BY b
-- ⚠️ 部分能用:a 用了范围,后面的 c 退化成逐条过滤
WHERE a > 1 AND c = 3
最后一条最容易被忽略。范围条件会截断索引的可用性 :a > 1 之后,b 和 c 就没法再用索引定位了,只能逐条判断。
所以联合索引的通用排序原则是:
等值条件在前,范围条件在后,排序条件放在能利用的位置。
三星索引
有个经典的三星评价体系,判断一个索引有多好:
第一星 :把所有等值条件的列放在索引最前面,让扫描范围最小。 第二星 :把 order by 的列也放进索引,且顺序一致,避免 filesort。 第三星:索引包含查询需要的所有列,实现覆盖。
sql
-- 查询
SELECT id, order_no FROM orders
WHERE shop_id = 10241 AND status = 1
ORDER BY created_at DESC
LIMIT 20;
-- 三星索引候选
ALTER TABLE orders ADD INDEX idx_three_star
(shop_id, status, created_at, order_no);
- 第一星:
shop_id、status是两个等值条件,放最前面。 - 第二星:
created_at紧跟其后,order by created_at DESC可以直接利用索引顺序。 - 第三星:
order_no加在最后,select的两列(id是隐含的主键)全在索引里。
这条索引一建,EXPLAIN 的 Extra 会变成 Using index,rows 也降下来了。
当然,三星齐全的索引往往偏大。实战里能拿到两星就很好了,第三星要权衡写入代价。
六、一个完整的优化过程
把上面的工具串起来,走一遍真实案例。
原始 SQL:
sql
SELECT id, order_no, amount, address
FROM orders
WHERE shop_id = 10241 AND status = 1
ORDER BY created_at DESC
LIMIT 20;
第一版:只有单列索引
sql
KEY idx_shop (shop_id)
text
type: ref, key: idx_shop, rows: 600000
Extra: Using where; Using filesort
问题:60 万行全部回表,还要额外排序。4 秒。
第二版:加联合索引
sql
ALTER TABLE orders ADD INDEX idx_shop_status_time (shop_id, status, created_at);
text
type: ref, key: idx_shop_status_time, rows: 583000
Extra: Using where
filesort 消失了,因为 created_at 的顺序可以直接利用。但 rows 还是 58 万------因为 status = 1 在这个店铺里占了 97%,优化器的估算认为走索引再回表不如全表扫。
时间降到 1.1 秒,还是慢。
第三版:覆盖索引 + 延迟关联
sql
-- 先只取主键,这部分能被索引完全覆盖
SELECT o.id, o.order_no, o.amount, o.address
FROM (
SELECT id FROM orders
WHERE shop_id = 10241 AND status = 1
ORDER BY created_at DESC
LIMIT 20
) t
JOIN orders o ON o.id = t.id;
text
-- 子查询
type: ref, key: idx_shop_status_time, rows: 20
Extra: Using index
-- 外层
type: eq_ref, key: PRIMARY, rows: 1
Extra: NULL
60 毫秒。
关键不是"改写 SQL 更快",而是:子查询被索引完全覆盖,只读了 20 行索引;外层按主键取 20 次,每次都是等值查找,几乎不花钱。 中间那 58 万行,一次都没回表。
第四版(可选):如果 address 也不需要,直接省掉 JOIN
sql
SELECT id, order_no, amount
FROM orders
WHERE shop_id = 10241 AND status = 1
ORDER BY created_at DESC
LIMIT 20;
配合 (shop_id, status, created_at, order_no) 索引,直接 Using index,20 毫秒以内。
这也说明一件事:很多时候最快的优化是"少查几个字段"。
七、能带走的三件事
第一,回表的代价是"次数 × 随机性",不是"数据量"。 1000 次随机回表可能比 10 万行顺序扫描还慢。所以优化的方向永远是两个:减少次数(索引下推、更精确的索引),或者消除回表(覆盖索引、延迟关联)。
第二,Using index 和 Using index condition 是两个世界。 前者不回表,后者少回表。看到 Using where 就该警觉------那说明所有过滤都发生在 Server 层,索引只帮你缩小了范围,没帮你筛掉任何一行。
第三,索引列的顺序是给查询模式排优先级。 等值在前、范围在后、排序能用上、能覆盖就覆盖。这四句话能解决大部分联合索引的设计问题。至于列到底放几列,回到上一篇的容量算法:每多一列,索引就大一点,树就可能高一层。没有免费的覆盖。
MySQL 的性能问题,九成是"读了多少页"和"读的页在哪"的问题。索引管前者,Buffer Pool 管后者。