从线上事故开始说吧。那是一个周五晚上,业务方反馈说后台某个列表页打开要十几秒,数据库CPU直接飙到90%。我上去一看,慢查询日志里全是同一条SQL,单次执行耗时8秒。当时我就意识到,这八成是索引出了问题。果不其然,EXPLAIN一跑,type=ALL,全表扫描,一张500万行的表从头扫到尾。那段时间我连续排查了五六个类似的索引问题,每一个都看起来“理所当然应该有索引”,但数据库就是不走。这篇文章我把这5个坑完整复盘一遍,包含每一次的排查思路、根因定位和生产环境最终的解决方案,希望能帮你少熬几个夜。
1. 隐式类型转换:字段上做了不该做的事,索引全废
1.1 一条看起来绝对没问题的SQL,EXPLAIN却显示全表扫描
第一坑发生在一个用户查询接口上。表结构大概是这样的:
sql复制CREATE TABLE `user` (
`id` bigint unsigned NOT NULL AUTO_INCREMENT,
`user_id` varchar(32) NOT NULL COMMENT '业务用户ID',
`phone` varchar(20) NOT NULL COMMENT '手机号',
`status` tinyint NOT NULL DEFAULT 1,
PRIMARY KEY (`id`),
KEY `idx_user_id` (`user_id`),
KEY `idx_phone` (`phone`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
业务代码里传入的参数是从上游接口拿到的字符串,SQL写成:
sql复制SELECT * FROM user
WHERE user_id = 'U123456789' AND status = 1;
字段是varchar,索引也有,条件值也是字符串,看起来天衣无缝。但我EXPLAIN一看,type=ALL,key=NULL,rows=500万。
问题出在另一个接口上。同一个user_id字段,另一个调用方是从旧系统同步过来的数字ID,Java代码里直接传了Long类型。MyBatis的SQL动态拼接后变成:
sql复制SELECT * FROM user
WHERE user_id = 123456789 AND status = 1;
MySQL发现字段类型是varchar,而比较值是数字,会自动把字段值转成数字再比较。于是索引列上隐式加了CAST函数,函数操作会让B+树的索引查找完全失效。这就是隐式类型转换导致索引失效的典型场景。
1.2 隐式转换的完整排查链路
我当时排查这个问题的步骤,其实值得记录一下:
- 拿到慢SQL后先跑EXPLAIN,确认走没走索引。这一步最关键,
key字段如果是NULL或者跟预期不一致,基本就锁定问题了。 - 用
SHOW WARNINGS查看MySQL优化器改写后的SQL。这个命令很多人忽略,但它是定位隐式转换的神器。执行完EXPLAIN后输入SHOW WARNINGS;,MySQL会告诉你它实际执行的SQL版本。如果看到cast(user_id as signed)这种字样,那就是隐式转换没跑了。 - 检查表结构确认字段类型。这里注意,不光要看字段定义,还要看字符集和排序规则。
- 最后检查传入参数的类型。通过步骤2的结果,很快就能定位到是Java代码里传入了数值类型。
这里补充一个小知识:为什么函数操作会导致索引失效?InnoDB的B+树索引是按照索引列的值排序存储的。如果索引列被CAST包了一层,那么B+树里存储的值和比较的值就不是同一个东西了——比较前必须对全表的索引列都做一次转换,然后再逐个比较,这等价于把排序树打乱,优化器只能退化为全表扫描。用生活类比就是:你按姓氏笔画排好了一本通讯录,结果有人问你“电话尾号是8899的人叫什么”,你只能从头翻到尾,没法用目录直接翻到目标位置。
1.3 生产级解决方案
知道了根因,解决方案其实分两层:
第一层:修掉当前的错误SQL。 既然是参数类型问题,那就让代码里传字符串,或者SQL里显式写CAST(123456789 AS CHAR)。但我不建议在SQL里写CAST,因为代码里每次都要记得加,一旦忘了又会踩坑。最好的做法是:在Java代码里,把Long参数转成String再传入。这些历史代码散落多个地方,我直接用正则全局搜user_id = ?,把所有传Long的地方改成String.valueOf()。
第二层:防止再次发生。 有两个手段:
- 代码评审时,要求所有查询条件参数必须和数据库字段类型严格匹配。
- 在数据库层面建一个监控:用
pt-query-digest定期分析慢查询日志,凡是EXPLAIN结果里type=ALL的SQL都触发告警。
补充一点:type=ALL不一定都是隐式转换,但一旦出现SHOW WARNINGS里的cast字样,基本就是它。这个坑最可气的地方在于,它在测试环境根本不会暴露——测试库数据量小,全表扫描也就几十毫秒,只有到了生产500万行数据才原形毕露。所以生产环境的慢查询监控必须早点搭起来。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 字符集和排序规则不一致:两张表JOIN,索引白建
2.1 关联查询慢,两个字段明明都有索引
第二个坑发生在订单表和用户表JOIN的场景。SQL长这样:
sql复制SELECT o.order_no, u.user_name
FROM order o
INNER JOIN user u ON o.user_id = u.user_id
WHERE o.create_time >= '2024-01-01'
ORDER BY o.create_time DESC
LIMIT 20;
order.user_id有索引idx_user_id,user.user_id也有索引idx_user_id,但执行计划里user表那一行居然显示了type=ALL。我当时第一反应是:是不是关联字段的类型不一致?结果一查,两边都是varchar(32)。
后来仔细看了表结构,发现问题出在**排序规则(collation)**上。order表是utf8mb4_unicode_ci,user表是utf8mb4_general_ci。两个表的同一字段存储内容没区别,但MySQL在JOIN时发现两边的collation不同,必须做一次字符集转换,于是user表的索引被“降级”了,只能全表扫。
2.2 为什么collation不一致会影响索引
这个坑的隐蔽性在于:SHOW CREATE TABLE很容易被忽略。大多数人看表结构只看字段类型,不看DEFAULT CHARSET和COLLATE。而MySQL官方文档明确写着:当比较的两个字符串列使用了不同的排序规则时,优化器无法直接使用索引,因为索引的排序是基于原始collation的,转换后顺序可能不一样。
整个排查过程如下:
- EXPLAIN看到
user表type=ALL。 - 检查字段类型,两边都是varchar(32),排除类型不一致。
- 检查字符集,两边都是utf8mb4,排除字符集不一致。
- 最后检查collation,发现一个是
unicode_ci,一个是general_ci。 - 验证方式:单独执行
SELECT user_name FROM user WHERE user_id = 'xxx',很快,走索引;但JOIN起来就慢。说明不是单表问题,而是JOIN时发生了collation冲突。
注意:这一步排错时,不能只看SHOW CREATE TABLE里表的默认collation,因为字段可以单独指定collation。要精确到字段级别。
2.3 生产级解决方案
解决方案有两种,我用了第一种:
- 方案A:修改表的collation,让两边统一。 因为
order表数据量更大,我选择把user表改成utf8mb4_unicode_ci。注意,修改collation会重建表,建议用pt-online-schema-change这样的工具在低峰期执行,避免锁表。
sql复制ALTER TABLE `user` CONVERT TO CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
- 方案B:在SQL里显式指定collation。 比如JOIN条件写成
ON o.user_id = u.user_id COLLATE utf8mb4_unicode_ci。这个做法改动小,但要理解一点:这样改可能会让order表的索引依然可用,但user表的索引还是可能不能用,所以不是最优解。
我的经验是:新库设计时,所有表统一使用utf8mb4_unicode_ci或utf8mb4_0900_ai_ci。线上存量表的话,优先考虑低峰期批量转换。另外,用information_schema查一下所有表的collation统计,把不一致的全部揪出来:
sql复制SELECT table_schema, table_name, table_collation, COUNT(*)
FROM information_schema.tables
WHERE table_schema = 'your_db'
GROUP BY table_schema, table_name, table_collation;
这个坑的教训是:建表规范必须写进团队开发文档。两个开发同事分别建表,一个默认utf8mb4_unicode_ci,一个默认utf8mb4_general_ci,你防不胜防。
3. 最左前缀原则被忽略:复合索引排序/过滤顺序不对
3.1 明明建了复合索引,查询却依然慢
第三个坑是复合索引顺序搞反。表结构:
sql复制CREATE TABLE `order_detail` (
`id` bigint PRIMARY KEY,
`order_no` varchar(32) NOT NULL,
`goods_id` bigint NOT NULL,
`status` tinyint NOT NULL,
`create_time` datetime NOT NULL,
KEY `idx_order_status_time` (`order_no`, `status`, `create_time`)
) ENGINE=InnoDB;
业务上最常见的查询是:根据订单号+状态+创建时间来查分页列表:
sql复制SELECT * FROM order_detail
WHERE order_no = 'NO202401010001'
AND status = 1
ORDER BY create_time DESC
LIMIT 20;
这个SQL看起来完美匹配idx_order_status_time的三列顺序:order_no等于+status等于+create_time排序。但实际执行时,ORDER BY create_time DESC居然产生了filesort。为什么?
因为索引的默认顺序是升序。create_time在索引里是升序排列的,而SQL要求DESC。MySQL 5.7及之前的版本不支持索引倒序扫描,所以当查询要求DESC时,优化器无法直接利用索引的顺序,只能先筛选出符合条件的行,再做一次文件排序。数据量一大,filesort就会占用临时磁盘空间,慢查询自然就来了。
3.2 最左前缀原则的实质与应用边界
这里要展开讲一下最左前缀原则。复合索引(a, b, c)实际上会创建三个索引:(a)、(a, b)、(a, b, c)。查询条件必须从最左列开始,才能命中索引。WHERE b = 1 AND c = 2无法使用这个索引,因为它没有从a开始。
但很多人忽略的是:“从最左列开始”不只是过滤条件,也包括排序条件。ORDER BY也可以作为索引使用的依据,前提是排序列必须满足最左前缀原则,并且排序方向一致。
我踩的坑是这样:业务方有一个查询,条件是status = 1 AND order_no = 'xxx',排序是create_time DESC。我把索引建成了(status, order_no, create_time),因为我觉得status区分度低,应该放前面。但实际上status只有0/1/2三个值,区分度极低,放在最左列会让索引的过滤效果大打折扣。而业务查询中order_no才是真正的高区分度条件,应该放在最左列。
正确的思路是:索引列顺序应该按照“区分度从高到低”排列,而不是随意指定的顺序。同时,排序字段的方向要和查询方向一致。
3.3 生产级解决方案
当时我做了什么?建了一个新索引:
sql复制ALTER TABLE `order_detail`
ADD KEY `idx_order_status_time` (`order_no`, `status`, `create_time` DESC);
MySQL 8.0开始支持DESC索引,这样ORDER BY create_time DESC就能直接利用索引的顺序,避免filesort。但5.7不行,5.7的解决方案是:把create_time存成倒序值,或者用ORDER BY create_time ASC后再在应用层反转,但这太反人类了。更实际的做法是:
- 如果SQL里
ORDER BY字段是DESC,在5.7上可以考虑把索引里的时间列去掉,让查询先用索引过滤order_no和status,然后文件排序。因为过滤后的结果集可能已经很小,filesort代价不大。 - 或者,调整查询方式:先用索引定位到满足条件的最小主键范围,再排序。这个方案偏复杂,不展开。
所以我的结论是:如果生产环境还是MySQL 5.7,复合索引里排序字段方向跟SQL不一致时,不要硬扛。用EXPLAIN看rows预估,如果rows小于几千,filesort可接受;如果rows很大,就要考虑8.0的DESC索引或者分区表方案。
另外还要提醒一句:ORDER BY里如果有多列,比如ORDER BY status DESC, create_time ASC,这个顺序跟索引顺序不一致,也会导致filesort。在设计索引时,必须同时考虑等值条件、范围条件的顺序和排序字段的方向,不能只盯着WHERE。
4. 统计信息不准:优化器选了错误的索引
4.1 测试库秒回,生产库慢吞吞
第四个坑是最玄学的:同一条SQL,在测试库跑几十毫秒,到了生产库就要跑好几秒。最要命的是,生产库的表结构和索引跟测试库完全一样,数据量也就多了十几倍,理论上不应该出现数量级差异。
拿到的慢SQL:
sql复制SELECT * FROM payment
WHERE merchant_id = 10086
AND pay_status = 1
AND pay_time BETWEEN '2024-05-01' AND '2024-05-31'
ORDER BY id DESC
LIMIT 50;
表上有两个索引:idx_merchant_paytime(merchant_id, pay_time)和idx_pay_status(pay_status)。EXPLAIN显示优化器选了idx_pay_status,但pay_status=1的数据占了全表的50%以上,这个索引的区分度极低,导致扫描行数巨大,然后还要回表过滤。而idx_merchant_paytime理论上更合适,因为merchant_id的区分度更高。
为什么优化器会选错?因为InnoDB的统计信息是基于采样的,不是精确的。当表数据频繁增删改,或者采样比例不够时,统计信息可能严重失真,优化器就对索引的“预估行数”产生误判,选了实际代价更高的索引。
4.2 定位和验证优化器选错索引的完整流程
我的排查过程:
- 先跑EXPLAIN,看到
key=idx_pay_status。 - 用
EXPLAIN FORMAT=JSON查看成本估算,cost_info里read_cost和eval_cost都会显示。 - 强制走另一个索引验证:
sql复制SELECT * FROM payment FORCE INDEX (idx_merchant_paytime)
WHERE merchant_id = 10086
AND pay_status = 1
AND pay_time BETWEEN '2024-05-01' AND '2024-05-31'
ORDER BY id DESC
LIMIT 50;
这一步很关键:如果FORCE INDEX后执行时间从3秒降到0.1秒,就说明优化器确实选错了。
- 查看统计信息状态:
sql复制SHOW INDEX FROM payment;
关注Cardinality这一列。如果merchant_id的Cardinality明显偏低,或者跟实际行数差距很大,就说明统计信息过期了。
- 查看当前事务隔离级别和自动更新统计信息的开关。在
innodb_stats_auto_update开启的情况下,InnoDB会在某些条件下自动更新统计信息,但阈值是表变更行数超过10%才触发,所以高并发大表很容易出现统计信息滞后。
4.3 生产级解决方案
短期方案:直接给SQL加FORCE INDEX,强制使用idx_merchant_paytime。但要注意,FORCE INDEX是双刃剑——如果未来数据分布发生更大变化,强制索引可能反而变成灾难。所以这个手段建议只用于紧急止血。
长期方案:
- 定期对关键大表执行
ANALYZE TABLE,在业务低峰期更新统计信息。 - 如果表非常大且变更频繁,考虑调低
innodb_stats_transient_sample_pages或调整采样页数,让统计信息更接近真实分布,但代价是收集统计信息的时间变长。 - 优化SQL本身:把
pay_status = 1这个低区分度条件去掉,或者把merchant_id和pay_time范围条件写得更紧凑,让优化器更容易选择正确索引。 - 用
MySQL 8.0的optimizer_trace看看优化器的完整决策路径,能知道它为什么选错了。这个工具在MySQL 5.6+也有,但8.0更完善。
sql复制SET optimizer_trace="enabled=on";
SELECT ... ;
SELECT * FROM information_schema.OPTIMIZER_TRACE;
执行完这一串,你会看到join优化阶段的成本计算,每一步都有rows和cost。这对理解优化器的行为非常有帮助。
这个坑的核心教训是:索引不是建了就完事,统计信息的健康度直接决定索引能不能被正确使用。尤其是那种数据量千万级、每天大量写入的表,一定要把ANALYZE TABLE纳入定期运维计划。
5. 过度索引导致写放大:插入/更新慢成狗
5.1 查询快了,写入却扛不住了
第五个坑跟前四个不太一样,前四个都是“索引没生效”导致查询慢,这个坑是“索引太多”导致写入慢。当时做的是一张日志流水表,每天承载几百万行写入。后来为了各种报表查询,陆陆续续加了十来个索引。结果某天业务方反馈:写入接口的P99延迟从50ms涨到了800ms,数据库写入积压严重。
我第一反应是看慢查询日志,发现慢的全是UPDATE和INSERT语句。之前明明只需要更新一行数据,现在要同时维护十几个索引的B+树结构。这就是索引带来的写放大问题:每次写入一行,InnoDB需要插入聚簇索引和所有二级索引,数据页可能触发页分裂、页面重写,这些都会产生额外的随机IO和日志。
用生活类比:你往一个文件夹里加一页纸,如果只有一份目录,很快;但如果同时维护十份不同排序的目录,每份目录都要插入新页码,当然是越写越慢。
5.2 定位哪些索引是冗余的
我的排查步骤:
- 查看表上的所有索引:
sql复制SHOW INDEX FROM log_flow;
重点看Seq_in_index、Column_name、Cardinality。Cardinality很低的索引,比如布尔字段,区分度差,价值有限。
- 使用
performance_schema.table_io_waits_summary_by_index_usage查看每个索引的实际使用次数。
sql复制SELECT OBJECT_SCHEMA, OBJECT_NAME, INDEX_NAME,
COUNT_STAR, COUNT_READ, COUNT_WRITE
FROM performance_schema.table_io_waits_summary_by_index_usage
WHERE OBJECT_SCHEMA = 'your_db'
ORDER BY COUNT_STAR DESC;
这个查询会告诉你哪些索引被读得多,哪些几乎没被读过,哪些写入开销很大。我那次查出来有3个索引的COUNT_READ几乎为0,但COUNT_WRITE却非常大,说明它们纯属“为了跑报表建的,但报表上线后根本没走这些索引”。
- 分析所有业务SQL,确认哪些查询真的需要这些索引。这一步需要跟开发对线,把他们的查询SQL全拿过来,用
EXPLAIN逐一跑一遍。凡是EXPLAIN用不上的索引,都标记为“可删”。
5.3 生产级解决方案:索引合并与删除策略
最终我做了三件事:
- 删掉3个完全没被读过的索引。 删除时用
pt-online-schema-change避免长时间锁表。 - 把几个查询条件高度重叠的索引合并成复合索引。 比如原来有
(a, b)和(a, c),合并成(a, b, c)或(a, c, b),具体顺序看哪个条件更常用、区分度更高。 - 对报表类只读查询,拆到只读从库。 主库只保留核心业务必需的索引,从库上可以再加专门服务于报表的索引。
提一下“索引合并”这个点,MySQL优化器其实支持在同一张表上使用多个索引进行Index Merge,比如WHERE a = 1 AND b = 2可以分别用idx_a和idx_b,然后合并结果集。但Index Merge有代价,不一定比单个复合索引快。所以生产上我还是推荐人工拆分合并成最合适的复合索引,别指望优化器每次都聪明到做Index Merge。
另外,索引数量上限没有绝对标准,但我个人的经验是:单表二级索引建议控制在5个以内。日志流水这种高写入表,索引越少越好。查询需求如果实在多,宁可配合ES或者ClickHouse去做查询,也不要让MySQL这张写入表背太多索引。当时我们的最终方案就是:主库MySQL只保留3个最核心索引,报表查询全部走ClickHouse,彻底治好了写放大问题。
5.4 一个容易被忽略的隐藏坑:冗余索引占用
删索引的时候还要注意,KEY idx_a (a)和KEY idx_a_b (a, b)其实是冗余的——因为idx_a_b已经覆盖了a的查询场景。如果没有特别的需求,idx_a可以安全删除。这种冗余索引删掉之后,写入开销会进一步下降。
判断冗余索引的规则是:如果复合索引的最左列集合完全包含某个单列索引的列集合,那么那个单列索引大概率是冗余的。用信息模式查询可以自动检测:
sql复制SELECT * FROM information_schema.statistics
WHERE table_schema = 'your_db'
GROUP BY table_name, index_name;
但自动检测不能100%替代人工判断,因为有的单列索引可能被Index Merge用到。所以我的做法是:先跑一段时间的performance_schema监控,确认某个索引0次读取,再删。
这个坑的总结就一句话:索引是“读”的加速器,也是“写”的减震器。 建索引之前必须想清楚这个索引到底服务哪条查询,能不能复用现有索引,别一股脑全加。
写在最后
这5个坑,本质上对应了MySQL索引使用中的五个层面:类型匹配、字符集匹配、索引列顺序、优化器统计信息、索引数量与写开销。每一个都让我在半夜爬起来翻过日志,也让我对索引执行计划的理解从“会用EXPLAIN”升级到了“能解释优化器为什么这么做”。
如果你在实际运维中也遇到类似问题,我建议按这样的顺序排查:先看type和key,再查SHOW WARNINGS,然后核对字段类型、字符集、collation,最后用EXPLAIN FORMAT=JSON定位成本。不要一上来就FORCE INDEX,那样治标不治本。
最后分享一个小技巧:我每个月会写一个巡检脚本,统计全库索引的读取次数、写入次数、Cardinality和无效索引,输出一份报告。坚持了半年,很多潜在问题都在业务投诉之前被提前铲掉了。索引这个东西,不是建完就一劳永逸,它需要持续维护和管理。希望这篇复盘能给你一些参考。
