MySQL索引底层原理:主键与联合索引优化实战
2026/9/7 21:13:16 网站建设 项目流程

这周帮同事排查一条慢查询的时候,我发现他的表在联合索引上建错了顺序,导致一个原本应该毫秒级返回的查询硬生生跑成了三秒多。这个场景我想大家都不陌生——人人都知道“索引能优化查询”,但真正到了设计主键、设计联合索引的时候,往往就只剩下“把常用字段放前面”这一句话。至于为什么放前面、放错了会怎样、主键到底应该怎么选,很少有人能讲透。今天这篇就来把 MySQL 中主键索引和联合索引的原理掰开揉碎聊一遍,你会理解 B+ 树到底长什么样、聚簇索引和非聚簇索引有什么区别、联合索引为什么要求最左前缀,以及如何用 EXPLAIN 判断自己的索引设计是否合理。内容不绕弯子,尽量用大白话还原底层逻辑,适合所有写过 MySQL 的开发者,也适合准备面试时临时抱佛脚的读者。

1. 索引的底层世界观:一张按顺序排好的目录

1.1 没有索引时,MySQL 是怎么找数据的

MySQL 的数据存储在磁盘上,InnoDB 引擎把数据组织成一个个 16KB 的页(Page),页之间通过指针串联。没有索引时,你要查一行数据,数据库只能从第一个页开始,一个页一个页读下去,每一行都做比较,直到找到匹配的记录。这条路径叫全表扫描(Full Table Scan)。表只有几百行时感觉不到问题,但表到了千万行,每页假设存几百行,一次查询可能先后读上万个页,磁盘 IO 的耗时自然就上去了。

磁盘 IO 和内存访问不一样,内存随机读也许只要几十纳秒,而磁盘一次随机读大概要几毫秒,差了好几个数量级。所以“减少 IO 次数”就成了索引设计的核心目标。索引本质上就是把数据的关键信息单独提取出来,做成一张有序的目录,查询时先翻目录,再按目录上的地址去取数据,这样就不用把整本书从头翻到尾了。

1.2 B+ 树:MySQL 默认的索引结构

MySQL 的 InnoDB 引擎默认使用 B+ 树来组织索引。B+ 树是一种平衡多路查找树,它的特点是:非叶子节点只保存索引键和指向子节点的指针,不保存实际数据;真正的数据或主键值都保存在叶子节点上,并且所有叶子节点通过双向链表串在一起。

这句话有很多人听过,但你要理解它为什么这么设计。一个 16KB 的页能容纳很多索引键,假设每个索引键加上指针占 20 字节,那么一个非叶子节点就能存大约 800 个键。三层 B+ 树就能存下 800×800×800,也就是五亿多条记录。换句话说,一个几千万行的大表,你从根节点出发,最多只需要三次磁盘 IO 就能定位到叶子节点。第一层读根节点,第二层读中间节点,第三层读叶子节点。这是 B+ 树相对于 B 树和二叉树最大的优势:树矮、层数少、IO 少。

叶子节点用双向链表相连,又让范围查询变得非常顺畅。比如你要查 id 从 100 到 200 的所有记录,B+ 树只需要先定位到 100 所在的叶子节点,然后顺着链表往后读就行,不需要一层一层重新查找。

1.3 聚簇索引和非聚簇索引到底差在哪

这里需要引入两个概念:聚簇索引(Clustered Index)和二级索引(Secondary Index,也叫辅助索引)。聚簇索引的叶子节点存的是整行数据。二级索引的叶子节点存的是索引键 + 主键值。所以通过二级索引查询时,如果想要的字段不在二级索引的叶子节点里,就要拿着主键再回聚簇索引查一次,这一步叫回表(Bookmark Lookup)。

主键索引在 InnoDB 里天然就是聚簇索引,二级索引则对应我们平时给普通字段建的索引。有人会觉得“主键索引不过是一个普通索引”,其实不是。聚簇索引决定了整个表的数据在物理磁盘上的排列顺序,它不仅仅是加速查询,还决定了表的数据组织方式。

了解这些之后,接下来两部分我们分别深入主键索引和联合索引。顺序上先聊主键,因为联合索引的叶子节点里存的就是主键值,主键怎么设计,会直接影响所有二级索引的体量。

2. 主键索引:整张表的“骨架”

2.1 数据行为什么长在主键索引的叶子上

InnoDB 表的数据行并不是独立散落在磁盘上的,它们就是按主键顺序存储在聚簇索引的叶子节点里的。你创建一张表并定义主键时,InnoDB 会以该主键为聚簇索引来构建整个表结构。如果没有显式主键,InnoDB 会找一个非空唯一索引来充当聚簇索引;如果也没有,它就会生成一个隐藏的 6 字节 rowid 作为聚簇索引。

这个机制带来的直接后果是:主键的顺序基本决定了数据的物理写入顺序。插入一条记录时,InnoDB 会把它放到对应主键位置所在的页上,如果页满了就要做页分裂(Page Split)。后面讲到 UUID 主键的危害时会详细展开。

2.2 主键等值查询为什么快

当你执行SELECT * FROM user WHERE id = 123时,InnoDB 的查询流程是:从 B+ 树根节点开始,比较 id 与根节点里存储的键值,确定应该走哪个子节点,逐层下探,到达叶子节点后用二分法在当前页内定位到目标记录,然后直接返回整行数据。全程不需要回表,因为叶子节点已经包含了该行的所有字段。

这就是聚簇索引最大的优势:按主键查询时,一条查询的 IO 次数等于 B+ 树的高度。对千万级表来说是 3 到 4 次磁盘 IO,和逻辑上扫描几十万甚至几百万行相比,不是一个数量级。

2.3 自增主键真的更好吗

我见过不少团队在主键选择上全凭习惯:有的用业务编号,有的用 UUID,有的用雪花 ID。从搜索性能和写入性能两个角度看,自增主键通常是最稳妥的选择。

自增主键是严格递增的,新插入的行总是排在上一条数据的后面,追加写入即可,不需要频繁移动已有数据。UUID 是随机的字符串,插入位置完全随机,很容易触发页分裂和页重组,产生大量碎片,写入性能会明显下滑。还要注意,UUID 比 BIGINT 占用的字节多,主键越大,二级索引叶子节点里存的主键值就越大,整个表的索引占用空间就越大,内存缓冲池里能缓存的有效索引数据也越少。

当然,自增主键并不适合所有场景。比如分库分表后需要全局唯一主键时,多半会用雪花 ID 这类有序的分布式 ID。但即便如此,也要尽量保证生成的 ID 是趋势递增的,不要用纯随机 UUID。我自己的习惯是:本地单库用自增 BIGINT,分布式场景用雪花 ID 或类似方案,并尽量把它设计成 BIGINT 类型而不是字符串。

2.4 主键长度的隐形影响

主键长度这个坑,很多面试过 MySQL 的人都知道,但实际建表时还是会忽略。主键不仅自己占据聚簇索引,还会作为“引用地址”出现在每一个二级索引的叶子节点里。你定义了 5 个二级索引,每个二级索引里都会保存主键值。所以主键每多 1 字节,所有二级索引都会跟着多 1 字节。

假设一张表有 500 万行数据,如果用CHAR(32)的 UUID 做主键,比用BIGINT做主键,在每个二级索引上都要多出大约 (32-8)×500万 ≈ 1.2 亿字节的存储。这不是一个小数字。所以设计主键我有一条原则:能用整型不用字符串,能用短整型不用长整型,但又要留足业务增长空间,所以 BIGINT 是最常用的选择。

3. 联合索引:把多列“拼”成一个键

3.1 联合索引在 B+ 树里怎么排列

联合索引也叫复合索引,比如在(a, b)两个字段上建立一个索引,B+ 树里并不会存在两套独立的树,而是把这俩字段拼接成一个组合键,然后按组合键的字典序排序。具体排序规则是:先按第一个字段 a 排序;如果 a 相同,再按 b 排序;如果 a 也相同,b 也相同,再按主键排序。

用一个生活化的例子来说:一个购物清单,先按品牌名排序,同一品牌内部再按价格排序。那么你找“所有品牌的单价小于 100 的商品”时,因为价格不是第一排序条件,你就没法用这个清单快速定位;但找“某个品牌的所有商品”或“某个品牌下某个价位的商品”就很方便。这就是联合索引排序规则的直观解释。

3.2 最左前缀原则:为什么 where b=? 用不上索引

有了上面的排序规则,最左前缀原则就不难理解了。假设有联合索引(a, b),B+ 树首先保证 a 是有序的,只有当 a 相等的时候,b 才是有序的。换句话说,b 的有序性完全依赖于 a。你单独拿 b 来查询,等于在一个“先按 a 排,a 内部再按 b 排”的目录里查价格,目录里并不是全局按 b 排好的,自然没法使用这个索引的查找能力。

实践中具体如下表所示:

查询条件能否用到联合索引 (a, b)原因
WHERE a = 1能用a 是第一列,直接定位
WHERE a = 1 AND b = 2能用先按 a 找,再按 b 找
WHERE b = 2不能有效利用b 不是第一排序键,无法定位
WHERE a = 1 ORDER BY b能用a 确定后 b 有序,排序也能用
WHERE a BETWEEN 1 AND 3 AND b = 2部分能用a 走 range,b 无法用于定位

这里要额外说一句:WHERE b = 2 AND a = 1这种情况,因为优化器会重排条件顺序,通常也能使用索引。我们说的“最左前缀”,指的是索引定义里最左边的列必须出现在查询条件中,而不强求 WHERE 条件书写顺序。

3.3 联合索引的列顺序:等值在前,范围靠后

设计联合索引时,列顺序选择的优先级大概是:先看等值条件,再看范围条件,最后看排序需求,同时考虑字段的选择性。

选择性是指某个字段去重后的值的分布情况。比如性别字段通常只会有两三个值,选择性很低;身份证号基本每行都不同,选择性很高。联合索引第一列应该尽量放选择性高的字段,因为第一列决定了 B+ 树里分叉的有效性。举一个例子:

一张用户订单表,查询场景是WHERE user_id = ? AND status = ? ORDER BY create_time DESC。这里 user_id 区分度最高且通常是等值条件,放在联合索引第一位;status 是等值条件但区分度低,放在第二位;create_time 是排序字段,放在最后一位,可以顺便替代 ORDER BY 的 filesort。最终联合索引可以设计为(user_id, status, create_time)

反过来,如果把 status 放第一位,索引中会有大量 status 相同的记录,定位 user_id 时需要在相同 status 范围内再做一次二次筛选,效率明显降低。

3.4 联合索引如何帮 ORDER BY 省掉文件排序

文件排序(Filesort)是 MySQL 无法利用索引顺序直接返回有序结果时,在内存或磁盘上额外做的一次排序操作。数据量少时可能无所谓,数据量大时,filesort 往往比查询本身还贵。联合索引因为天然按列顺序排序,相当于已经排好序了,可以省掉这步额外动作。

比如索引(user_id, create_time)下执行:

SELECT * FROM orders WHERE user_id = 123 ORDER BY create_time DESC LIMIT 20

  • 先通过 user_id 定位到对应叶子节点区间。
  • 由于同一 user_id 下 create_time 已经按顺序排列,直接反向扫这个有序链表即可。
  • 取前 20 条,不需要 filesort。

如果换成ORDER BY create_time, user_id,因为索引排序规则是 user_id 优先,而这里查询要求 create_time 优先,顺序对不上,MySQL 就无法直接利用索引排序,只能走 filesort。面试里容易被问的“为什么建立索引后排序还是慢”,多半就是这个原因。

4. 从原理到实操:三个优化案例

4.1 案例一:UUID 主键引起的写入抖动

背景:一张日志表,日均写入 200 万行,前期开发图省事直接用 UUID(字符串)做主键,结果每隔一段时间插入延迟就会飙高。排查时发现两个问题:一是随机 UUID 导致数据插入位置随机,频繁触发页分裂;二是主键太长,导致大量磁盘碎片,缓冲池被索引数据大量占用。

解决:

  1. 将主键改为自增 BIGINTid
  2. 原 UUID 字段保留,加一个唯一索引uniq_token,用于业务上的幂等校验。
  3. 通过ALTER TABLE ... DROP PRIMARY KEY, ADD PRIMARY KEY(id), ADD UNIQUE KEY uniq_token(token)完成迁移(实际生产会在低峰期做在线 DDL 或使用工具)。

结果:写入延迟和抖动明显减少,磁盘空间占用也下降了不少。这个案例说明:一个看似不起眼的主键选择,对写入吞吐量的影响可能比大多数二级索引设计还大。

4.2 案例二:联合索引让排序查询提速百倍

背景:一个消息中心表,核心查询是SELECT * FROM message WHERE user_id = ? ORDER BY send_time DESC LIMIT 20。刚开始只在 user_id 上建了普通索引,执行计划里出现Using filesort,用户量大时接口响应不稳定。

解决:将普通索引idx_user_id(user_id)调整为联合索引idx_user_id_send_time(user_id, send_time)。调整后 EXPLAIN 显示 Extra 变成了Using index condition或直接用索引顺序返回数据,filesort 消失。为什么?因为同一 user_id 下 send_time 已经有序,ORDER BY 不需要额外排序。

这个优化之所以立竿见影,核心逻辑就是前面说的:联合索引不仅仅是“多列查得快”,它还能把排序需求“吃”进索引里。

4.3 案例三:覆盖索引避免回表

背景:一个高频接口需要查询SELECT user_name, avatar FROM user_profile WHERE age = ?,表数据 2000 万行,每次查询都要回表,导致很多随机读。

解决:在原索引基础上加一个覆盖索引(age, user_name, avatar)。二级索引的叶子节点里有 (user_name, avatar, 主键 id),查询的字段都能在索引页里找到,回表被省掉。EXPLAIN 中 Extra 显示Using index,表示该查询只扫描索引即可返回结果,不需要访问聚簇索引。

这种方案适合查询字段固定、数据量大的高频业务。但要注意,覆盖索引的列不宜过多,因为叶子节点里存的列越多,索引占空间越大。

4.4 索引失效场景速查

原理理解了以后,很多“索引失效”的问题其实都能推理出来:

场景原因结果
WHERE name LIKE '%张%'前导通配符导致无法比较大小范围无法使用 name 索引
WHERE UPPER(name) = 'ZHANG'对索引列用了函数,B+ 树中保存的是原始值无法使用索引
WHERE phone = 12345678901(phone 是 VARCHAR)隐式类型转换,将列转为数字无法使用索引
WHERE age + 1 = 30索引列参与了运算,破坏了有序比较无法使用索引
WHERE a = 1 OR b = 2OR 两侧条件不一致,优化器难以用单索引并集可能全表扫描
WHERE city = ? AND age = ?(索引是 (age, city))city 不是最左列age 需经过 city 筛选后才有序

这些场景我列出来并不是为了让你死记硬背,而是想说明一个底层逻辑:B+ 树索引本质上依赖有序键值的大小比较,任何破坏“列本身作为排序键”的行为,比如对它做函数、运算、格式转换,都会让索引失去意义。

5. 常见问题与排查技巧实录

5.1 用 EXPLAIN 判断索引是否真的生效

理论聊再多,落到现场还得靠工具。MySQL 里排查索引问题最常用的一条命令就是EXPLAIN SELECT ...。重点关注以下几列:

列名含义常见取值
type访问类型ALL、index、range、ref、eq_ref、const
key实际使用的索引NULL 表示没用索引
rows预估扫描行数越小越好
Extra附加信息Using index、Using filesort、Using temporary
  • type = ALL:全表扫描,基本可以判定索引没起作用。
  • type = ref:使用了非唯一索引进行等值匹配,比较理想。
  • type = range:索引范围查询,也可以。
  • type = const:主键或唯一索引等值匹配,最快。
  • Extra 里出现Using filesortUsing temporary,说明排序或分组没有充分利用索引,需要考虑联合索引的表意是否覆盖了排序字段。

5.2 优化器选错了索引怎么办

虽然 MySQL 的优化器通常很聪明,但偶尔也会出现统计数据不准、选错索引的情况。这时可以先尝试ANALYZE TABLE 表名;重新更新索引的基数统计信息。如果还不行,可以在 SELECT 语句里使用FORCE INDEX(索引名)强制走某个索引,但不要把它当成长期方案,更根本的是看索引设计是否合理。

我自己遇到过很多次 MySQL 选择了主键而非二级索引的情况,明明二级索引选择性更高,优化器却因为统计信息偏差选择了更大的扫描范围。遇到这种问题,第一步永远是刷新统计信息,而不是急着改 SQL。

5.3 索引是不是越多越好

索引越多,写入表时需要更新的索引也就越多。每插入一条记录,除了聚簇索引,所有二级索引都要同步维护,插入更新速度会明显下降。另外索引本身就占磁盘空间,也会占用缓冲池内存。所以索引是典型的“空间换时间”,不是免费的午餐。

我见过有表有十几个索引,几乎每个字段都建了一个,结果写入性能惨不忍睹。常规建议是:单表索引数量一般控制在 5 个以内,单条 SQL 涉及的联合索引尽量覆盖到等值、范围、排序三类需求。建索引前先想清楚最核心的几条查询路径,而不是把所有字段都铺一遍索引。

5.4 我的排障流程总结

近期几次线上慢查询排查,我的固定套路是:

  1. 拿到慢查询 SQL,先看 WHERE、JOIN、ORDER BY 的字段。
  2. EXPLAIN看 type 和 Extra。
  3. 如果 type 是 ALL,检查查询字段是否在某个联合索引的最左列。
  4. 如果 Extra 有 Using filesort,考虑把排序字段纳入联合索引。
  5. 如果走了索引但 rows 仍然很大,检查索引列的选择性。
  6. 修正 SQL 或索引后,对比前后 EXPLAIN 和实际耗时。

这套流程并不复杂,但每一步都需要索引原理支撑。比如第 3 步,如果你不理解最左前缀原则,可能根本想不起来去看联合索引的字段顺序;第 4 步,如果你不理解 B+ 树的排序特性,也不会把 filesort 和索引设计联系起来。

结尾

最后再分享一个我个人的体会:很多人用完 EXPLAIN 看到 type 是 ref 就觉得万事大吉,其实忽略了 Extra 里的 Using filesort。用上面的消息中心案例来说,最初那条 SQL 其实已经用上了 user_id 的索引,type 是 ref,但是排序时仍然需要把满足条件的多行数据一股脑拿出来再做排序。等到数据量一上来,filesort 瞬间就变成了瓶颈。所以排查慢查询时,我总会顺手把排序、分组、去重这些“隐藏需求”一起考虑进索引设计里。这也是这篇文章想强调的核心:索引优化不是把 WHERE 字段堆进索引完事,而是要理解 B+ 树是有序的,聚簇索引决定数据物理布局,联合索引的列顺序决定查询与排序能力。把这些底层逻辑串起来,你再去设计索引,就不再是背规则,而是真正从数据结构出发做决策。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询