ARTICLE DETAIL

资讯详情

深耕网站视觉设计与运营推广的一线实战洞察。

吃透MySQL索引:B+树、组合索引与失效场景实战指南

吃透MySQL索引:B+树、组合索引与失效场景实战指南 “1000万行的大表查询慢第一反应是什么”——这是面试官最爱问的开场也是很多业务开发同学第一次意识到自己“会用索引但不懂索引”的瞬间。加索引谁都会create index idx_name on table(col)一行SQL的事。但被追一句“为什么加了索引就快”“底层用的什么结构”“什么情况下索引会失效”很多人就开始卡壳了。在MySQL里摸爬滚打这些年我在索引上踩过的坑比在业务代码上多得多。组合索引顺序建反导致全表扫描、隐式类型转换让5亿行大表的查询慢成灾难、明明建了索引却被优化器放弃……这些坑每一个都真实发生过。这篇把我对MySQL索引的理解从底层数据结构到类型选型再到面试避坑一次性梳理清楚给那些希望真正吃透索引而不是背题目的同学。1. 索引到底快在哪从全表扫描到B树的磁盘IO账本1.1 全表扫描为什么慢——一切都是磁盘IO惹的祸要理解索引为什么快先得搞明白没有索引时MySQL是怎么查数据的。假设有一张用户订单表里面有1000万行数据按user_id查一条记录。没有索引时InnoDB只能从第一个数据页开始一页一页读下去把每一行都翻出来比对user_id直到找到目标行。这叫全表扫描full table scan。这里有个关键点MySQL操作数据的最小单位不是“行”而是“页”。InnoDB的默认页大小是16KBMySQL以页为单位从磁盘读取数据到内存哪怕你只需要一行数据也要把整页读进来。1000万行数据如果每行按1KB算大概要占10GB左右换算成数据页就是65万多个页。全表扫描意味着要把这65万个页全部读一遍每次磁盘IO就算1毫秒机械硬盘其实是10毫秒级别SSD也要零点几毫秒最少也是650秒的物理读取时间这还只是粗略估算实际查询还要加上内存中逐行比对的CPU开销。所以全表扫描慢的根本原因是磁盘IO次数太多。理解了这一点你就能明白所有数据库优化的底层逻辑——不管加索引、做缓存、换SSD、调buffer pool本质上都是在减少磁盘IO。1.2 索引的本质用空间换时间的“目录系统”索引到底是什么我的理解是索引就是数据库给数据做的“目录”或者“查找表”。就像一本很厚的技术书如果没有目录你想找“B树”在哪一页只能一页一页翻有了目录先定位章节再定位小节翻过去直接看。索引的本质是一种独立于数据的、有序的数据结构它只保存“索引列的值 指向对应数据行的指针或主键值”体积远小于整张表。因为它是排序好的查找时可以用二分查找等高效算法而不需要线性扫描。举个例子给订单表的user_id字段建索引后MySQL会额外生成一份只包含user_id值和指向原行数据的引用关系的有序列表。查询user_id 123456时在这个有序列表上用二分查找几步就能定位到对应的数据位置然后回原表取出整行。这个过程访问的数据页可能只有3~5个跟全表扫描65万个页相比速度差出几个数量级。1.3 从IO次数算一笔账三层B树能支撑多少数据InnoDB的索引使用的B树结构树的高度直接决定了查询需要多少次磁盘IO。树的高度越低查询越快。为什么B树天然适合做索引我们可以实算一笔账InnoDB一个数据页默认16KB假设表的主键是bigint占8字节每个指向子节点的指针占6字节那么一个非叶子节点大约能存的键值对数量 16KB / (86) ≈ 1170个叶子节点只存数据行每一行按1KB估算一页能存约16行。第一层根节点只有1个页能指向1170个二级节点第二层有1170个页每个页再指向1170个三级节点所以二级节点总共能指向 1170 × 1170 ≈ 137万个叶子节点第三层叶子节点有137万个页每个页能存16行数据于是两层非叶子节点就能支撑 137万 × 16 ≈ 2190万行数据。也就是说一张2000万行级别的表索引树的高度也就是3层。查询任何一行数据最多只需要3次磁盘IO实际上因为根节点常驻内存通常只需要2次。对比全表扫描的65万次IO索引的性能优势完全是碾压级别的。这也是为什么MySQL即使在你写select *时也会悄悄尝试用索引——少读几个数量级的磁盘能不香吗2. B树为什么能成为InnoDB的默认选择2.1 哈希索引的致命短板只能精确匹配MySQL其实也支持哈希索引但默认存储引擎InnoDB里哈希索引是自适应的——由存储引擎自己根据访问模式决定是否自动为热点数据构建DBA没法手动创建Memory引擎可以显式建哈希索引。哈希索引为什么做不了主力因为它用哈希函数把键值映射到一个固定长度的桶中等值查询确实O(1)级别的快但任何范围查询、排序、模糊匹配都必须全表扫描。举个例子where user_id 123哈希索引很快但where user_id 100 and user_id 200这种范围查询哈希索引直接跪了——因为哈希散列后的值是无序的没法利用“相邻”特性。而业务里几乎逃不开范围查询、ORDER BY、GROUP BY这几个场景全是B树的强项。所以哈希索引只能在某些精确查询场景做辅助加速成不了索引的主力类型。2.2 二叉树和红黑树树太高IO次数扛不住二叉树包括平衡二叉树AVL和红黑树的问题很直观每个节点最多只有两个子节点树的高度会随着数据量增大急剧上升。以红黑树为例2000万行数据构成的树高度大概在25层左右也就是查询一条数据最多需要25次磁盘IO。25次跟B树的2~3次比差了近10倍在大并发下这个差距会被无限放大。而且红黑树这种平衡二叉树因为要维持平衡插入和删除时旋转操作频繁数据库是写多读多的场景维护成本也很高。所以虽然大学数据结构课上红黑树讲得最多但到了实际数据库引擎里它反而不具备工程可行性。2.3 B树和B树一字之差工程上天壤之别B树也是多路搜索树和B树长得像但有个核心区别B树的每个节点既保存索引键又保存数据所有节点都可以查到数据而B树的所有数据都集中在叶子节点非叶子节点只存索引键和指针。这个区别直接带来了两个工程优势。第一B树的非叶子节点因为不存数据同样的16KB页能存更多的键值树自然更矮更宽IO次数更少。第二B树的叶子节点之间用链表串起来了范围查询和排序可以直接沿着叶子节点指针顺序遍历不用再回到非叶子节点回溯而B树如果要范围查询中序遍历非常麻烦。我常说B树是“用冗余的索引键换查询稳定性”。所有数据都落叶子节点意味着任何查询的路径长度都是一样的——永远走树的高度那么多次IO不会出现某条数据在根节点一下查到、某条数据在深层查到的不稳定情况。对数据库这种对延迟需要稳定预期的系统来说这一点非常关键。2.4 数据页与磁盘IO为什么16KB这个值不能乱调前面提到InnoDB默认页大小16KB这个值不是随便定的。太小意味着一个页能装的索引键少树变高IO变多太大读取一次磁盘加载的数据多但如果数据利用率不高反而占内存、浪费IO带宽。16KB是MySQL团队在机械盘时代反复权衡的结果即便到现在的NVMe SSD时代绝大多数场景依然适用。还有一点值得说的是预读机制。InnoDB读取数据页时不只是读目标页还会把相邻的页一起预读到buffer pool。B树这种物理上相邻的叶子节点存储结构刚好和预读机制形成绝配——你查一条范围数据时相邻数据大概率已经顺带被加载到内存了后续查询直接命中内存。3. 聚簇索引与非聚簇索引一张表的物理存储真相3.1 聚簇索引主键就是数据的“物理顺序”InnoDB里一张表的主键索引就是聚簇索引而且它的特殊之处在于表的数据行本身就是按主键顺序存储在B树的叶子节点上的。也就是说聚簇索引的叶子节点存的是完整的行数据索引键就是主键。这意味着两件事。第一一张InnoDB表必然有一个聚簇索引不需要你手动建如果你建表时没指定主键InnoDB会找一个非空的唯一键当主键找不到就自动生成一个6字节的rowid作为隐藏主键。第二数据行物理存放的顺序跟主键顺序强相关所以主键怎么设计直接影响写入性能和空间碎片。很多人问我“为什么主键一定要用自增整型”其实和聚簇索引结构直接相关。自增主键在插入时是顺序追加的新数据总是插在B树的最后一页叶子节点不需要频繁分裂而UUID这种随机主键插入时可能要往树的中间某个位置插造成页分裂、数据重排、碎片增加写入性能断崖式下跌。大数据量场景测试下来随机主键的写入吞吐可能比自增主键低一半以上。3.2 二级索引与回表为什么明明用了索引还是慢除了聚簇索引其他索引都叫二级索引也叫非聚簇索引、辅助索引。二级索引的叶子节点存放的不是完整行数据而是“索引列的值 主键值”。所以用二级索引查数据要分两步走先通过二级索引的B树找到对应的主键再拿着主键去聚簇索引的B树里再查一次完整行数据。这第二次查询就叫“回表”。回表是性能损耗的主要来源。如果一条查询命中了二级索引但需要返回的列不在索引里每一行都要做一次回表。数据量大时回表次数多性能就下来了。我在实际优化中见过不少SQL明明走了索引但还是很慢EXPLAIN一看扫描行数几千但extra列里没有Using index就知道在疯狂回表这时候要么改查询字段要么建覆盖索引。3.3 覆盖索引查询优化里的“免回表”大杀器覆盖索引不是一种独立的索引类型而是一种“索引刚好覆盖了查询所需所有字段”的状态。如果查询的列全部在二级索引中那查询连回表都不需要直接遍历二级索引的叶子节点就能返回结果。最经典的场景查用户表时只需要user_id和nickname两个字段而你建了联合索引(user_id, nickname)那么select user_id, nickname from user where user_id 1直接用索引就能返回完全不用碰聚簇索引。这种优化在统计类SQL里效果拔群比如select count(*) from order where status 1如果status有索引count操作直接在二级索引上完成因为二级索引的叶子节点比聚簇索引小很多扫描时IO压力也小不少。3.4 长字段索引与前缀索引索引不是越全越好给很长的字符串列比如varchar(255)的URL、文章正文摘要建索引时整列都放进索引里会让索引变得又大又慢。这时候可以做前缀索引——只取字段的前N个字符建索引比如alter table user add index idx_email (email(20))。前缀索引能大幅减少索引体积提升写入速度。但前缀索引有两个坑。第一order by email没法用前缀索引排序因为索引里存的不是完整值。第二区分度可能不够——如果取的前缀太短大量行的索引值一样无法快速定位反而退化成近似全表扫描。实战中一般取一个能让区分度达到95%以上的前缀长度用select count(distinct left(email, N)) / count(*) from user这种SQL反复试几个N值来定。4. 常用索引类型与组合索引选型实战4.1 从语法到用途主键索引、唯一索引、普通索引、全文索引怎么分MySQL索引从功能上可以分成主键索引、唯一索引、普通索引、全文索引和空间索引。实际开发里碰到的绝大多数场景前四种就够了。下面这张表是我自己的理解面试时答这个面试官基本不会再深挖索引类型特点典型使用场景主键索引聚簇索引一张表只能有一个不能为NULL叶子存整行每张表必须有一般用自增id或雪花id唯一索引索引列的值不能重复允许NULL多个NULL不冲突业务上要求唯一约束的字段如手机号、身份证号、订单号普通索引没有任何限制只是加速查询高频查询的非唯一字段如status、user_id全文索引基于分词匹配InnoDB从5.6开始支持适合大文本搜索文章内容搜索、标题搜索但复杂场景建议Elasticsearch组合索引多个字段一起建的索引遵循最左前缀多条件联合查询的主力索引创建语法也非常简单。主键一般建表时指定普通索引和唯一索引常用alter table或create index来加-- 普通索引 alter table user add index idx_user_id (user_id); -- 唯一索引 alter table user add unique key uk_mobile (mobile); -- 组合索引 alter table user add index idx_user_status_time (user_id, status, create_time); -- 前缀索引 alter table user add index idx_email_prefix (email(20));4.2 组合索引与最左前缀你建的索引可能根本用不上组合索引也叫联合索引、多列索引是面试绕不过去的点。它的底层逻辑是把多个字段按顺序拼成一个复合键来排序。比如(a, b, c)这个组合索引B树里的键排序规则是先按a排a相同按b排b相同按c排。所以这个索引可以支持a、a,b、a,b,c这三种条件的查询但没法支持跳过a直接用b或c的查询。这就是最左前缀原则——查询条件必须从组合索引最左边开始连续匹配索引才会生效。说起来容易实际开发里我看到最多的问题就是组合索引的字段顺序建反了。很多人想当然地“哪个字段最常用就放前面”或者干脆按表结构字段顺序建结果查询时只用到了后面的字段索引根本没被匹配上等于建了个寂寞。选字段顺序的标准后面专门说这里先记住一条组合索引里字段的顺序就是索引查找能覆盖的范围。4.3 一次订单表的索引设计实战用一个业务例子来说明怎么组合。假设有一张用户订单表核心查询模式就三种查某用户最近订单、查某用户某状态下的订单、按订单号精确查询。建表字段大致如下字段名类型说明idbigint主键自增user_idbigint用户IDorder_novarchar(64)订单号业务唯一statustinyint订单状态create_timedatetime创建时间amountdecimal(10,2)金额goods_namevarchar(128)商品名称对应的索引设计应该是-- 1. 主键索引idInnoDB自动建 -- 2. 订单号唯一索引业务上唯一精确查询也很常用 alter table order add unique key uk_order_no (order_no); -- 3. 用户查订单的组合索引user_id create_time 覆盖最高频的查询 alter table order add index idx_user_create (user_id, create_time);第三个索引为什么这样建核心逻辑是按用户过滤后再按时间排序。user_id等值条件可以快速把数据定位到某个用户的所有订单区间然后create_time天然排好序order by create_time desc不用额外排序直接倒序扫描就行。如果再加status过滤建议另建(user_id, status, create_time)但如果status区分度低比如只有5种状态值反而要谨慎——加进组合索引后索引变大扫描性能不一定更好需要实测。4.4 区分度与基数为什么说“性别字段别建索引”索引的区分度cardinality基数是指索引列中不同值的个数。区分度越高索引越有价值。比如user_id有1000万种值区分度就很高status只有3~5种值区分度就很低。对低区分度的字段建索引典型的反面案例是性别。一张千万级表里性别只有“男”“女”“未知”三种值索引B树里的键值重复率极高查询时依然会扫描出几百万行。优化器一算账发现走索引和全表扫描的成本差不多干脆放弃索引直接全表扫描了索引彻底沦为摆设。一句话经验区分度低于20%的字段不要单独建索引。真要优化低区分度字段的查询用组合索引把高区分度字段带上去。5. 索引失效场景排查面试挂掉率最高的8个深坑5.1 违反最左前缀组合索引被“拦腰斩断”前面讲了最左前缀这里必须强调它是索引失效的重灾区。假设组合索引是(a, b, c)下面这些查询都不会好好走索引-- 跳过了a直接查b——索引大概率失效 where b 1 and c 2 -- a用了范围查询b和c就没法用索引定位了 where a 100 and b 1面试时讨论最左前缀我建议用一个实际案例表来说明idx_a_b_c(a, b, c)这种索引的顺序是查询优化器根据SQL的where顺序自动调整的MySQL并不要求SQL里字段书写顺序和索引字段顺序一致——where b 1 and a 2也能走索引。所以背着“字段顺序必须和索引顺序一致”的答案去面试是会被追问打穿的。5.2 范围查询导致右边字段失效组合索引(a, b, c)如果查询条件是where a 1 and b 100 and c 3那么a可以用等值匹配定位b可以用范围扫描但c就没办法用索引定位了只能对b范围扫出来的结果逐行过滤。原理也好理解B树里先按a排序a相同按b排序b相同才轮到c。一旦b是一个范围不是确定值那c在这个范围内不再有统一的排序规则索引自然无法精确定位。这也是为什么组合索引设计时高频的范围查询字段要尽量往右放把等值查询字段往左放。5.3 对索引列使用函数或运算这是工作中最容易踩的隐蔽坑。查询条件对索引列做了计算或套了函数索引直接失效-- 对create_time用了DATE函数索引失效 where DATE(create_time) 2024-01-01 -- 对id做了加0运算索引失效 where id 1 100正确的姿势是改写SQL让索引列独立出现在比较符一侧where create_time 2024-01-01 and create_time 2024-01-02 where id 100 - 1从8.0版本开始MySQL对部分函数做过一定优化比如某些场景下能用函数索引但最稳妥的策略依然是“别对索引列动手脚”。5.4 隐式类型转换字符串列上遇到整数查询这是那种“线上事故级”的坑。表里有个varchar类型的mobile字段你写where mobile 1380013800013800138000是个整数MySQL会把mobile列隐式转换成数值类型再比较等于对索引列用了函数索引自然失效。反过来索引列本身是int类型查询参数传了字符串138MySQL反而能自动转换成整数索引还能用。所以记住一句口诀索引列是字符串参数别传数字索引列是数字参数传字符串通常没事但为了规范和可预测能避免就避免。5.5 like通配符开头前缀查询可以后缀查询不行like abc%这种前缀模糊查询可以走索引因为B树有序性允许你定位到所有以abc开头的键值。但like %abc或like %abc%这种以通配符开头的查询因为不知道从哪里开始匹配只能全索引扫描或全表扫描。实际业务里真要搜后缀方案一般有两个一是用覆盖索引碰碰运气看优化器会不会选二级索引全扫二是形态复杂的话直接上Elasticsearch别硬用MySQL。5.6 or连接非索引条件where user_id 123 or status 1如果user_id有索引、status没索引or两边有一个条件没法走索引MySQL就只能把两个条件都全表扫描合并结果。要解决要么给status也建索引要么把or拆成两个查询再用union all合并。5.7 三个特殊的“负向查询”NOT IN、、is not nullwhere status 1、where id not in (1,2,3)、where name is not null这类负向查询走索引的意义也不大。B树的有序结构天生适合“锁定一段范围”而负向查询相当于“全范围排除一段”优化器算下来还是全表扫更划算。这不是绝对失效但如果你在EXPLAIN里看到负向查询没有走索引别意外多半是优化器觉得全表扫更快。5.8 优化器的“任性”统计信息不准导致索引被放弃最后一种情况最气人——字段有索引、条件也符合但EXPLAIN显示全表扫描。原因往往是优化器基于统计信息预估走索引成本更高或者统计信息过期了。这时候用analyze table 表名重新收集统计信息有时就能解决问题。还要检查一下是不是数据量太小比如表里才几百行优化器觉得全表扫描比走索引找页还快——这是合理行为不是bug。5.9 用EXPLAIN验证别猜直接看执行计划排查索引失效我强烈建议所有人在怀疑索引时第一件事就是把SQL丢进EXPLAIN里看执行计划。重点关注四列列名关注点type从好到差依次是system const eq_ref ref range index ALL见到ALL就是全表扫key实际用到的索引名NULL说明没走任何索引rows预估扫描行数全表扫描的rows往往非常离谱Extra出现Using filesort说明排序没用上索引Using index说明覆盖索引Using temporary说明用了临时表一个经验是如果type是ref或range索引基本是正常生效的如果key为NULL或者type为ALL那就要回头检查上面说的各种失效场景了。6. 面试高频追问与我的避坑经验6.1 一条面试追问链从索引到锁的深度博弈面试官不会只问“索引类型有哪几种”这种填空题他更愿意从一条链路追问你先说加索引能让查询变快——他追问为什么变快你答B树磁盘IO少——他追问为什么不用哈希你答哈希不支持范围查询——他追问那B树叶子节点为什么用链表串联你答范围查询和排序……一环接一环直到挖到你不会为止。还有一个高频追问是“如果一张表频繁delete和update索引会怎样”。答案是索引会膨胀、碎片化查询性能下降。因为聚簇索引的数据页删除后不会立即重用需要定期optimize table来整理表空间重建索引。这个追问在真正做过后台数据清理的同学那里很容易答没做过的同学往往懵。6.2 组合索引字段顺序的“标准答案”面试或者实际设计时组合索引的字段顺序我会按三个原则来排等值查询字段优先放前面因为等值条件在B树中能最高效地锁定范围然后放需要排序的字段让索引直接提供有序性避免filesort范围查询字段放最后因为范围条件会导致右侧字段失效。举个例子查询是where user_id ? order by create_time desc组合索引就建(user_id, create_time)user_id等值定位create_time天然有序a和b一举两得。这比盲目地把“查询里出现的所有where字段”全部塞进索引要聪明得多。6.3 建索引前先问自己三个问题在提出建索引方案前我一般强迫自己回答三个问题这条SQL的执行频率有多高低频统计任务即使慢也不值得为它加索引拖慢写入。查询返回的数据量占全表的比例有多大返回超过20%的数据全表扫描可能比索引更快这是优化器的真实算盘。索引列的区分度够不够性别这种区分度只有3的列建了也白建。6.4 几条实战里长期有用的避坑心得最后分享几个只有真正做线上维护才会积攒下来的心得第一冗余索引是隐形杀手。(a, b)和(a)这两个索引前者其实已经覆盖了后者的功能后者就是冗余的。每次写入要同时维护多个索引写入性能白白受损。定期用show index from 表名检查把能被组合索引覆盖的单独索引删掉。第二SELECT返回的列越少越好。select *会让索引覆盖变得极难实现几乎必然回表。还是那个订单表的例子如果查询只需要order_no和status而组合索引(user_id, status, create_time)里没有order_no就不得不回表。改成明确列出需要的列索引覆盖的机会大增。**第三count()比count(字段)更推荐。**你可能听过“count()性能差”这种说法但那是MyISAM时代的老黄历。在InnoDB里count(*)有专门的优化路径能在没有where条件时直接扫描最小的索引树统计行数count(字段)反而要判断字段是否为NULL开销更大。**第四慢日志和EXPLAIN要配合用。**线上慢SQL出来后别急着加索引。先用EXPLAIN看执行计划如果typeALL再加索引如果typeref但rows很大检查是否需要回表、能否改覆盖索引。很多时候慢的根源不在于没索引而在于索引没建对。MySQL索引这个知识体系越往下挖越有意思也越能感受到底层设计的一环扣一环。从B树的磁盘IO优化到聚簇索引的物理存储再到最左前缀和覆盖索引的实战权衡每一步都是围绕“减少磁盘IO”和“避免无谓的数据读取”展开的。把这些底层逻辑吃透了面试题会答线上问题的排查思路也会清晰很多。
返回列表