← 返回博客
mysql2026-09-07 19:12:256 分钟 · 1,773 0

MySQL 索引底层(二):聚簇索引、二级索引与回表

InnoDB 的表本身就是一棵 B+ 树——这是理解一切索引行为的总开关。这篇拆开聚簇索引和二级索引的叶子存了什么,回表的成本从哪来,联合索引 (a,b,c) 的列顺序在树里怎么排,以及索引下推省了哪一步。

上一篇推完了 B+ 树本身:非叶子只存键,叶子存数据、串成链表。但 InnoDB 里"数据"这个词要拆开说——聚簇索引的叶子存整行,二级索引的叶子只存主键。这一个差别,衍生出回表、覆盖索引、最左前缀、索引下推这一整串面试高频词。这篇把它们一次讲透,全部从树的结构推出来,不需要背。

总开关:表就是树

InnoDB 的表不是"文件里一堆行 + 独立的索引",而是表本身就是一棵 B+ 树——按主键组织,叶子节点按主键有序排列着整行数据。这棵树叫聚簇索引(clustered index)。

由这个定义直接推出三个后果:

  1. 按主键查询最快:在树上一路走到叶子,整行就在那里,查一次结束。
  2. 主键顺序写入最快:新行的主键总比已有的大,永远插在 B+ 树最右侧叶子的末尾,顺序追加。这就是"自增主键"被反复推荐的结构性原因。
  3. 主键不能太长、不能频繁改:后面会看到,主键被拷贝进每一棵二级索引;主键一改,二级索引全部要动。

没有主键的表 InnoDB 也不允许"无序存放":取第一个非空唯一索引当聚簇索引;都没有就生成一个 6 字节的隐藏列 row_id 兜底。row_id 全局共享一个递增计数器,写热点集中,所以"建表必设自增主键"不是教条,是给 InnoDB 一条干净的追加路径。

二级索引:另一棵树,叶子只放主键

给 phone 字段建索引,InnoDB 会另建一棵 B+ 树:键是 phone,叶子按 phone 有序,但叶子里存的是主键值,不是整行数据

为什么存主键而不是行的物理位置(比如页号 + 偏移)?因为行会动——页分裂会把一批行挪到新页,物理位置全变。而主键不变。用主键做两级跳转,二级索引树永远稳定,代价只是多走一步。

这一步就是回表:在二级索引树上查到主键 5,再回到聚簇索引树上,用主键把整行取出来。一次逻辑查询,两棵树各走一遍。

SELECT * FROM users WHERE phone = '13800138000';
-- EXPLAIN: type=ref, key=idx_phone
-- 执行流程:idx_phone 树定位 phone → 拿到 id=5
--          → 聚簇索引树用 id=5 取整行

回表的成本取决于命中行数。命中 1 行,多读一个页,无感;命中 1 万行,就是 1 万次聚簇索引树查找——如果这 1 万个主键在磁盘上不连续,那就是 1 万次接近随机的读。很多"加了索引反而更慢"的案例,慢的不是索引树,是后面那一长串回表。优化器估算回表量太大时会直接放弃索引走全表扫描,这是第三篇的主题之一。

覆盖索引:不回表的查询

既然回表是因为"二级索引叶子上缺列",那把查询要的列全放进索引,回表就不会发生:

-- idx_status_create (status, create_time)
SELECT id, create_time FROM orders
WHERE status = 2 AND create_time > '2026-09-01';

-- EXPLAIN Extra: Using index  ← 覆盖索引生效

status 和 create_time 都在二级索引叶子上,主键 id 是叶子自带的,三个字段齐了,聚簇索引树一次都不用碰。高频接口上这一招收益明显:我见过一个订单列表页,加了覆盖索引后接口从 180ms 降到 12ms,瓶颈正是几十万行级别的回表。

设计覆盖索引时注意主键会跟着每一行:二级索引叶子的每条记录 = 索引列 + 主键 + 回滚指针等。主键用 bigint(8B)没问题,用 36 字节的 UUID 字符串,每棵二级索引每个叶子都被撑胖三分之一以上,扇出下降、树变高——这就是"主键要短"的算术来源。

联合索引:多列在树里怎么排

建一个 (a, b, c) 的联合索引,InnoDB 生成一棵树,键是三元组,排序规则:先按 a 排,a 相同按 b 排,a、b 都相同按 c 排。看几行示意数据就明白:

(1, 1, 1)
(1, 1, 5)
(1, 3, 2)   ← a=1 的行里,b 才有序
(2, 1, 9)   ← a 换了,b 重新从无序开始
(2, 2, 4)

这份数据回答了所有"哪条查询能用上索引"的问题——索引能用上多少,取决于查询条件能不能沿着这个排序"走"下去

  • WHERE a = 1:a 有序,能用。
  • WHERE a = 1 AND b = 3:a 定位后 b 在 a=1 内部有序,能用。
  • WHERE b = 3:b 只在各自的 a 分组内有序,全局看 (1,3) 和 (2,3) 中间隔着 (1,5) 等无数行——跳过 a 直接找 b,树帮不上忙,全表扫描。
  • WHERE a = 1 AND c = 2:b 缺席,c 的有序性被 b 隔断,只用上 a。
  • WHERE a = 1 AND b > 2 AND c = 1:a、b 都用上了,但 b 是范围条件,b > 2 命中的行里 c 又乱了序——c 用不上索引定位,只能逐行过滤。范围列后面的列断序,这条决定联合索引列顺序的黄金法则:等值列放前,范围列放后。

这就是"最左前缀原则"的全部内容。它不是一条要背的规则,是三元组排序的直接推论。

索引下推:把过滤推到引擎层

MySQL 5.6 之前有个尴尬:WHERE name LIKE '张%' AND age = 25,索引 (name, age) 明明两列都有,但 LIKE 是范围条件,按上面的规则 age 断序。执行流程是:引擎在索引树上找到所有姓张的行 → 逐行回表取出整行 → 返回 server 层,由 server 层过滤 age。

5.6 引入索引下推(ICP, Index Condition Pushdown):age 就躺在索引叶子上,判断 age = 25 根本不需要整行——引擎在回表之前先在索引里把 age 过滤掉,只对通过筛选的行回表。

-- EXPLAIN Extra:
-- 5.6+ : Using index condition  ← ICP 生效
-- 更老 : Using where(全量回表后过滤)

姓张的有 1 万人、其中 25 岁的有 50 人:ICP 把回表次数从 1 万压到 50。索引列上的过滤越早做,浪费的回表越少。

小结

聚簇索引 = 表本身,叶子存整行,所以按主键查最快、自增写最快;二级索引 = 另一棵树,叶子只存主键,缺的列靠回表补;把要查的列都放进索引就消掉了回表(覆盖索引);联合索引是一串按"先 a 后 b 再 c"排序的三元组,能用上多少由排序能否连续走出条件决定(最左前缀);ICP 把 server 层的过滤搬回引擎层,在回表前止损。下一篇转到写路径:写入时页怎么分裂、change buffer 攒了什么、以及 EXPLAIN 里那些失效场景长什么样。

相关推荐

本文为原创文章,采用CC BY-NC-SA 4.0协议授权,转载请保留署名与原文链接。原文链接:https://www.wxbuluo.com/article/173