先声明一下:索引这个东西不是背概念就能会的,它是需要在真实业务里踩过坑才能真正理解的一环。我见过太多同事把索引停留在“给查询快的字段加索引”这个层面,结果一上生产就出各种幺蛾子:索引建了一堆,慢查询照样慢;明明有索引,执行计划却不用;加了个索引,写入性能直接掉半个数量级。这篇就把MySQL索引从头到尾彻底拆一遍,从数据结构到执行计划,从设计方法论到线上排坑,尽量把每一层逻辑都讲明白。
这篇内容适合几类人:刚接触MySQL、想搞清楚索引为什么能加速的初学者;写过SQL但从来没认真看过执行计划、遇到慢查询只会瞎加索引的业务开发;以及正在做数据库优化、想系统梳理索引设计方法论的运维和DBA。核心就一句话:彻底搞懂MySQL索引的底层原理和实战用法,遇到索引问题能自己分析、自己解决,而不是靠猜。
1. 索引到底在解决什么问题
1.1 没有索引时数据库是怎么查数据的
先从最根上说。数据库存储数据的最小单位是数据页,一个数据页默认16KB,里面装着一行一行的记录。整个表在物理上是由很多个数据页组成的,这些页之间通过双向链表串联,页内部的记录之间也有单向链表串联。
在没有索引的情况下,你要执行一条SELECT * FROM user WHERE age = 25,InnoDB只能从表的第一页开始,沿着页链表一页一页地扫,把每一页里的记录逐条读出来,判断age是不是等于25。这个操作叫作全表扫描(full table scan),也是全表扫描这个名字的字面意思。
全表扫描的问题在于:数据量一大,扫描的页数量就会非常恐怖。1000万行的表,数据可能占据大几十万个页,哪怕每页都在内存里,逐页逐行判断的成本也极高;如果页不在内存里,还得从磁盘加载,性能就更是灾难。
做个类比就很好理解:全表扫描相当于你在图书馆里找一本书,但图书馆没有索引目录,你只能从第一排书架开始,一本一本地看封面,一直看到最后一排。数据量小的时候无所谓,数据量一大就完全不可接受了。
1.2 索引的代价:为什么不能无脑加
索引的本质,是用一种额外的、有序的、可以快速定位的数据结构,去替代“全表逐行扫描”。它像一本书的目录:先通过目录定位到目标章节,再翻到具体页码,而不是从第一页开始逐页翻。
但这里有一个非常容易被忽略的点:索引是有代价的。
- 存储代价:每建一个索引,InnoDB就要额外维护一棵B+树,这棵树要占用磁盘空间。索引建得越多,额外占的空间越大。
- 写入代价:每次INSERT、UPDATE、DELETE,除了要改数据页本身,还要同步维护所有相关索引的B+树结构。索引越多,写入时的维护操作就越多,写入性能下降越明显。
- 优化器负担:MySQL的优化器在生成执行计划时,需要评估走哪个索引最优。索引太多,优化器需要分析的路径变多,某些情况下反而会选错索引。
所以最核心的索引设计原则就是:索引不是越多越好,而是越精准越好。建索引之前先问自己几个问题:这条SQL的执行频率有多高?查询条件是什么?返回哪些列?排序和分组依赖哪些字段?只有高频率执行的慢查询,才值得专门设计索引。那种“顺手加一个”的索引,迟早变成线上事故的隐患。
2. 索引背后的数据结构:为什么偏偏是B+树
2.1 B树和B+树的区别
提到索引,很多人的第一反应就是“B+树”。但为什么是B+树,而不是普通的平衡二叉树,也不是B树?这个问题值得认真讲一讲。
先说平衡二叉树。它的问题非常直接:树的高度太高。MySQL的数据是存储在磁盘上的,每次从磁盘读取一个节点都伴随着一次磁盘I/O,I/O的延迟是内存延迟的几个数量级。平衡二叉树的每个节点存储的数据量很小,导致树的高度随数据量增长得非常快。1000万条数据,树高大概在24层左右,意味着最坏情况下要发生24次磁盘I/O。这个成本是灾难性的。
为什么B树能好一些?B树是多路搜索树,每个节点可以存放多个键值和多个指针,一个节点差不多对应一个数据页的大小。这样在同样的数据量下,B树的层数会被压得很低,一般3到4层就能覆盖千万级别的数据量。每层对应一次磁盘I/O,所以从根节点到叶子节点,最多三四次磁盘I/O就能定位到目标。
那B+树又比B树好在哪?关键差异有两点:
第一,B+树的所有数据都存放在叶子节点,非叶子节点只存放索引键和指针,不存数据行。这意味着非叶子节点可以容纳更多的键值,同样的高度下能索引的数据量更大,树的高度进一步被压扁。
第二,B+树的叶子节点之间通过双向链表串联,并且整个叶子节点层是一个有序的链表结构。这让它天然支持高效的范围查询和排序扫描。B树的范围查询需要在中序遍历中反复回溯,效率低得多。而B+树只需要找到范围的起点,然后沿着叶子链表的指针一路向后扫就行了。
这个设计思路其实很朴素:非叶子节点做“目录”,叶子节点做“数据”,目录宁可多存点冗余,也要把树的高度压下去,同时让范围查询变成简单的链表遍历。数据库里像WHERE age BETWEEN 20 AND 30、ORDER BY age这类高频操作,都被B+树的这个结构安排得明明白白。
2.2 InnoDB页与B+树的物理实现
理解了B+树的逻辑结构,再看InnoDB的物理实现就会清晰很多。InnoDB中一个节点就是一个数据页,默认大小16KB。B+树的每一层节点对应磁盘上的一批页,页与页之间通过指针关联。
有一个细节值得特别提一下:主键索引的B+树,叶子节点直接存储完整的数据行,这种结构叫聚簇索引(clustered index)。InnoDB表中聚簇索引只有一个,就是主键索引,如果没有显式定义主键,InnoDB会选择第一个非空的唯一索引作为聚簇索引;如果连唯一索引都没有,InnoDB会隐式生成一个6字节的rowid作为聚簇索引。
聚簇索引有几个非常实际的连带影响。最典型的就是:数据行在物理存储上,是按照主键顺序排列的。所以如果你用一个随机性很强的UUID作为主键,插入新行的时候,数据页可能会频繁发生页分裂,因为新行要插入到链表的中间位置,页装不下就得拆页;而用自增整型主键插入,新行总是追加到链表尾部,写入性能就稳定得多。实践里我见过太多UUID主键导致写入性能差、碎片率高的案例,这个坑一定要避开。
二级索引(非聚簇索引)的B+树结构则完全不同:它的叶子节点保存的是索引列的值,再加上一个主键值,而不是完整的数据行。也就是说,通过二级索引找到一条记录,拿到的只是一个“指针”——主键值,还需要再回到聚簇索引里去查一次才能拿到完整行数据,这个动作就叫回表。
2.3 哈希索引与自适应哈希
B+树不是索引的唯一数据结构。MySQL中还有哈希索引,但需要分清楚:InnoDB引擎并不支持用户显式创建哈希索引,你写的USING HASH在InnoDB下会被自动改回B+树;而Memory引擎默认使用的正是哈希索引。
哈希索引的思路非常简单:对索引列计算哈希值,用哈希值定位到对应槽位。等值查询如WHERE id = 123是它的强项,O(1)级别;但它不支持范围查询,也不支持排序,因为哈希值是无序的。所以哈希索引在OLTP场景中极少作为显式索引出现。
但InnoDB内部有一个非常有意思的机制:自适应哈希索引(Adaptive Hash Index,AHI)。InnoDB会根据实际查询模式,在B+树之上自动为某些频繁访问的页面构建哈希索引,用来加速等值查询。整个过程不需要人工干预,完全是存储引擎内部自我优化的结果。它解决的一个典型痛点是:WHERE a = 'xxx'这种重复查询频率极高的场景,如果每次都从B+树根节点走一遍查找,开销是浪费的;有了AHI,可以直接定位到目标页。
这里面的判断逻辑值得聊两句。AHI并不是对所有查询都构建,它需要满足条件,比如对某个页的访问次数达到一定阈值,或者某种查询模式连续出现多次。InnoDB会持续监控访问模式,动态决定构建还是淘汰。所以你在线上可能会观察到AHI的命中率忽高忽低,这属于正常现象。了解它的存在,有助于解释某些奇怪的现象:比如一条SQL的响应时间突然变快,但其实索引和SQL都没变过,可能就是AHI起了作用。
3. MySQL索引的完整分类与适用场景
3.1 主键索引、唯一索引、普通索引
从使用功能角度,索引通常分成几类:主键索引、唯一索引、普通索引、联合索引(组合索引)、全文索引。很多人搞不清这些分类之间的层次关系,其实它们是两个维度:一个是索引的字段构成,一个是索引的约束属性。
主键索引是每张表必须有的,InnoDB下它同时也是聚簇索引。主键索引有两大核心特征:不能为NULL,不能重复。设计主键的时候,最推荐的方式是自增整数主键,或者用雪花算法等生成的有序整数ID。自增主键在插入时天然有序,能最大程度避免页分裂;而像UUID这种无序主键,会在插入时产生大量的随机写和页分裂,导致性能抖动。
唯一索引的特点是:索引列的值在表内必须是唯一的,但允许有一个NULL。它的作用不只是加速查询,还能作为约束去保证业务字段唯一性。比如用户表里的手机号、邮箱这类业务唯一字段,适合建唯一索引。需要注意的是,唯一索引和普通索引在查询性能上几乎没有差别,真正需要权衡的是写入时:唯一索引在写入时要额外做一次唯一性检查,因此写入性能会比普通索引差一些。
普通索引就是最基础的索引,只负责加速查询,不附加任何约束。它的设计自由度最高,但也最容易被人乱建。我的建议是,普通索引一定要配合执行计划来设计,不要凭感觉建。
3.2 组合索引与最左前缀原则
组合索引又叫联合索引,是实战中最重要的索引类型。它指多个字段共同组成一个索引,比如idx_abc(a, b, c)。组合索引的核心价值在于,可以同时覆盖多个查询条件的过滤,减少回表和扫描行数。
但组合索引有一个人尽皆知、又总有人踩坑的规则:最左前缀原则。MySQL在组合索引中,会按照字段定义的顺序,从左到右逐个使用字段来定位数据。如果你想用到索引,查询条件必须包含最左边的字段,且不能跳过中间的字段。
举例说明:有组合索引(a, b, c),下面这几种查询的索引生效情况完全不同:
WHERE a = 1 AND b = 2 AND c = 3:完全命中,三个字段都能用到索引,最优。WHERE a = 1:命中前缀a,能走索引。WHERE a = 1 AND c = 3:只能命中a,c字段的过滤在索引里用不上,需要拿a过滤后的结果再逐行判断c。WHERE b = 2:完全不命中,因为跳过了最左的a字段。
为什么有这个规则?因为组合索引的数据排列顺序,是先按第一个字段排,再按第二个字段排,再按第三个字段排。这就像电话簿先按姓氏排,同姓再按名字排。你不用姓氏去查,只看名字,目录就帮不上忙了。
理解了这个规则,组合索引设计就变得有章可循了。核心原则是:把区分度高、最常用于等值查询的字段放到最左边;范围查询字段往后放。看到WHERE a = 1 AND b > 100这样的查询,应该考虑将a放在b前面,让a做等值过滤、b做范围过滤。如果把范围字段放前面,后面的字段就全浪费了。
3.3 覆盖索引、回表、索引下推
这三个概念是理解索引优化效果的关键,很多优化手段的核心都围绕它们展开。
回表前面已经讲过:通过二级索引查到主键,再用主键去聚簇索引查完整行。回表本身不是坏事,但回表次数越多,查询的性能损耗越大。如果二级索引过滤出来的结果集很大,比如几十万行,那就要回表几十万次,哪怕每次都走聚簇索引,累计耗时也非常吓人。
覆盖索引就是专门用来杜绝回表的方案:让一个索引包含查询所需的全部列。比如表里有(a, b, c)三个字段,查询是SELECT b FROM t WHERE a = 1,如果你建了组合索引(a, b),那么查询只需要扫描二级索引的B+树就能拿到b的值,完全不需要回表。执行计划的Extra里会显示Using index,意思就是覆盖索引生效了。
索引下推(Index Condition Pushdown,ICP)则是一个容易被忽视但实实在在能优化查询的机制。它解决的问题是:二级索引过滤时,某些条件下存储引擎层能提前过滤,而不是把全部结果传给Server层再过滤。
举个例子:组合索引(a, b),查询WHERE a > 100 AND b > 50。没有ICP的话,InnoDB会先按a把符合条件的记录全部查出来,回表拿到完整行,再交给Server层过滤b > 50;启用ICP后,InnoDB会在二级索引的叶子节点上直接判断b > 50,把不符合条件的记录在回表前就过滤掉。这样回表次数大幅减少,性能提升明显。在MySQL 5.6以后,ICP默认开启,执行计划的Extra里如果显示Using index condition,说明已经生效。
3.4 全文索引与空间索引
全文索引在OLTP场景里用得相对少,但它是个独立的索引类型。它解决的问题是文本 like 模糊匹配效率太低的问题,采用倒排索引机制,把文本拆分成词项,每个词项映射到包含它的文档列表。像WHERE content LIKE '%关键字%'这种查询,普通索引基本帮不上忙,只有全表扫;全文索引则可以直接根据关键词定位到目标文档。
使用全文索引有一些具体限制:InnoDB的全文索引要求文本列的格式匹配特定解析器,支持自然语言模式和布尔模式。布尔模式可以用+、-控制关键词的有无,灵活性更好。需要注意的是,全文索引和中文分词是相爱相杀的关系:英文按空格分词天然友好,中文如果不配分词器,默认的全文索引效果会很差。所以生产环境的中文全文搜索,我通常建议直接考虑专业搜索中间件,而不是死磕MySQL全文索引。
空间索引则是GIS相关场景才会用到,比如地理位置数据的范围查询,依赖MySQL对OpenGIS标准的支持。日常业务基本碰不到,了解即可。
4. 实战:从建表到看执行计划
4.1 用EXPLAIN看懂索引是否生效
索引建完到底有没有生效,不是靠猜的,也不是看SQL跑得快不快,而是看执行计划。MySQL中使用EXPLAIN关键字,就能查看一条SQL的执行计划。
以一条示例SQL为例:
EXPLAIN SELECT id, order_no, amount FROM orders WHERE user_id = 12345 AND status = 1 ORDER BY create_time DESC;执行后重点关注这几个字段:
| 字段 | 含义 | 需要警惕的值 |
|---|---|---|
| type | 访问类型 | all(全表扫描)、index(全索引扫描)需要重点优化 |
| key | 实际用到的索引 | null 表示没走索引 |
| key_len | 索引使用长度 | 可以判断组合索引里到底用到了几个字段 |
| rows | 预估扫描行数 | 越小越好,偏差过大会影响优化器判断 |
| Extra | 附加信息 | Using filesort、Using temporary 都是需要警惕的 |
type字段的取值从好到坏大概是:system > const > eq_ref > ref > range > index > all。const和eq_ref代表通过主键或唯一索引定位,属于最优情况;ref是普通二级索引等值命中;range是范围扫描;index比all好一点,但本质上也是扫了整棵索引树;all就是全表扫描,看到这个类型基本可以认定索引设计有问题。
rows字段的预估行数也很有参考价值。如果你明明建了索引,但rows还是很大,说明走索引的成本可能不比全表扫描低,优化器放弃了索引。这种情况就要分析是不是索引区分度太差,或者统计信息过期了。
Using filesort是另一个高频优化点。它表示MySQL在内存或磁盘上额外做了排序操作,而排序是性能杀手。如果排序字段没有包含在索引中,或者顺序不符合索引的排列顺序,就会出现这个提示。设计组合索引时,把排序字段放进去,往往就能消除filesort。
4.2 组合索引设计的方法论
组合索引设计是我最想强调的部分,因为大多数糟糕的索引设计都出在这里。一套比较成熟的方法是:分析SQL的WHERE条件、排序、分组,按使用频率和区分度综合排序,再决定字段顺序。
步骤可以这样走:
第一步,找出所有核心SQL,把它们涉及的过滤字段、排序字段、分组字段全部列出来。所谓核心SQL,就是执行频率高、或者单条耗时长的查询,不是所有SQL都值得优化。
第二步,为每一类SQL设计索引,优先满足等值查询字段。比如多个SQL分别是WHERE a = ?、WHERE a = ? AND b = ?、WHERE a = ? ORDER BY c,那么索引(a, b, c)就能一次性覆盖三种场景:a单独用,a和b联合用,a + 排序用c。这就叫一个索引覆盖多类SQL。
第三步,区分度高的字段放前面。区分度指的是某个字段值的种类数占总行数的比例,比如性别字段只有几个取值,区分度极低;订单号这种字段几乎一值一行,区分度极高。低区分度字段放前面会严重浪费组合索引的能力。
同样一个组合索引(status, user_id)和(user_id, status),在WHERE user_id = 12345 AND status = 1这个查询下,后者的扫描范围要小得多。原因不复杂:B+树的索引是先按第一个字段排序的,第一个字段区分度越高,就能越早排除掉大量不相关的记录。
这里给一个非常实用的技巧:在设计索引之前,先跑一下字段基数统计。可以用这条SQL查看每个候选字段的区分度:
SELECT COUNT(DISTINCT user_id) / COUNT(*) AS user_id_selectivity, COUNT(DISTINCT status) / COUNT(*) AS status_selectivity FROM orders;区分度越接近1,说明这个字段过滤效果越好。把区分度高的字段放到组合索引的左侧,是设计组合索引最不容易错的原则。
4.3 索引失效的7个常见场景
索引建好了,但执行计划显示没走索引,这种情况十有八九是踩了索引失效的坑。下面这7个场景是我在线上环境遇到过最多的问题,按出现频率排序:
第一,对索引列使用函数或表达式。典型例子:WHERE DATE(create_time) = '2024-01-01'。MySQL无法对create_time的原始B+树顺序直接用于表达式结果,索引失效,必须先对整列计算函数值。正确做法是改成范围查询:WHERE create_time >= '2024-01-01 00:00:00' AND create_time < '2024-01-02 00:00:00'。
第二,隐式类型转换。如果字段是varchar,查询条件却传了数字,比如WHERE phone = 13812345678,MySQL会把字符串字段隐式转换成数字再比较,索引失效。反过来,字段是数字类型,查询字符串却不会出这个问题。解决方法是查询时保持字段类型一致。
第三,模糊查询以%开头。WHERE name LIKE '%张%'无法利用索引,因为B+树是有序的,最左前缀匹配才能使用索引。LIKE '张%'可以走索引。如果想优化%张%这类中间匹配查询,可以考虑全文索引或搜索引擎,而不是死磕普通索引。
第四,OR连接导致索引失效。WHERE a = 1 OR b = 2,如果a和b只有一个字段有索引,MySQL可能选择全表扫描,因为OR意味着只要满足其中一个条件就要拿出来。解决办法是将OR拆成UNION ALL,或者确保OR两边的字段都有独立索引。
第五,范围查询右边的列失效。组合索引(a, b, c),如果WHERE a = 1 AND b > 10 AND c = 3,b是范围条件,那b右边c的索引就用不上了。设计组合索引时,范围字段要尽量靠后放。
第六,使用!=或NOT IN。这类反向查询在大多数情况下不走索引,因为索引的有序排列对“排除”操作没有明显优势。
第七,索引列允许NULL并且查询用了IS NULL。这个要分场景,MySQL对IS NULL其实可能走索引,但如果是IS NOT NULL,在索引列大量为NULL的情况下统计信息会影响优化器判断。最稳妥的方式是业务上尽量避免索引列出现NULL,设计表结构时用NOT NULL DEFAULT默认值替代。
这7个场景看起来简单,但实际排查慢查询时,基本90%的问题都出在这里面。掌握它们比背概念有用得多。
5. 索引维护与性能优化的那些坑
5.1 冗余索引与重复索引识别
很多团队的表里会堆积大量冗余索引,时间久了没人敢删,删了怕出问题。这其实是个可以系统性解决的工程问题。
先看怎么识别重复索引。如果两个索引覆盖的字段完全相同,只是顺序不同,其中有一个就是纯粹的浪费。比如同时有(a, b)和(a, b, a)这种排序上的细微差异,大部分情况下后者是多余的。
更隐蔽的是冗余索引。比如已经有了组合索引(a, b, c),又建了单列索引(a),那这个单列索引就是冗余的,因为(a, b, c)的最左前缀已经覆盖了以a开头的查询。可以这样查一下表里的所有索引:
SHOW INDEX FROM orders;然后逐个分析每个索引是否能被其他索引的最左前缀覆盖。业内有一些现成工具可以帮助检查,但核心方法论还是人工分析执行计划。我的建议是:每个季度做一次索引梳理,把执行频率低、且被其他索引覆盖的冗余索引直接删除。删除索引时要观察一段时间慢查询和写入性能的变化,确认没有副作用再彻底清理。
5.2 深分页、大字段与排序问题
分页查询是最容易出现性能问题的场景之一。LIMIT offset, size这种写法在offset很大时会奇慢无比。原因不复杂:MySQL需要先扫描出前offset条记录,再跳过它们,返回后面的size条。offset越大,扫描的无效行数就越多。
举个例子:SELECT * FROM orders WHERE user_id = 123 ORDER BY create_time LIMIT 1000000, 20。即便这条SQL完全走了索引,MySQL也需要先把前100万条记录都找出来,再丢掉才能取到第100万条之后的20条。这个成本非常高。
优化深分页有两个主流方案。一个是延迟关联:先通过覆盖索引查出目标主键ID,再用主键去关联原表取完整行。另一个是书签法:记住上一页最后一条记录的排序字段值,下一页查询用WHERE create_time < 上次的值 ORDER BY create_time DESC LIMIT 20,跳过offset直接定位。书签法对用户正在逐页浏览的场景效果极其显著,但要注意排序字段必须是唯一的,否则可能出现跳页或重复。
大字段问题则是指TEXT、BLOB这类列。如果一张表里有很多大字段,但大部分查询根本不需要读这些列,那它们的B+树聚簇索引主键叶节点体积会很大,导致一个数据页能容纳的行数变少,进而让整棵B+树更高,所有查询的扫描成本都会上升。解决方案是垂直拆分:把这些大字段抽到独立的扩展表,主表和扩展表用主键关联,按需查询。
5.3 大表加索引的正确姿势
对千万级甚至亿级的大表直接执行ALTER TABLE ... ADD INDEX,是一件非常危险的事情。因为MySQL在InnoDB下默认的DDL操作会把整张表复制一份,期间会锁表,所有写入都会被阻塞。在业务高峰期执行这种操作,基本等于把线上服务干掉。
正确做法是使用在线DDL工具。目前最常用的是percona出品的工具,它通过创建一张新表、触发器和数据同步机制,在不阻塞写入的情况下完成索引变更。实际操作步骤如下:
第一步,在低峰期创建新表结构,新表包含要添加的索引。第二步,在原表上创建触发器,把增量写操作实时同步到新表。第三步,分批把原表的历史数据拷贝到新表。第四步,完成拷贝后锁定原表几秒,切换业务流量到新表。整个过程业务基本无感知。
另一个细节是:加索引之前先确认是否有足够磁盘空间。因为在线DDL通常需要原表大小一倍左右的额外空间,磁盘打满会直接导致复制失败甚至数据库宕机。
如果你的表数据量还没有大到需要在线DDL的程度,也建议遵循一个基本原则:在业务低峰期执行DDL,并且提前备份。宁可多等一小时,也不要拿生产环境的安全去赌几分钟的便利。
还有一个我经常看到有人操作错误的细节:给大表加索引之前,最好先分析一下查询模式,确认加的是真正有用的索引,而不是为了“万一用得到”而加的。大表上的每一次索引变更成本都很高,试错成本不可接受。把分析执行计划、统计区分度、评估扫描行数这些工作前置,才是线上安全操作的正确打开方式。
写在最后的个人建议
从数据结构的原理到线上排查的实操,索引这条线其实没有太多玄学,核心就是对查询模式和数据特征的理解。我自己这么多年用下来,最大的感受是:索引设计不是一次性的,业务在变,数据量在变,SQL也在变,索引也需要持续跟进维护。
最后一个建议:每次优化完索引,把执行计划截图保存下来,过一段时间再回头看。你会发现当时觉得很聪明的设计,在数据量翻倍之后可能已经成了瓶颈。带着这种持续的审视去看索引,就不会再被慢查询打得措手不及了。