多彩编程 多彩编程MZPH · CODE BLOG
ARTICLE DETAIL

文章详情

深耕前端与后端开发技术的一线实战笔记与踩坑复盘。

MySQL索引优化实战:从B+树原理到联合索引与EXPLAIN调优

MySQL索引优化实战:从B+树原理到联合索引与EXPLAIN调优 手头数据库越来越慢翻遍日志发现上百条慢查询第一反应是加内存、扩CPU、换SSD但我做了这么多年MySQL性能调优最想先动手的永远是索引。MySQL是后端开发最常用的关系型数据库线上问题里有相当大的比例都能追溯到索引设计不合理而索引优化恰恰是投入产出比最高的手段改一条SQL可能只省几十毫秒但一个合理的联合索引能救活整个模块。这篇内容从索引的底层原理讲起逐步过渡到索引类型怎么选、联合索引怎么搭、EXPLAIN怎么读、索引失效怎么避坑最后聊聊索引和排序、锁、事务之间的联动。覆盖了MySQL创建索引、mysql性能调优、mysql锁的分类这些常被搜索的高频话题。适合所有跟MySQL打交道的开发者和运维同学不管你是刚入门想补基础还是工作几年想查漏补缺这里面都有可以直接抄作业的内容。1. 先搞懂索引为什么能提效全表扫描的账算给你看1.1 一张千万行表的全表扫描成本我见过不少项目表刚上线时只有几万行性能一点问题没有等数据涨到几百万行突然就崩了。这不是程序写得不对而是MySQL的查询方式被迫发生了转变。没有索引时InnoDB只能做全表扫描——把聚簇索引的叶子节点从头到尾读一遍每一行都过一遍WHERE条件命中的留下不命中的丢弃。光讲概念不好理解我替大家算一笔账。假设订单表order_info有800万行单行数据不算大按1KB估算主键索引的叶子节点总共就是8GB左右。InnoDB默认页大小是16KB那就是51万多个数据页。老机械硬盘顺序读算150MB/s全表扫一遍要55秒就算换成企业级SSD1GB/s的顺序读也要8秒。更要命的是你业务高峰期不止一个查询每个都这么读磁盘IO和Buffer Pool都会被拖垮。索引存在的意义就八个字减少扫描的数据量。它把“我不知道数据在哪只能全翻一遍”变成“我知道数据大致在哪直接去那一小片区域取”。查询优化的第一原则永远是先想怎么减少扫描行数索引是最直接、性价比最高的实现方式。1.2 为什么偏偏是B树而不是B树或哈希很多人一看B树就头疼其实只需要抓住它的三个关键特性矮、叶子节点才存数据、叶子节点之间有序相连。矮意味着层数少层数少意味着查询只需少数几次磁盘IO。B树的非叶子节点只存键值和子节点指针不存整行数据所以一个16KB的页能塞下非常多的键值。按主键BIGINT8字节指针6字节算一个页约能装1170个键值。两层就是1170x1170约137万三层就是16亿。也就是说一张上亿行的表普通查询最多三次磁盘IO就能定位到叶子节点。这是几乎所有数据库都选B树的核心原因。B树和B树最大的差异在于数据存放位置。B树几乎每个节点都存数据树很容易变胖变高B树把数据全部收敛到叶子层内部节点只做索引。这直接降低了树高同时带来一个额外福利叶子节点之间用双向链表连接范围查询顺着链表一路读就行不用回根节点重新走。数据库里最频繁的等值查询和范围查询B树一张结构全照顾到了。哈希索引适合单点等值查询确实能做到O(1)但完全不支持范围查询也没有顺序性。你可以把哈希索引想象成一本只有目录、没有页码的书找某个词很快但想“从第100页开始顺序看到第200页”就抓瞎了。所以InnoDB的索引主结构始终是B树哈希只以自适应哈希索引的形式在极少数的辅助场景出现。提示InnoDB页大小默认是16KB从5.7到8.0都没变很多参数调优都是围绕这个“页”做文章。理解页才是理解索引的起点。2. 索引类型选型主键、唯一、普通、全文索引怎么取舍2.1 四种索引一张表说清楚建索引之前很多人从来没认真想过该建什么类型的索引。先放个对照表。索引类型是否允许重复是否允许NULL典型场景注意点主键索引不允许不允许每张表唯一InnoDB聚簇索引的载体尽量用自增整数避免随机值导致页分裂唯一索引不允许允许业务上有唯一性要求的字段如手机号、订单号与主键不同它可以建多张辅助索引普通索引允许允许高频查询条件、排序字段、关联字段最常见的索引类型注意不要滥用全文索引只关注分词允许大文本内容的模糊检索中文分词需要插件支持别当like用主键索引在InnoDB里不是“一个索引”而是表本身。因为InnoDB是聚簇索引组织表表数据就存在主键索引的叶子节点上。这就是为什么InnoDB要求每张表必须有主键——如果没有显式主键它会悄悄找一个非空的唯一列当主键再找不到就生成一个隐藏的ROW_ID。这个隐藏主键是全局自增的在高并发下会影响写入性能但用户完全感知不到。所以建表时老老实实给一个自增BIGINT主键是最省心的习惯。2.2 聚簇索引与二级索引的回表代价很多新手拿到“回表”这个词很蒙其实一句话就能解释聚簇索引的叶子节点是整行数据二级索引的叶子节点只存索引列的值和主键值。当你通过二级索引找到主键还要再用主键去聚簇索引里查一次完整行这个动作就叫回表。打个比方聚簇索引像一本正文排版完整的书二级索引像书末的“关键词-页码”索引表。你查“索引优化”这个词先在索引表里找到页码再翻到对应页读正文——翻页这一下就是回表。如果正文里那些页恰好把你要的内容都附在索引表里了那就连正文都不用翻这就是前面提过的覆盖索引后面细讲。回表本身不是洪水猛兽但量大了就是灾难。假设WHERE条件命中5万行二级索引扫描很快但每条都要回表查一次聚簇索引5万次随机IO可能比全表扫描还慢。优化器很聪明它如果发现二级索引过滤出的行数占比太高会干脆放弃索引选择全表扫描。行数占比的阈值并没有固定值跟表大小、统计信息、Buffer Pool命中率都有关系唯一确定的是回表次数越少越好。3. 索引设计实战列怎么选联合索引怎么搭3.1 区分度是索引列的第一筛选标准没有实践经验的人建索引容易犯一个毛病看到WHERE后面跟了什么字段就单独给什么字段建索引。结果索引建了一堆慢查询一个没少。判断一个列适不适合做索引核心指标是区分度。区分度 该列不同值的数量 / 表总行数。区分度越接近1索引选择性越好。你可以用一条SQL算出来SELECT COUNT(DISTINCT column_name) / COUNT(*) AS selectivity FROM table_name;比如性别列只有两个值区分度是2/100万无限趋近于0建索引几乎没有任何效果——因为无论查哪个性别都要扫掉一半的行优化器大概率还是全表扫描。而订单号、手机号这类高区分度字段单独建索引就能直接命中极少数行。日志表里常有的status字段状态只有三五种但业务里90%的查询都按status过滤这时候要不要建索引可以建但真正的优化是再组合一个高区分度字段做联合索引而不是单建status索引。我见过一张用户表同时有7个单列索引全部独立SQL里却经常两三个条件联合过滤。MySQL 8.0之前没有索引合并优化时这种设计就是一个坑——优化器只能勉强选一个走其余条件全部回表过滤。8.0里虽然Index Merge能勉强救一下但效果远不如一个联合索引。3.2 联合索引的最左前缀法则联合索引是索引优化里最值钱的部分也是最容易出问题的地方。它的底层不是“多列各建一个索引”而是把所有索引列拼成一个复合键按从左到右的顺序排序存储。所以查询必须从头匹配跳过第一列直接用第二列索引就用不上。这就是最左前缀法则。比如联合索引(a, b, c)能生效的查询条件组合有a、ab、abc、ab或ac命中a和c但b的过滤条件无法走索引只能回表过滤而单独的b、单独的bc、单独的ac如果没带a都无法走完整索引。实操上最重要的原则是把最常用、区分度最高的列放最左。假设订单查询通常带上user_id、order_status、create_time那联合索引就应该从user_id开始构建因为这是每次查询都带的条件。如果你把create_time放最左但很多查询压根不筛时间这个索引就废在第一个字段上了。3.3 覆盖索引查询的免费午餐覆盖索引的意思是要查的所有列都在索引里不需要回表。这是所有索引优化里最想达到的理想状态。最常见的优化手法就是“索引列SELECT列”一起进联合索引。比如高频SQL是查订单的金额和状态SELECT order_amount, order_status FROM order_info WHERE user_id 123;如果你只建了user_id单列索引查询流程是扫二级索引找到user_id123的所有主键然后每条回表查order_amount和order_status。但如果建联合索引(user_id, order_amount, order_status)二级索引的叶子节点上已经包含这三个字段查询直接扫描索引返回一次回表都不用。实践中覆盖索引不是万能的。索引列越多写入成本和存储空间越大尤其是VARCHAR、TEXT这类字段尽量别往里塞。黄金法则是覆盖高频查询、少覆盖低频查询、坚决不覆盖超大字段。注意MySQL 8.0开始索引列支持隐藏功能和降序索引。降序索引如ORDER BY a DESC, b ASC能直接避免文件排序但要注意它并不是免费的写入性能会有一定牺牲。4. 用EXPLAIN让执行计划说实话四张表抄作业4.1 关键字段逐个拆解建完索引别急着走用EXPLAIN验证是最基本动作。EXPLAIN不会真正执行SQL只是让优化器输出一份执行计划。关键字段就几个字段含义重点关注type访问类型从高到低system const eq_ref ref range index ALLkey实际用到的索引NULL意味着没用到索引rows预估扫描行数越小越好代表优化器认为需要看多少行Extra附加信息重点关注Using filesort、Using temporary、Using indextype是最直观的体检报告。const和eq_ref说明MySQL定位到唯一一行是最优的ref和range说明走索引做了范围扫描正常水平index看着像用了索引实际上是全索引扫描通常也比全表强不了多少ALL就是全表扫描该立刻优化。我见过不少人只看key字段非NULL就认为索引生效了这是最大误区。key有值不代表“好用”如果type是ALL或者rows特别大这个索引即使被采用效率也不一定高。Extra里的Using filesort尤其要警惕——这意味着排序没走索引MySQL要在内存或磁盘里临时排一遍。大结果集的filesort会直接吃掉大量CPU和临时空间。4.2 两个真实案例改造第一个案例订单列表页按用户查最近订单SQL是SELECT * FROM order_info WHERE user_id 100 ORDER BY create_time DESC LIMIT 20;改造前的执行计划是typerefkeyidx_user_idrows84200ExtraUsing filesort。用户订单多排序丢给了文件排序页面上每次都等很久。改造方案是建联合索引(user_id, create_time)。第一个字段user_id支撑WHERE过滤第二个字段create_time支撑ORDER BY排序。改造后Extra变成空type仍然是ref但rows下降到几十行offset的排序也直接在索引上完成。这个改动就是普通索引变成联合索引连SQL都不用改。第二个案例分页很深时比如LIMIT 99990, 10MySQL会先读出前10万条再丢掉前99990条只回最后10条。这是全表扫描文件排序的典型组合。优化思路是延迟关联先查出目标主键再原表关联取完整行。SELECT t.* FROM order_info t INNER JOIN (SELECT id FROM order_info WHERE user_id 100 ORDER BY create_time DESC LIMIT 99990, 10) tmp ON t.id tmp.id;子查询里只扫二级索引列不用回表拿大字段MySQL能走覆盖索引完成排序过滤极大减少回表量。这个技巧在做翻页场景时实测能把原本1.8s的查询压到0.2s以内效果相当震撼。5. 索引失效高发场景七个坑一次避开5.1 哪些写法会让索引白建索引失效是最头疼的问题坏就坏在SQL看起来很正常执行计划却告诉你索引没走。我整理了七个高频场景。对索引列做函数操作。WHERE YEAR(create_time) 2024 或者 WHERE DATE(create_time) 2024-01-01优化器无法直接用索引定位区间因为它要先对每一行算函数结果。改成范围条件 create_time 2024-01-01 AND create_time 2024-01-02 立刻就能走索引。隐式类型转换。索引列是VARCHAR查询条件写成WHERE phone 13812345678不带引号MySQL要把phone字段转成数字去比较索引就废了。规则是字段是什么类型条件就写什么类型别让数据库做隐式转换。LIKE前置通配符。WHERE name LIKE %张% 无法用索引因为B树是根据前缀排序的MySQL不知道%前的锚点在哪。解决思路能改成范围就改范围改不了就上全文索引或者搜索引擎。联合索引跳列。前面说过(a,b,c)索引查询条件是a和c中间的b没带只能走a的索引c的过滤被迫回表。负向查询。WHERE status ! 1 或 WHERE status NOT IN (1,2)。B树索引最适合等值匹配负向条件无法定位区间通常只能全索引扫描。OR连接条件。WHERE a 1 OR b 2如果a和b各建单列索引MySQL 8.0之前不会做索引合并。优化方向是用UNION ALL拆开或者改成IN。对索引列做运算。WHERE age 1 30把列放在表达式里索引就用不上。运算应该在等号另一端完成。5.2 索引下推ICP被低估的8.0优化MySQL 5.6以后有个特性叫索引条件下推英文Index Condition Pushdown。它说的是当你使用联合索引查询二级索引的叶子节点上有多个字段MySQL可以把WHERE里的部分过滤条件下推到存储引擎层在索引扫描阶段就过滤掉不符合条件的记录减少回表次数。举个例子联合索引(name, age)查询WHERE name LIKE 张% AND age 28。没有ICP时MySQL通过name前缀把命中的主键全部捞出来再一条条回表然后过滤age。有ICP时age28的判断直接在二级索引扫描阶段完成只有同时满足name前缀和age条件的行才回表。这个特性默认开启你可以在EXPLAIN的Extra里看到Using index condition字样。但要注意ICP只能帮你在索引内部过滤不是真正的覆盖索引。最彻底的办法依然是让所有过滤列都进索引。6. 排序、Limit与索引的三方协作6.1 filesort到底有多贵ORDER BY没有走索引时MySQL会把结果集放进排序缓冲区sort_buffer排完再返回。结果集小还好一旦超出sort_buffer_size就会在磁盘上创建临时文件做归并排序。这些操作全是CPU和磁盘开销还阻塞整个查询。排序走索引是有条件的。最简单的是ORDER BY和WHERE条件共用同一个联合索引并且顺序一致。前面举过(user_id, create_time)的例子就是让排序字段紧跟过滤字段进入联合索引。另外MySQL 8.0支持降序索引ORDER BY create_time DESC也能直接反向扫描索引完成。排序还有一个坑是ORDER BY字段顺序必须和索引列顺序完全一致只要顺序颠倒优化器就弃用索引。工作里我把这种问题统称为“索引顺序强迫症”设计联合索引时必须把排序字段的升降序也考虑进去。6.2 LIMIT深分页的黄金解法LIMIT 100000, 20 这种深度分页是很多后台管理系统的噩梦。问题核心在于MySQL必须扫描并丢弃前10万行而不是直接从第100001行开始读。B树没有“跳到第N行”的接口只能顺着链表一路读。最实用的两个方案第一个是延迟关联上一节已经演示过本质是让子查询只扫索引列、利用覆盖索引定位主键再回原表关联取完整行。第二个是游标分页业务上把“上一页最后一条记录的排序字段值”传进来用WHERE create_time 上一页最后时间 ORDER BY create_time DESC LIMIT 20 来取下一页。数据量越大游标分页的优势越明显但需要业务改造有些团队接受不了这种改动。我自己做管理后台的时候前10页用传统分页没压力但用户点“跳转到第5000页”的场景直接给延迟关联方案基本能保证100ms级别返回。7. 索引与锁、事务的高级联动7.1 MySQL锁的分类与加锁范围MySQL锁的分类是个高频面试题也是优化时的隐蔽陷阱。简单分三类。按粒度表级锁MyISAM、行级锁InnoDB、页级锁。按类型共享锁S、排他锁X。按算法记录锁Record Lock、间隙锁Gap Lock、临键锁Next-Key Lock。InnoDB默认在REPEATABLE READ隔离级别下使用临键锁即“记录锁间隙锁”的组合。它锁的不仅是被命中的记录还包含记录之前的一段“间隙”。这个设计解决了幻读问题但也带来一个副作用范围条件越大锁住的间隙越多并发度越低。高并发写入场景最怕的就是“锁范围失控”。比如WHERE收到一堆等于某个user_id的UPDATE如果user_id没有索引InnoDB为了安全会加上锁甚至锁掉全表间隙把其他用户的写入全部卡住。所以更新语句的WHERE条件永远是重点检查对象确认它走了索引锁的范围才会收敛。7.2 二级索引更新时的锁顺序问题这条很多人没注意过但实际踩坑的人不少。当UPDATE通过二级索引定位记录时InnoDB的加锁顺序是先锁二级索引项再回表锁主键记录。与此同时另一条通过不同二级索引更新的并发事务可能也按自己的顺序去锁二级索引项和主键。两个事务如果各自的加锁顺序交叉比如事务A先锁索引1再锁主键事务B先锁主键再锁索引1就会形成死锁。死锁的直接报错是“Deadlock found when trying to get lock”很常见。规避思路有几个。第一尽量减少一条事务里对多条记录的UPDATE大批量更新拆成小批次。第二让所有更新语句都按照主键顺序访问行比如WHERE id IN (...) 里排好序加锁顺序就从根上统一了。第三监控死锁日志show engine innodb status看锁等待链定位到底哪个索引的加锁顺序和别人冲突。提示用事务处理大批量数据时我习惯把一条大的UPDATE拆成每批500条的小事务。既避免长事务锁持太久也降低死锁概率。长事务不仅锁多还会拖大Undo日志影响MVCC快照读。8. 慢查询监控与整体调优流程8.1 打开慢查询日志让问题自己跳出来所有的索引优化第一步都应该先做监控而不是凭感觉建索引。MySQL自带慢查询日志配置一下就能把所有执行时间超过阈值的SQL记录到文件里。-- 查看当前设置 SHOW VARIABLES LIKE slow_query_log%; SHOW VARIABLES LIKE long_query_time; -- 动态开启重启MySQL前有效 SET GLOBAL slow_query_log ON; SET GLOBAL long_query_time 1; SET GLOBAL log_queries_not_using_indexes ON;long_query_time建议从1秒起步。千万别直接设成0不然日志会爆炸全是些几十毫秒的正常查询。开启log_queries_not_using_indexes后即使查询不慢只要没走索引也会进日志这对发现扫全表的老代码特别有用。拿到慢查询日志后用mysqldumpslow分析TOP SQL也可以把日志文件交给pt-query-digest这类工具做聚合分析。核心就是找执行次数多、单次耗时长、总耗时占比高的三类SQL。这三个指标分别指向不同类型的问题执行次数多说明业务逻辑高频依赖它单次耗时长说明当前执行计划有问题总耗时长说明它在服务器上造成了真正的压力。8.2 整套优化流程的固定步骤做多了以后我的索引优化流程就固定在六个步骤。第一步收集慢日志按次数、耗时、总耗时排序。第二步对每条TOP SQL执行EXPLAIN记录type、rows、Extra。第三步分析WHERE、ORDER BY、JOIN字段列出候选索引列。第四步用区分度SQL过滤掉低区分度字段组合成联合索引。第五步EXPLAIN验证看type是否提升、rows是否下降、Extra还有没有filesort。第六步观察一段时间慢日志确认TOP SQL没有重复出现。这个流程看起来简单但每一步都有一个隐藏难点统计信息可能是旧的。MySQL索引优化器依赖表的统计信息做决策如果统计信息长时间没更新执行计划会非常离谱。跑业务高峰期前可以执行ANALYZE TABLE 让统计信息新鲜一点再回归验证执行计划。8.3 索引优化的三个“别做”最后分享几条经验级的避坑这些不是技术原理是我在线上踩出来的。别过度建索引。一张表十几个索引写入性能全被拖垮。二级索引每多一个INSERT和UPDATE都要同步维护一颗B树高并发写入下代价非常明显。我见过最夸张的一张表23个索引结果写入TPS只有改造前的三分之一。别删索引太猛。删索引之前一定要看历史慢日志和监控系统确认这个索引确实没有高频查询依赖。有些索引是给后台报表用的白天流量低看不出来一删报表就跑不动。别忽略复合场景。查询优化别只盯着单列WHERE、ORDER BY、GROUP BY、JOIN的多个字段往往可以合并进同一个联合索引。我的经验是先合并后考虑拆分别一上来就每个字段建一个单列索引。我心里一直有一条个人准则每次看到全表扫描的执行计划都当成一次“免费的检查机会”——要么是业务查询方式有问题要么是表数据量超过了当初的设计预期。处理慢查询时不急着加索引先花两分钟看索引设计、统计信息和数据增长趋势往往能发现更本质的问题。索引不是越多越厉害而是每一棵索引树都必须有它存在的理由这条经验希望你能带走。
返回列表