最近做Java后端面试复盘时发现,不管你是面初级、中级还是高级岗位,MySQL索引都是绕不开的必答点。我参加过不少面试,也帮朋友模拟过面试,印象最深的一次是:面试官只问了“联合索引在什么情况下会失效”,看起来是一道送分题,结果候选人支支吾吾说了几条,又被追问“为什么索引列上做函数计算会失效”,整个人就卡住了。答案不全不致命,但说不清底层原理,在面试官眼里就等于“只会背八股”。
这篇文章我打算按“面试场景题”的方式,把MySQL索引的知识点重新梳理一遍。不是简单罗列几条规则,而是像复盘面试问答一样,把高频考法、容易踩的坑、需要现场推导的原理一起讲清楚。内容会围绕索引底层结构、聚簇索引和二级索引、联合索引与最左前缀、覆盖索引与索引下推、索引失效场景、慢SQL排查这几个方向展开。既能给准备Java面试的人当复习资料,也能给日常写SQL但不懂为什么慢的人一个排查思路。
1. 索引面试题从哪里开始:一条SQL慢下来之后
1.1 搞懂InnoDB在没有索引时是怎么读数据的
很多JDBC层面的Java开发同学,对索引的理解只停留在“加索引会变快”。但面试官通常不会这么轻易放过你,他们会从一条SQL入手:“给你一张1000万行数据的用户表,用手机号查一条记录,为什么有时候会慢到秒级?”
这个问题的本质,要先回到MySQL的存储引擎层去理解。以最常用的InnoDB为例,数据并不是一行一行散落在磁盘上的,而是按数据页(Page)来管理。每个数据页默认16KB,页内部会存放很多行记录。当你要根据某个字段查一行数据,而这个字段上没有索引时,InnoDB没有办法直接定位到记录在第几号数据页,只能从第一个数据页开始,沿着页和页之间的链表做全表扫描,一个一个去比对,直到找到目标行。
数据量大起来以后,这个扫描过程的关键瓶颈不在CPU计算,而在磁盘IO。InnoDB是以页为单位做IO的,即使你只需要一条记录,也得把整页数据先读到内存里。1000万行的表可能有几十万个数据页,全表扫描意味着要产生大量随机读请求,单次查询的耗时自然变得不可接受。
1.2 加索引的本质:给数据重新建立一种快速定位的“目录”
索引能够解决全表扫描问题,核心思路和我们在字典里查字一模一样。字典正文按拼音或偏旁排列,如果你要直接从头翻到尾找一个偏僻字,效率很低,所以字典开头会放一个目录。先查目录确定字所在页,再翻到对应页去取正文,整个过程只需要少量几次翻页。
MySQL里的索引,就是数据库为表里的某一列或多列额外维护的一份“目录”。这份目录里记录了“索引值 -> 所在位置”的关系,并按索引值排好序。查询的时候,MySQL先到这份目录中找到目标值对应的位置,再去数据页里读取具体行,不需要再把整张表从头到尾扫一遍。
不过这里有一个面试官爱埋伏笔的细节:在InnoDB中,二级索引的叶子节点记录的并不是行所在的物理磁盘地址,而是该行的主键值。这一点会在后面讲“回表”时细化,这里先留一个悬念。
1.3 面试开场的标准答题节奏
被问到“索引是什么”时,我建议你按这个节奏回答:先解释索引是帮助MySQL高效获取数据的有序数据结构,再强调它底层用的是B+树,然后以InnoDB为例说明数据按页存放、索引按主键或索引列有序组织。
最好带上“没有索引时全表扫描”的对比,讲清楚自己为什么需要它。这样的回答既完整,又能自然地把话题引到你熟悉的B+树上。很多面试者怕冷场,一上来就背“索引是一种数据结构”这句话,反而让面试官找不到追问的点。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. B+树底层原理:把B+树掰开揉碎讲清楚
2.1 为什么是B+树,而不是哈希、二叉树或者B树
面试官问到“为什么MySQL索引用B+树”,常见的错误是只回答一句“B+树查询效率高”。你需要给出几个候选结构的对比,才能显得真懂。
先说哈希表。哈希索引在等值查询场景下确实可以达到O(1)复杂度,比如select * from user where id = 100,哈希索引能很快定位。但它的致命弱点是没法做范围查询,也做不了排序操作。比如你要查id > 100的记录,哈希表只能先算出每个可能值的哈希再一一访问,完全发挥不出顺序优势。而B+树的叶子节点是双向链表且天然有序,对范围查询非常友好。
再说二叉树。二叉树在最坏情况下会退化成链表,层数会很深。如果一张表有1000万条数据,二叉树根节点到叶子节点的路径可能高达几万层,而每一层访问都对应一次磁盘IO,几万次磁盘IO根本没法用。即便换成自平衡的AVL树或红黑树,它们把高度限制在log2(N)级别,1000万条数据大约需要24层,依然意味着几十次磁盘IO,InnoDB的页分裂、页合并也会非常频繁。
B树在二叉树上做了多路平衡,每个节点都能存多个子节点指针,高度下降了很多,所以数据库早期确实有人用B树。但B树的麻烦在于:它的叶子节点和非叶子节点都存储数据。相同容量的页,非叶子节点存储完整记录会占用很多空间,出度变小,树的高度变高。而且范围查询需要中序遍历多个层级,不如B+树叶子节点串成链表来得直接。B+树把所有数据都放在叶子节点,非叶子节点只存索引键和子节点指针,同样的16KB页能够容纳更多索引项,整棵树的高度往往只有2到3层。
2.2 B+树的两个关键设计:非叶子节点只存索引,叶子节点双向链表连接
B+树和B树最大的区别,说直白一点,就是“索引和数据分层”。B+树的非叶子节点只负责导航,每项存的是“键值+指向子节点的指针”。叶子节点则存完整的主键和行数据地址,在InnoDB里主键索引的叶子节点甚至直接存整行记录。
另外,InnoDB的B+树叶子节点之间用双指针链表串联起来,并且叶子节点内部的记录本身按索引键顺序排序。这意味着当你执行范围查询时,比如select * from user where id between 100 and 200,找到id=100对应的叶子节点后,只需要沿着链表顺序向后读取后续叶子节点就行,不需要再回到父节点去回溯查找。这也是B+树结构上胜过B树和哈希表的重要一点。
2.3 两层B+树能覆盖多少数据:面试现场的估算能力
面试官有时候会问:“一张表有1000万数据,为什么只需要3次磁盘IO左右就能查到?”这是一道可以拿计算过程证明自己的题目。
假设InnoDB每个数据页默认16KB,主键用bigint占8字节,页目录里的指针占6字节左右,于是一个非叶子节点大约可以存储16KB / (8 + 6) ≈ 1170个索引项。这里的单位是“记录”而不是行,因为非叶子节点里只存索引键和指针,不存整行数据。
第一层是根节点,它能指向1170个第二层节点。第二层每个节点如果也是索引页,就又能指向1170个第三层节点。第三层是叶子节点,每个叶子页内按平均每行记录1KB左右计算,大约可以放16行数据。整棵B+树最多能管理的数据量约为1170 * 1170 * 16 ≈ 2190万行。
和你听到的“3次磁盘IO找到一条数据”是吻合的:第一次把根节点读入内存,第二次定位到中间层节点,第三层就直接定位到叶子页。如果没有任何索引直接全表扫,可能要读几十万个数据页。把B+树层数和IO次数联系起来,面试官基本能确认你是真的理解索引结构。
2.4 自增主键为什么能减少页分裂
理解B+树的有序性之后,自增主键这个设计也就不难解释了。InnoDB的主键索引是聚簇索引,数据行在物理存储上按照主键顺序排列。如果使用自增整型主键,每次插入数据时新的主键值都比上一条大,B+树会倾向于在右侧叶子节点末尾追加新记录,很少发生页分裂。
反过来,如果主键是UUID这种随机字符串,每次插入的位置都难以预测。新记录经常要插入到已有页的中间位置,为了保持B+树有序,InnoDB不得不把原来的数据页分裂成两页,重新分配空间,产生大量随机IO,并发插入性能也会明显下降。这就是为什么网上所有博客、面试答案都会告诉你“建表尽量使用自增主键”,背后的原理就在这里。
3. 聚簇索引、二级索引与回表:面试连环问的关键分水岭
3.1 聚簇索引的叶子节点到底存了什么
InnoDB中,每张表都会有一个聚簇索引。聚簇索引的叶子节点保存的是一整行记录的数据,可以直接理解成“数据即索引,索引即数据”。当你通过主键id查询时,在B+树上定位到叶子节点之后就拿到了目标行的所有字段,不需要再做额外的回表或查询。
聚簇索引的选择规则要记住三点:如果表有主键,MySQL会默认使用主键作为聚簇索引;如果没有显式主键,MySQL会找第一个非空的唯一索引作为聚簇索引;如果连唯一索引都没有,InnoDB会隐式生成一个6字节的rowid作为聚簇索引。
很多Java开发同学会把“给表加索引”和“主键也是一种索引”混为一谈。主键不光是业务上用来保证唯一性的键,还是整张表行数据物理组织的依据。如果你不确定一张表的聚簇索引是哪个,可以用show index from 表名看Primary索引的列顺序,但要知道它背后的含义不是简单约束。
3.2 二级索引为什么要存主键值?回表是怎么发生的
二级索引也叫非聚簇索引。比如你在user表name字段上建了普通索引,这个索引的B+树叶子节点存储的就不是整行记录了,而是name值和对应的主键id值。
当你执行select * from user where name = '张三'时,MySQL会先到name这个二级索引的B+树里找到叶子节点,拿到name='张三'对应的主键id。但此时你只拿到了id,还没有拿到这一行的年龄、手机号等字段,所以MySQL还需要拿着这个id再到聚簇索引的B+树里查一次,按主键id定位完整行记录。这种先查二级索引,再回主键索引查完整行的过程,就叫回表。
回表并非没有代价。如果第一条SQL命中了二级索引,但select列表用了*,需要回表1000次,那就是1000次随机主键查询。如果查询条件本身区分度不高,查出大量行,回表成本会成倍上涨。面试官在这里通常会继续追问:“怎么避免回表?”这就自然过渡到了覆盖索引的知识点,后面有一节专门展开。
3.3 唯一索引和普通索引,选哪一个更合适
唯一索引和普通索引的选择,在刚入行时很容易被忽视。从查询角度来说,唯一索引在查到第一条满足条件的记录后就可以直接停止,因为唯一性约束保证不会再有第二行;普通索引则需要继续扫描到下一条不满足条件的记录为止。但在实际MySQL执行中,这个差异对于“等值查询只查一条”的场景几乎无法感知。
更重要的是写入差异。唯一索引因为要校验唯一性,插入或修改时会把相关数据页读入内存做判断,如果冲突率不高,性能影响其实有限。但如果业务本身允许重复值,不要为了“索引更强大”去强行加唯一约束。唯一索引会限制业务扩展,比如一个用户可能有多个收货地址,就绝不应该在user_id上建唯一索引。
另一个面试细节是:change buffer只对普通二级索引生效。If unique constraint is set, because MySQL must confirm no duplicate exists at insert time, it must read the page into memory to check uniqueness, and thus cannot completely offload the write to merge later. 所以写过大量普通索引时能明显感受到change buffer带来的优化。这个点不算高频,但说出来比较亮眼。
3.4 联合索引的本质就是一次建多个列的排序目录
联合索引和单列索引最本质的区别,不是简单地把多个索引放一起,而是它只维护一棵B+树,树里的排序规则按索引字段的先后顺序定义。比如在(name, age)上建立联合索引,那么所有记录会先按name排序,name相同的情况下再按age排序。
正因为这个排序规则,查询条件里如果直接跳过第一个字段只看age,比如where age = 20,这棵B+树根本帮不上忙。因为树的第一层索引键是name,age只是第二层比较项。你手上只有age,面对一棵先按name排好序的树,无法直接定位到具体节点,只能全树扫描或回退到其他索引,这就是“联合索引最左前缀原则”的来源。
4. 联合索引与最左前缀:高频面试考点完整还原
4.1 一道典型面试题:联合索引(a,b,c)到底能匹配哪些查询
这几乎是MySQL索引面试里出现频率最高的一道原题。面试官会写下一行SQL,然后问你下面这些查询能不能走到联合索引:
- where a = 1
- where a = 1 and b = 2
- where a = 1 and b = 2 and c = 3
- where b = 2 and a = 1
- where a = 1 and c = 3
- where b = 2 and c = 3
- where a > 1 and b = 2
答案首先要记住:最左前缀原则要求从联合索引的最左列开始连续命中。所以第一、二、三条都可以命中,第四条b = 2 and a = 1也没问题,因为MySQL优化器有查询重写能力,会调整等值条件的顺序,把它等价成a = 1 and b = 2。
第五条where a = 1 and c = 3是个典型的边界情况。a字段能用到联合索引,B+树先用a = 1定位到一批记录,但b这个中间字段没有出现在查询条件里,导致索引无法继续在b层级精确定位c = 3。MySQL只能拿到所有a = 1的记录后,再一一代入主键回表过滤c = 3。所以这条SQL会“部分用到索引”。
第六条where b = 2 and c = 3完全没用到该联合索引,因为首列a没有被限制,B+树无法定位。第七条where a > 1 and b = 2也有讲究,a用上了范围查询,但a > 1会让索引在a这一层扫过一个区间,区间内每个值对应的b是否等于2就不可预知了,后面的b字段无法通过索引直接过滤。熟悉这个推导过程后,再遇到类似题目就能现场分析,不会因为背错规则翻车。
4.2 为什么不能跳过中间字段
很多人虽然记住了“跳过中间字段会导致部分索引失效”,但没理解原因。我习惯用查字典的类比来解释:联合索引(name, age, city)就像一本先按姓、再按名字首字母、最后按城市排的通讯录。你想找所有“张姓且在杭州的人”,你可以靠“姓”快速翻到张姓区域,但这一区域里的人到底哪些在杭州,你得一张一张看,因为通讯录并没有先按城市排过。同理,B+树里age字段没有参与条件时,c字段所处位置无法形成有效的前缀条件,索引只能止步于a和b之间的空洞。
如果面试官再追问“那怎么优化”,可以回答两种方案:把SQL改写为(a, b, c)三个条件同时给全,但这往往不符合业务输入;更合理的方案是把索引结构调整为(a, c, b),或者根据业务查询频率把首列a放到最前,尽量设计成“字段都能连续命中”的组合。这里要强调联合索引不是越多越好,每增加一个索引就增加一份写放大成本。
4.3 联合索引字段顺序选择的三个经验法则
我实际设计索引的时候,不靠感觉,基本按三条规则挑选字段顺序:
第一,优先把等值查询的字段放在最前面。比如订单状态、渠道来源这些业务上经常作为固定筛选值的字段。放在前面可以让索引一开始就精确收敛到一个较小的范围。
第二,把区分度高的字段尽量往前放。区分度类似性别这种只会有两个取值,放进索引后每个值对应的记录数太大,很容易让优化器觉得扫描成本比回表还高,从而放弃索引。而订单号、手机号这类区分度接近1的字段放前面,可以快速把候选集缩小到很少的行。
第三,把经常用来排序或分组的字段纳入索引。如果一条SQL既where又order by,联合索引顺序能够天然满足排序,就可以避免文件排序。
但这三条之间存在冲突,比如等值字段区分度不高、排序字段区分度高,怎么办?最终还是要看具体SQL的执行计划。一般建议以高频查询条件为主,用EXPLAIN比较不同索引候选执行计划的rows,而不是死记硬背设计规则。
4.4 联合索引底层的一棵B+树,不是三个独立索引
有一个很常见的误区:面试者在联合索引(a,b,c)上误以为相当于给a、b、c各建一个单列索引。事实上它只有一棵B+树,这棵树只会在最左列无法覆盖条件时才退化为部分索引。如果想单独用b查询仍可以而且走索引,必须另外建立以b为首列的索引,或者把原联合索引调整成适应业务的顺序。
这也是为什么索引维护要克制的原因。假设表上有(a,b)、(a,c)、(b,c)三组联合索引和a单列、b单列,带来的写入更新开销不容小觑。每次insert、update都要同步维护多棵B+树,索引页增多还会占用更多buffer pool内存。对Java后端同学来说,面试时能说出“索引是空间换时间、有维护代价”这句话,往往能让回答有工程感。
5. 覆盖索引与索引下推:两个容易被忽略又能拉开差距的考点
5.1 覆盖索引是什么:不用回表就是最大的优势
当你查询的字段全部包含在二级索引中时,InnoDB可以直接从二级索引的B+树上返回查询结果,不需要再去主键索引查完整行。这种效果叫覆盖索引。
举个最简单例子,表中有id、name、age三个字段,其中id是主键,name上有二级索引。执行select id, name from user where name = '张三'时,二级索引的叶子节点存放的是索引列name和主键id。select列表里的id和name都在二级索引里找到了,MySQL就可以直接返回,不用回表。如果select的是age,那age不在二级索引里,就必须拿着主键id回表,从聚簇索引中把age取出来。
日常开发中,我特别建议避免写select *,核心原因之一就是它基本不可能用到覆盖索引。你查询的列越多,二级索引覆盖不到的可能性越大,回表概率越高。只要把select列表压缩到业务真正需要的字段,就可能让一条高频SQL从“回表上百次”变成“索引直接返回”,这个优化比单纯加一个索引还见效快。
5.2 索引下推出现之前的查询路径:先回表再过滤
索引下推(Index Condition Pushdown,ICP)是MySQL 5.6推出的优化。要理解它的价值,得先还原没有ICP时的执行过程。
假设表里有联合索引(name, age),查询是select * from user where name like '张%' and age > 20。在没有ICP的情况下,B+树只能根据name的前缀找到一批name以“张”开头的二级索引记录,但age > 20这个过滤条件无法直接在二级索引里使用,因为索引树的排序规则是先按name后按age,name前缀相同情况下age是有序的,按理是可以直接利用的,只是早期存储引擎不支持。
所以MySQL的旧逻辑是:把所有满足name like '张%'的二级索引记录,一条一条拿到主键id,再逐条回表到聚簇索引查找完整行,最后在Server层判断age > 20是否成立。整个过程最大的浪费在于,很多行可能age根本不大于20,本不需要回表,但还是回了表,产生大量无效IO。
5.3 有了索引下推之后:存储引擎层先过滤一次
启用ICP后,MySQL会尽可能把age > 20这个条件“下推”给存储引擎,让InnoDB在遍历二级索引的过程中,就检查当前记录是否同时满足name like '张%'和age > 20。只有两个条件都满足的记录才需要回表。额外信息里会显示Using index condition,意思是“索引条件被下推到存储引擎层”。
这里需要辨析一个概念:索引下推不等于覆盖索引。覆盖索引连回表都省了,而索引下推只是减少了回表次数,仍然可能需要访问聚簇索引。如果查询改成select name, age from user where name like '张%' and age > 20,由于叶子节点本身就包含name和age,不需要回表,Extra里会显示Using index,这属于覆盖索引优化。如果select包含其他不在索引中的列,则Extra通常为Using index condition,回表次数被大幅降低但并没有完全消除。
ICP对于联合索引非常有用,因为它能在索引遍历阶段排除大量不满足条件的行,保证索引条件下推的内存副本不会太大。默认情况下,MySQL 5.6以后ICP是开启的,你可以在optimizer_switch系统变量里查index_condition_pushdown参数确认。如果真的遇到使用了ICP但性能不理想的情况,先检查当前查询是否真的把条件下推到二级索引上,再看看联合索引列顺序是否造成了没法下推的问题。
5.4 面试中怎样把覆盖索引和索引下推销出去
如果面试官问你“覆盖索引和索引下推有什么区别”,不要只背定义。我建议用一个二维表格来说:
| 优化类型 | 是否避免回表 | 额外信息标志 | 前提 |
|---|---|---|---|
| 覆盖索引 | 完全不回表 | Using index | select列和where列都包含在索引内 |
| 索引下推 | 减少回表但可能回表 | Using index condition | 联合索引部分行被引擎层提前过滤 |
再补充一句:“覆盖索引更适合查询频繁但更新少的场景;索引下推主要针对联合索引的模糊查询或范围查询。”这个答题结构会让面试官觉得你真的区分开了两个容易混淆的概念。
6. MySQL索引失效的8种场景:别等SQL慢到报警才后悔
6.1 失效场景速查表:先记住规律再理解原理
“索引失效”是面试中提问率最高的知识点,甚至比联合索引原题还高。下面我把最常见的失效场景整理成一张表,建议直接当成面试复习材料。
| 失效场景 | 例子 | 失效原因 |
|---|---|---|
| 对索引列使用函数 | where YEAR(create_time) = 2023 | 函数改变了索引列的值,B+树无法对结果排序 |
| 对索引列做隐式类型转换 | where phone = 13800001111(phone是varchar) | 类型转换相当于对列套了一层转换函数 |
| LIKE以%开头 | where name like '%张%' | 无法用索引列前缀进行匹配 |
| OR连接非索引列 | where name = '张三' or age = 20(age无索引) | 要扫描全表才能判断OR的另一半 |
| 联合索引不满足最左前缀 | 索引(a,b,c),where b=1 and c=2 | B+树第一层键是a,无法定位 |
| 范围查询右侧列失效 | 索引(a,b),where a > 1 and b = 2 | a走范围后b无法保证有序过滤 |
| 对索引列进行运算 | where id + 1 = 100 | 列值被改变,索引顺序失去意义 |
| 使用不等于、NOT IN、IS NOT NULL | where status != 0 | 优化器认为结果集太大,索引扫描不如全表 |
表里每一行的失效原因,都能落到B+树的有序性上。只要你在索引列上做函数、运算或类型转换,结果就等于抛弃了原值排序,B+树当然无法使用。OR并不是一定失效,如果OR两侧的字段都有索引,MySQL可能会对两个索引分别取结果再合并;但只要OR连接了一个没有索引的列,整条SQL就很容易退化为全表扫描。
6.2 隐式类型转换:面试官最爱追问的隐藏杀手
隐式类型转换的经典案例是电话号码字段。很多开发者在设计表时将phone存成varchar,查询时却习惯了写where phone = 13800001111。MySQL看到条件右侧是整数,左侧是varchar,会自动将字符串列转换为数值进行比较。这个转换操作相当于对每一行的phone值套了一次CAST函数,索引列上发生了函数操作,索引自然失效。
如果字段本身是int类型,而查询条件传了字符串'123',反过来的情况不太一样。MySQL会把字符串转换为数字,但转换的是查询条件这一侧,不是索引列,所以索引通常还能正常使用。面试中如果能知道这个细微区别,会显得理解很扎实。
我在实际业务里还见过一个坑:订单号列存的是带前导0的字符串,比如'001234'。如果业务查询时不小心写成where order_no = 1234,不仅潜在匹配到'01234'这类值造成误查,还可能因为类型转换让索引失效,连跑都跑不出来。这里建议在写代码时保持参数类型和表结构定义完全一致,能避免大量SQL性能问题。
6.3 范围查询之后为什么右侧列失效
当你执行where a > 100 and b = 5,假设联合索引是(a, b),很多人以为a走索引之后b也能继续走索引。但实际是b无法承担B+树里的精确定位功能,因为a > 100意味着a不是一个固定值,而是一个大区间。在这个区间范围内,b字段是否等于5,需要将每一条a都单独拿出来做判断,因为B+树中的所有记录是“按a排列,a相同再按b排列”。同一棵树的叶子节点在a变化后并不会保持b有序。
如果改成where a = 100 and b > 5,情况就完全不同。a固定后,b在后续层里有序,所以b可以继续使用索引进行范围扫描。如果写成where a > 100 and b > 5,同样是a走范围,b无法在索引上完全发挥,只会被作为普通过滤条件。
这给索引设计带来的启示是:联合索引中范围字段要尽量往后放,因为范围条件一旦触发,它右侧的字段通常都会被“截断”。等值字段尽量往左放、范围字段紧接其后,是联合索引顺序设计的重要原则之一。
6.4 用EXPLAIN判断索引走没走
面试时讲得天花乱坠,不如一句“我会先EXPLAIN看执行计划”更有说服力。在MySQL客户端里,执行EXPLAIN select * from user where phone = '13800001111',会得到一张表,里面关键的列有type、possible_keys、key、rows、Extra。
type列从好到差基本是system、const、eq_ref、ref、range、index、ALL。实际开发中,ref和range都属于尚可接受的范围,index意思是“扫描了整棵索引树”,比全表扫描好一点但不算高效,ALL就是全表扫描。看到了ALL,就要重点怀疑SQL有没有触发索引失效。
rows列是优化器估算需要读取的行数,不代表实际值但能反映大致量级。Extra列出现Using filesort、Using temporary、Using index condition、Using index这些关键字,会让执行计划信息量变得很大。Java开发虽然不用每天手动执行EXPLAIN,但每次上线前对关键SQL跑一遍EXPLAIN,能挡掉大多数慢查询事故。
7. 从面试题到线上排查:一条慢SQL的索引优化实践
7.1 先找到慢SQL:打开慢查询日志
索引相关的题,面试官常常会用“你线上遇到慢SQL怎么排查”来收尾。这时候不要再讲理论,而是要给出完整的动作序列。第一步是确认慢查询日志打开了,否则很多问题根本发现不了。
可以在MySQL配置里开启slow_query_log,并设置long_query_time为1或者更小值。随后系统会把超过阈值的SQL写入慢日志文件。有些公司会直接把慢日志接入监控系统,但独立排查时也可以用mysqldumpslow工具快速汇总,它能按执行次数、平均耗时排序,看出哪些SQL频繁踩坑。
如果线上不方便开启慢查询日志,还有另外一个手段:performance_schema里的事件统计,以及sys.session一类的实时视图。通过查询信息,快速定位当前执行时间最长的会话和SQL,再针对它做EXPLAIN分析。Java侧也可以在数据库连接池开启慢SQL拦截,把执行超过某个毫秒数的SQL统一打印出来。
7.2 一个实战案例:订单表查询慢的优化过程
曾经遇到一条线上SQL,简化后是:
sql复制SELECT *
FROM order_info
WHERE order_status = 0
AND create_time BETWEEN '2023-01-01' AND '2023-01-31'
ORDER BY id DESC
LIMIT 10;
表有近千万数据,order_status只有0、1、2三种值,create_time上建有普通索引。EXPLAIN后发现possible_keys里能看到create_time,实际key也是create_time,但rows估算非常多。
为什么慢?首先order_status = 0会命中大量历史订单,即使create_time有范围限制,一个月内也可能有几十万订单。B+树根据create_time找到对应范围的叶子节点后,还要把节点记录读出,逐一过滤order_status = 0,再按id排序取最后10条。由于select *需要回表读取大量行,性能瓶颈明显。
优化措施是调整联合索引为(order_status, create_time, id)。因为order_status是等值条件,放在最前面可以先把数据收敛到一小部分记录;create_time作为范围条件放在第二层;id拿来覆盖order by id的排序场景。加完索引后,SQL执行很快从秒级降到了毫秒级,EXPLAIN中的rows下降了几个数量级,Extra也不再有filesort。
这个例子说明,索引不只是给where条件加一个列那么简单,还要把order by、select列一起考虑进去。面试时能讲出这样完整的案例,远比背十条规则更能赢得认可。
7.3 深分页、OR改写和前缀索引的实战经验
线上常见的另一个慢查询模式是分页太深。比如Java网页后台习惯用limit 100000, 20,虽然可能命中索引,但MySQL必须先扫描并丢弃前10万条记录,再返回第100001到100020条。即使每条都来自索引页,这些被丢弃的记录也要经历定位和判断,造成大量无效IO。
处理深分页一个办法是“延迟关联”或“游标分页”。比如先通过子查询或联表查询只拿到需要返回行的主键id,再用id关联原表获取完整数据。这样前段的随机访问变成对二级索引的顺序扫描,大大减小压力。另一种方案是记录上一页最后一条数据的id或时间戳,下一页查询用where id > ? limit 20代替offset,让数据库直接定位到下一页起点。
OR的改写也值得注意。比如where status = 0 or category = 'A',如果category没有索引,MySQL只能全表扫描,那即使status上有索引也救不回来。可以尝试拆成两条SQL union all,或者对category字段单独加索引。具体用哪种方式,要在区分度和索引维护成本之间权衡。
前缀索引同样是Java后端常用的优化手段。比如一个字段保存的是一段很长的文章链接或URL,直接对这个长字段建全文索引会浪费大量空间,B+树页的存储量也会下降。此时可以考虑建索引时只取前N个字符,例如idx_url(url(100)),来降低索引体积。但要注意,前缀索引无法覆盖该字段的排序或分组操作,也无法利用覆盖索引优化,需要在区分度和长度之间做测试。
7.4 Java开发可落地的索引优化清单
结合这些案例,我会在日常代码评审中关注这么几点:
- 确认所有where条件里的字段都有合适的索引,尤其关注类型是否与表结构一致,避免隐式转换。
- 针对高频查询确认是否覆盖业务字段,尽量不用select *,让select列落在二级索引中。
- 联合索引顺序是否按等值字段优先、范围字段靠后、排序字段跟随这个思路设计。
- 少建单列索引,多考虑能覆盖多个查询场景的联合索引,但也不能为了覆盖所有场景把索引列建得过多过长。
- 上线前对核心SQL跑EXPLAIN,观察type列在range及以上、key列是否真正命中预期索引。
- 对深分页、批量更新等特殊操作做针对性设计,不要把所有压力都丢给MySQL索引解决,必要时通过缓存或异步任务分流。
实战和面试最大的不同是,线上系统不会有标准答案,索引方案要不断根据数据分布和业务变化调整。今天覆盖得挺完美的索引,明天数据量增长,SQL执行计划就可能变化。
8. 这个索引知识点,我自己平时是怎么梳理的
坦白讲,MySQL索引的这些知识点,只靠一篇八股文是记不扎实的。我每年面试别人或者被别人面试,都会被问出新的角度。后来我养成一个习惯:每次处理完一条慢SQL,不急着删日志,先拿这条SQL做一次复盘,看它为什么开始慢、是在哪个阶段慢、是统计信息过期还是数据量涨了,还是查询条件本身有了变化。
很多Java开发同行觉得索引是DBA的事,自己写CRUD就好。但线上真正出现问题的时候,DBA未必了解你这条SQL的业务含义,你要能自己看懂执行计划、解释得清慢在哪。这也是我为什么一直强调“面试场景题”这种学习方式:以问答为入口,以原理解释为路径,以线上问题为验证。
这一篇的MySQL索引内容还只是索引知识体系里的一角。后续可以把范围查询优化、分页查询优化、写放大与页分裂、explain各字段详细解读、甚至常见误区的反面案例继续整理出来,作为每日一题的材料。每天抽出十分钟看一个知识点,比面试前突击背一晚上要靠谱得多。
