过去一周我几乎天天在帮人排查慢 SQL,最典型的一次是张 120 万行的订单表,统计某天已支付订单数量,索引建了好几个,看似合理,可 EXPLAIN 一敲就是 type=ALL、possible_keys 为空。问题最后定位到 WHERE 里写了 DATE(create_time) = '2025-01-10',对索引列套了一层日期函数,MySQL 根本没法走 create_time 上的索引。“索引失效”这种问题,在 Java 后端出现频率特别离谱,写代码时谁也不会觉得 DATE() 有什么问题,可它确确实实把整个查询打回原形。
所以我想把 MySQL 索引进阶这件事彻底讲透。全文路径很清晰:先用真实案例把索引失效的常见场景逐个过一遍,再顺着 B+ 树和优化器原理解释为什么这些场景会失效,然后落到主键索引、联合索引、前缀索引和索引下推这些具体设计上,最后聊聊索引背后那点架构哲学。内容既适合面试前需要理清思路的人,也适合被慢查询折磨的维护者直接拿去做排查清单。
1. 从慢查询日志谈起:一次索引失效的完整排查链路
1.1 第一次 EXPLAIN 看到了什么
表结构可以抽象成下面这样:
sql复制CREATE TABLE t_order (
order_id BIGINT PRIMARY KEY AUTO_INCREMENT,
user_id INT NOT NULL,
order_status VARCHAR(16) NOT NULL,
create_time DATETIME NOT NULL,
pay_type TINYINT NOT NULL,
amount DECIMAL(12,2),
KEY idx_status_pay (order_status, pay_type),
KEY idx_create_time (create_time),
KEY idx_user_status (user_id, order_status)
) ENGINE=InnoDB;
业务方传上来的 SQL 是:
sql复制SELECT COUNT(*)
FROM t_order
WHERE order_status = 'PAID'
AND DATE(create_time) = '2025-01-10';
用 EXPLAIN 一敲,执行计划长这样:
| 列 | 值 |
|---|---|
| type | ALL |
| possible_keys | idx_status_pay, idx_create_time |
| key | NULL |
| rows | 1100000 |
当时第一反应是“索引明明都在 possible_keys 里,为什么 key 是 NULL”。这就是失效排查里最常见的困惑点:possible_keys 只是告诉优化器“这里有可用索引”,最后用不用,要看成本模型怎么评估。这里 order_status='PAID' 能匹配全表接近三成数据,优化器认为走 order_status 索引还不如直接扫表;create_time 的索引又因为 DATE() 函数无法被利用,于是它干脆选了全表扫描。
这个例子里两个点都有代表性:一是低选择性的等值条件撑不起索引,二是对列做函数处理会让索引查找路径彻底断掉。
1.2 第一个坑:对索引列做运算,int+5 就是典型案例
网上搜“mysql中int+5”,搜出来的几乎都是同一个问题:WHERE age + 5 >= 30 为什么不走索引?答案很简单,B+ 树的叶子节点按 age 的原始值排序,一旦条件变成 age + 5,存储引擎需要先对每一行做一次加法,再去和 30 比较,索引的有序性在加法面前完全失效。
很多人会把这类问题记成“索引列不能出现在表达式的左边”,但这句话太机械。真正该做的事是改写 SQL,把运算从列上挪走:
sql复制-- 失效写法
SELECT * FROM user WHERE age + 5 >= 30;
-- 生效写法
SELECT * FROM user WHERE age >= 25;
改写后,条件从“函数/运算后的值”变成了“原始列的区间”,优化器可以直接在 B+ 树上做二分定位,扫出 age 在 [25, +∞) 的所有记录。同样的道理也适用于 year(create_time) = 2024,应该改成 create_time >= '2024-01-01' AND create_time < '2025-01-01'。
这种改写不是玄学,它是把 SQL 从“无法利用有序性”翻译成“可以被索引检索利用的形式”。排查时如果看到执行计划里某个索引列附近有函数、四则运算、位运算,优先怀疑这里。
1.3 第二个坑:隐式类型转换
另一个高频坑是隐式类型转换。最常见的是手机号、订单号这类字段用 varchar 存储,但代码里传参时没加引号:
sql复制-- phone 是 varchar,条件却传了数字
SELECT * FROM user WHERE phone = 13800138000;
MySQL 的规则是:当字符串列和数字比较时,它会把字符串转成数字再比。这个转换相当于对索引列做了一次 CAST,索引再次失效。更麻烦的是,这种 SQL 不一定在 EXPLAIN 里看到函数,很多人盯半天都看不出问题。
解决办法是让类型完全匹配,传字符串:
sql复制SELECT * FROM user WHERE phone = '13800138000';
如果你用的是 MyBatis 这类框架,还要注意 #{phone} 和 ${phone} 的区别。${} 直接拼接字符串,容易把数字写成不带引号的常量;#{} 会走预编译参数绑定,类型保持得更好。Java 端拼 SQL 时,隐式类型转换真的是一个很隐蔽的重灾区。
1.4 第三个坑:函数包住列,FIND_IN_SET 就是一个典型代表
“findinset能走索引吗”这个问题我见过不止一次。直接回答:绝大多数场景不能。FIND_IN_SET(priority, '1,2,3') 这种写法,第一个参数是待查找的值没问题,但第二个参数是一个逗号分隔的字符串,MySQL 必须把它拆开再逐个比对。索引里存的是列原始值,和这种“动态解析后的集合”完全没有可比性,优化器只能全表扫。
类似情况还有:
sql复制-- 不走索引
WHERE FIND_IN_SET(order_status, 'PAID,REFUND');
WHERE LEFT(order_no, 5) = 'PO202';
WHERE SUBSTRING(name, 1, 3) = '张';
-- 可以走索引
WHERE order_status IN ('PAID', 'REFUND');
WHERE order_no LIKE 'PO202%';
WHERE name LIKE '张%';
核心原则一句话:别让索引列参与任何函数调用。如果确实要做前缀匹配,用 LIKE 'xxx%',因为它在优化器眼里是一个可区间的“范围条件”。如果确实要按日期统计,就把日期转成区间,而不是在列上套 DATE()。
1.5 梳理一张排查对照表
踩坑经验积累到最后,我习惯把它收敛成一张小表:
| 失效写法 | 失效原因 | 生效改写 |
|---|---|---|
| WHERE DATE(create_time) = '2025-01-10' | 列被函数包裹 | create_time 落在两个边界值组成的时间区间 |
| WHERE age + 5 >= 30 | 列上做算术运算 | WHERE age >= 25 |
| WHERE phone = 13800138000 | 字符串列与数字比较,发生隐式转换 | WHERE phone = '13800138000' |
| WHERE FIND_IN_SET(status, 'PAID,REFUND') | 索引列参与函数式集合判断 | WHERE status IN ('PAID','REFUND') |
| WHERE name LIKE '%张%' | 前导通配符让范围无从谈起 | WHERE name LIKE '张%' 或引入全文检索 |
这些都是基础功,但基础功恰恰是面试里最容易翻车的地方。我面过不少候选人,能把“索引失效三大类”背得很全,但一给真实 SQL 就分析不出来了。原因就是只背了结论,没理解失效背后的结构逻辑。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 失效场景背后的 B+ 树逻辑:为什么背列表没用
2.1 B+ 树定位数据的核心:有序性
InnoDB 的索引本质是一棵 B+ 树。主键索引(聚簇索引)的叶子节点存整行数据,二级索引的叶子节点存主键值。无论哪种索引,查找动作都是:从根节点向下走,通过多级目录定位到某个叶子页,然后在叶子页内按有序链表扫描。
整个流程之所以高效,依赖两个前提:键值有序,且查询条件是“某个确定值”或“某个连续区间”。一旦条件变成 f(column) = 某个值,存储引擎就不知道 f(column) 在 B+ 树里的排列规律了。f 可能改变顺序,优化器不能假设 f(key) 和 key 保持同样的有序性,只能退而求其次去扫全部候选数据。
2.2 为什么函数、运算、隐式转换都会破坏这条路
用一个生活类比:电话号码本按姓氏拼音排序。我要找“张”,直接翻 Z 区,很快。但如果我拿到的条件是“名字里第二个字是‘三’的人”,这个条件跟姓氏顺序无关,我必须把整本电话本翻一遍才能数出结果。索引失效就是这个道理,函数和运算让键的“原始顺序”失去意义。
age + 5 也一样。age=25 和 age=26 在 B+ 树里相邻,但 age+5 分别是 30 和 31,它们仍然相邻;可如果 age 的跨度变大了,age+5 的排序就不再等于 age 的排序。因为优化器无法百分百确定函数是否保序,所以干脆不做这个冒险,全部按扫描处理。
2.3 优化器成本模型:有时索引失效是“假失效”
还有一种情况容易误判:SQL 写法完全正确,索引也存在,但优化器就是不用。这是它基于统计信息做的成本决策。比如一张 100 万行的表,某个等值条件筛出 60 万行,那走索引需要大量回表,每行一次随机 I/O,大概率比全表顺序扫描还慢。所以执行计划里出现 type=ALL 不代表 SQL 写错了,可能只是这个条件下“全表扫描真的更划算”。
遇到这种情况,先别急着 FORCE INDEX。先看索引的选择性:如果某个字段的值分布太集中,比如 status 只有 3 种状态且分布均匀,那它就不适合单独做索引条件,应该搭配其他字段建联合索引。另外,长期运行的表写完大批量数据后,偶尔会出现统计信息滞后,执行一次 ANALYZE TABLE t_order; 刷新一下,执行计划可能就正常了。
面试时如果被问到“索引失效的场景”,我会建议把这类成本判断也讲进去。面试官想听的往往不是列表,而是你能不能说出“生效”和“失效”的临界条件。
2.4 联合索引的九种组合记忆法
联合索引(也叫复合索引、多层索引)的失效问题比单列索引复杂,因为存在最左前缀原则。假设有联合索引 (a, b, c),查询条件可能有多种组合:
- 条件只命中 a:走索引,效果好。
- 条件命中 a 和 b:走索引,通常效果更好。
- 条件命中 a、b、c:全索引覆盖,最优。
- 条件跳过 a 直接命中 b:用不上索引,全表扫。
- 条件命中 a、c 但跳过 b:只有 a 用得上,c 的过滤要靠回表后判断。
网上有人把“等值、范围、排序”三类情况组合起来,总结出九种组合来记忆联合索引行为,比如“a 等值 + b 排序 + c 范围”这种。我的看法是,九宫格可以帮你理解,但真正设计时不需要背全。你只需要回答三个问题:
- 哪些列是高频等值条件?
- 哪些列需要排序或范围?
- 能不能用覆盖索引避免回表?
回答完这三个问题,联合索引的列顺序基本就浮出水面了。
3. 从“建索引”到“设计索引”:主键、联合索引、前缀索引的落地方案
3.1 主键索引是地基,不要乱用 UUID
InnoDB 表是聚簇表,数据行物理上按主键的顺序存储在叶子节点上。这意味着主键索引不仅是索引,它还决定数据行的物理分布。如果主键是 UUID 这类随机值,每次插入都会落在叶子节点中间,触发大量页分裂和页重排,写入性能会雪崩。
自增主键是最省心的方案,新记录永远插在 B+ 树最右侧,写放大最小。使用雪花 ID 或业务上的有序 ID 也可以,关键是“趋势递增”。至于“主键业务化”,比如用身份证号当主键,一听就很危险:一旦业务规则变化,改主键的代价比改任何二级索引都大。所以我的默认建议是:无脑自增主键,除非你能说清楚为什么必须用业务主键。
二级索引的叶子节点存的是主键值,所以主键越长,每个二级索引也越占空间。这也是为什么我不建议在 MySQL 里用 UUID 字符串做主键,32 个字符会让所有二级索引体积膨胀,内存缓冲池被无效占用。
3.2 联合索引列顺序的三条铁律
联合索引的列顺序,决定了一个索引能“服务”多少查询。三条铁律:
- 等值条件列放最前面。
- 排序字段紧随其后。
- 范围条件放最后。
举个例子:
sql复制SELECT order_id, user_id, order_status
FROM t_order
WHERE user_id = ?
ORDER BY create_time DESC
LIMIT 20;
这个查询有两个关键信息:user_id 是等值,create_time 要排序。如果建一个 (create_time, user_id) 联合索引,优化器只能按 create_time 范围扫,再过滤 user_id,排序也没法利用索引;如果建 (user_id, create_time),那么先根据 user_id 拿到精确的子树,再按 create_time 的索引顺序取出前 20 条,连 filesort 都省了。
MySQL 8.0 还支持降序索引,可以显式写成 KEY idx_user_create (user_id, create_time DESC),这在排序需求非常明确时很有用。不过降序索引不是银弹,5.7 及更早版本不支持,索引扫描时要小心反向扫描的开销。
3.3 前缀索引:长字符串列的省空间战术
当列特别长,比如 email、URL、备注信息,给整列建索引会让索引文件大得离谱。这时候可以只对前 N 个字符建索引,也就是前缀索引。
怎么确定 N?看选择性。选择性公式是:
sql复制SELECT COUNT(DISTINCT LEFT(email, 8)) / COUNT(*) AS sel8,
COUNT(DISTINCT LEFT(email, 10)) / COUNT(*) AS sel10,
COUNT(DISTINCT LEFT(email, 12)) / COUNT(*) AS sel12
FROM user;
一般选择“选择性接近完整列”的最短前缀。比如 sel8=0.9821,sel10=0.9967,sel12=0.9970,完整列是 0.9971,那 N=12 基本够用。
前缀索引有三个限制要记住:不能用于 order by、group by;不能做覆盖索引扫描;也无法利用前缀做很精确的范围查询。所以它只适合“大字段等值过滤”场景,不适合高频排序查询。
3.4 覆盖索引:最高性价比的读取优化
覆盖索引太容易被忽略了。很多 DBA 只关注“查询有没有走索引”,却忽略了“回表次数”。假设有联合索引 (user_id, order_status),执行:
sql复制SELECT user_id, order_status
FROM t_order
WHERE user_id = 10086;
由于需要的两个列都在 idx_user_status 里,存储引擎扫完二级索引就直接返回,不需要回表查主键聚簇索引,Extra 会显示 Using index。
覆盖索引的价值是减少随机 I/O。二级索引体积通常比聚簇索引小很多,同样一个扫描动作,读二级索引的页数更少,速度更快。设计联合索引时,如果在保证选择性前提下,把查询里高频出现的列放到索引末尾,就可能顺手薅到覆盖索引的羊毛。但别走极端——把所有列都塞进索引,会让写入成本爆炸。
4. 索引下推:5.6 之后经常被忽略的“提前过滤”红利
4.1 索引下推一句话理解
“mysql 5.6 索引下推是指什么”,这几乎是 MySQL 面试必问题。它指的是:MySQL 5.6 开始,存储引擎在读取二级索引记录时,可以直接对索引中包含的列做 WHERE 过滤,只有满足条件的记录才回表。过滤动作从 Server 层“下推”到了存储引擎层。
没有下推之前,联合索引只能按最左前缀处理,后面的列就算在索引里,也得等回表后再过滤。有了下推,索引里后面的列也可以在“读索引的当下”参与过滤。
4.2 一个最经典的例子
联合索引 (name, age),查询:
sql复制SELECT *
FROM student
WHERE name LIKE '张%'
AND age = 18;
按最左前缀,这里的 name LIKE '张%' 能走索引,但 age 无法继续作为索引查找条件。5.6 之前,MySQL 的做法是:先把所有 name 以“张”开头的索引记录都查出来,每条都回表,拿到完整行后再判断 age = 18。
5.6 之后,InnoDB 在扫描索引记录时就判断 age 是否等于 18,不满足的直接跳过,不回表。如果“张”姓学生有 3000 人,但其中 18 岁的只有 120 人,下推优化可以把回表次数直接从 3000 降到 120,效果极其明显。
4.3 怎么验证索引下推有没有生效
EXPLAIN 输出里有个 Extra 字段,如果看到:
code复制Using index condition
就表示索引下推生效了。注意它不等于 Using index,Using index 是覆盖索引,Under index condition 是“索引条件下推”。两者可以同时出现,也可以单独出现。
控制开关是:
sql复制SET optimizer_switch = 'index_condition_pushdown=on';
不过这是全局或 session 级设置,生产环境默认开启就好,一般不需要关。
4.4 索引下推对 SQL 写作的反向启示
下推给了我们一个很实用的建议:哪怕某个字段不在联合索引最左前缀能命中的范围内,只要它在联合索引里,就尽可能把它写进 WHERE。不要因为“反正走不了索引”就不写。比如联合索引是 (a, b, c),查询条件 WHERE a = 1 AND c = 2,传统理解里只有 a 能走索引,但有了索引下推,c 的过滤会在索引扫描阶段完成一部分,减少回表。这比完全不带 c 条件要快得多。
5. 索引架构哲学:把索引当作数据访问设计,而不是“加个字段”
5.1 索引是查询模式与数据模型之间的“投影”
加索引这个动作,本质上是在回答一个问题:你的业务要按哪些维度、以什么顺序、多频繁地访问数据?
一张表就是一个数据模型,索引则是数据模型到查询模型之间的投影。它把高频查询关心的字段组合,映射成一条可以从根节点直达叶子页的“快路”。所以设计索引前,先列高频查询,而不是拍脑袋给每个字段单列建索引。比如“查用户最近订单”和“查用户某状态订单”,两者字段顺序完全不同,分别对应 (user_id, create_time) 和 (user_id, order_status)。
5.2 读快写慢的账单:索引数量不是越多越好
架构上最容易被忽视的,是索引对写入链路的放大。每次 INSERT 要写入主键索引和所有二级索引;每次 UPDATE 只要改了索引列,就要同步改对应索引;每次 DELETE 也要逐个索引删除记录。索引越多,写入路径越长,缓冲池里“脏页”刷盘压力越大。
在高并发写入场景下,索引数量与写入吞吐几乎是反比关系。我曾经在一个百万级日活的后台系统里见过一张表建了 14 个索引,结果每次 insert 都慢到不可接受。后来砍到 6 个,查询没怎么变慢,写入延时降了一个数量级。
架构决策上,索引是典型的空间换时间,但这个空间不只是磁盘,还有内存缓冲池和写入 I/O。为了一个“偶尔用一次”的查询建索引,性价比非常低。
5.3 索引生命周期:统计信息、冷热数据与归档
索引不是建完就一劳永逸的。数据分布会随着业务变化而变化,一年前的数据分布和今天的可能完全不同。大表做完大批量更新或导入后,执行计划容易跑偏,这时候 ANALYZE TABLE 是成本最低的维护手段。
冷热数据分层也会影响索引价值。对一张只读归档表,索引主要服务的是少量历史查询;对一张热表,索引要围绕实时查询设计。如果业务上已经做了冷热分离,切不可把热表的索引原封不动搬到冷表,因为冷表的访问模式已经变了。
5.4 加索引也走审查流程
我在团队里定过一条规矩:任何新增索引,必须对应一条真实存在的慢查询或核心高频查询,并且要在评审时回答三个问题:这个索引覆盖了哪些查询路径?会不会影响写入性能?有没有可能用现有联合索引调整顺序替代?
听上去很机械,但非常管用。它逼着每个人从“这列经常用,加个索引吧”上升到“这个索引到底在服务谁”。时间长了你会发现,索引设计其实是在设计数据访问方式,它和接口设计、缓存设计一样,都是架构的一部分。
我个人踩过最深的一次坑,就是在一张流水表上为了“以防万一”建了一堆单列索引,结果写库高峰期 CPU 直接飙满。后来拆掉那些索引,把高频查询的联合索引重新排了序,整个系统的瓶颈瞬间移到了别的地方。从那之后我就明白了:索引从来不是越多越好,它是对访问模式的一种精确表达,多一分是浪费,少一分是灾难。
