我最早真正意识到“索引”这两个字值多少钱,是在一次线上事故复盘。那是一个刚上线不久的业务报表模块,数据量还不到一千万行,可一个列表查询接口愣是从 200ms 涨到了 2.3s,数据库 CPU 直接飙到 80%。当时的解决方式简单粗暴——DBA 用一条 ALTER TABLE 加了个索引,接口瞬间掉回 80ms。那次之后我就明白了一个道理:MySQL 性能优化,90% 的收益都压在索引上,而索引优化的核心是“理解数据在磁盘上的组织方式,再用最小成本把要的数据拿回来”。
这篇内容我准备系统讲一遍 MySQL 索引优化,从 InnoDB 的底层存储逻辑,到建索引的分析方法,再到索引失效和慢查询排查,最后是索引运维里那些文档不会写的坑。内容适合刚接触索引优化、想搞懂原理的新手,也适合写了好几年 SQL 但没仔细抠过执行计划的开发,以及正在排查线上慢 SQL 的运维/后端同学。
1. 索引到底是什么,为什么能让查询“快成闪电”
很多人背过“索引是 B+ 树”“索引能加速查询”,但问到为什么 B+ 树就比全表扫描快,就支支吾吾了。我觉得搞懂索引优化的前提,是先搞懂两个问题:没有索引时 MySQL 在做什么,有索引时它又在做什么。
1.1 全表扫描的真实成本:磁盘 IO 才是命门
先看没有索引的情况。MySQL 的 InnoDB 存储引擎把数据放在磁盘上,最小读写单位是 16KB 的数据页。假设一张用户表有 1000 万行数据,每行数据大约 300 字节(包含各种用户字段),那么一个数据页大概能装 50 行,这张表总共需要约 20 万个数据页。
如果 SQL 是 SELECT * FROM user WHERE name = '张三',而 name 列上没有索引,MySQL 只能把 20 万个数据页逐一从磁盘读入内存,逐行比对 name 字段。就算每次磁盘 IO 只要 10ms(SSD 实测随机读大概 0.1ms,机械盘会慢很多),20 万次 IO 算下来也要 20 秒起步——这就是慢查询的根源。
这里有个关键概念:磁盘随机 IO 比顺序 IO 慢几个数量级。全表扫描本质上是大量随机读取,而索引的核心价值,就是把“随机读”尽可能变成“少量精确读”。
1.2 为什么偏偏是 B+ 树,而不是哈希或二叉树
MySQL 索引默认选择 B+ 树,不是偶然。核心原因有三个:
- 矮胖结构,查询次数少:B+ 树是典型的多叉平衡树,一个节点(对应一个 16KB 数据页)能存几百上千个键值。以主键 bigint 为例,一条记录大概 8 字节,加上指针 6 字节,一页能存约 1000+ 个键值对。三层 B+ 树大约能存 2000×2000×叶子页容量,轻松覆盖千万级数据。也就是说,走索引查询只需要 3~4 次磁盘 IO,和全表扫 20 万次 IO 是天壤之别。
- 叶子节点有序链表:B+ 树所有数据都存在叶子节点,且叶子节点之间通过双向指针串联。这保证了范围查询(比如
WHERE age BETWEEN 18 AND 30)和排序查询(ORDER BY age)都可以像读链表一样顺序扫描,极大提升性能。 - 非叶子节点不存数据,只存索引键:这让每页能容纳更多键值,树的层数更低,IO 次数更少。
作为对比,哈希索引虽然单次精确查找(WHERE id = 100)能做到 O(1),但它不支持范围查询,也不支持排序,所以在 OLTP 场景下 B+ 树是更通用的方案。
1.3 聚簇索引和二级索引:回表到底是怎么回事
InnoDB 里有两类索引,理解它们的差异,才能明白为什么“不能乱建索引”。
- 聚簇索引(Clustered Index):表按主键组织,聚簇索引的叶子节点直接存储整行数据。如果没有显式主键,InnoDB 会选第一个非空唯一索引作为聚簇索引;如果也没有,就生成隐藏的 rowid 作为聚簇索引。所以主键就是数据的物理存储顺序,这也是为什么强烈建议使用自增整型主键——写入时顺序追加,避免页分裂。
- 二级索引(Secondary Index):叶子节点不存整行,而是存“索引列的值 + 主键值”。查询时如果只靠二级索引拿不到完整行数据,就需要拿着主键去聚簇索引再查一次,这个过程叫回表。
打个比方:聚簇索引是书的正文页,二级索引是书末的“术语索引表”。查“索引”这个词,先去术语表找到它在第 200 页,再翻到第 200 页看正文,这就是回表。如果术语表中直接附带了完整解释,就不用翻正文了——这就是后面的“覆盖索引”能大幅提速的原因。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 索引设计之前,先学会用 EXPLAIN 读懂一条 SQL
建索引不是拍脑袋,而是“先诊断、再用药”。我见的很多开发同学,拿到慢 SQL 第一反应就是加索引,加完也不知道有没有生效。正确做法是先用 EXPLAIN 看执行计划,搞清楚 MySQL 对这条 SQL 的访问路径,再来设计索引。
2.1 EXPLAIN 输出到底该怎么读
拿一条实际 SQL 举例:
sql复制EXPLAIN SELECT id, name, age FROM user WHERE status = 1 ORDER BY create_time DESC LIMIT 20;
输出里有几个核心列需要关注:
| 列名 | 含义 | 优化的信号 |
|---|---|---|
| type | 访问类型 | const > eq_ref > ref > range > index > ALL,能到 range 以上基本合格 |
| possible_keys | 可能用到的索引 | 看优化器有没有候选可选 |
| key | 实际选用的索引 | 如果为 NULL,说明这条 SQL 走的是全表扫描 |
| rows | 预估扫描行数 | 越大越危险,和真实扫描行数偏差太大说明统计信息滞后 |
| Extra | 附加信息 | 出现 Using filesort、Using temporary、Using where 都要警惕 |
type 是第一个要看的指标。const/eq_ref 代表按主键或唯一索引精确命中,是最优状态;ref 代表非唯一索引等值匹配,常见且可接受;range 代表索引范围扫描(比如 age > 20),性能也还行;index 代表全索引扫描(比全表好一点,但仍不理想);ALL 就是全表扫描,这种类型出现在大表上,基本就是慢 SQL 元凶。
Extra 里的几个“Using”是排查重点:
Using filesort:文件排序,通常意味着ORDER BY的字段没走索引,MySQL 不得不把结果集先排序再返回。数据量大时特别伤。Using temporary:用了临时表,常见于GROUP BY、DISTINCT、UNION等场景,说明分组或去重操作没利用到索引。Using index:这是好信号,代表当前查询在索引上就能拿到全部需要的数据,不用回表,也就是“覆盖索引”生效了。
2.2 用真实案例演示:一秒定位索引该加在哪
假设订单表 orders 有两个高频查询:
sql复制-- 查询某用户最近的订单
SELECT * FROM orders WHERE user_id = 123 ORDER BY order_time DESC LIMIT 20;
-- 查询某商户某天的订单
SELECT * FROM orders WHERE merchant_id = 45 AND create_time >= '2024-01-01' AND create_time < '2024-01-02';
先用 EXPLAIN 看第一条,大概率 type 是 ALL、Extra 里有 Using filesort,此时索引设计目标很明确:
- 要能快速定位 user_id 对应的数据 → 等值条件走索引;
- 要能免去文件排序 → 把 ORDER BY 的字段也带进索引。
于是建联合索引:
sql复制ALTER TABLE orders ADD INDEX idx_user_time (user_id, order_time DESC);
MySQL 8.0 支持索引的降序声明,这里 order_time DESC 就能直接支撑“按时间倒序”,避免排序。第二条语句同理:
sql复制ALTER TABLE orders ADD INDEX idx_merchant_time (merchant_id, create_time);
这里我强烈建议:每写一条 WHERE 条件较多的查询,都先 EXPLAIN 一遍再决定索引,而不是一股脑给所有字段都建索引。索引不是越多越好,后面第 4 节会专门讲多索引的代价。
2.3 联合索引字段顺序:最左前缀原则的实战理解
联合索引 (a, b, c) 实际是先按 a 排序,a 相同再按 b 排序,b 相同再按 c 排序。所以它能直接命中的查询条件组合是有讲究的,这就是“最左前缀原则”。
举例说明,索引 idx_a_b_c(a, b, c) 可以匹配:
WHERE a = 1→ 命中 a 前缀,没问题;WHERE a = 1 AND b = 2→ 命中 a、b 前缀,没问题;WHERE a = 1 AND b = 2 AND c = 3→ 完全命中;WHERE a = 1 AND c = 3→ 只用到 a,c 字段用不上索引(因为中间少了 b);WHERE b = 2→ 完全用不上索引,直接全表扫描;WHERE a > 100 AND b = 2→ 因为 a 是范围条件,b 的索引匹配在范围之后会中断。
所以设计联合索引时,字段顺序的基本逻辑是:先把等值查询的字段放前面,把区分度高的字段放前面,把范围查询的字段放后面。等值条件放前面是为了让更多查询能用到前缀,区分度高是为了更快收敛数据;范围字段放后面,是避免范围条件中断后续字段的索引匹配。
注意:这里说的“区分度高”也并不是绝对真理。如果某字段是性别这种区分度极低的,放第一位反而容易让优化器放弃索引。所以实操中要针对真实查询模式做取舍,没有银弹。
3. 索引失效的六大经典场景,你大概率踩过其中几个
索引建好了,只是第一步。SQL 写法不对,优化器照样不走索引。下面这些场景我全部在线上见过,每个都可以单独写一篇事故复盘。
3.1 索引列上做计算或函数操作
sql复制-- 反例:在索引列上使用 DATE_FORMAT
SELECT * FROM order_info WHERE DATE_FORMAT(create_time, '%Y-%m-%d') = '2024-01-01';
-- 正例:直接使用范围查询
SELECT * FROM order_info
WHERE create_time >= '2024-01-01' AND create_time < '2024-01-02';
为什么函数会导致索引失效?因为 B+ 树的叶子节点存的是原始字段值,如果查询条件要把字段先计算成别的值再比较,MySQL 就无法基于原始排序结构去搜索,只能全量取出数据做计算。类似的反例还包括 WHERE id + 1 = 10、WHERE LEFT(name, 2) = '张',只要索引列参与了运算或函数,基本就告别索引了。
3.2 隐式类型转换
sql复制-- 反例:phone 字段是 varchar,却用数字比较
SELECT * FROM user WHERE phone = 13800001111;
-- 正例:字符串字段就用字符串匹配
SELECT * FROM user WHERE phone = '13800001111';
MySQL 在比较不同类型时会发生隐式转换。这里需要理解一个细节:当索引列是 varchar 类型、传入 int 时,MySQL 会把索引列转成数字再比较(即在列上加了 CAST 函数),索引失效;反过来如果索引列是 int、传入字符串,则会先把字符串转成数字再比较,索引通常还能用。最稳妥的做法是保证应用程序传入的类型和字段类型一致。
3.3 LIKE 前导通配符
sql复制-- 反例:前导 % 导致无法使用索引树搜索
SELECT * FROM article WHERE title LIKE '%MySQL%';
-- 正例:后缀 % 能利用索引前缀匹配
SELECT * FROM article WHERE title LIKE 'MySQL%';
B+ 树的查找是从根节点逐层比较键值路径的,LIKE 'MySQL%' 相当于一个范围查询(从 'MySQL' 到 'MySQNz' 之间),索引能定位到起点然后顺序扫描。而 LIKE '%MySQL%' 的前面是未知字符,索引排序就失去了参照,优化器只能全表扫描。如果业务确实需要搜索任意位置子串,应该选用全文索引或专门的搜索引擎(如 ES),而不是硬刚 MySQL。
3.4 OR 条件里混入非索引列
sql复制-- 反例:user_id 有索引,age 没索引,OR 连接后整条 SQL 索引失效
SELECT * FROM user WHERE user_id = 100 OR age = 30;
-- 正确姿势:把 OR 拆成 UNION ALL 或 UNION
SELECT * FROM user WHERE user_id = 100
UNION ALL
SELECT * FROM user WHERE age = 30;
原因不难理解:B+ 树执行路径是唯一的,OR 如果一边能用索引、一边不能,优化器无法只走索引部分,为了保证查询正确,只能放弃索引走全表扫描。这也是“mysql 的 or 能去重吗”这类问题背后的常考逻辑:拆成 UNION 时要注意去重语义,UNION 会去重,UNION ALL 不去重但性能更好,按业务语义选择即可。
3.5 范围查询右侧的字段索引中断
sql复制-- 索引 idx_status_time(status, create_time)
SELECT * FROM order_info
WHERE status = 1 AND create_time BETWEEN '2024-01-01' AND '2024-01-31'
ORDER BY amount DESC;
这条 SQL 里 status 是等值、create_time 是范围。前面已经说过,范围条件右侧的字段无法继续匹配索引。也就是说,即使把 amount 也放进索引,(status, create_time, amount) 中只有 status 和 create_time 发挥了作用。优化手段是:把等值条件放前面保持索引连续性,实在需要的排序字段,可以考虑让排序走 filesort 或者设计不同形状的索引。业务里最理想的是“等值字段全部在最左,范围字段随后”。
3.6 SELECT * 导致无法覆盖索引
sql复制-- 反例:SELECT *,二级索引肯定不够用,必然回表
SELECT * FROM user WHERE name = '张三';
-- 优化:把查询列和索引列对齐
SELECT id, name FROM user WHERE name = '张三';
如果查询需要所有列,那就必须回表。但在高频查询场景里,只要业务不需要所有字段,就尽量把需要的列控制在索引覆盖范围内,让 Extra 显示 Using index。这背后是在用“冗余少量字段”换“大量 IO 减免”,是很划算的买卖。
4. 慢 SQL 实战优化:从定位到改写的完整流程
理论讲完,我们完整走一遍实际案例。假设线上有一条查询用户订单列表的 SQL 特别慢,下面是我个人习惯的排查优化流程。
4.1 第一步:开启慢查询日志,抓出问题 SQL
先确认慢查询日志开着:
sql复制-- 查看当前设置
SHOW VARIABLES LIKE 'slow_query_log';
SHOW VARIABLES LIKE 'long_query_time';
-- 临时开启(生产环境如果要开,记得评估磁盘空间)
SET GLOBAL slow_query_log = 'ON';
SET GLOBAL long_query_time = 1;
long_query_time = 1 表示超过 1 秒的 SQL 会被记录。注意这里有个坑:long_query_time 的单位是秒,我见过有人把它设成 0.1,结果日志文件每小时涨几个 GB。线上建议先设 1 秒,观察几天再调优。
4.2 第二步:用 EXPLAIN 分析访问路径
拿到的慢 SQL 长这样:
sql复制SELECT o.id, o.order_no, u.name, o.amount, o.create_time
FROM orders o
LEFT JOIN user u ON o.user_id = u.id
WHERE o.status = 2
ORDER BY o.create_time DESC
LIMIT 20;
EXPLAIN 结果里 orders 表 type 是 ALL,rows 预估 480 万行,Extra 还有 Using filesort。这就是典型的“既没过滤掉大部分数据,又要额外排序”的坏查询。
4.3 第三步:按查询模式设计索引并验证
这条 SQL 的核心过滤条件是 status = 2,排序条件是 create_time DESC。于是建立联合索引:
sql复制ALTER TABLE orders ADD INDEX idx_status_time (status, create_time DESC);
再次 EXPLAIN,type 从 ALL 变成 ref,rows 从 480 万降到约 12 万,Extra 里不再有 Using filesort。查询时间从 2.8s 降到 50ms 左右,效果立竿见影。
这个案例说明一个通用流程:先找 WHERE 的等值条件和排序字段,按“等值优先、排序随后”的顺序组织联合索引。遇到多个等值字段时,用区分度高的靠左。
4.4 第四步:深分页问题怎么破
慢 SQL 还有一个高频场景是深分页:
sql复制-- 反例:offset 到 20 万,MySQL 要扫描并丢弃 20 万行
SELECT * FROM orders
WHERE status = 2
ORDER BY create_time DESC
LIMIT 200000, 20;
这个查询即使有索引,MySQL 也需要从联合索引中定位到满足条件的第一行,然后沿链表往后扫 20 万行,再把前面 20 万行丢弃。优化方案通常有两种:
- 延迟关联(子查询先取主键):
sql复制SELECT o.* FROM orders o
INNER JOIN (
SELECT id FROM orders
WHERE status = 2
ORDER BY create_time DESC
LIMIT 200000, 20
) t ON o.id = t.id;
先把“第 200001 到 200020 行的主键”查出来,这段查询可以走索引覆盖(只查 id),然后再回表拿完整行,减少大范围回表的浪费。
- 游标分页(记录上一页最后一条的排序值):
sql复制-- 上一页最后一条记录的 create_time 是 '2024-01-15 18:30:00'
SELECT * FROM orders
WHERE status = 2
AND (create_time, id) < ('2024-01-15 18:30:00', 10000) -- 注意这是复合条件
ORDER BY create_time DESC, id DESC
LIMIT 20;
游标分页在千万级数据下是真正的长期方案,但要求业务前端配合(不能随手改页码)。我们项目里就把后台列表改成了“下一页/上一页”的游标模式,性能稳定且非常快。
4.5 排序和分组:能用索引就不要让数据库硬排序
ORDER BY、GROUP BY 不走索引时,MySQL 会把数据装入排序缓冲区(sort_buffer_size)做 filesort,数据量大于缓冲区还要转磁盘临时文件,性能断崖下降。让排序走索引的核心是:把 ORDER BY 的字段包含在联合索引中,并且保证前面没有范围条件“截断”。
GROUP BY 的场景也常被忽略:GROUP BY 默认会对分组字段排序。如果业务上不需要排序,建议直接 GROUP BY column ORDER BY NULL(在 MySQL 8.0 中排序行为已有变化,但老版本常这么写)。更推荐的做法是把分组字段也纳入索引设计,比如:
sql复制SELECT status, COUNT(*) FROM orders
WHERE create_time >= '2024-01-01'
GROUP BY status;
如果能建立 (status, create_time) 联合索引,等值过滤 + 分组统计就能同时走索引,避免临时表和 filesort。
5. 索引运维:会建索引是入门,会“瘦身”才是高手
很多系统跑着跑着就变慢了,不是没有索引,而是索引太多了。索引不是免费的,每个索引都要占用磁盘空间,每次 INSERT/UPDATE/DELETE 都要同步维护索引结构。我见过一个表建了 12 个索引,写入性能惨不忍睹,查询也没快多少——因为优化器在多个索引之间做选择时,也可能选错。
5.1 如何找出冗余索引和不用的索引
MySQL 8.0 提供了 sys 库,可以直接查没有使用过的索引:
sql复制SELECT * FROM sys.schema_unused_indexes;
这个视图基于 performance_schema 的统计,能列出“长时间没被使用的索引”。结合 information_schema.statistics 可以判断哪些索引是重复或冗余的:
sql复制SELECT table_name, index_name, GROUP_CONCAT(column_name ORDER BY seq_in_index) AS cols
FROM information_schema.statistics
WHERE table_schema = 'your_db'
GROUP BY table_name, index_name
HAVING cols LIKE ...
运维判断冗余索引的核心原则:如果一个索引的前缀字段与另一个索引完全相同,那么后者很可能就是冗余的。例如有了 idx_a_b_c(a, b, c),又建了 idx_a_b(a, b),后者基本没用,因为前者已经能覆盖后者的查询场景。区分度极高的前缀已经能锁定大部分数据时,多列索引后面的字段作用也比较有限。
清理索引要谨慎,先确认没有业务依赖再删除,最好在低峰期执行。删除索引的代价除了 DDL 锁表,还包括应用重启后可能踩到之前被隐藏的问题 SQL。
5.2 索引碎片与空间回收
InnoDB 表在频繁增删后,索引页会出现碎片——叶子节点不再连续,链表跳跃,磁盘 IO 随之增加。判断碎片程度的指标在 information_schema.tables 里的 DATA_FREE 字段,它表示该表可用的空闲空间。
sql复制SELECT table_name, data_free, data_length
FROM information_schema.tables
WHERE table_schema = 'your_db'
ORDER BY data_free DESC;
如果某张大表的 data_free 相对数据量占比很高,可以考虑重建表来整理碎片:
sql复制ALTER TABLE orders ENGINE = InnoDB;
这个操作会在低峰期做,因为它本质上是重建整张表,期间会加锁(MySQL 8.0 在线 DDL 支持度更高,但风险仍需评估)。我在生产上一般选择在凌晨跑,大表先评估执行时间,超时则拆成分区处理。
5.3 索引数量到底多少合适
没有一个绝对标准,但我个人实践里的经验值是这样:
| 表类型 | 索引数量建议 | 说明 |
|---|---|---|
| 小表(万行内) | 2~4 个 | 主要为了唯一约束和业务主查询 |
| 普通业务表(百万级) | 5~7 个 | 覆盖核心查询 + 唯一约束,避免冗余 |
| 大表(千万级以上) | 8~10 个以内 | 每个索引都要评审,并考虑分区/归档策略 |
| 写入极高频的表 | 2~4 个 | 索引越少写入越快,查询性能依赖覆盖索引设计 |
索引数量的下限不是零,而是满足“所有核心查询都能走索引”的最小数量。宁可多写几行代码做覆盖索引,也不要无脑给所有 where 条件都加索引。
5.4 统计信息与优化器选错索引的应急处理
还有一个容易被忽略的运维点:InnoDB 的统计信息更新时机。如果数据分布发生巨变(比如从 1000 万删到 100 万),优化器依据的统计信息还是旧的,可能选错索引。紧急处理方法是手动更新统计信息:
sql复制ANALYZE TABLE orders;
这句话在低峰期执行即可。平时我一般在批量导入数据、大批量删除数据之后,都顺手跑一次。
优化器选错索引还有一种极端情况:明明 A 索引更快,MySQL 却选了 B 索引。在生产上我们可以临时用 FORCE INDEX 强制指定索引验一次性能:
sql复制SELECT * FROM orders FORCE INDEX (idx_status_time)
WHERE status = 2 ORDER BY create_time DESC LIMIT 20;
但 FORCE INDEX 只能作为应急手段,长期解决方案还是优化索引结构或者调整 SQL 写法——因为一旦数据分布再次变化,强制索引反而会成为新的瓶颈。
6. 写在最后:索引优化是一场“理解数据 + 克制设计”的持久战
我在实际复盘这些年经手的慢查询问题时,发现 80% 的最终根因都不是数据库配置,而是索引设计没有跟上业务变化。新功能上线、查询条件变更、数据量增长,都会让陈旧的索引设计逐渐失效。所以我把“索引优化”定义成一个持续迭代的过程:上线前用 EXPLAIN 审查每条核心 SQL 的访问路径,上线后靠慢查询日志和 sys 库持续观察,每周/每月定期清理无效索引。
最后再分享一个我非常受用的小技巧:在开发环境中,把 long_query_time 直接设为 0.1 秒,任何超过 100ms 的语句都会进日志。这会让开发人员更早意识到“这条 SQL 有问题”。虽然代价是日志增长快,但开发环境完全可以接受。线下提前发现,好过线上被 DBA 找上门。
索引不是银弹,但绝大多数 MySQL 性能问题都出在索引上。希望这篇内容能帮你在“建索引”之前先学会“为什么建这个索引”,少走一些我当年走过的弯路。
