上一篇推完了 B+ 树本身:非叶子只存键,叶子存数据、串成链表。但 InnoDB 里"数据"这个词要拆开说——聚簇索引的叶子存整行,二级索引的叶子只存主键。这一个差别,衍生出回表、覆盖索引、最左前缀、索引下推这一整串面试高频词。这篇把它们一次讲透,全部从树的结构推出来,不需要背。
总开关:表就是树
InnoDB 的表不是"文件里一堆行 + 独立的索引",而是表本身就是一棵 B+ 树——按主键组织,叶子节点按主键有序排列着整行数据。这棵树叫聚簇索引(clustered index)。
由这个定义直接推出三个后果:
- 按主键查询最快:在树上一路走到叶子,整行就在那里,查一次结束。
- 主键顺序写入最快:新行的主键总比已有的大,永远插在 B+ 树最右侧叶子的末尾,顺序追加。这就是"自增主键"被反复推荐的结构性原因。
- 主键不能太长、不能频繁改:后面会看到,主键被拷贝进每一棵二级索引;主键一改,二级索引全部要动。
没有主键的表 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 里那些失效场景长什么样。