去年我在排查一个线上慢查询时,遇到一张300万行的订单表。运营后台要按商家、时间范围和支付状态筛订单,SQL跑完要2.3秒。当时的修复动作很简单——加了一个MySQL索引,查询耗时立刻掉到40毫秒。但真正让我难受的不是“加索引”这个动作,而是之后几周我一直在想:为什么加了这个B+树索引就变快,换一种SQL写法索引又会失效?如果只停留在“慢就加索引”的层面,你迟早会被“索引失效”折磨到怀疑人生。
这篇内容我分成四个大块:先把“索引到底在解决什么问题”讲清楚,然后把B+树从二叉搜索树到B树再到B+树的演进路线完整走一遍,接着进到InnoDB引擎内部看聚簇索引和二级索引是怎么配合的,最后给出一套可以直接上手的索引操作实战方法和踩坑记录。适合刚接触索引的初学者,也适合那些会用索引但经常被“为什么不用索引”卡住的后端开发。
1. 从一次慢查询开始:索引到底快在哪
1.1 一条SQL从2.3秒到40毫秒的现场
我给那张订单表加的索引是联合索引,包含商家ID、下单时间、状态三个字段。加之前EXPLAIN的结果是type=ALL,优化器在300万行里做了全表扫描,rows显示31万——扫描范围大得吓人。加完之后同样的SQL再跑,type变成了ref,rows直接从31万掉到几千,查询时间从2.3秒变成40毫秒。
这个过程中发生变化的核心,不是数据总量变少了,而是定位数据的方式变了。全表扫描是“把所有数据页从头翻到尾”,走索引则是“沿着B+树的路径直接摸到数据所在的叶子页”。理解这一点,是理解索引一切知识的前提。
1.2 为什么磁盘IO比数据量更值得关注
数据存放在磁盘上,而InnoDB引擎读写磁盘的最小单位不是一行,而是一个“页”,默认大小16KB。哪怕你只想查一行数据,存储引擎也要把这个数据所在的整个页从磁盘加载到内存。一次磁盘随机读的耗时大约在几毫秒到十几毫秒,内存随机读则是几十纳秒到几百纳秒,这里差了差不多5个数量级。
所以MySQL查询优化的核心目标,就是减少磁盘IO次数。全表扫描300万行,假如每页能装100行,就要读3万个页;而走索引,假设B+树只有3层,最多读3个页就能定位到目标数据所在的叶子页,再顺着叶子链表读相邻数据。一个是3万次IO,一个是3次IO,差距自然就是几个数量级。
1.3 索引是有代价的:空间和写放大
索引不是免费午餐,它本质上拿“空间”和“写入成本”换“查询速度”。每建一个索引,InnoDB就额外维护一棵B+树;每次INSERT、UPDATE、DELETE都要同步修改所有相关索引。索引越多,写入越慢,磁盘占用也越大。
所以开发里最忌讳的行为,就是对着一堆不常查的字段各建一个单列索引。正确思路是:为高频查询场景设计联合索引,让一个索引覆盖多个查询条件。后面第4章我会详细说建索引前的判断维度,这里先记住一个结论——索引是给查询设计的,不是给表设计的。
2. B+树是怎么一步步走到今天的设计
2.1 二叉搜索树和它的“退化”隐患
最早大家想用树结构来加速查找,二叉搜索树(BST)是最朴素的选择。它的规则很简单:左子树所有节点值小于根节点,右子树所有节点值大于根节点。理想情况下查找是二分式的,时间复杂度O(logN)。
但BST有个致命问题:它不保证平衡。如果插入顺序碰巧是有序的,比如按主键1、2、3、4顺序插入,树会直接退化成一个链表,查找复杂度变成O(N)。后来出现了AVL树和红黑树,通过旋转操作维持树高平衡,解决了退化问题。可它们依然是二叉树——每个节点只存一个键。假设有1亿条数据,满打满算也要大约27层。如果这棵树长在磁盘上,最坏情况就是一次SQL要触发27次磁盘随机IO,这在MySQL这种高并发场景里根本没法接受。
2.2 B树:多叉化之后,范围查询成了新痛点
B树的关键改进,是让一个节点不再只存一个键,而是存一组键和对应的一组子节点指针。通俗点说,二叉树是“一个抽屉只放一张卡片”,B树是“一个抽屉放一摞卡片,并且每张卡片都指向下一层的子抽屉”。这样一来,同样的数据量下树的高度大幅下降,一次查询需要的磁盘IO次数也大幅减少。
但B树有一个隐藏痛点:它的数据(或者说不论是键值还是行数据)分散在每一层节点上。也就是说,一次单点查找可能要跨多层节点跳转;更麻烦的是范围查询,比如查出某一区间的所有记录,B树需要做中序遍历,在父子节点之间反复回溯。而相邻记录之间的物理位置也不连续,很可能触发大量随机IO。范围查询是数据库里出现频率极高的操作,这个问题绕不过去。
2.3 B+树的关键设计:数据进叶子,叶子串成链
B+树是在B树基础上的进一步改造,核心变化有两个。
第一个变化:非叶子节点只存键和指针,不存数据。这样每个页里能容纳的索引条目比B树多得多。举个例子,假设主键是BIGINT占8字节,指针占6字节,一个索引条目约14字节,一个16KB的页就能放下大约1170个索引条目。三层B+树意味着根节点有1170个分支,第二层每个节点又有1170个分支,第三层叶子页放数据记录,假设每页放100行记录,三层就能支撑约1.37亿条数据。
第二个变化:所有数据都放在叶子节点,且叶子节点之间用指针按顺序串联。单点查询从上往下最多走三层;范围查询则在定位到第一个满足条件的叶子后,顺着叶子链表顺序往后扫,相邻记录在物理上也大概率相邻,极大减少了随机IO。
这就是B+树在InnoDB里的核心优势:单点查找快、范围查找更快、树矮可预测。
2.4 三种数据结构在一张表里的真实对比
把三种结构的差异放到一张表里,你会看得更清楚:
| 结构 | 单点查找 | 范围查询 | 写入维护 | 磁盘IO量级 |
|---|---|---|---|---|
| BST/AVL/红黑树 | O(logN),但树高约27层 | 中序遍历,需回溯 | 频繁旋转 | 高 |
| B树 | O(logN),但数据分散各层 | 跨层中序遍历,随机IO多 | 节点分裂合并 | 中 |
| B+树 | O(logN),树高3~4层 | 叶子链表顺序扫描 | 节点分裂合并 | 低 |
我特意把AVL和红黑树放在一起对比,是因为很多资料会混淆。红黑树在内存场景里很优秀,比如Linux内核用它管理进程调度,但MySQL的数据存储在磁盘上,核心成本是磁盘IO而不是CPU比较次数。B+树把树高压缩到三四层,让最耗时的磁盘访问变成常数级,这才是它最终胜出的根本原因。
3. InnoDB引擎里索引的两种形态:聚簇与二级
3.1 聚簇索引:表本身就是一棵B+树
InnoDB和MyISAM最大的区别之一,就是InnoDB把表数据和索引放在同一棵B+树里。这张表的B+树被称为聚簇索引,它的叶子节点直接存放整行数据。换言之,InnoDB的表不是“数据文件+索引文件”的独立组合,而是“一棵以主键为排序键的大树,行数据挂在叶子节点上”。
聚簇索引有非常实际的影响。只要你通过主键查数据,比如WHERE id = 100,直接走这棵树定位叶子页,取出的就是完整行,不需要任何额外跳转。所以InnoDB表强烈建议必须有主键——如果没有显式主键,InnoDB会找一个非空唯一索引充当聚簇索引;实在找不到,它还会生成一个隐藏的6字节rowid来建树。
3.2 二级索引、回表与覆盖索引
除聚簇索引以外,其他索引统称二级索引,或者叫辅助索引。二级索引也是一棵B+树,但叶子节点放的不是整行数据,而是“索引列的值+主键值”。
这就引出回表的概念。比如你给mobile字段建了索引,执行SELECT * FROM user WHERE mobile = '138xxxx',MySQL会先走二级索引,找到对应的主键值,再拿这个主键值去聚簇索引里定位整行数据。这一步回表,本质上又是一次主键索引查询。代价比直接走主键索引多了一轮。
想省掉回表,就要用到覆盖索引。覆盖索引不是某种特殊索引类型,而是一种查询状态:SELECT需要的所有字段都包含在某个二级索引的叶子节点里。比如有联合索引(mobile, name),执行SELECT name FROM user WHERE mobile = '138xxxx',因为二级索引叶子节点本身就同时存了mobile和name,查询直接在二级索引上完成,不需要回表聚簇索引,EXPLAIN里的Extra会显示Using index。这个优化在线上高频查询里非常常用,能省掉一次完整的树查找。
3.3 主键索引、唯一索引和普通索引到底差在哪
面试里经常被问到主键索引和唯一索引的区别,这里把三个概念一次理清楚:
| 索引类型 | 约束能力 | 是否允许NULL | 每张表数量 | 叶子内容 |
|---|---|---|---|---|
| 主键索引 | 唯一且非空 | 否 | 只能一个 | 整行数据 |
| 唯一索引 | 只保证唯一 | 允许,且可多个NULL | 可多个 | 索引列+主键值 |
| 普通索引 | 无约束 | 允许 | 可多个 | 索引列+主键值 |
这里有两个容易踩的点。第一个,唯一索引允许NULL,而且允许多个NULL——因为MySQL把每个NULL都当作不同的值处理,不参与唯一性比较。第二个,InnoDB里主键索引就是聚簇索引,其他索引都是二级索引。你如果做“唯一索引和非唯一索引”的性能对比,会发现存储结构相似,但唯一索引对查询有一定的提前终止优化,写入时多一次唯一性校验,性能差异不大但确实存在。
3.4 为什么推荐自增主键:页分裂的代价
B+树的叶子页是按主键顺序排列的,插入新行时如果主键是自增的,新数据总是追加到叶子链表尾部,最省事。如果用UUID或随机字符串做主键,新行的主键值会随机落在B+树中间位置,导致某个叶子页空间不足,强行发生页分裂。
页分裂的代价不光是多一次磁盘写,它会让原来的页产生碎片,影响后续扫描效率。对一个高写入的系统来说,自增主键几乎是必须的。这个“为什么”理解了以后,你会明白为什么很多规范里直接写“InnoDB表用自增主键”,而不是空喊口号。
4. 索引操作实战:建索引、看索引、验证索引
4.1 常用的索引SQL写法
建索引的方式有好几种,我列一下日常工作最常用的写法:
-- 普通索引 CREATE INDEX idx_mobile ON user(mobile); -- 唯一索引 CREATE UNIQUE INDEX uk_account ON user(account); -- 联合索引 CREATE INDEX idx_merchant_time ON orders(merchant_id, create_time); -- 建表时直接指定 CREATE TABLE article ( id BIGINT PRIMARY KEY AUTO_INCREMENT, category_id INT NOT NULL, create_time DATETIME NOT NULL, KEY idx_category_time (category_id, create_time) );如果想给很长的字符串字段建索引,比如文章标题或URL,可以只用字段的前N个字符做前缀索引:
CREATE INDEX idx_url_prefix ON article(url(64));前缀索引能显著节省空间,但代价是可能无法用于ORDER BY排序,也无法完全覆盖查询列。要不要用,得看字段长度和区分度。
4.2 动手之前要先过的四道判断
现在这条SQL值不值得建索引、该建什么索引,我通常按下面四个维度过一遍。
第一,查询频率够不够高。如果SQL只是临时跑一次,全表扫描也无所谓,没有建索引必要;如果是接口核心路径,每秒钟跑几十次,那就要认真设计索引。第二,区分度够不够高。区分度指“这个字段有多少不同值”。性别、状态这类只有两三个值的字段区分度极低,建索引后B+树虽然有结构,但过滤后仍要回表访问大量行,优化器宁可全表扫描也不会走它。第三,字段长度合不合理。太长的字段优先考虑前缀索引或放后面。第四,能不能顺便做覆盖索引。如果高频查询的返回字段比较固定,把它们一起放进联合索引里,可以直接免掉回表。
4.3 EXPLAIN是唯一的验证标准
索引建得好不好,不能靠感觉,要用EXPLAIN看执行计划。最核心的几列我先说明:type表示访问类型,从好到差大概有system、const、eq_ref、ref、range、index、ALL;key表示优化器实际选择的索引;rows是预估扫描行数;Extra里会出现Using index(覆盖索引)、Using where(回表后过滤)、Using index condition(索引下推)等信息。
举个实际例子:
EXPLAIN SELECT * FROM article WHERE category_id = 5;如果type=ref,key=idx_category_time,rows很小,说明索引生效。如果type=ALL,key为NULL,说明这条SQL在选择全表扫描。这时候不要急着骂优化器,先检查SQL写法是不是让索引失效了。
4.4 查看与删除索引的正确姿势
日常维护里还要会查看和清理索引:
-- 查看表结构时能看到所有索引 SHOW CREATE TABLE user; -- 查看更详细的索引信息 SHOW INDEX FROM user; -- 删除索引 DROP INDEX idx_mobile ON user; ALTER TABLE user DROP INDEX idx_mobile;线上清理无用索引时建议在低峰期操作。InnoDB删除索引虽然不像加索引那样大动干戈,但仍会影响写入。顺序是先确认没有慢SQL在用这个索引,再放低峰窗口执行,执行后观察一段时间慢日志。
5. 索引失效场景全集:每个坑都帮你踩过一遍
5.1 最左前缀不是规则,是B+树排序逻辑的必然
联合索引的排序规则是:先按第一列排序,第一列相同再按第二列排序,依次类推。所以当你查询条件缺少联合索引最左边的列时,后面的列即使有序,在整体上也是无序的,B+树没法利用它们进行快速定位。
拿(idx_merchant_time)也就是(merchant_id, create_time)来说:
-- 走索引,等值匹配merchant_id SELECT * FROM orders WHERE merchant_id = 1024; -- 走索引,先等值再范围 SELECT * FROM orders WHERE merchant_id = 1024 AND create_time > '2024-01-01'; -- 不走索引(或者说优化器没法高效使用),因为缺了merchant_id SELECT * FROM orders WHERE create_time > '2024-01-01';同理,联合索引(a, b, c)里你用WHERE a = 1 AND c = 3,只有a列能用来定位;用WHERE a > 1 AND b = 2,a用了范围后,b列的有序性在大范围里被打破,b通常也排不上用场。这就是“范围查询右侧列失效”的原理。
再说一个进阶点:MySQL 5.6以后引入的索引下推(ICP)可以在部分条件下缓解这一问题。比如联合索引(a, b),执行WHERE a = 1 AND b LIKE 'abc%',虽然b列无法参与定位,但存储引擎在回表之前,会先用二级索引里存在的b值做一次过滤,减少回表次数。EXPLAIN的Extra里出现Using index condition,就是这个机制在工作。
5.2 一张表看清常见的失效SQL
我把线上最常见的索引失效写法整理在一张表里,方便你对照自查:
| 场景 | SQL示例 | 失效原因 | 改写建议 |
|---|---|---|---|
| LIKE前缀通配 | WHERE name LIKE '%张' | B+树顺序扫描无法从中间开始 | 改name >= '张' AND name < '姓'范围查询 |
| 对索引列做函数运算 | WHERE YEAR(create_time)=2024 | 索引存的是原始值,不是函数结果 | 改create_time >= '2024-01-01' AND create_time < '2025-01-01' |
| 隐式类型转换 | WHERE mobile = 13800138000 | varchar列与数字比较,列上发生CAST | 改mobile = '13800138000' |
| OR连接非索引条件 | WHERE id=1 OR name='张三' | 要么全表扫,要么用index_merge合并 | 拆SQL用UNION合并结果 |
| 违反最左前缀 | WHERE create_time > '2024-01-01'(缺少merchant_id) | 联合索引首列缺失 | 根据其他等值条件补最左列 |
| 对索引列做运算 | WHERE id + 1 = 100 | 索引无法定位计算后的结果 | 改id = 99 |
这里有个重要提醒:上面说的“失效”不是绝对的物理规则,而是优化器成本估算的结果。同样一条SQL,数据分布不同,优化器可能走索引也可能全表扫描。所以任何“失效”结论都要结合EXPLAIN验证。
5.3 优化器说“我不想用索引”时怎么办
有时候SQL写法没问题,索引也确实存在,但EXPLAIN里type还是ALL。这种情况通常是优化器觉得全表扫描更便宜。常见原因有三个:表行数太小,扫描全表就几个页,比走索引加回表更省;字段区分度太低,比如一个字段90%的值都是0;统计信息过期,导致优化器估算偏了。
处理办法分几步:先确认统计信息要不要更新,执行ANALYZE TABLE your_table后重新EXPLAIN;再检查索引设计是否符合查询模式,比如查询是等式匹配还是范围匹配;最后才考虑用FORCE INDEX强制走索引——这一招要谨慎,它能绕过优化器,但也可能引入新的性能问题。
SELECT * FROM orders FORCE INDEX(idx_merchant_time) WHERE merchant_id = 1024 AND create_time > '2024-01-01';我的经验是,FORCE INDEX更像手术刀,不是日常菜刀。一旦发现需要靠强制索引才能稳定走索引,优先考虑改写SQL或重新设计联合索引,而不是跟优化器硬刚。
5.4 一次生产环境联合索引调优复盘
最后分享一个真实调优案例。运营后台有个高频查询,从300万行的订单表里按商家、下单时间范围、支付状态筛选列表。原表里已经有create_time单列索引,但查询依然很慢,EXPLAIN出来type=index,意思是在做索引的全量扫描,扫完后再逐行判断其他条件。
排查过程是这样的:先看慢日志定位SQL,发现WHERE条件里merchant_id = 1024 AND create_time BETWEEN ... AND ... AND status = 1。然后看发现create_time索引虽然能范围定位,但merchant_id和status都得靠回表后过滤。针对这个场景,我把索引改成了(merchant_id, create_time, status)。
为什么要把merchant_id放最左?因为它是等值匹配,区分度又高,能在B+树里直接裁剪出极小的搜索范围。create_time放中间负责范围定位。status放最后,等值过滤剩余行。优化后type从index变成了ref,rows从30万掉到几百,查询时间从2.3秒降到40毫秒。
这个案例里最值得学习的不是“最后建了什么索引”,而是“为什么排这个顺序”。MySQL联合索引的列顺序,本质上是把等值条件高区分度的列放前面,范围条件放中间,辅助过滤放后面。顺序排对了,一棵B+树能撑起一套查询模式;排反了,索引就会沦为摆设。
我个人在多次踩坑之后的体会是:MySQL索引不是一个孤立的数据结构,它是存储引擎、磁盘IO、SQL优化器三者之间的协调器。每次做索引优化,都应该用EXPLAIN验证后再上线,不要看执行时间下结论——第一次跑缓存是冷的,第二次跑缓存是热的,时间差会把你的判断带偏。日常巡检慢日志时,如果发现某条高频SQL总是走全表扫描,先别急着加索引,把WHERE和SELECT字段摊开,设计一个能覆盖这个查询模式的联合索引,往往比狂加一堆单列索引管用得多。