一开始看到“Mysql索引八股”这个标题,我第一反应是:又是个准备面试的。这个词在程序员圈子里已经成了特定暗号——指的是MySQL索引那套被反复问到、几乎可以“背诵默写”的知识点。但说实话,面试考索引考了这么多年,还能一直考,恰恰说明它不只是八股,而是关乎线上SQL快慢、磁盘IO高低、慢查询能不能救回来的核心技能。如果你能把索引这套东西从“背结论”变成“讲原理、看执行计划、能调优”,那不管是应对面试,还是在公司排查慢SQL,都算真正掌握了。
这篇文章我打算按自己梳理索引体系的方式来讲,不打算给你列一个又一个孤立的题,而是从底层结构到索引设计,再到执行计划验证,串成一条线。适合准备后端/数据库相关面试的同学,也适合刚接触MySQL、想知道“为什么索引能快”的开发者。内容基于我个人使用经验,MySQL版本以8.0为主,涉及存储引擎的地方默认指InnoDB。
1. 先弄懂B+树为什么能赢,索引八股才不算死记硬背
很多面试题第一问就是“MySQL索引底层为什么用B+树”,大多数人能背出“叶子节点存数据”“非叶子节点只存索引”“树矮”这几句,但能被追问几句就说乱了。所以我想先把这个底层结构拆透,因为它几乎是后面所有问题的地基——回表、覆盖索引、最左前缀,全都建立在这棵树上。
1.1 哈希、二叉树、B树、B+树到底差在哪
先拿哈希来说。哈希索引做等值查询确实快,理论上是O(1),内存操作一遍算出来就定位了。但它有两个硬伤:第一,没法做范围查询,你查where age between 20 and 30,哈希结构只能把所有值算一遍;第二,排序也麻烦,哈希表天然不维持有序。而MySQL里between、>、<、order by都是再常见不过的操作,所以哈希只能当辅助索引,或者像Redis那样纯粹走内存。
再看二叉树,包括红黑树。问题很简单:数据量大之后树变高。如果一张表有1000万条数据,二叉搜索树的理想高度大约是log2(1000万)≈24层,但数据插入不总是理想均衡的,实际可能更深。数据库里每访问一层树节点,就要做一次磁盘IO,24次IO对于一次查询来说已经很夸张了。红黑树能维持平衡,高度控制在log2级别,但依然解决不了“层数多”的问题——内存里用红黑树没问题,磁盘上不行。
B树是升级版,每个节点能存多个索引项,整体树高大幅降低,通常两三层的B树就能支撑千万级数据。但B树有个特点:数据和索引在一起,每个节点的容量有限,在同样的节点大小下,B+树的非叶子节点可以只放索引不放数据,一页能放更多索引项,树更矮。同时B+树把所有数据都放在叶子节点,叶子节点之间用链表串起来,做范围查询和排序的时候,只要沿着链表往后走就行,B树则要反复回溯。所以B+树同时拿到了“树矮”和“链表范围扫描”两个优势。
1.2 磁盘IO和页到底怎么算
MySQL的默认页大小是16KB,InnoDB读写磁盘的最小单位就是一页。B+树每个节点物理上对应一个页,查询一条记录时,从根节点到叶子节点每经过一层,就要读一个页,也就是一次磁盘IO。
以主键索引为例,假设一行数据大概占用200字节,一个16KB的叶子页大约能存80行左右(实际还要算页头和槽位开销,这里简化说明)。非叶子节点的一页里,每个索引项只存主键和指针,假设占16字节,一页大约能放1000个索引项。那么三层B+树能容纳的叶子页数量大约就是1000×1000=100万个页,乘以每页80行,大约是8000万行。也就是说,8000万行的表,走主键索引查询,三层树最多也就3次磁盘IO,这个量级在任何业务里都几乎可以忽略。
这也能解释为什么建索引能改善查询:没有索引时,全表扫描意味着要读所有数据页,而百万行级别的大表,数据页数量是几万甚至几十万量级的,IO次数完全不在一个维度。
1.3 一个常被追问的细节:为什么不直接全部放内存
面试官很喜欢沿着这个方向问:“既然磁盘IO这么重要,那我把数据全放内存不就不用IO了?”数据库确实有buffer pool把热数据页缓存在内存里,类似Redis全内存方案也真实存在。但内存有两个现实问题:一是成本远高于磁盘,电商订单、用户行为日志这类动辄几个TB的数据全放内存,成本无法接受;二是进程重启后内存数据会丢,即使做了持久化,也仍然需要磁盘上的核心存储结构。所以磁盘存储是必须的,而B+树正是为了“省磁盘IO”设计出来的方案。
提示:如果面试聊到这里,可以顺势提一句“其实InnoDB的buffer pool会把根页面常驻内存,所以三层树很多时候实际只有最后两次IO需要真正走磁盘”,这句话能明显提升专业感。
2. 聚簇/非聚簇与回表:这三兄弟是八股题的高频陷阱
讲完B+树底层,紧接着就是聚簇索引、非聚簇索引、回表这一组概念。面试里最容易出现的情况是,候选人口头知道“回表不好”,但让他说清楚什么是回表、什么时候回表、怎么避免回表,就卡住了。这套概念没那么难,但确实需要体系化地理解。
2.1 InnoDB的聚簇索引到底长什么样
InnoDB表的数据本身是按主键索引的B+树结构存储的,这张B+树的叶子节存的是整行数据。换句话说,主键索引既是索引,也是数据本身,这个结构就叫聚簇索引。因为叶子节点上直接挂着完整行数据,所以通过主键查记录是效率最高的方式——直接定位到页,把行取出来,没有额外操作。
一张表只能有一个聚簇索引,因为行数据物理上只能有一份,按一种顺序组织。如果你建表时没指定主键,InnoDB也不是不干活,它会挑一个非空的唯一索引当主键;再没有,就生成一个隐藏的rowid当主键。所以我一直建议:建表显式设计主键,别让InnoDB帮你做隐式选择。
2.2 二级索引的叶子节点到底存了什么
除主键索引外,其他索引都叫二级索引,也叫非聚簇索引。二级索引的叶子节点不存整行数据,存的是索引列的值加上主键值。你给字段name建一个普通索引,那么这棵B+树里每个叶子节点存的是“name的值”和“主键id值”。
这就自然引出了回表:如果你查select * from user where name='张三',MySQL会先去name索引树里找到对应的主键id,然后再用这个id去主键索引树里查一遍完整行数据。前一次查name树是一次索引查找,后一次按主键查整行是另一次索引查找,这两步合起来就是回表。
回表不是一定很慢,这里要走两层树,而两层树各自可能只涉及几次磁盘IO;但如果在一次查询里需要回表成千上万行,那性能就很成问题。这也是为什么后端编码规范里常常强调少用select *——不是迷信,而是select *基本必然带回表,而只查索引里已有的列往往可以省掉这一趟。
2.3 覆盖索引:唯一能堵住回表的方案
覆盖索引指的就是:你用到的查询列、筛选列、排序列全都在同一个二级索引里,那么MySQL在这棵二级索引树上就能拿到所有需要的数据,不需要再回主键索引查一遍。执行计划里对应Using index标志,这个“index”指的就是索引覆盖。
举个例子:表里有一个idx_name_age的联合索引,列是name, age。执行select name, age from user where name='张三'时,二级索引叶子节点本身就有name和age和主键id,MySQL直接扫描这棵树,找到name='张三'的记录,取出age,整个查询完全不回表。而select *因为要拿更多列,叶子节点没有,就只能回表去取。
所以你在设计索引时,可以有意地把高频查询里作为结果返回的字段也放进索引,用空间换回表。但要注意,索引不是越多越好,覆盖索引的本质是“索引列越多,写操作维护成本越高”,后面第6部分我会专门展开设计取舍。
3. 复合索引与最左前缀:工作里天天踩,面试里天天考
复合索引是整个索引八股里含金量最高的一块,既考验记忆,更考验理解和场景分析。最左前缀四个字背出来容易,但面试官只要换一个查询条件组合,很多人就懵了。这一部分我带着你从头推一遍这个规则的来龙去脉。
3.1 联合索引为什么必须“最左前缀”
假设我们有一个联合索引(a, b, c),它的底层存储逻辑是:先按a排序,a相同再按b排序,a和b都相同再按c排序。这个排序顺序直接决定了索引能支持什么查询。
如果把索引想象成一本先按姓氏、再按名字排序的电话簿,你要查“王小明”,只要去王姓那一段,再找小明,效率很高;但如果你只知道名字叫小明、不知道姓氏,你根本不知道从哪一段开始,只能把整本电话簿翻一遍。MySQL里联合索引同理,查询条件必须能命中索引的最左列开始,才能沿着排序结构逐步定位。
所以(a, b, c)索引能高效支持以下几种查询:
where a = ?where a = ? and b = ?where a = ? and b = ? and c = ?
而下面这些情况用不上这个索引,或者说只能部分用上:
where b = ?:跳过了最左列awhere c = ?:跳过了a和bwhere b = ? and c = ?:同样跳过最左列
3.2 范围查询会让右边的索引列失效
范围查询是最左前缀里最容易踩的坑。还是(a, b, c)索引,执行where a = 1 and b > 100 and c = 3,MySQL能用索引定位到a=1且b>100的记录,但c的条件就没办法走索引了,因为b的范围切分后,同一段里c已经不是有序的,无法用二分精准查找。准确说,范围查询右边的列退化为普通过滤条件,本可以用a、b两列走索引,c只能回表后在行里逐条过滤。
实际工作中设计索引时,我会尽量把等值查询的列放在前面,把范围查询的列放后面。举个例子,一个订单表有“用户ID”和“下单时间”,常用的查询是“查某个用户最近一段时间的订单”,索引设计就应该用(user_id, order_time),先定位用户,再在用户内部切时间范围;如果反过来(order_time, user_id),时间范围后无法继续高效按用户过滤,效果就差很多。
3.3 order by和group by同样吃这套规则
order by a, b, c如果和联合索引的顺序一致,MySQL可以按索引顺序直接扫描,取出记录就是排好序的,不需要额外排序,执行计划里不会出现Using filesort。filesort意味着MySQL把结果放到内存或磁盘临时排序,当结果集很大时,性能相当难看。
但要注意,order by a desc, b asc这类混合方向排序,在大多数版本下是没法直接用索引的,因为索引B+树本身是单调有序的,不可能同时满足一个升序一个降序。8.0后虽然支持了降序索引,但实际踩坑概率仍高,建议优先保持排序方向一致,否则就要能接受filesort。group by的优化思路基本相同,因为分组本质上也需要按组字段排好序才能统计相邻键值。
3.4 索引下推:面试容易漏掉的加分项
MySQL 5.6之后有一个索引下推,用它来做联合索引的深度优化。还是(a, b, c)索引,执行where a = 1 and b like '2%'时,老版本会先把a=1的主键们都回表取出来,再在服务层过滤b;而索引下推会在索引遍历时直接判断b是否满足条件,只有真正满足条件的才回表。显然这能明显减少回表次数。
面试里提到这点,反应的是你不仅懂索引能筛什么,还知道引擎层在索引内做了前置过滤。实践上,只要你的查询符合最左前缀,MySQL优化器大概率会自动启用索引下推,一般不需要人工干预,但理解这个机制有助于分析执行计划里Using index condition这个标记。
4. 索引失效避坑清单:每个原理都得能讲出为什么
工作里真正的挑战不是建索引,而是建了索引后,发现SQL还是慢,一看执行计划,索引根本没用上。网上流传的“索引失效”清单很多,能全部背出来的人也不少,但面试如果要追一句“为什么失效”,很多人就答不上来了。这里我不只列结论,把每条背后的原理也一并讲清楚。
4.1 对索引列使用函数或表达式
比如where upper(name) = 'ZHANG',只要对列做了计算,索引的B+树有序性就被破坏了。树结构里存的是原始name值,你把它包进upper()后,原有的排序信息无法帮助你定位任何值,优化器只能放弃这个索引。同理,where salary * 2 > 10000也会让salary索引失效,因为你要比较的是表达式结果,而不是原值本身。
正确做法是把计算放到常量侧,改成where name = 'ZHANG',或者干脆在业务代码里先把name转成小写/大写再查。如果确实没法避免,比如必须查“月份=7”的订单,索引列是完整的日期时间,那么更合理的方案是建一个函数索引,或者重新设计存储字段,把月份单独拆出来存。
4.2 隐式类型转换
这是线上出问题最多的一种,也是最隐蔽的。如果某个字段是varchar类型,里面存了“123”,你写where phone = 123,等号两边类型不一致,MySQL会把字段列拿去做数值转换,这一转,列的原始值就被函数化了,索引自然失效。执行计划里通常能看到Using where加上一个很大的rows扫描。
诊断思路其实很简单:翻出这条SQL的执行计划,再对比字段的建表定义,如果类型对不上,基本就是了。修复方式也就是在SQL里把常量老老实实写成字符串where phone = '123',类型一致才能让索引正常工作。你甚至可以把这条经验总结成一句团队约定:所有查询条件值,必须严格匹配字段类型。
4.3 LIKE的前缀匹配问题
like '%关键词'之所以不走索引,关键也在有序性。B+树按完整字段值排序,你要查“以某个子串结尾”的值,任何一棵有序树都没法直接定位到这一类记录,只能全量扫描。而like '关键词%'就可以走索引,因为前缀相同的字符串在B+树里是连续排列的,能直接利用树结构从第一个匹配位置开始扫。这背后还是同一个原理:只要查询条件能用上顺序,就能用上索引。
如果你的业务确实需要“包含某关键字”的搜索,比如商品名称模糊搜索,结论是:别指望普通索引,直接引入全文索引或者更专门的外部搜索组件,而不是在SQL上纠结。这也是技术选型的一部分。
4.4 负向查询和OR的两种情况
not in、!=、<>这类负向条件,索引不一定完全失效,但绝大多数情况下优化器会评估数据占比:如果几乎全部行都不满足这个“不等于”条件,走索引只能带来大量回表,成本高于全表扫描,优化器就会放弃。所以准确的说法是“负向查询在大多数场景下命中索引的价值不高”,而不是“负向查询一定失效”。
OR的情况类似,核心是每个OR分支都得能独立走索引。where name='张三' or name='李四',两边都由同一个索引列驱动,优化器可以把它改写成两次索引查找再合并。但where name='张三' or age=20,如果只有name有索引,age没有,那OR右边的分支只能全表扫,整个查询退化成全表扫,索引就废了。实际工作中我更建议把这类OR拆成两条SQL或者用union all拼结果,在业务层合并,性能往往更稳定。
4.5 优化器不傻:全表扫描成本更低的时候它不会用索引
这是很多人容易忽略的一点:不是所有能走索引的查询真的走了索引。假设性别人是字段,索引区分度很低,1000万行里男和女各占一半,你用where gender='male',优化器一算,走索引先拿到500万主键,再回表500万行,还不如直接全表扫一遍来得直接。所以索引对低区分度列通常没价值,这也是为什么不要在类似status这样的状态列上随便建索引。
排查时如果发现该走索引的SQL没走,第一件事不是加force index,而是先在执行计划里看两个数字:rows和filtered。rows表示预估扫描行数,filtered表示过滤比例。如果比例过大,比如优化器预估要覆盖30%以上的表数据,它大概率选择全表扫。这属于优化器成本模型里的正常判断,真要想强走索引,也优先从SQL改写入手,少用force index,因为一旦数据分布变化,强制索引可能带来更大问题。
5. explain才是检验八股的标准答案
八股背得再熟,最后还是要落到执行计划上。面试官经常问“你怎么排查一条慢SQL”,如果你只会说“看执行计划”,但说不出每一个关键字段到底在干嘛,那等于没说。我建议每个人都应该把explain的几个核心输出字段理解到“能现场解读一条实际SQL”的程度。
5.1 type列:从const到ALL的阶梯
type列展示了这次查询访问数据的方式,常见取值按性能从好到差排列:
const:主键或唯一索引等值查询,最多返回一行,是最好的情况ref:二级索引等值查询,可能返回多行,比如where name = '张三'range:索引范围扫描,比如where id between 1 and 100index:遍历整棵索引树,虽然没回表,但扫描范围是全部索引,比ALL稍好ALL:全表扫描,基本意味着这个SQL在高数据量下会出问题
看到ALL先别急着判死刑,要结合表体量看。一两千行的配置表全表扫,可能连几十毫秒都不到,优化器选它就是最优解。但如果是千万级核心业务表频繁出现ALL,那这条SQL就必须优化——要么改查询条件,要么加索引。
5.2 key和key_len:索引到底用哪一列、用了几分
key告诉你实际采用的索引名。如果为NULL,说明这条查询没用上任何索引。很多人只盯这个字段,但实际上key_len信息量更大。
key_len表示索引使用的字节数,它能反向定位联合索引用到了哪几个列。比如联合索引(a, b, c),a是int占4字节,b是varchar(100)但在utf8mb4下要按4字节算,那么key_len = 4 + 400 + 2(长度字节)= 406,如果再算c字段,字节数会增加。如果当前SQL里只有a和b条件,key_len只显示404,你就知道c列没有参与索引过滤。这个细节在分析范围失效、最左前缀截断时非常有用。
5.3 Extra列里的常用关键字与其业务含义
Using index:走了覆盖索引,没有回表,性能最优Using index condition:发生了索引下推,部分过滤在索引层做了Using where:索引定位后,又在存储引擎层做了额外条件过滤,需要重点检查是否有函数/隐式类型转换Using filesort:结果需要额外排序,如果数据量大,基本就是性能瓶颈Using temporary:使用了临时表,常见于group by字段不在索引里时,影响较大
我处理线上慢查询的固定流程大概是:先拿慢SQL跑一次explain,看type是不是ALL、key是不是NULL、key_len对不对得上、Extra有没有filesort。哪一项异常,就往对应方向查。一次经验是:曾有一条订单查询,type=ALL,看建表语句明明有索引,后来发现是查询条件里写了where order_status = 1,但order_status列被定义为varchar,而查询传出的是数字1,隐式类型转换把列转成了数值,索引整个被干掉。这种问题不explain根本发现不了。
我后来养成了一个习惯:任何SQL上线前,先用测试数据跑一遍explain,截图留档,避免线上只要慢查询报出来就要重新排一遍。
6. 索引设计与面试追问里容易被忽略的细节
索引不只是解决“快不够快”的问题,索引本身有成本。建索引的时候要考虑写入性能、存储空间、维护代价,这些虽然在SQL层面不容易直接看到,但线上数据量大之后,问题会非常明显。以下是我实际项目中积累的几个设计经验,面试中也常被问到。
6.1 主键为什么用自增而不是UUID
自增主键的优势在于新记录的主键值大体上单调递增,插入时B+树的叶子节点从右侧顺序扩展,页分裂概率很低;而UUID这类随机主键会让插入位置散落在整棵树各处,触发大量页分裂,并且随机写入还带来页缓存失效和额外IO。
MySQL社区有个常用比喻:数据库记录按逻辑主键排序存储,随机主键就像在一个按编号排好序的档案柜里反复从中间抽插文件,每插一次都要挪动一堆档案。所以线上业务几乎都推荐自增主键或用雪花算法生成的有序ID,尽量避免纯UUID。
6.2 什么时候真的不建议建索引
一是表太小。几千行以内,全表扫描和索引查询的耗时差距基本可忽略,但索引占空间、插入要维护,性价比为负。二是频繁更新的列。每个UPDATE都可能引发索引结构调整,更新频次很高时,索引越多越拖累性能。三是区分度极低的列,这个上一节已经说过,性别、状态这类列的索引对查询几乎没有意义,反而浪费空间。四是复杂前缀匹配需求,普通索引解决不了,应该转向全文索引或专门检索组件。
面试如果问到“给你一张订单表,你要怎么设计索引”,不能只回答“给订单号加索引”。更好的思路是:先列出常见查询,分析等值条件和范围条件,然后把等值条件放前、范围放后,再考虑排序字段,最后用explain验证。把整个链路讲出来,比任何一句正确结论都更有说服力。
6.3 一条SQL的where条件和order by条件不一致时的取舍
这条比较进阶。比如where a = 1 order by b,如果分别给a和b建索引,优化器很可能会用a的索引过滤数据,然后额外排序得到有序结果;如果建成(a, b)联合索引,那既能过滤又能按序返回,完全避免filesort。
但实际业务往往有多个查询模式,一个联合索引支持不了所有组合。我会按“过滤优先、排序补充”的原则处理:先把出现频率最高、过滤性最强的等值条件放前面,再把常用的排序字段紧随其后。如果还有第二类查询用不到这个排序,再单独评估是否需要第二个索引。索引设计本质是取舍,没有银弹。
6.4 唯一索引和普通索引在写入时的差异
唯一索引每次插入时要检查唯一性,这个检查本身也是一次查找;普通索引不做这个检查,并且可以利用change buffer做写优化,非唯一索引的插入在缓存中就能完成部分合并,后台刷新到磁盘。所以在保证业务约束的前提下,能用普通索引就不用唯一索引,能省不少写入开销。但业务必须唯一,比如订单号、手机号,那该建唯一索引还得建,这是数据完整性优先的问题,不能为了性能牺牲正确性。
最后说说总体的实操体会。面试准备阶段,很多人拿着索引八股背诵清单反复背,但一到工作实际,往往是某个字段传了个字符串把索引搞没了,或者是数据量上来后优化器选错了执行路径。所以我建议所有开发同学都亲自去建一张几百万行的测试表,分别验证一下联合索引的最左前缀、范围失效、like前缀匹配、隐式类型转换这些现象,再对应到explain的输出上。这个过程跑通一遍,比我上面写的任何一段话都管用。毕竟索引这玩意儿,真正用到线上,快一秒是实打实的快,慢一秒也是能感觉出来的慢。