做了几年的数据库开发和优化,我越来越觉得,MySQL 索引这玩意儿,属于典型的“会用的人觉得不难,不会用的人天天踩坑”。前阵子刚帮一个团队排查线上慢查询,一个订单列表接口,数据量 200 万出头,带条件查询竟然要 4 秒多,用户点一次等半天。拿到 SQL 一看,where 条件里对索引字段做了函数运算,索引直接废掉,全表扫描。这种问题,懂的人两分钟定位,不懂的人可能压测好几轮都搞不定。
所以这篇东西,我打算把 MySQL 索引进阶这件事一次讲透。从最常见的索引失效场景开始,到联合索引顺序的底层逻辑,再到索引下推、覆盖索引这些进阶优化点,最后聊聊索引设计背后的架构取舍。不堆概念,全部按实际排查思路来讲,该给的表和命令都会给,可以直接用到你自己的工作里。
这篇文章适合谁?已经写过 SQL、会用 explain,但是对索引只有零散了解的同学;或者被线上慢查询折磨过、想系统梳理一遍的开发和 DBA。看完不敢说让你成为顶级专家,但至少下次再遇到索引问题,能按一套稳定的排查路径走下去,不会像无头苍蝇一样瞎试。
1. 先说清楚:索引为什么会“该走却没走”
1.1 索引失效常见场景清单
关于索引失效,网上一搜一大把,但罗列的人多,讲清楚原理的少。我按自己实际排查时的经验,把最常遇到的几种情况先整理成一个速查表,后面再逐个展开。
| 失效场景 | 典型写法 | 原因简述 |
|---|---|---|
| 隐式类型转换 | 字符串字段 = 数字 | 字段类型与查询参数类型不一致,MySQL 对列做转换,导致索引失效 |
| 对索引列使用函数 | where DATE(create_time)='2024-01-01' | 对列本身做运算或函数处理,破坏索引有序性 |
| 对索引列做运算 | where id + 1 = 100 | 索引列参与运算后,无法直接匹配 B+ 树中的键值 |
| LIKE 左模糊 | where name LIKE '%张三%' | 无法利用 B+ 树有序性定位起点,只能扫描 |
| OR 连接非索引列 | where id=1 or status=2(status 无索引) | 优化器无法同时走两条路径,干脆全扫 |
| 联合索引不满足最左前缀 | 联合索引(a,b),只查 b 字段 | 没有 a 的前缀约束,无法定位范围起点 |
| 范围查询右侧列失效 | 联合索引(a,b,c),where a=1 and b>10 and c=5 | b 用范围后,右侧 c 无法利用索引有序性过滤 |
| 优化器判断全表更快 | 区分度低的列,或回表代价太大 | 优化器估算全表扫描成本更低时,会主动放弃索引 |
1.2 用一个真实案例串起整个排查链路
之前那个 4 秒的慢查询,具体长这样:
sql复制SELECT order_id, user_id, total_amount, status
FROM orders
WHERE DATE(create_time) = '2024-05-20'
AND status = 1
ORDER BY order_id DESC
LIMIT 20;
orders 表大概 200 万行,create_time 上有普通索引。这个查询的问题很明显:DATE(create_time) 把 create_time 这一列套进函数里了。从 MySQL 的角度看,索引 B+ 树是按照 create_time 的原始值排好序的,你告诉它的却是“帮我找日期等于 2024-05-20 的记录”,它得先把每一行的 create_time 取出来算一遍 DATE(),然后才能判断是不是你要的。这跟全表扫描没区别,索引自然就废了。
排查手法也简单,EXPLAIN 一下:
text复制+----+-------------+--------+------+---------------+------+---------+------+---------+-----------------------------+
| id | select_type | table | type | possible_keys | key | key_len | rows | Extra |
+----+-------------+--------+------+---------------+------+---------+------+---------+-----------------------------+
| 1 | SIMPLE | orders | ALL | idx_create_time| NULL | NULL | 205 | Using where; Using filesort |
+----+-------------+--------+------+---------------+------+---------+------+---------+-----------------------------+
看到 type 是 ALL,key 是 NULL,rows 是全表估算行数,Extra 还带个 Using filesort,基本就实锤了。改法也简单,把函数从列上挪开:
sql复制SELECT order_id, user_id, total_amount, status
FROM orders
WHERE create_time >= '2024-05-20 00:00:00'
AND create_time < '2024-05-21 00:00:00'
AND status = 1
ORDER BY order_id DESC
LIMIT 20;
同理,WHERE id + 1 = 100 也要改写成 WHERE id = 99。原则就是:别碰索引列本身,把运算放到条件值那一边去。
1.3 怎么快速判断走了没有:EXPLAIN 几个关键字段
很多人用 EXPLAIN 就是在看 type 是不是 ALL,其实信息远不止这点。我平时至少会盯四个地方:
- type:访问类型。从好到差大致是 system > const > eq_ref > ref > range > index > ALL。看见 ALL 就要警觉,看见 index 也要小心,index 是“扫描了整棵索引树”,不一定比 ALL 快多少。
- key:实际用到的索引。如果 NULL,说明压根没用上。
- rows:优化器估算的扫描行数。这个数字和实际耗时高度相关,优化空间大的时候,往往就是 rows 从几十万降到几百的过程。
- Extra:额外信息。出现 Using filesort 或 Using temporary 要警惕,说明排序或分组没有直接利用索引;出现 Using index 是好事,代表覆盖索引扫描。
注意:EXPLAIN 的 rows 是优化器的估算,不是精确值。如果统计信息过期,rows 会严重失真,后面我会专门讲统计信息的问题。
- 联合索引设计:对于高频查询,把等值条件的列放前面、范围条件的列放后面。例如高频查询是
where user_id=? and status=?,就建立联合索引(user_id, status);如果还有时间范围查询,可以再设计(user_id, status, create_time)。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 联合索引与最左前缀原则:顺序是一切的前提
2.1 联合索引在 B+ 树里到底是怎么存的
很多人理解联合索引,以为是“分别维护多列索引”,这是最典型的误解。联合索引 (a, b, c) 不是三棵 B+ 树,而是一棵 B+ 树,它的排序规则是:先按 a 排序,a 相同的情况下按 b 排序,b 也相同再按 c 排序。打个比方,就像电话簿先按姓排序,同姓的人再按名排序。
这个排序方式决定了:你只有先限定 a,才能利用 b 的有序性;只有同时限定 a 和 b,才能利用 c 的有序性。这就是最左前缀原则最底层的来源。用树的结构想,联合索引的每个节点存的是 (a, b, c) 的完整组合值,搜索时从根节点开始,第一步必须用 a 的值来比较,否则你连走哪条子树都不知道。
所以这些场景下索引的使用情况大概是:
| 查询条件 | 能用到索引吗 | 用到哪部分 |
|---|---|---|
| a = 1 | 能 | a |
| a = 1 AND b = 2 | 能 | a, b |
| a = 1 AND b = 2 AND c = 3 | 能 | a, b, c |
| b = 2 | 不能 | 无 |
| c = 3 | 不能 | 无 |
| b = 2 AND c = 3 | 不能 | 无 |
| a = 1 AND c = 3 | 能 | a(c 用不了,中间断了) |
2.2 范围查询右侧的列为什么会“断掉”
这个要单独拎出来讲,因为踩坑率极高。还是联合索引 (a, b, c),查询是 where a = 1 and b > 10 and c = 5。
MySQL 可以先用 a=1 定位到一批记录,这批记录内部是按 b 排好序的,所以还能用 b>10 去确定一个范围。问题在于,c 的排序优先级是在 b 之下——b 是范围判断,b 大于 10 的记录里,c 并不是全局有序的,而是按 b 排序后再按 c 排。你没法用 B+ 树直接定位“b 大于 10 且 c 等于 5”的起始位置,只能把 b 落在范围内的所有记录捞出来,再逐条过滤 c。
所以网上常说的“范围查询右侧列失效”,准确说不是索引失效,而是索引只能用到 b 这一层,后面的 c 无法用于定位,只能作为普通过滤条件。如果业务上经常出现 范围 + 等值 的组合,就要考虑调整列的顺序,比如改成 (a, c, b) 或者 (c, a, b),把等值列放在范围列前面。
注意:范围判断导致后续列无法定位,这指的是单次查询中只能用部分索引列。优化器也会根据条件分布尝试不同方案,但最终结果通常是部分列生效,不要指望 MySQL 对 b>10 之后做神奇的跳跃扫描。
2.3 联合索引列顺序设计的三个基本原则
列顺序这事,其实没有绝对标准,但有几条被验证过很多次的方向:
第一,等值条件优先于范围条件。因为等值条件下,后续列还能保持有序性;范围条件一出现,后面的列基本就废了。除非那个范围列本身就是查询里区分度最高的列,且左侧等值列区分度太低,才考虑反常规设计。
第二,区分度高的列优先放在前面。区分度可以简单理解为“这个列有多少种不同的值”。拿性别和用户ID来说,性别只有男、女两种,区分度极低;用户ID几乎每条不同,区分度极高。把区分度高的列放前面,可以让 B+ 树在第一步就把扫描范围缩到很小。但注意,这个原则要和第一条结合考虑——如果区分度高的列是范围查询,区分度低的列是等值查询,那优先保证等值前缀可能更好。
第三,把 order by / group by 的列纳入索引。如果查询经常按某个列排序,把这个列加进联合索引末尾,能直接避免 filesort。比如订单列表需要 where user_id = ? order by create_time desc,联合索引 (user_id, create_time) 就能同时覆盖过滤和排序。这时候 create_time 虽然区分度高,但它是排序条件,不是范围条件,放在 user_id 后面正好。
2.4 前缀索引:长字段的折中方案
遇到很长的字符串列(比如 URL、文章标题),如果直接建全列索引,B+ 树会变得非常大,一个页能存的键值数量急剧下降,树层数变高,反而性能更差。这时可以用前缀索引,只对字段的前 N 个字符建立索引。
做法很简单:
sql复制ALTER TABLE articles ADD INDEX idx_title_prefix (title(20));
N 选多少合适?核心看区分度。你可以这样估算:
sql复制SELECT COUNT(DISTINCT title) / COUNT(*) AS full_cardinality
FROM articles;
SELECT COUNT(DISTINCT LEFT(title, 10)) / COUNT(*) AS prefix_cardinality_10,
COUNT(DISTINCT LEFT(title, 20)) / COUNT(*) AS prefix_cardinality_20
FROM articles;
一般前缀区分度达到全列区分度的 90% 以上,就可以用。举个例子,全列区分度是 0.95,前缀 10 个字符是 0.93,前缀 20 个字符是 0.94,那 20 就是比较稳妥的选择。N 再大收益有限,反而增加索引体积。
前缀索引的代价是:无法利用索引完成 order by、group by,还存在“先到索引找前缀,再回表确认完整列值”的额外步骤。如果字段本身不长,就别矫情搞前缀索引,直接全列索引更省事。
3. 索引下推、覆盖索引与回表:优化器是怎么“偷懒”的
3.1 回表是什么意思,为什么回表多了会慢
InnoDB 的索引分两类:聚簇索引和二级索引。聚簇索引就是主键索引,叶子节点直接存整行数据;二级索引的叶子节点存的是“索引列的值 + 主键值”。你通过二级索引查到主键后,还得再用主键去聚簇索引里捞整行数据,这个过程就叫回表。
回表不是问题,但回表次数多了就是大问题。比如一个普通索引区分度不高,一次查询匹配出 5 万行,就意味着要回表 5 万次,每次都是一次主键查找。在主键是随机 UUID 的情况下,这 5 万次主键查找对应的数据页可能全在磁盘的不同位置,IO 开销直接爆炸。
所以优化的一条主线就是:尽量减少回表次数,甚至完全不回表。
3.2 覆盖索引:让查询在索引里就把活干完
覆盖索引就是指“查询需要的所有列,都能在二级索引中找到”。这时 MySQL 不用回表,直接在索引树上取数据就返回了。EXPLAIN 里 Extra 字段显示 Using index,就是覆盖索引生效的标志。
举个实际例子:
sql复制SELECT user_id, status FROM orders WHERE user_id = 100;
如果 orders 上有联合索引 (user_id, status),这个查询要的列(user_id 和 status)都在索引里,不需要回表。如果你改成 SELECT * ...,命中的可能就不再是覆盖索引,因为索引里没有完整的行数据,必须回表取其他列。
所以覆盖索引在真实业务里的价值很大。做报表、统计类查询时,如果只需要少数几个字段,可以针对性地设计“窄索引”(覆盖所需字段),避免频繁回表。代价是这个索引可能冗余,要考虑写放大,不能滥用。
注意:覆盖索引不是一种“特殊的索引类型”,而是一种“索引被使用的方式”。只要二级索引包含了查询所需的全部列,它就自然实现了覆盖。
3.3 索引下推:过滤条件提前到索引层
索引下推(Index Condition Pushdown,ICP)是 MySQL 5.6 引入的优化。作用简单说就是:把 where 条件里那些“没法直接用索引定位,但能在索引层判断”的条件,提前到索引遍历时过滤,减少回表。
举个例子,联合索引 (name, age),查询是:
sql复制SELECT * FROM users WHERE name LIKE '张%' AND age = 20;
没有 ICP 的时候,MySQL 只能根据 name LIKE '张%' 定位到一批主键,然后回表取出完整行,再逐行判断 age 是否等于 20。有 ICP 之后,在二级索引的遍历过程中,发现 age 不等于 20 的,直接跳过,根本不需要回表。对比下来,回表次数从“所有姓张的人数”降为“姓张且年龄为 20 的人数”。
怎么确认有没有用到 ICP?EXPLAIN 的 Extra 字段里如果显示 Using index condition,就是下推生效了。日常优化中,这个特性不需要你做什么特别配置,但要理解它:有些 SQL 看起来联合索引右侧列“用不上”,其实优化器已经在索引层帮你做了一层过滤,性能未必差。
3.4 ORDER BY 与 GROUP BY 如何吃上索引红利
排序和分组是慢查询的重灾区。没有索引的情况下,MySQL 需要把结果集先放到临时表,再排序(Using filesort)。如果结果集很大,这个过程会非常慢。
如果查询条件里已经用了索引,并且 order by / group by 的列正好是联合索引的后续列,MySQL 就可以直接按索引顺序读取,天然就是排好序的,连 filesort 都省了。
一个常见的例子:
sql复制SELECT user_id, create_time
FROM orders
WHERE user_id = 100
ORDER BY create_time DESC;
有联合索引 (user_id, create_time),这个查询就非常舒服:先定位到 user_id=100 的所有记录,这些记录内部已经按 create_time 排序,直接逆序读就行。如果没有这个联合索引,MySQL 得把 user_id=100 的记录找出来,再在临时表里做一次排序。
所以设计索引时,把高频排序、分组的列放进联合索引,往往收益巨大。
4. 索引设计的架构哲学:为什么说“少建索引也是一种优化”
4.1 主键索引选型:自增、UUID 还是业务主键
主键索引是 InnoDB 的聚簇索引,它的选择直接影响整张表的物理存储布局。很多人建表时随手定主键,后来才意识到问题。
自增主键的优点是写入顺序和磁盘顺序一致。新插入的行总是追加到 B+ 树的最右侧,页分裂少,写入性能稳定。缺点是分布式场景下不方便做分库分表,主键容易被猜到。UUID 主键的优点是全局唯一、生成简单,但 UUID 是随机的,每次插入都要在 B+ 树中找到合适的位置,频繁触发页分裂,写入性能明显下降。而且聚簇索引叶子节点是用主键排序的,UUID 乱序会让数据碎片化严重。
所以在分库分表场景下,比 UUID 更好的方案是雪花 ID 或类似算法生成的趋势递增 ID:全局唯一,又保证了写入基本有序,兼顾了自增和 UUID 的优点。用不用业务字段做主键?能用稳定的业务唯一键做主键也行,但业务主键一旦后续有变化,牵一发动全身,谨慎为上。
在实际项目中,我见过不少把一个无业务意义的自增列作为主键、再给业务唯一键单独加唯一索引的设计,这种做法在大多数场景下是最省心的。
4.2 索引不是免费的:写入放大与索引争用
索引本质是“用空间换读时间”,但这个“空间”不仅仅指磁盘,还包括写入时的维护成本。每新增一条记录,除了写聚簇索引,还要同步更新这张表上的每一个二级索引。索引越多,写入路径越长,TPS 掉得越明显。对于写多读少的业务,索引设计要克制。
插一条容易被忽视的情况:数据库开启审计功能后,审计日志的写入可能非常频繁,如果审计表设计不当(比如对审计表的非必要字段建立了过多索引),会导致索引争用和写入阻塞,进而影响主业务的数据库性能。这是个真实的运维问题,也是“索引不是免费的”的典型案例。建索引前,想清楚这张表的写入频率和索引的命中率。
4.3 唯一索引与普通索引的取舍
唯一索引除了查询优化,还承担着完整性约束。在插入时,即使你只是插入一条不冲突的记录,InnoDB 也要先做一次唯一性检查。这个检查在普通索引上是不存在的。所以,如果某个字段只是“大概率唯一”但不要求强一致,就别用唯一索引,改普通索引,写入能有小幅提升。
另一方面,唯一索引对查询优化器是好消息。比如 WHERE user_id = 100 如果 user_id 上是唯一索引,优化器知道最多只有一条记录命中,直接用 const 访问类型,效率极高;如果是普通索引,它还得假设可能有多条,按 ref 处理。所以业务要求唯一,就用唯一索引,别为了省一点写入开销破坏数据完整性。
4.4 索引生命周期:加索引容易,管理索引难
很多团队的索引管理非常随意:上线一个功能加一个索引,从没做过清理。最终生产库上可能有几十个索引,其中一半长期没有命中,白白拖着写入性能。
我建议用一套简单的日常检查机制:
- 开启慢查询日志,定期分析慢 SQL,找出哪些查询经常“全表扫描”但没被索引覆盖。
- 使用
performance_schema或统计工具查看各索引的使用频率,长期未使用的索引考虑下线或删除。 - 给索引制定统一的命名规范,并在表结构文档里记录索引用途,避免后期维护时看到一堆命名混乱的索引无法下手。
- 删除索引要谨慎,最好先在测试环境模拟业务高峰,观察一段时间再下线。
经验之谈:一个索引如果创建后三个月里没有在 explain 或慢日志优化中出现过,它大概率是可以删掉的。留着一个长期不用的索引,等于每写一条数据都在为它付费,而它从来不回馈你。
5. 日常排查工具与完整优化流程
5.1 从慢日志到 EXPLAIN:一套标准动作
线上遇到性能问题,我习惯按这个顺序处理:打开慢日志 → 捞慢 SQL → 逐条 EXPLAIN → 针对性优化 → 压测验证 → 观察效果。
慢日志默认是关的,需要手动打开。在 MySQL 8.0 里,可以这样设置:
sql复制SET GLOBAL slow_query_log = 'ON';
SET GLOBAL long_query_time = 1;
SET GLOBAL log_output = 'TABLE';
long_query_time 设为 1 表示超过 1 秒的 SQL 会被记录。log_output 设为 TABLE,慢 SQL 会写入 mysql.slow_log 表,方便查询。如果想落文件,把 log_output 改成 FILE,并配置 slow_query_log_file 路径。
捞慢 SQL 时别直接看文件,用 mysqldumpslow 工具做聚合,找出出现频率最高、总耗时最长的 SQL:
bash复制mysqldumpslow -s t -t 10 /var/log/mysql/slow.log
-s t 按总耗时排序,-t 10 只显示前 10 条。拿到 SQL 之后,逐条前面加 EXPLAIN,观察上面说的 type、key、rows、Extra 四个关键字段,往往问题一眼就暴露了。
5.2 在线加索引:别在高峰期直接 DDL
生产环境要给大表加索引,直接 ALTER TABLE 很危险。MySQL 5.6 之后虽然支持在线 DDL(ALGORITHM=INPLACE),但如果表数据量巨大,操作过程中仍可能产生额外开销,甚至阻塞写入。
我常用的方案是:如果是 MySQL 5.7+,评估表大小后选择 ALGORITHM=INPLACE, LOCK=NONE 在线加索引;如果数据量极大,或者 MySQL 版本较老,就用 pt-osc(Percona Toolkit)或 gh-ost 这类工具,通过建影子表把变更做掉,业务无感知。
sql复制ALTER TABLE orders ADD INDEX idx_user_status (user_id, status), ALGORITHM=INPLACE, LOCK=NONE;
注意,LOCK=NONE 不意味着完全零风险,执行期间还是要盯一下数据库的负载和主从延迟。
5.3 统计信息失效导致执行计划错乱
优化器判断走不走索引,依赖表的统计信息。如果统计信息过期,优化器会做出离谱的估计,导致本该走索引的查询选择全表扫描,或者反过来。
常见触发场景:大量数据变更后没来得及更新统计信息;或者 innodb_stats_persistent 配置不合理,统计信息更新频率过低。遇到 EXPLAIN 与实际执行表现严重不符的情况,可以手动更新统计信息:
sql复制ANALYZE TABLE orders;
也可以分析表之后再看执行计划有没有变化。如果一条 SQL 突然从快变慢,且 EXPLAIN 显示 rows 估算和实际明显不符,先不要急着改代码,很有可能是统计信息惹的祸。
我之前遇到过一次:一张表每天删除大量数据,统计信息还停留在刚插入大量数据时的状态,优化器估了十几万行,导致一个本来走索引的 SQL 改成全表扫描。ANALYZE TABLE 之后,执行计划立刻恢复正常,前后不到一分钟。
5.4 字符集与排序规则对索引的隐性影响
字符集不一致也是导致 JOIN 或条件查询无法走索引的常见原因。比如表 A 的 user_id 是 utf8mb4,表 B 的 user_id 是 utf8,两张表 JOIN 时,MySQL 必须把一边的字符集转换成另一边才能比较,索引在这个过程中可能失效。
排查这类问题,可以检查两张表的字符集:
sql复制SELECT TABLE_SCHEMA, TABLE_NAME, TABLE_COLLATION
FROM information_schema.TABLES
WHERE TABLE_NAME IN ('table_a', 'table_b');
如果发现排序规则不一致,比如一张是 utf8mb4_0900_ai_ci,一张是 utf8mb4_general_ci,建议统一。建表时尽量把同一个业务域的字符字段统一,避免后续踩这种比较隐蔽的坑。
5.5 EXPLAIN 几个关键字段的速查表
最后给一个我平时参考的简化版字段速查表,适合贴在工位旁边:
| 字段 | 关注点 | 备注 |
|---|---|---|
| type | 是否从 ALL 提升到 ref/range 等 | ALL 和 index 都是大忌,除非表极小 |
| possible_keys | 有哪些候选索引 | 这里是空的就说明没索引可用 |
| key | 实际命中的索引 | NULL 表示没走索引 |
| key_len | 联合索引实际用到了多少列 | 可以通过长度反推用了联合索引的哪几列 |
| rows | 估算扫描行数 | 越小越好,但不是精确值 |
| Extra | Using filesort / Using temporary | 出现时要考虑是否能通过索引消除 |
| Extra | Using index | 覆盖索引,好事 |
| Extra | Using index condition | 索引下推,好事 |
这套表配合前面的排查流程,基本能覆盖日常 80% 的性能问题。索引优化这件事,最忌讳“背一堆规则”却不理解背后的树结构和扫描路径。当你把 B+ 树、回表、联合索引底层逻辑想明白了,很多看似玄学的问题,其实都能推导出来。
最后再分享一个个人的小习惯:每次优化完一条慢 SQL,我都会把优化前后的 EXPLAIN 输出和实际耗时存到一份本地笔记里,标注清楚当时的表数据量和业务场景。几个月后回头看,这些记录比任何教程都有用——因为数据库性能问题从来不是孤立的,数据量、并发、业务的增长都会不断改变最优解。如果你现在也被索引问题搞得头疼,不妨从打开慢日志开始,先摸清你线上到底有哪些 SQL 在受苦,这比盲目的“多建几个索引”要靠谱得多。
