先说个我自己的现场。有一回线上活动报表跑不动,一条 SQL 从几十毫秒干到七八秒,DBA 跑过来让我看。我 EXPLAIN 一放,优化器直接在整张 3000 多万行的表上做全表扫描,而表上明明有两个候选索引,其中一个还是专门为这条 SQL 建的。项目催得急,我到今天还记得当时一行行看索引定义、翻 MySQL 执行计划的心情。后来问题查清楚了:联合索引的三列里,我用了中间一列做等值、最后一列做排序,第一列被写进了函数里,整条索引瞬间“作废”。
从那以后我对“MySQL 索引”这四个字就不再只当它是面试题里背 B+树、背回表、背最左前缀的概念了。这篇文章我就按自己踩坑、看别人踩坑摸出来的路子,把索引相关的底层设计、分类、创建语法、最佳实践、失效场景、优化机制全部过一遍。不敢说“全网最细”,但至少你建索引之前能想到的问题,这文章里基本都能找到答案。
1. 为什么最终活下来的是 B+树:一次磁盘读代价里的数据结构选型
MySQL 的 InnoDB 引擎默认索引结构是 B+树,这个结论几乎所有人都知道。但真要问一句“为什么是 B+树,不是红黑树、不是 B 树、不是哈希”,能讲清楚的人其实不多。要讲透这一点,得先从存储介质的真实物理特性说起。
现代数据库的数据大多在磁盘上,内存只是缓存。业务访问越集中、数据总量越大的表,数据页被驱逐出内存再重新读盘的概率就越高。而磁盘随机读的代价远比你直觉里大得多:内存随机访问大约是 100ns 这个量级,SSD 随机读在 0.01ms~0.1ms 之间,机械硬盘随机读通常在 10ms 上下。换句话说,一次机械硬盘的随机读,差不多是内存读的一万到十万倍,这种量级差异决定了索引结构的首要设计目标不是“算法上快”,而是“尽量少读盘”。
1.1 数据库的预读单位是“页”,不是“行”
如果你对 InnoDB 的磁盘组织方式有观察,应该知道它不仅按行存数据,还把连续空间按 16KB 一页切开。操作系统也有自己的页缓存,InnoDB 和磁盘交互的最小单位就是这一个个“页”。这就引出一个结论:只要走一次磁盘 IO,扛回来的就是整个 16KB 页,而不是某一行的几十个字节。
基于这个特性,一种优秀索引树的设计思路就出来了:让每个节点刚好占用一个页或若干个页,并且节点里能压尽可能多的“路由信息”。B+树的数据页如此,索引页也如此;当你检索时,只需要沿着树从根节点往下走,依次把路上经过的内部节点读进内存就够了。如果在内存里能命中,那全程可能只需要一次磁盘 IO 去读叶子页;如果命不中,则每层都有可能触发 IO。
1.2 二叉树、B树、哈希为什么在这套场景里都不够“香”
很多人会疑惑:B树不也是多路平衡查找树吗?为什么 InnoDB 选的分明是 B+树?可以从三个层面理解。
- 二叉树/红黑树:树太高。假设你存 1000 万行数据,平衡二叉树的高度大约在 24 层左右,每次定位一个叶子可能要访问二十多个节点,每个节点如果不在内存就各是一次磁盘随机读,这代价是致命的。B+树之所以矮,是因为它是多叉的,一个节点里能放几百上千个键值,三层左右就能覆盖千万行数据。
- B 树:B 树每个节点既存索引键也存数据,能利用的“路由密度”被削弱了。非叶子节点中每一条索引记录如果连数据一起占满空间,同一页里能保存的子节点指针数量就少了,树就变高;而 B+树把数据全部收敛到叶子节点,内部节点只存键和指针,可以做到一个 16KB 页里放下上千个键值。这相当于把“树高”这个致命指标压到了可接受范围。
- 哈希表:单点等值查询确实能做到 O(1),但它天生不适合范围查询和排序。
WHERE age > 20 AND age < 40这种需求在哈希结构里只能全部扫描,而业务 SQL 里范围、排序、分组出现的频率远高于纯粹的等值查询。
从工程角度看,B+树还额外送了一个红利:叶子节点通过双向链表串联。这意味着扫全表、扫索引、范围查询,都能从第一个命中叶子页开始顺着链表顺序读,磁盘层面表现为相对连续的预读,速度比零散随机读高一个量级。所以不是 MySQL 不想用彩虹屁数据结构的“高端算法”,而是它在“磁盘慢、页预读、业务查询多种多样”这三大前提里,选择了最合适的那一棵树。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 聚簇索引和二级索引是怎么配合的:从字典说到回表
要理解 InnoDB 的索引结构,脑子里不能只有一棵抽象 B+树,你得把它想象成一本有“正文”和“书后索引”两种用法的大字典。
在 InnoDB 里,表的数据行本身按主键顺序存放在 B+树的叶子页上,主键索引就是一本“正文顺序本身就是索引顺序”的字典。每次你通过主键来查,只要定位到某个叶子页,这一行需要的其他列全都在同一个页里,不需要再做第二次查找。这就是聚簇索引。它对应的叶子节点存储的是整行记录,而不是某个指向其他位置的“书签”。一张 InnoDB 表只能有一个聚簇索引,因为表数据只能有一种物理排序方式,物理上不可能同时按两套顺序摆放数据的“正文”。
而你在非主键列上额外建的索引,属于二级索引,也叫非聚簇索引。它的结构也是 B+树,但叶子节点里存的不是完整行记录,而是“这行数据对应的主键值”。这就像字典末尾的偏旁部首检字表:先通过偏旁找到目标字所在的具体页码,然后再拿着页码去正文里翻。任何一次通过二级索引找到主键后,还要再回聚簇索引里查一次完整行的动作,就是 MySQL 语境下常说的回表。
2.1 每张表都应该有一个有意义的主键
InnoDB 要求每张表必须有聚簇索引。如果你建表时没指定主键,它会先找第一个非空且唯一的索引作为聚簇索引;如果连这也没有,就会生成一个对用户不可见的 6 字节 rowid 作为隐藏主键。这意味着:
- 即使你没有显式建立主键,表在物理层面依然在按某个规则组织。
- 如果不给表设计主键,而让 InnoDB 偷偷生成隐藏主键,那么你的所有二级索引叶子节点存的其实是隐藏 rowid,之后你很难对数据分布做任何主动规划。
- user_id、order_id 等业务上天然稳定的列,比那种事后随意增删的列更适合当主键。
实际项目里,主键选择最常见的大坑是业务订单号、流水号直接当主键。这类字段如果是数字型且递增,问题不大;一旦是 U
