ARTICLE DETAIL

资讯详情

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

MySQL索引下推实战:让联合索引慢查询提升3倍

MySQL索引下推实战:让联合索引慢查询提升3倍 MySQL慢查询优化做了几年最常被问到的问题就是明明建了联合索引EXPLAIN里也显示走了这个索引可查询还是慢得离谱。排查到最后执行计划里那一行Extra往往就是破局关键——如果看到的是Using where说明MySQL 5.6引入的索引下推Index Condition PushdownICP根本没有生效如果显示Using index condition说明过滤条件已经被推到了存储引擎层提前处理回表次数大幅减少。索引下推这个词在面试题里出现频率很高可真要顺着执行计划把它讲清楚、用明白的人并不多。这篇文章我想用一次完整的慢查询排查经历把索引下推的原理、适用边界、验证方法和常见误区一次性讲透适合正在搞MySQL性能调优、准备面试或者被莫名其妙慢查询困扰的各位。1. 先搞清楚索引下推到底在解决什么问题1.1 一次普通查询背后发生了什么先从一个最常见的场景说起。假设有一张电商订单表联合索引建在(user_id, status)上CREATE TABLE order_info ( id BIGINT NOT NULL AUTO_INCREMENT, order_no VARCHAR(32) DEFAULT NULL, user_id INT NOT NULL, status TINYINT NOT NULL DEFAULT 0, pay_amount DECIMAL(10,2) DEFAULT NULL, create_time DATETIME DEFAULT NULL, PRIMARY KEY (id), KEY idx_user_status (user_id,status) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;现在执行这样一个查询SELECT * FROM order_info WHERE user_id 3000 AND status 1;按直觉想索引idx_user_status包含user_id和status两个字段这条SQL应该能高效执行。但关键点在于user_id 3000是一个范围条件。MySQL利用联合索引定位时只能确定扫描的起始位置在user_id3000那条索引记录附近然后一路往后扫。至于status 1这个条件由于联合索引中status排在user_id之后它没法继续参与缩小扫描范围。也就是说存储引擎必须从user_id 3000开始一直把整个二级索引叶子节点扫到末尾把所有user_id大于3000的索引记录全部翻出来。在没有索引下推的MySQL 5.6之前存储引擎层会沿着索引一路扫描把每一条满足user_id 3000的索引记录都回表取一遍完整行数据然后把整行抛给Server层由Server层再去判断status是否等于1。这里最大的浪费在于很多status ! 1的行本来在索引里就能看出来却依然被白白回表了一次完整行也白白在Server层和存储引擎层之间来回传了一遍。打一个生活化的比方。你要去一栋写字楼里找所有在10层以上工作、并且工牌是红色的人。没有ICP之前的做法是楼管先把10层以上所有员工全部叫到一楼大厅然后你自己在人群里挨个挑出红工牌的人。而ICP的做法是楼管在楼上巡楼的时候看到红工牌就直接领下楼不是红工牌的压根不用下楼效率差别一目了然。1.2 回表为什么是慢查询的隐形杀手所谓回表是指InnoDB中二级索引的叶子节点只存储索引列和主键值不能直接拿到完整记录必须用主键去聚簇索引里再查一次。聚簇索引的叶子节点才保存整行数据。这里再补充一个知识点InnoDB的表本身就是按聚簇索引组织的主键索引就是聚簇索引而其他索引都是二级索引。回表意味着一次随机IO而且每回表一次InnoDB通常要在聚簇索引的B树上做一次主键查找。当二级索引筛选出来的记录条数很多时回表次数会很夸张随机IO带来的延迟会直接反映在查询耗时上。这也是很多慢查询明明走了索引却比全表扫描还慢的根本原因——大量回表造成的随机IO开销已经超过了扫描全表顺序读的开销。在实际做性能分析时我习惯用performance_schema或慢日志里的Rows_examined与Rows_sent的比值来判断问题。如果扫描了几万行最后只返回几十行那就意味着绝大部分数据在读取之后被丢弃了。这种查询即使走了索引也会因为海量回表和无效数据传输而变慢。而索引下推要改变的恰恰就是这条链路中“逐条回表再做二次过滤”的顺序。1.3 索引下推的核心思路索引下推的核心思路非常朴素把WHERE条件中、能够使用索引列来判断的那部分过滤条件从Server层“下推”到存储引擎层让存储引擎在扫描索引记录、回表之前就先把不合格的记录过滤掉。用上面的例子来说status 1这个条件虽然不能继续缩小user_id 3000的扫描范围但status字段本身就在二级索引idx_user_status里。存储引擎在扫描索引记录时完全可以直接读取索引项里的status列先判断是否等于1等于了才回表不等于就跳过。这就是“下推”的含义过滤动作从Server层下沉到了存储引擎层。这样一来收益体现在两个地方回表次数从“满足user_id 3000的所有记录数”降为“同时满足user_id 3000且status 1的记录数”Server层也从被动接收大量无用的完整行变成接收已经被过滤得差不多的结果集减少了数据传输和执行器二次过滤的开销。这个优化有一个硬性前提WHERE条件里用于过滤的列必须全部在这个联合索引里。如果索引里只有user_idstatus不在索引中那存储引擎在索引扫描时根本拿不到status的值ICP自然无从谈起。2. 索引下推的工作原理与执行流程2.1 一条SQL在MySQL内部的执行路径要理解索引下推得先知道一条SQL在MySQL内部是怎么流转的。MySQL逻辑上分成两层Server层和存储引擎层。Server层负责连接管理、语法解析、优化器、缓存、执行器等存储引擎层负责实际的物理数据读取比如InnoDB的B树扫描、事务控制、行锁管理。普通二级索引查询在没有ICP时的执行流程大概是这样的优化器决定使用二级索引idx_user_status根据user_id 3000定位到索引扫描的起始记录存储引擎沿着B树叶子节点的双向链表向后扫描每扫到一条索引记录就取出里面的主键id用这个主键id去聚簇索引中回表读取完整行把完整行返回给Server层执行器Server层拿到行数据后继续判断status 1符合条件才放入结果集重复第2步直到扫描完整个范围或到达索引末尾。开启ICP之后第3到5步会发生变化优化器决定使用二级索引定位到扫描起始记录存储引擎扫描索引记录同时直接读取索引项中的status列在存储引擎内部判断status 1不满足的直接跳过不回表满足条件的才用主键id回表读取完整行把满足条件的完整行返回给Server层Server层不再重复过滤status条件或只做极少数边界情况校验。逻辑上就是把原来Server层的WHERE过滤拆成两部分索引列能判断的部分放到存储引擎提前做剩余部分Server层继续做。这也是为什么ICP在官方文档里被叫做“Index Condition Pushdown”把索引条件真正压到了离数据更近的地方。2.2 ICP能生效的条件清单不是所有条件下推都能成功。我在排坑过程中总结了一下能生效需要同时满足这些条件MySQL版本在5.6及以上且优化器开关里index_condition_pushdown为on默认就是开启状态访问类型必须是二级索引的ref、range、eq_ref、index等全表扫描没有索引可推WHERE条件里要过滤的列必须存在于当前使用的索引中条件不能包含子查询不能被包在存储函数或非确定性函数里比如NOW()、RAND()这类不能用不能有隐式类型转换导致索引列被函数化。比如把字符串字段和数字比较MySQL会对字段做隐式CAST这种情况下ICP无法安全下推主键索引访问场景下ICP没有意义因为主键索引就是聚簇索引叶子节点直接有完整行不存在回表问题。最后一句话需要展开解释一下。很多初学者以为ICP是“所有索引都能用”其实它主要针对二级索引。聚簇索引的叶子节点本身就保存了整行数据存储引擎读出记录的同时就已经拿到了所有列不需要额外回表自然也不需要把过滤条件下推来减少回表。真正需要ICP挽救的恰恰是二级索引查询时那一次次痛苦的回表。2.3 EXPLAIN里如何识别ICP判断一条查询是否真正用了索引下推最直接的方法就是看EXPLAIN里的Extra列出现Using index condition说明ICP生效出现Using where说明条件是在Server层过滤的ICP没有生效或者该查询本来就不适用ICP。这里有个容易混淆的点要提醒一下Extra里如果同时出现Using index说明用的是覆盖索引压根不需要回表那么自然没有“下推过滤减少回表”的必要这是一种比ICP更高效的状态。另外如果你看到一个查询的Extra是Using index condition但type是index说明优化器选择了全索引扫描同时利用ICP对索引里的列做了过滤避免了大量回表。这种情况常见于过滤条件里的列都在索引中但查询需要select的列不在索引里无法彻底走覆盖索引。我在线上排查时有个习惯看到慢查询第一件事先跑EXPLAIN第二件事看type是否在ref/range级别以上第三件事盯住Extra里有没有Using index condition。如果只有Using where那么用户感知到的“走了索引但还是慢”基本就和回表过多直接相关接下来就该检查条件列是否真的存在于索引中以及索引设计是否符合查询模式。3. 实操验证从建表到对比EXPLAIN3.1 准备测试数据为了直观感受索引下推的效果我用MySQL 5.7.44实例做演示。表结构就用上面的order_info然后快速造一批数据。我用存储过程插入30万行让user_id分布在1到5000status在1到5之间随机这样user_id 3000的记录大概有12万条而status 1的行数约占五分之一。DROP PROCEDURE IF EXISTS insert_order_data; DELIMITER $$ CREATE PROCEDURE insert_order_data() BEGIN DECLARE i INT DEFAULT 1; WHILE i 300000 DO INSERT INTO order_info(order_no, user_id, status, pay_amount, create_time) VALUES ( CONCAT(SO, LPAD(i, 8, 0)), FLOOR(1 RAND() * 5000), FLOOR(1 RAND() * 5), ROUND(RAND() * 1000, 2), NOW() - INTERVAL FLOOR(RAND() * 365) DAY ); SET i i 1; END WHILE; END$$ DELIMITER ; CALL insert_order_data();数据造好之后先看一下查询目标SELECT COUNT(*) FROM order_info WHERE user_id 3000 AND status 1;我这边的结果是大概2.4万行。如果不开ICP理论上要回表12万次开了ICP后大约只需要回表2.4万次差距非常直观。3.2 对比开关前后的EXPLAIN输出先跑一次开启状态下的EXPLAINEXPLAIN SELECT * FROM order_info WHERE user_id 3000 AND status 1\G结果大概是这样id: 1 select_type: SIMPLE table: order_info type: range possible_keys: idx_user_status key: idx_user_status key_len: 4 ref: NULL rows: 120000 filtered: 20.00 Extra: Using index condition注意几个关键点type为rangekey为idx_user_statusExtra是Using index condition说明ICP已经生效。key_len是4也就是只用到了user_id这个int字段来界定扫描范围status列没有参与范围界定但被用来做索引条件下推了。filtered显示20.00%意思是经过存储引擎层下推过滤后大概还有20%的记录会继续往上交。然后关闭ICP再看看对比SET SESSION optimizer_switch index_condition_pushdownoff; EXPLAIN SELECT * FROM order_info WHERE user_id 3000 AND status 1\G结果变成id: 1 select_type: SIMPLE table: order_info type: range possible_keys: idx_user_status key: idx_user_status key_len: 4 ref: NULL rows: 120000 filtered: 20.00 Extra: Using where这里就有意思了索引还是那个索引范围也是同一个范围但Extra从Using index condition变成了Using where。也就是说status 1这个条件没有被下推存储引擎会把所有user_id 3000的记录全部回表再交给Server层判断。看完之后记得把开关恢复SET SESSION optimizer_switch index_condition_pushdownon;3.3 性能数据实测EXPLAIN只能说明执行计划想验证真实收益还得看运行指标。我先用状态变量来观察存储引擎的工作量FLUSH STATUS; SELECT COUNT(*) FROM order_info WHERE user_id 3000 AND status 1; SHOW STATUS LIKE Handler_read%;Handler_read_next表示按索引顺序扫描时读取下一行的次数。关闭ICP时这个值接近12万开启ICP后因为存储引擎在回表前过滤掉了大量status ! 1的记录Handler_read_next依然要扫描完整个索引范围因为范围扫描必须走到头但回表次数只能从另外的指标观察。更直观的是用profiling直接看耗时SET profiling 1; SELECT COUNT(*) FROM order_info WHERE user_id 3000 AND status 1; SHOW PROFILES;我实测的结果在30万行数据、普通机械硬盘压力较大的机器上关闭ICP时这条查询耗时约0.9秒开启ICP后耗时约0.3秒。如果在SSD上差距会缩小一些但趋势非常稳定数据量越大、索引范围越大、过滤后剩余比例越低ICP带来的收益越明显。此外还可以借助performance_schema看rows_examinedSELECT SCHEMA_NAME, DIGEST_TEXT, ROWS_EXAMINED, ROWS_SENT, TIMER_WAIT FROM performance_schema.events_statements_summary_by_digest WHERE DIGEST_TEXT LIKE %order_info%;开启ICP后ROWS_EXAMINED会明显下降而ROWS_SENT不变这正好印证了ICP减少了无效行读取和回表。4. 索引下推的边界与限制4.1 哪些场景下ICP派不上用场索引下推不是万能的理解它的边界比理解它的原理更能避免踩坑。我整理了几类常见的不适用场景第一主键索引查询。前面说过主键索引是聚簇索引叶子节点直接带着完整行没有回表问题ICP无优化空间。第二覆盖索引已经生效的情况。如果查询的所有列都在索引里Extra显示Using index这时候根本没发生回表ICP的意义就不大覆盖索引是更优解。第三条件列不在索引中。比如索引只有(user_id, status)但查询里还加了pay_amount 100pay_amount不在索引里ICP拿不到这个列的值自然无法下推。第四函数包裹和隐式类型转换。例如WHERE DATE(create_time) 2024-01-01或者字符串字段与数字比较索引列被函数化之后ICP无法判断原始值是否满足条件。第五OR条件跨索引或跨列。如果OR的两端条件不属于同一个索引覆盖范围优化器可能直接放弃索引更谈不上ICP。另外分区表在5.6里对ICP的支持有限5.7以后逐步完善。如果你还在用老版本MySQL而且上了分区表建议升级前多验证一下执行计划。4.2 ICP、覆盖索引与MRR怎么配合这三个特性经常被放在一起比较但它们解决的是链路不同阶段的性能问题。覆盖索引是把查询需要的列全部放进索引连回表都省了效果最彻底但代价是索引体积变大、写入变慢。ICP是在无法覆盖索引的情况下把回表前的过滤提前做掉减少回表次数。MRRMulti-Range Read解决的则是另一个问题回表时主键不连续随机IO很多MRR先把二级索引查出来的主键缓冲排序再按主键顺序回表把随机IO变成顺序IO。实操中这三个特性可以组合使用。ICP先减少回表数量MRR再降低单次回表的成本。比如一条SQL扫出来1万个需要回表的主键如果不用MRR这1万次IO是乱序的用了MRR主键排好序后回表更像是顺序扫描性能提升也很明显。MySQL 5.6把这几个特性一起引入不是偶然它们共同构成了二级索引查询优化的组合拳。需要强调的是ICP并不改变扫描范围或排序结果它只负责“在回表之前多过滤几行”。所以如果你看到查询里还有filesort别指望ICP能把排序省掉那需要靠索引的天然有序性解决。4.3 ICP对索引设计思路的影响关于联合索引很多DBA的传统认知是“最左前缀原则下范围条件后面的列基本废了”。比如联合索引(a, b)查询where a 100 and b xb因为a是范围条件无法参与索引范围定位。这条经验本身没错但有了ICP之后这个结论要打一个折扣——b虽然不能用来缩小扫描范围但只要b在联合索引里存储引擎就能在扫描索引时判断b x把不满足的行提前过滤掉。所以设计索引时可以这样考虑等值条件的列放在前面范围条件的列放在后面这是铁律范围条件后面的过滤列只要查询中经常出现也尽量放进联合索引为ICP创造机会但不要盲目把大量列塞进索引每个额外索引列都会增加存储空间和写放大区分度很低的列即使参与ICP过滤收益也可能微乎其微。我在实际项目里见过一个反面案例有人为了“让ICP生效”把一张表的十几个字段都塞进一个大联合索引结果写入性能暴跌查询收益却几乎为零。ICP是给合理的索引设计锦上添花不是让你把索引变成宽表。5. 常见问题排查与避坑实录5.1 我的SQL为什么没走索引下推如果发现查询没走ICP按照下面这个清单排查基本能定位问题第一查版本和开关。确认MySQL版本大于等于5.6执行SELECT optimizer_switch看index_condition_pushdown是不是on。有些云数据库或自研分支可能改过默认值。第二查索引列。条件中想要下推的列必须在当前索引里比如where status 1索引里必须包含status。第三查隐式类型转换。这是最容易忽视的一点比如表里status是varchar传入参数是数字1MySQL会对status做CAST索引列被函数化ICP就失效了。第四查函数包裹。条件写成LENGTH(name) 5或者DATE_FORMAT(create_time, %Y-%m-%d) 2024-01-01同样无法下推。第五查OR条件。如果OR的一边条件在索引A上另一边在索引B上优化器很可能选择全表扫描ICP也就无从谈起。还有一个很隐蔽的情况当查询用了主键索引但没有显著过滤条件时Extra里什么都看不到。这不是Bug只是ICP本来就不服务于聚簇索引。需要区分“没生效”和“不需要生效”这两个概念。5.2 三个线上判断ICP生效的手段线上环境往往不能随意改执行计划所以判断ICP是否生效我一般用三个手段EXPLAIN里的Extra。最直观适合分析单条SQL。performance_schema的events_statements_summary_by_digest。按SQL指纹聚合能看ROWS_EXAMINED、ROWS_SENT、TIMER_WAIT。如果ROWS_EXAMINED远大于ROWS_SENT说明中间环节丢弃了大量行ICP大概率没有覆盖到位。慢日志里的Rows_examined与Rows_sent比例。和上面同理Row_examined出现数量级偏高时把所有慢SQL拉出来对比一次往往能发现一批漏掉索引下推的查询。另外有个经验分享对比开启和关闭ICP时同一SQL的执行计划是验证理解最好的方法。我第一次完整走这个流程时才发现原来同一个SQL在开关切换后Extra可以从Using index condition变回Using whereRows_examined相差好几倍。从此以后再看到Extra列就不会只把它当成一个无意义字符串了。5.3 实战心得与避坑建议最后分享几条写进团队规范里的心得第一ICP减少的是回表次数但不能消除回表。想要极致性能优先考虑覆盖索引覆盖索引做不了再靠ICP兜底。第二分页查询也能从ICP获益。比如WHERE user_id 3000 AND status 1 ORDER BY id LIMIT 10ICP可以让Limit更快拿到足够行避免因为扫描无用索引记录导致排序和取数的数据量膨胀。第三不要依赖ICP去治理索引失效问题。如果优化器直接选择了全表扫描或者因为数据分布、区分度等原因放弃索引那ICP再厉害也帮不上忙还是得回到索引设计和SQL改写本身。第四版本升级后执行计划可能变化。5.6到5.7、5.7到8.0ICP的细节行为和优化器成本模型都在演进升级大版本前要把核心SQL的执行计划做一遍回归对比。我个人在实际操作中的体会是索引下推不是那种需要背源码才能理解的“炫技”特性它更像一个朴素的工程优化思路能不传上来的数据就别传上来能提前干掉的行就提前干掉。第一次感受到它的威力是我把一条订单报表SQL从1.8秒优化到0.4秒那次没有新增任何索引只是重写了条件让ICP生效并确认了覆盖索引的使用。如果你也在排查“索引明明生效却还是很慢”的查询建议先别急着加索引动手看看Extra列说不定让索引下推发挥出来就够你把性能提上一个台阶了。
返回列表