ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

MySQL索引八股变实战:B+树、回表、最左前缀与SQL调优

MySQL索引八股变实战:B+树、回表、最左前缀与SQL调优 一开始看到“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层但数据插入不总是理想均衡的实际可能更深。数据库里每访问一层树节点就要做一次磁盘IO24次IO对于一次查询来说已经很夸张了。红黑树能维持平衡高度控制在log2级别但依然解决不了“层数多”的问题——内存里用红黑树没问题磁盘上不行。B树是升级版每个节点能存多个索引项整体树高大幅降低通常两三层的B树就能支撑千万级数据。但B树有个特点数据和索引在一起每个节点的容量有限在同样的节点大小下B树的非叶子节点可以只放索引不放数据一页能放更多索引项树更矮。同时B树把所有数据都放在叶子节点叶子节点之间用链表串起来做范围查询和排序的时候只要沿着链表往后走就行B树则要反复回溯。所以B树同时拿到了“树矮”和“链表范围扫描”两个优势。1.2 磁盘IO和页到底怎么算MySQL的默认页大小是16KBInnoDB读写磁盘的最小单位就是一页。B树每个节点物理上对应一个页查询一条记录时从根节点到叶子节点每经过一层就要读一个页也就是一次磁盘IO。以主键索引为例假设一行数据大概占用200字节一个16KB的叶子页大约能存80行左右实际还要算页头和槽位开销这里简化说明。非叶子节点的一页里每个索引项只存主键和指针假设占16字节一页大约能放1000个索引项。那么三层B树能容纳的叶子页数量大约就是1000×1000100万个页乘以每页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和主键idMySQL直接扫描这棵树找到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 3MySQL能用索引定位到a1且b100的记录但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%时老版本会先把a1的主键们都回表取出来再在服务层过滤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 age20如果只有name有索引age没有那OR右边的分支只能全表扫整个查询退化成全表扫索引就废了。实际工作中我更建议把这类OR拆成两条SQL或者用union all拼结果在业务层合并性能往往更稳定。4.5 优化器不傻全表扫描成本更低的时候它不会用索引这是很多人容易忽略的一点不是所有能走索引的查询真的走了索引。假设性别人是字段索引区分度很低1000万行里男和女各占一半你用where gendermale优化器一算走索引先拿到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。哪一项异常就往对应方向查。一次经验是曾有一条订单查询typeALL看建表语句明明有索引后来发现是查询条件里写了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的输出上。这个过程跑通一遍比我上面写的任何一段话都管用。毕竟索引这玩意儿真正用到线上快一秒是实打实的快慢一秒也是能感觉出来的慢。
返回列表