☰
别再凭感觉加索引:MySQL索引优化避坑指南与慢SQL排查实践
2026/10/15 20:53:43 网站建设 项目流程

做开发这几年,我接过不少线上事故,数据库被拖垮的场景见过不止一次,其中十次有八次不是 SQL 写得多复杂,而是索引加得想当然。索引优化听着是基础课,可真到生产环境,隐式类型转换、最左前缀、覆盖索引、统计信息过期……随便一个细节,都能让一条本该毫秒级返回的查询变成几十秒的全表扫描。这篇避坑指南,围绕“索引优化”讲我踩过的坑、帮别人擦过的坑,也讲我后来怎么一步步把索引设计从“凭感觉”变成“看执行计划”。适合正在被慢查询折磨的后端开发,也适合刚接手线上数据库的运维和 DBA 新人。

1. 为什么加了索引反而更慢:先拆掉对索引的三大误解

1.1 误解一:只要查询慢,加索引就对了

很多人的第一反应是:查询慢?加索引。这个思路本身不算错,但错在“不加分析地加”。

我遇到过一张几百万行的业务表,前端列表接口超时,开发直接给 where 条件里的每个字段都建了一个索引。结果查询确实快了一点,但写入和更新肉眼可见地变慢,索引文件比数据文件还大。索引的本质,是在原表之外再维护一棵 B+ 树。每次 INSERT、UPDATE、DELETE,除了改数据行,还要同步更新对应的索引树。索引越多,写的放大越明显。一张表如果一半字段都有索引,写入性能不降才怪。

正确做法是先用慢查询日志把具体 SQL 捞出来,通过执行计划分析到底卡在哪一步,而不是无差别加索引。我之前排查过一张表,被加了 12 个索引,实际常用查询用到的不到 5 个,剩下全是历史包袱。后来分批清理,写入性能提升了差不多 30%。记住:加索引不是目的,让那条慢 SQL 走对路径才是目的。

1.2 误解二:索引越多越好

这个误解和上一个很像,但坑点不同。索引多不只是浪费空间,还会干扰优化器的判断。

每个二级索引都包含索引列和主键值。InnoDB 里数据本身存在主键聚簇索引上,二级索引查完往往还要回表查一次完整数据。如果一条查询能用到两个单列索引,优化器可能选择 index merge,也可能觉得合并成本太高,直接全表扫。这时候开发会困惑:明明两个字段都有索引,为什么不用?不是没用,是优化器算完账之后觉得不划算。

多索引的另一个问题是,优化器的成本估算依赖统计信息,索引越多,估算路径越多,选错的风险也越高。尤其在一些老项目里,每个新人都按自己的习惯加索引,最后索引泛滥成灾。我见过一张 800 万行的表,光二级索引就有十几个,数据页没多大,索引页倒是占了好几个 G。这种表,简单查询都可能变慢,因为优化器在路径选择上耗费的时间也在增长。

1.3 误解三:索引建好就一劳永逸

索引不是建完就完事的静态对象。表里的数据天天在变,查询模式也在变。

有个典型场景:某订单表一开始在 status 字段上建了索引,上线时效果很好。跑了大半年,历史数据越来越多,status=1 的行占了全表 90%。优化器一算,通过 status 索引要读出的数据量太大,不如直接全表扫。于是这条 SQL 又开始慢了。索引没变,SQL 没变,但数据分布变了,执行计划就变了。

另一个常见情况是:原本的高频查询条件加入新字段后,旧的联合索引不再匹配。比如原来高频查询是 where user_id = ?,后来改成 where user_id = ? and status = ?,旧的单列索引 user_id 能定位用户,但还要在大量行里过滤 status,性能逐渐吃紧。这时候需要建新的联合索引,同时评估旧索引是否还有保留价值。所以索引是要持续 review 的,而不是装上去就不管了。

2. 索引到底是怎么“失效”的:7个最容易踩的坑

2.1 隐式类型转换:字符串字段上查数字

先看一个极其常见的例子。表里有个订单号字段,定义是 varchar:

CREATE TABLE `user_order` ( `id` bigint unsigned NOT NULL AUTO_INCREMENT, `user_id` bigint unsigned NOT NULL, `order_no` varchar(64) NOT NULL, `status` tinyint NOT NULL DEFAULT '0', `create_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (`id`), KEY `idx_user_create` (`user_id`, `create_time`) ) ENGINE=InnoDB;

有人在代码里图省事,直接查数字:

SELECT * FROM user_order WHERE order_no = 20240115001;

order_no 是字符串,查询条件是数字。MySQL 在做比较时,会把字符串列转换成数字再比较,相当于给索引列包了一层 CAST。但 B+ 树索引里存的是原始字符串,没法按转换后的数字直接二分查找,于是只能放弃索引。这种问题很隐蔽,因为返回结果可能看起来是对的,只是扫描行数从几十变成了上百万。

如果你建了索引但 EXPLAIN 显示 type 是 ALL,先检查一下字段定义和查询参数类型是否一致。手机号、订单号、身份证这种“长得像数字”的字段,建议一律用字符串类型存储,查询时也严格传字符串,不要依赖数据库帮你转。

2.2 对索引列使用函数或计算

日期范围查询是最典型的重灾区:

SELECT * FROM user_order WHERE DATE(create_time) = '2024-01-15';

如果 create_time 上有索引,这条 SQL 大概率用不上。原因很简单:索引树里存的是 create_time 的原始值,不是 DATE(create_time) 这个计算结果。你在索引列上套了函数,等于改变了列的内容,B+ 树的排序结构就帮不上忙了。

正确写法是把它改成范围查询:

SELECT * FROM user_order WHERE create_time >= '2024-01-15 00:00:00' AND create_time < '2024-01-16 00:00:00';

同理,WHERE total_amount + 1 > 100这种对列做数学运算的写法,也应该改写成WHERE total_amount > 99。核心原则是:索引列保持独立,别让它出现在函数、表达式、运算的右侧或参数里。

2.3 前导模糊查询和 OR 条件

模糊查询是业务里躲不开的需求。LIKE 'abc%'能用索引,因为 B+ 树可以根据前缀 abc 快速定位。但LIKE '%abc'用不上,因为你要的字符串可能出现在字段任意位置,索引树无法从中间开始匹配。更麻烦的是LIKE '%abc%',不仅前导模糊,而且尾部也不确定,索引基本用不上。

如果业务确实需要任意位置的模糊匹配,常规索引帮不了,应该考虑全文索引、专门的搜索组件,或者采用反向存储之类的手段。这属于架构选型问题,不是靠硬调 SQL 能解决的。

OR 条件的坑也很典型:

SELECT * FROM user_order WHERE user_id = 1001 OR order_no = 'A1001';

如果 user_id 和 order_no 都有索引,优化器可能选择 index merge,但这不是一定发生。一旦其中一个条件没有可用的索引,或者优化器认为合并后的成本仍然高于全表扫描,它就会选择 ALL。处理这类查询,更稳妥的方式是用 UNION ALL 拆开,让两边各自走自己的索引:

SELECT * FROM user_order WHERE user_id = 1001 UNION ALL SELECT * FROM user_order WHERE order_no = 'A1001';

当然,拆之前要确认两边都不产生重复数据,业务允许再去重。

2.4 联合索引顺序与最左前缀

假设现在只有一个联合索引 idx_user_create(user_id, create_time),执行:

SELECT * FROM user_order WHERE create_time >= '2024-01-15';

这条 SQL 用不上索引。因为联合索引的排序规则是:先按 user_id 排序,user_id 相同时再按 create_time 排序。单独拿 create_time 来查,等于在一本先按姓再按名排序的电话本里,只知道名不知道姓,翻遍全本也定位不到。

这就是最左前缀原则。你可以把索引定义顺序理解成“从左往右逐层生效的筛选器”:查询条件里必须从联合索引的第一个字段开始匹配,才有机会用到后续字段。等值条件放前面,范围条件、排序字段放后面,是联合索引设计的基本准则。比如:

SELECT * FROM user_order WHERE user_id = 123 AND create_time >= '2024-01-15';

这条就能用 idx_user_create,因为索引先定位 user_id=123,再在结果集里按 create_time 范围扫描,顺序天然匹配。

2.5 字符集与排序规则不一致

这个坑多在表关联时出现。比如订单表和支付表都有 order_no 字段,但一张表是 utf8mb4_unicode_ci,另一张是 utf8mb4_general_ci,或者干脆一张是 latin1,一张是 utf8mb4。MySQL 在比较两个字符串时,发现字符集或排序规则不一致,会做隐式转换,其中一边的索引列就可能失效。

我排查过一个例子:两张表关联查询,A 表 order_no 是 utf8mb4,B 表 order_no 是 utf8mb3,执行计划里 B 表走了全表扫描。业务方一直以为数据量问题,后来把两边字段统一成 utf8mb4_0900_ai_ci(或至少使用相同排序规则),关联查询从十几秒降到几十毫秒。

建议在建表之初就定好一套统一的字符集规范,尤其对订单号、用户名这类需要跨表关联的字符串字段,千万别各建各的。检查时可以先用 SHOW CREATE TABLE 看两边的字段定义,把字符集和排序规则调成一致再观察执行计划。

2.6 索引列上大量的 NULL

很多人不太关注 NULL 对索引的影响。MySQL 的 B+ 树索引本身是允许 NULL 的,但 NULL 在索引中会占用额外标记,复合索引里某个字段大量为 NULL 时,索引的选择性会变得很差。

更麻烦的是查询条件。比如:

SELECT * FROM user_order WHERE status IS NULL;

如果 status 列绝大多数是 0 和 1,只有极少数 NULL,优化器可能仍然走索引,因为 NULL 有唯一标记。但如果 NULL 占比很高,数据分布很偏,优化器算完成本后很可能放弃索引。还有一种情况是IS NOT NULL,不等值语义本来就难走索引,尤其返回大量行时。

设计上的建议是:字段尽量不要允许 NULL,业务上用默认值代替,比如 status 默认 0,最后更新时间默认 0 或 1970-01-01。这样不仅索引效果更稳定,查询条件写起来也干净很多。

2.7 优化器觉得全表扫描更快

最后这个“坑”严格说不是索引失效,而是优化器主动选择了不用索引。常见原因有两个:一是表太小,比如只有几百行,优化器认为全表扫比走索引回表更划算;二是数据分布太偏,比如某字段 95% 都是同一个值,通过索引要读出的行数过多,还不如直接扫。

遇到这种情况,不要立刻FORCE INDEX硬刚。先看统计信息是否过期,执行ANALYZE TABLE user_order;刷新一下。然后看数据分布,如果字段枚举值太少且有明显倾斜,这个字段本来就不适合做高选择性的索引。真正该做的是改写查询条件、调整索引设计,或者接受全表扫描的结果。

3. 正确的索引设计实操:从 EXPLAIN 到 SQL 改写

3.1 先看执行计划,而不是猜

当你面对一条慢 SQL,第一步永远是 EXPLAIN,而不是拍脑袋决定要不要加索引。看执行计划时,我重点看几个字段:

字段含义我的判断
type访问类型从好到差大致是 system > const > eq_ref > ref > range > index > ALL
rows预估扫描行数这个数越少越好,但不绝对,还要看回表成本
Extra额外信息出现 Using filesort、Using temporary 时,通常需要优化

实际查看时,最怕只看 type。有人看到 type 是 ref 就觉得没问题,但 rows 显示几十万,还带着 Using filesort,这明显还要优化。反过来,如果表很小,type 是 ALL 也不一定需要加索引,优化器自己会判断。

我常用的检查步骤是:

EXPLAIN SELECT id, order_no, status, total_amount FROM user_order WHERE user_id = 123 AND status = 1 AND create_time >= '2024-01-15 00:00:00' ORDER BY create_time DESC LIMIT 20;

先看 type 和 rows,再看 Extra 里有没有 filesort 或回表。一条 SQL 如果每一步都能说清楚“索引怎么被用上的、还有多少行需要回表”,索引设计才算合格。

3.2 联合索引设计顺序:等值在前,范围在后

联合索引的字段顺序,直接决定了这条索引能服务哪些查询。设计时遵循一条主线:等值条件字段放在最前面,范围条件和排序字段放在后面。

为什么?索引树先按第一个字段排序,第一个字段相同再按第二个字段排序。等值条件能把范围一下子缩小到一个确定的分支,后续字段在这个分支下依然保持有序,所以范围、排序都能顺利用上。如果反过来,让范围字段播种,后续字段在满足范围的所有节点里不是全局有序的,优化器就要额外做过滤甚至排序。

来看一个实例。查询是:

SELECT id, order_no, status, total_amount FROM user_order WHERE user_id = ? AND status = 1 AND create_time >= ? ORDER BY create_time DESC LIMIT 20;

适合的联合索引是(user_id, status, create_time),而不是(status, user_id, create_time)。原因很直接:user_id 和 status 都是等值条件,先通过它们把记录定位到很小范围,create_time 在这个范围内依旧有序,可以服务范围过滤和 ORDER BY。

这里还可以算一下字段区分度。比如:

SELECT COUNT(DISTINCT user_id) / COUNT(*) AS user_sel, COUNT(DISTINCT status) / COUNT(*) AS status_sel FROM user_order;

假设表有 100 万行,user_id 有 10 万种取值,选择度约 0.1;status 只有 3 种取值,选择度约 0.000003。单看选择度,status 不适合放第一位,但它作为联合索引里 user_id 之后的等值过滤条件,能把 User 下的结果再缩小,依然有价值。所以选择度不是唯一标准,关键要看这条索引到底为哪些查询服务。

3.3 覆盖索引:用空间换回表

InnoDB 的二级索引叶子节点存储的是“索引列 + 主键值”。如果查询需要的字段正好都包含在索引里,就不需要再回表查主键聚簇索引,执行计划里会出现Using index。这就是覆盖索引。

看这个例子:

SELECT id, user_id, create_time FROM user_order WHERE user_id = 123 AND create_time >= '2024-01-15 00:00:00' ORDER BY create_time DESC;

如果表上有(user_id, create_time)联合索引,这个查询需要的 id、user_id、create_time 都在索引里,id 是主键,二级索引自带,所以全程不需要回表,Extra 里能看到 Using index。

覆盖索引的诱惑很大,但不能无脑加字段。如果你为了让查询不回表,把 order_no、total_amount、status 全塞进索引,那么这个联合索引会变得又宽又大,写入时维护成本暴涨。我的判断标准很简单:这条 SQL 是不是核心高频查询?每次回表是不是真的很多行?如果都满足,而且追加的字段长度可控,覆盖索引才有性价比。否则,宁可回表也别把索引堆成一张“小表”。

3.4 一个完整案例:订单查询优化的全过程

用一个实际业务场景收尾。订单模块里有个高频查询:用户中心列表页要展示“当前用户最近 30 天成功的订单”,按创建时间倒序,分页 20 条。

初始 SQL 大致是这样:

SELECT id, order_no, status, total_amount FROM user_order WHERE user_id = ? AND status = 1 AND create_time >= DATE_SUB(NOW(), INTERVAL 30 DAY) ORDER BY create_time DESC LIMIT 20;

第一版,表上只有主键,没有任何二级索引。EXPLAIN 显示 type 是 ALL,rows 接近全表,Extra 里有 Using where,明显是全表扫描。实际线上查询耗时 2 秒多,这还只是单用户查询。

第二版,我先加了一个(user_id, create_time)联合索引。EXPLAIN 变成了 type 是 range(因为 create_time 是范围条件),rows 大幅减少,ORDER BY create_time 也能利用索引顺序,不再 filesort。但这时候还有问题:status = 1 的过滤需要回表后逐行判断,如果某个用户的数据量很大,回表行数依然不少。

第三版,改成联合索引(user_id, status, create_time)。两个等值条件先定位,create_time 做范围,从里到外顺序正好匹配。EXPLAIN 里 type 是 range,rows 进一步缩小。注意,这个场景里 status 不是放在最前面,而是放在 user_id 后面,因为 user_id 才是把选择性提到最高的字段。

优化前后对比大概是这样:

方案typerowsExtra线上耗时
无索引ALL300 万Using where2200 ms
(user_id, create_time)range2 万Using where180 ms
(user_id, status, create_time)range800Using where40 ms

这个案例的教训是:一条 SQL 的索引设计不是一步到位,而是跟着执行计划一步一步收敛。每一步改动,都要用 EXPLAIN 的新结果来验证,别改完了不看效果。

4. 索引维护与排查:上线之后怎么持续避坑

4.1 慢查询日志和统计信息

索引上线只是开始。真正的考验是它在真实负载下能不能稳定工作。我一般会先打开慢查询日志,把执行时间超过一定阈值的 SQL 抓出来,看看有没有走偏的索引。

慢查询抓到后,除了看 SQL,还要看统计信息。MySQL 优化器依赖统计信息来估算扫描行数,如果表数据发生了大批量导入、删除,或者数据分布剧烈变化,统计信息可能过时,优化器就会选错索引。这时候执行一句ANALYZE TABLE user_order;,让优化器重新采样,很多“没道理的慢查询”能直接缓解。

另外,SHOW INDEX FROM user_order;里的 Cardinality 字段可以粗略判断索引区分度。Cardinality 是优化器采样估算出来的,不是精确值,所以我只看它做初步筛选,真正选索引还是以 EXPLAIN 的 rows 和实际耗时为准。

4.2 索引冗余与重复怎么清理

索引维护里最容易被忽略的,是冗余索引。典型场景是:表上已有(user_id, create_time)联合索引,后来某个开发又加了一个(user_id)单列索引。从功能上看,所有能用(user_id)的查询,都能用(user_id, create_time)替代,因为最左前缀已经覆盖了 user_id。所以单列索引大概率是冗余的。

但清理前要谨慎。(user_id, create_time)虽然能覆盖 user_id 查询,但索引体积更大,如果 user_id 查询极其高频且对写入不敏感,保留单列索引也不是完全没道理。我的建议是:先做主从或测试环境验证,再用不可见索引的方式把候选索引“隐藏”掉,观察一段时间,确定没有业务依赖后再物理删除。

MySQL 8.0 里可以直接用:

ALTER TABLE user_order ALTER INDEX idx_user_id INVISIBLE;

然后跑一段时间的监控。如果慢查询没有增加,再执行 DROP INDEX 收尾。这个方法比直接删安全得多。

4.3 索引碎片整理和在线变更

索引维护还有一个容易忽视的点:碎片。频繁的 UPDATE 和 DELETE 会在 B+ 树里造成页分裂、页合并,长期下来索引页可能稀疏,扫描效率下降。查看表的数据碎片,可以看 information_schema:

SELECT table_name, data_free FROM information_schema.tables WHERE table_schema = 'your_db' AND table_name = 'user_order';

data_free 不是唯一标准,但能作为参考。如果碎片严重,可以用OPTIMIZE TABLE user_order;或者重建表来整理。不过大表执行 OPTIMIZE 会锁表,时间窗口不够的时候非常危险,基本只在低峰期才敢碰。

对大表加索引、删索引这类结构变更,更稳妥的是用在线变更工具,在业务低峰期分批完成,降低锁持有时间。切记不要在业务高峰期对一个几千万行的表直接执行 DDL,哪怕数据库号称支持在线 DDL,元数据锁和主从延迟也常常会给你上一课。

4.4 新增索引的灰度策略

最后说一个常被忽略的流程问题:索引变更也要灰度。

我见过不止一次,开发在测试环境验证了索引没问题,直接在生产大表上执行 CREATE INDEX,结果主从延迟飙到几百秒,业务读写全部受影响。这未必是索引本身不好,而是变更方式太粗暴。

我现在给大表加索引会这么走:

先在测试环境准备一份和生产量级相当的数据,验证执行计划和耗时。然后查询类索引可以用不可见索引先建上,让优化器在真实负载里“试探性”地走一遍,确认没问题再 VISIBLE。观察期里看慢查询、CPU、磁盘 IO,和加索引之前对比。如果性能没有提升,直接删掉,不留僵尸索引。

同时,每一条新建索引都要记录:服务哪条 SQL、创建时间、预期收益。这样三个月后再看这张表,还能说清楚每个索引活在世上的理由。

5. 我后来养成的几条索引习惯

这些习惯是我一次次把线上搞慢、又一点点擦回来的教训,不一定适合所有团队,但大概率能帮你少走弯路。

第一,先定位最耗时的 SQL,再谈索引。没有慢查询日志和 EXPLAIN 支撑的索引优化,都是猜。猜对了是运气,猜错了是事故。

第二,每张表主键之外,二级索引尽量控制在一个合理数量内。具体数量看写入量,写多读少的表宁可让统计报表慢一点,也别堆索引。索引是读的加速器,也是写的拖累者,这个平衡必须心里有数。

第三,设计联合索引时,先把这张表的高频查询都列出来,找条件字段的交集,别为一个查询单独建一个索引。很多冗余索引就是这么来的:每个查询各建各的,最后变成索引森林。

第四,发版前把新增索引和对应 SQL 的执行计划保存好,作为 review 依据。下次大促前检查性能,先翻历史记录,比临时猜靠谱得多。

第五,不要怕删索引,但要用不可见索引安全地删。索引删除不是回滚不回来的事,但删错了会让线上大面积变慢,先隐藏、再观察、后删除,这个顺序别跳。

说到底,索引优化不是炫技,而是理解数据结构和查询成本之后的克制。你每建一个索引,都应该能说清楚它服务哪条 SQL,会让哪条写入多付出多少代价。说不清楚,就别急着建。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询