先说结论:MySQL索引这个话题,面试八股常聊,但真正到了生产环境,很多人连执行计划都看不明白。索引失效不是一个孤立问题,它牵扯到查询优化器的选择逻辑、存储引擎的数据组织方式,甚至表结构设计的合理性。这篇文章从一次真实故障排查讲起,把索引失效的常见场景、联合索引的设计取舍,以及索引背后的架构哲学一并理清楚,适合刚接触索引优化、又不想只背结论的开发者。
1. 索引失效排查:先看懂执行计划再说
1.1 执行计划到底在说什么
排查索引失效,第一件事不是看SQL语法对不对,而是看执行计划。EXPLAIN这个词大家都不陌生,但很多人只盯着key字段看有没有走索引,其实远远不够。
完整的执行计划里藏着三层信息:
type字段告诉你MySQL在表里是怎么找数据的:const、eq_ref、ref、range、index、ALL,从优到劣。看到ALL就是全表扫描,这是索引彻底没起作用的最直接信号。key字段表示优化器最终选中的索引,key_len则是索引使用的字节数。key_len能算出来,比如int类型占4字节,varchar(100)在utf8mb4字符集下占100乘以4再加2字节(变长字段长度记录),这部分能反推联合索引实际用到了哪几列。Extra字段里藏着大量线索:Using where代表存储引擎返回记录后Server层又做了过滤,Using index代表覆盖索引扫描,Using filesort代表排序没走索引,Using temporary代表用了临时表。
我见过很多排查索引失效的人,只看key是否为NULL,只要不是NULL就觉得万事大吉。但实际上type=index和type=ALL在性能上相差并不大——index也会遍历整个索引树,只是比聚簇表扫描少一次回表罢了。
所以第一步的排查习惯必须是:把EXPLAIN的type、key、key_len、Extra四个字段连起来看,任何一个字段异常都不能放过。
1.2 高频失效场景复盘
索引失效的场景,网上列的版本非常多,但归纳下来大概有五类,每一类的原理都不一样,光记结论不记原理,换个场景照样懵。
第一类:对索引列做了计算或函数操作。WHERE id + 1 = 5会让索引失效,因为索引树里存的是原始列值,不是id+1的计算结果,优化器无法直接通过B+树定位。WHERE DATE(create_time) = '2024-01-01'同理,正确写法是create_time >= '2024-01-01 00:00:00' AND create_time < '2024-01-02 00:00:00'。这里要补充一个细节:如果对列本身做运算,比如id + 1,MySQL 8.0之后的版本在一些场景下会对常量表达式做优化,但不是所有情况都靠谱,最稳妥的做法永远是"列不参与运算"。
第二类:隐式类型转换。最常见的是字符串列和数字比较,WHERE phone = 13888888888,phone列是varchar类型,MySQL会把字符串转换成数字再比较,转换操作作用在索引列上,索引就失效了。更隐蔽的是WHERE user_id = '123',user_id是int,字符串转换成数字没问题,索引能走。判断依据是:转换的方向是"列转值"还是"值转列",列被转换了才会失效。为什么varchar列和数字比较时MySQL不把数字转成字符串,反而把列转成数字?因为varchar排序规则下,'10'和'9'的比较结果是'10'小于'9',无法自然比较,MySQL只能把列转为数字来比较。
第三类:LIKE模糊匹配以通配符开头。WHERE name LIKE '%张%'无法走索引,因为B+树的索引顺序是按照完整列值排序的,前缀未知的情况下无法定位范围。但'张%'可以走range扫描。这里有个容易被忽略的点:'%张'这种后缀匹配,在InnoDB里哪怕用覆盖索引也救不了,只能全表扫。所以业务上能避免模糊匹配就尽量避免,实在避免不了,选择搜索引擎或者全文索引,不要在MySQL里硬扛。
第四类:联合索引不满足最左前缀。INDEX(a, b, c),查询条件是WHERE b = 1 AND c = 2,跳过a直接查b,索引无法使用。这一点后面专门用一整个H2来展开,这里先不赘述。
第五类:OR连接的条件中,某个条件没有索引。WHERE id = 1 OR name = 'abc',如果name没有索引,整个查询会退化成全表扫描。原因很简单:OR意味着"满足其中一个即可",MySQL无法用单棵索引树同时定位两条分支,只能逐个判断,一旦某个分支的字段没有索引,就只能全表扫。解决思路是拆成两个查询用UNION ALL合并,或者给name也加索引。UNION ALL的写法是:
sql复制SELECT * FROM t WHERE id = 1
UNION ALL
SELECT * FROM t WHERE name = 'abc';
注意如果两条分支有重叠记录且服务层不去重有问题,就用UNION。这个取舍后面在索引架构部分再聊。
1.3 一个真实的慢查询排查案例
有一次线上告警,某张订单表的查询大量超时,慢查询日志里频繁出现这样一条SQL:
sql复制SELECT * FROM order_info
WHERE DATE(create_time) = '2024-03-15'
AND order_status = 0;
表上有联合索引idx_status_time(order_status, create_time),这看起来是一个很标准的组合,但还是慢。EXPLAIN一看,type=ALL,全表扫描,几千万行的表直接扫起来。
问题在DATE(create_time)这个函数把create_time列包裹住了,索引树的排序规则用的是完整的create_time值,函数处理后得到的日期值无法直接和索引键做比较。更重要的是,order_status = 0加上DATE(create_time)的组合想要用到联合索引,改写也非常简单:
sql复制SELECT * FROM order_info
WHERE create_time >= '2024-03-15 00:00:00'
AND create_time < '2024-03-16 00:00:00'
AND order_status = 0;
这样idx_status_time的最左前缀order_status能定位到所有状态为0的记录,再通过create_time的range条件缩小范围。对比执行计划,type从ALL变成了range,key_len也翻了一倍,扫描行数从千万级降到几万。
这个案例里面还有一层隐性成本:SELECT *导致每条记录都要回表拿全字段,如果查询字段和索引覆盖不一致,Extra会显示Using index condition而不是Using index。回表在range扫描的尾盘可能数量依然很大,这一步也是慢查询的重要来源。
排查慢查询有一个通用套路:先用EXPLAIN看执行计划,如果type不是理想状态,就用FORCE INDEX测试是否优化器选错了索引,再用ANALYZE TABLE更新统计信息,接着检查列是否有隐式转换或函数包裹,最后再确认SQL有没有更好的改写空间。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 联合索引设计的底层逻辑
2.1 最左前缀原则的"为什么"
联合索引INDEX(a, b, c)在InnoDB里的物理结构是:先按a排序,a相同的情况下按b排序,b也相同的情况下按c排序。
想象一本通讯录,先按姓氏排序,同姓氏的再按名字排序。你想找叫"王小明"的人,能先用"王"这个前缀定位到王姓区域,然后按拼音缩小范围。但如果你直接找叫"小明"的人,没有姓氏信息,这本通讯录根本帮不上忙。
这就是"最左前缀原则"的物理基础。B+树的节点中的键是按联合索引的列顺序组织的,只有知道了前缀列的取值,后续列的比较才有意义。
有了这层理解,再看几个常见的误区和边界:
WHERE a = 1 AND b = 2 AND c = 3当然能完整走索引。WHERE a = 1 AND c = 3能走索引的a部分,c无法利用索引但可以被Using index condition在存储引擎层过滤。WHERE b = 1 AND c = 2完全无法使用索引,只能全表扫。
但还有一个反直觉的点值得注意:WHERE a IN (1, 2) AND b = 3是可以走索引的,MySQL会把a的每个值拆成一个范围区间,在每个区间内使用b定位。IN的写法不会破坏最左前缀,它只是让优化器生成多个范围。WHERE a = 1 ORDER BY b LIMIT 10也可以利用联合索引排序,这个稍后在排序优化里细讲。
2.2 列顺序怎么排才合理
联合索引列顺序的选择,业内最常说的口径是"选择性高的放前面"或者"等值条件放前面"、"范围条件放后面",这些说法合在一起,才算完整。
选择性就是COUNT(DISTINCT col) / COUNT(*),这个比值越高,说明列的去重能力越强,让MySQL能更快速收窄范围.
比如性别列选择性极低,用户ID列选择性极高,联合索引(gender, user_id)还是(user_id, gender)看似都能用,但存储和定位逻辑完全不同。
实际操作时判断顺序,先用一个简单的经验法则:
- 有等值条件的列优先放前面;
- 有范围条件的列放后面;
- 多个等值条件之间,选择性高的优先;
- 如果某个字段经常作为
ORDER BY的排序键,可以考虑放在联合索引的末尾,利用索引的有序性省掉filesort。
以订单表为例,查询模式通常是WHERE merchant_id = ? AND status = ? ORDER BY create_time DESC LIMIT 20,那么联合索引可以考虑(merchant_id, status, create_time)。理由说出来也不复杂:merchant_id和status是等值过滤条件,create_time是排序条件。前两个等值条件把范围缩小到少量记录,create_time直接利用索引的有序性,避免内存排序。
反例是(status, create_time)放在(merchant_id, status, create_time)之前,业务上status选择性通常不高,即使merchant_id需要回表过滤,第一步的索引扫描范围也会很大。
2.3 索引下推是什么,怎么用
索引下推,英文是Index Condition Pushdown,简称ICP,是MySQL 5.6引入的优化。它解决的核心问题是:联合索引在"无法完全匹配"时,剩余的非索引列条件能不能在存储引擎层先过滤掉,减少回表次数。
为了好理解,先看一个具体的例子:
表结构:
sql复制CREATE TABLE user (
id INT PRIMARY KEY,
name VARCHAR(32),
age INT,
city VARCHAR(16),
KEY idx_name_age(name, age)
) ENGINE=InnoDB;
SQL:
sql复制SELECT * FROM user
WHERE name LIKE '张%'
AND age = 30;
name LIKE '张%'走range扫描,但age条件因为LIKE范围的存在,没办法直接用索引树的等值定位。MySQL 5.6之前,存储引擎把所有name前缀为"张"的记录全部返回Server层,Server层再对age = 30过滤。假设"张"姓有5万条,实际年龄是30的只有50条,那就要回表5万次。
开启ICP之后,存储引擎会在索引树遍历时,检查age = 30这个条件,只把满足条件的记录回表,回表次数从5万降到了50。
判断ICP生效的方法很简单:EXPLAIN的Extra字段显示Using index condition,就是走了索引下推。这里要注意,Using index condition不等于Using index,前者表示回表前先做了一次二级索引层面的条件过滤,后者表示根本没回表。前者减少了回表次数,后者直接避免了回表。两者不要混淆。
ICP不是万能的,它对主键索引没有意义,因为主键记录直接存在聚簇索引里,不存在"回表"一说。ICP对覆盖索引也没有意义,因为覆盖索引本身就不用回表。ICP只在二级索引需要回表时才有优化的价值。
3. 从索引失效到索引架构:业务视角的取舍
3.1 索引不是越多越好:写放大与存储成本
索引失效排查走到后面,你会遇见一个更深层的问题:到底哪些索引不该建?很多人给表加了五六七八个索引,每次写入时每个索引都要更新一遍B+树,主从复制延迟就是这么堆出来的。
写放大这个概念是从LSM-Tree领域借来的,放在InnoDB里也适用。InnoDB对二级索引的更新不是原地改,而是先写change buffer,后续异步合并。但如果索引太多,change buffer的合并压力变大,随机写转化为顺序写的效率就会下降,每次INSERT/UPDATE/DELETE的redo log量也跟着上来。
实操中判断一个索引该不该保留,我一般看三个维度:
SELECT查询里有没有真的用上。有慢查询日志和performance_schema的events_statements_summary_by_digest可以统计SQL模板的等待时间和扫描行数,顺藤摸瓜找出哪些索引从来没进过EXPLAIN的key字段。- 冗余索引是不是可以合并。
(a, b)和(a)两个索引,后者可以被前者完全覆盖,(a)就是冗余索引,可以直接删。用pt-duplicate-key-checker可以快速扫出这类重复索引。 - 写入频率和读取频率的对比。高频写入的表,索引数量要克制;报表查询类的低写入表,索引多一些问题不大。
一个常见的教训是:为了一个临时业务需求建了索引,业务下线了索引还留着。每个索引都占存储、拖慢写入,建议定期清理。
3.2 前缀索引、覆盖索引与回表的权衡
前缀索引在标题热词里出现了,这里单独拿出来说。所谓前缀索引,就是只对字符串列的前几个字符建立索引,比如:
sql复制ALTER TABLE t ADD KEY idx_email_prefix(email(10));
适用场景是email这类长字符串列,全文搜索性不高,但前10个字符基本能区分出绝大部分记录。优点是索引体积大幅降低,内存缓冲池能缓存更多索引页,缺点是key_len变短、选择性可能不足,并且ORDER BY email无法使用前缀索引。
使用前缀索引需要考虑一个核心问题:前缀长度如何选。实践里有一个通用方法,用SQL反复测试选择性和区分度:
sql复制SELECT COUNT(DISTINCT email) / COUNT(*) AS full_selectivity FROM t;
SELECT COUNT(DISTINCT LEFT(email, 10)) / COUNT(*) AS prefix_selectivity FROM t;
前后两个值差距小,说明10个字符基本覆盖了所有取值。如果差距大,就逐步增加前缀长度,直到选择性接近1。
覆盖索引是另一个常用优化。SELECT a, b FROM t WHERE a = ?如果索引是(a, b),查询所需字段全部在索引树中,就不用回表,Extra显示Using index。这里要强调一个很实际的优化思路:对于高频查询,把查询所需字段"塞"进联合索引的尾部,尽量让查询变成覆盖索引查询。但不要走极端,索引列太多也会导致存储成本直线上升。
回表的问题则要结合key_len和selectivity一起看。索引选择性强,回表次数少,SELECT *的代价还能接受;索引选择性差,回表次数可能是几何级增长,这种时候要么加覆盖列,要么在业务侧限定查询条件。
3.3 主键索引设计:自增主键还是业务主键
主键索引就是聚簇索引,InnoDB的数据行直接存在B+树的叶子节点上。这个特点决定了主键一旦设计不好,性能影响是全局性的。
自增主键是我在生产环境中的默认选择。原因在于InnoDB的页结构是顺序写入友好的:自增主键递增,新记录不断往最后一个页追加,页分裂概率低。如果使用UUID作为主键,因为UUID是无序字符串,每次插入都可能落在已有页的中间位置,触发页分裂和页合并来移动数据,写放大明显,同时聚簇索引树的高度也更容易增加。
但自增主键并不总是最优解。分库分表场景下,全局唯一ID需求如果用雪花算法,生成的ID本身带有时间戳和机器位,有序性虽然不如自增,但比UUID好得多。分布式ID方案下,主键本身就是业务的一部分,自增就不太适用。
主键设计还有一个容易忽略的点:所有二级索引的叶子节点都存储主键值。如果主键是varchar类型,二级索引的体积会成倍膨胀,索引扫描需要读入的页更多,内存命中率下降。所以即使业务上要用字符串做唯一标识,也应该考虑添加一个自增数字列作为主键,字符串唯一标识用普通唯一索引去保证。
实际操作时,我自己会这样判断:
- 单库单表、写入以插入为主的业务,无脑自增主键;
- 分库分表、需要全局唯一ID的业务,用雪花算法生成有序ID;
- 业务主键有明确的自然含义并且长度很短(比如国家码),才考虑直接作为主键,但要对二级索引的体积增长有心理准备。
4. 常见问题与排查技巧实录
4.1 索引失效排查速查表
长时间踩坑后,我整理了一张速查表,每次遇到慢查询都按这个顺序排查:
| 现象 | 可能原因 | 快速验证方法 | 解决办法 |
|---|---|---|---|
EXPLAIN中type=ALL |
索引失效或未建索引 | 看key字段是否为空 |
检查SQL写法,必要时FORCE INDEX |
key_len远小于预期 |
联合索引只用到了部分列 | 手工计算key_len,对比索引定义 |
调整查询条件或联合索引列顺序 |
Extra出现Using filesort |
ORDER BY列未走索引或排序方向不一致 |
检查排序键是否在联合索引的排序序列中 | 调整索引,让排序键包含在索引中 |
Extra出现Using temporary |
GROUP BY/DISTINCT需要临时表 |
查看分组列是否和索引前缀匹配 | 让分组列成为索引前缀列 |
type=index但扫描行数很大 |
覆盖索引扫描,但选择度低 | 对比rows和表总量 |
增加过滤条件或改成ref扫描 |
| 函数包裹索引列 | 无法使用索引 | 查看WHERE条件 |
改写为范围条件 |
| 隐式类型转换 | 索引列被转换 | 查看列类型与参数类型 | 参数改成和列类型一致 |
最后再补一条关于find_in_set的热搜问题:FIND_IN_SET能不能走索引?答案是不能,因为它本质上是一个函数计算,无法利用B+树的顺序查找。如果业务中确实有逗号分隔的标签需求,正确做法是拆成多行关联表,或者使用JSON_CONTAINS配合JSON类型的多值索引(MySQL 8.0支持),而不是在WHERE FIND_IN_SET()上纠结。
4.2 实际问题定位方法论
面对一个慢查询,不要对着一条SQL硬抠。更好的顺序是:先拿到真实的参数化SQL和表统计信息,再结合执行计划决策。
方法论可以分四步:
第一步是拿到完整SQL和所有相关列的类型。注意区分"实际SQL"和"日志里截断的SQL",很多慢查询日志默认只打印前几百字符,字段类型看着像int,实际是bigint,排查半天走偏方向。
第二步是查看表结构和统计信息。SHOW INDEX FROM t能看到索引的基数Cardinality,如果基数远低于实际行数,说明统计信息可能过期,执行ANALYZE TABLE t重建统计信息。这招在MySQL 5.7和8.0里都是安全操作,不会锁表影响线上。
第三步是跑EXPLAIN ANALYZE。这是MySQL 8.0引入的工具,比EXPLAIN多了一个实际执行的行数和耗时。它能告诉你每个步骤的真实耗时,很多"索引应该走但MySQL没走"的问题都能在这一步暴露出来。执行计划的数据要看actual time的分布,如果索引扫描本身很快,但回表数量巨大,问题就出在SELECT *和覆盖索引缺失上。
第四步是决策:改写SQL、调整索引、还是拆查询。这三者的优先级不能反,先保证SQL写法合规,再确认索引是否合理,最后才考虑改业务逻辑。一上来就加索引是很多人的第一反应,但SQL本身有隐式转换或函数包裹的时候,加索引是白加。
4.3 运维侧的几个实用提醒
最后分享几条我从生产环境踩坑中总结出来的建议,这几条放在文档里可能没人写:
线上加索引的窗口要谨慎。MySQL 8.0的INSTANT算法支持某些DDL操作不锁表,比如在表末尾加列,但加索引依然是INPLACE重建,虽然支持ALGORITHM=INPLACE, LOCK=NONE,大表加索引期间依旧有额外的IO负载和主从延迟。建议在业务低峰期操作,并且先加在从库上验证,再手动切换,避免无感知主从切换带来的风险。
索引管理要纳入代码评审。只靠DBA在线下维护索引很被动,应用改动SQL时,评审人要盯住两个关键点:新SQL是否走了既定索引、旧索引是否还有业务在用。这块用sys.schema_unused_indexes视图可以扫出一批长期未使用索引。
数据库审计和索引争用的问题是结合热搜词补充的一个点:开启数据库审计后,审计日志的写入操作会占用系统资源,间接引发索引争用,尤其是高频SELECT场景下,审计表如果本身没有合适索引,审计日志查询就会拖慢主业务。建议审计日志单独落库或使用独立的日志系统,不要和业务表混在同一个实例里。
我对索引架构哲学的体会是:索引的本质不是"优化手段",而是"以空间换时间"的存储模型。在使用索引前,先想清楚查询模式和数据写入模式,在完整性和冗余之间找到平衡点。每次看到一条SQL没有走索引,不要急着加索引,先问自己三个问题:这个查询是不是必须的?它能不能改写?表结构能不能重新设计?回答完这三个问题,很多"失效"其实一开始就不会发生。
