聚簇索引 vs 二级索引:回表、覆盖索引、索引下推

有一次帮同事看一条 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 = '许涛';
  1. 在 idx_name 上找到 name = '许涛',拿到主键 id = 1。
  2. 拿着 id = 1 去聚簇索引,从根页往下走三层。
  3. 到叶子取出整行,拿到 age。
  4. 返回。

如果只匹配一行,这个代价可以接受: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 及更早(没有索引下推):

  1. 在 idx_name_age 上按 name LIKE '张%' 找到 1000 条候选。
  2. 1000 条全部回表,把整行取出来。
  3. Server 层逐行判断 age = 25,剩下 10 条。

1000 次回表,990 次是白干的。

MySQL 5.6 之后(默认开启索引下推):

  1. 在索引上按 name LIKE '张%' 找到 1000 条候选。
  2. age 就在这条索引项上 ,直接在索引里判断 age = 25,筛到 10 条。
  3. 只对 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) 在页里的排列规则是:

  1. 先按 a 排序。
  2. a 相同的,按 b 排序。
  3. 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 管后者。

相关推荐
怪我冷i1 小时前
Rust语言——关联类型
1024程序员节
深圳老胡1 小时前
STM32 开发中 Trae 内置模型怎么选:Seed、MiniMax、GLM、DeepSeek、Kimi、Qwen 全对比
笔记·stm32·单片机·嵌入式硬件·代码规范·1024程序员节
喵了几个咪1 小时前
GoWind Admin 拆解:一个 Sender 接口接 39 个通知渠道
微服务·golang·go·1024程序员节·admin·gowind
.Hypocritical.1 小时前
JUC + 三大设计模式(单例、工厂、代理)
设计模式·juc·1024程序员节
bksczm1 小时前
Redis数据结构之hash
数据结构·redis·哈希算法·1024程序员节
2601_967987371 小时前
SvelteKit Superforms 中 SPA 模式下页面重定向问题的分析与解决
spa·1024程序员节·sveltekit·superforms·重定向问题·jsonschema
ERP管理软件1 小时前
【致远】致远协同提示当前安装版本已过期。
1024程序员节·当前版本已过期
传感器与混合集成电路1 小时前
多计算节点怎么融合?全国产通用融合计算机三子卡架构与任务配置方法
嵌入式硬件·1024程序员节
HAPPY酷1 小时前
C++ 死锁调试全流程实操手册:从复现到 GDB 定位
1024程序员节