ARTICLE DETAIL

资讯详情

深耕编程入门与网站建设的一线实战洞察。

MySQL索引下推(ICP)详解:从原理到生产实践,减少回表提升效率

MySQL索引下推(ICP)详解:从原理到生产实践,减少回表提升效率 上个月帮团队优化一条慢查询表只有几十万行联合索引也建得好好的可这条SQL每次都要跑两三百毫秒。EXPLAIN扫了一眼Extra列里有一行“Using index condition”。同事问我这行字到底是什么意思跟查询慢有什么关系。这就是今天要聊的索引下推也就是MySQL官方说的Index Condition Pushdown简称ICP。一句话先给结论索引下推是MySQL把一部分WHERE条件下推到存储引擎层提前过滤核心收益是减少回表次数、减少Server层和引擎层之间的行数据传输。如果你平时看执行计划只盯着type和key从不看Extra那这篇内容建议认真读完尤其是最后几节藏了不少面试和生产排障都用的上的细节。1. 先搞懂查询的执行路径索引下推解决的是哪一环的问题1.1 一条SQL从Server层到存储引擎层的基本流程不谈底层那些复杂的数据结构单说一条查询在MySQL内部是怎么走通的。MySQL的架构分两层上面是Server层负责解析SQL、生成执行计划、做优化下面是存储引擎层InnoDB在这里负责索引扫描、事务、缓冲池管理等等。一条查询大致经历这样几步SQL语句经过解析器和优化器优化器生成最终执行计划然后Server层调用存储引擎接口让引擎沿着索引“读数据”。引擎读到符合索引访问条件的记录后要么直接返回给Server层要么需要拿着主键去聚簇索引里查完整行数据再返回这一步就是回表。这里的关键在于WHERE条件并不一定全部都在引擎层完成过滤。在没有索引下推的时候引擎层只负责按索引的“访问边界”找数据找到之后整行丢给Server层由Server层再评估剩余的WHERE条件。也就是说过滤动作分了两段而且中间隔着一层接口调用多一次回表就多一次随机IO多返回一行记录就是多一次网络/内存传输。1.2 联合索引范围查询的经典困境要理解ICP的价值得先看一个非常典型的场景。假设你有一张学生表建了联合索引(age, city)然后执行这样一条查询SELECT * FROM student WHERE age BETWEEN 20 AND 30 AND city 北京;这个SQL的WHERE条件里age是范围条件city是等值条件。联合索引的匹配原则是“从左到右遇到范围条件如、、BETWEEN、LIKE后面前缀模糊等就会停止匹配”。所以age条件可以用索引定位到一段范围但city条件在索引里已经“排不上用场”了——因为age范围之后索引项是按age排列的age相同的前提下才去看city排序而age20到age30这个区间横跨了多个age值city在这个区间里并不是有序的无法用B树二分定位。问题来了城市条件用不上索引定位那这条SQL该怎么过滤city‘北京’办法有两个要么在索引扫描过程中直接判断要么回表拿到整行后再判断。后者就是没有ICP之前的做法前者就是ICP。索引下推解决的正是这种“索引本身包含了条件列却因为范围导致无法用于定位”的场景。2. 没有索引下推回表之后再过滤的老方案2.1 无ICP的完整执行链路我们把上面那条SQL放在一个没有索引下推的MySQL版本里跑一遍比如5.6之前或者手动关闭ICP开关看它到底是怎么执行的存储引擎沿着二级索引idx_age_city根据age BETWEEN 20 AND 30定位出所有满足age条件的索引项。对每一个索引项取出主键ID再到聚簇索引里回表读取完整行数据。把完整行数据返回给Server层。Server层在内存中判断city是否等于‘北京’不满足的就丢弃。注意第2步city是‘北京’这个条件在这个阶段完全没有参与过滤引擎层只是机械地按age范围扫描索引并把记录全部回表无论city是不是想要的。等到Server层拿到完整行之后才发现大量记录根本不符合city条件等于白回表了。我最初理解这条链路的时候脑子里闪过一个类比你去图书馆找一批书图书管理系统给了你一个“页码区间”你把这个区间里所有书都从书架上搬下来搬回座位之后才一本一本地看哪本才是你要的。那些搬了又没用的书就是浪费。2.2 无用功集中在哪里IO与行数这种“先全拿回来再过滤”的方式浪费主要在两个地方。第一个是回表带来的随机IO。InnoDB的聚簇索引本质上是一棵B树主键ID对应的行数据可能分散在不同的数据页里。每一次回表都是一次随机访问对于机械硬盘来说就是一次随机磁盘寻道即使是SSD页命中和未命中的开销也差很多。如果age条件命中了10000条记录那就意味着最多有10000次回表动作这里面可能只有1000条是真正符合city条件的另外9000次回表全是白做。第二个是Server层与存储引擎层之间的行传输开销。存储引擎回表拿到的完整行数据需要先写进InnoDB的行缓冲区再一层层返回给Server层。返回的行数越多内存占用、CPU拷贝、状态统计这些成本就越高。如果这个查询是在一个大的联表操作里作为子查询出现那行数差异还会被放大可能直接影响嵌套循环连接的整体性能。生产环境里更隐蔽的问题是锁范围。没有ICP时引擎层会把age区间的所有二级索引记录都“摸”一遍在隔离级别较高的情况下这些记录可能会加锁导致不必要的事务阻塞。ICP由于能在引擎层提前过滤不满足条件的索引记录甚至不需要加锁这是很多DBA容易忽略的隐性收益。3. 引入索引下推过滤动作前移带来的核心差异3.1 有ICP的完整执行链路索引下推的思路说白了就是把过滤动作“下放”到存储引擎层。当Server层优化器发现某个WHERE条件恰好是当前使用的二级索引里包含的列时就把这个条件下推给存储引擎让引擎在扫描二级索引的过程中直接判断。改造后的执行链路变成这样存储引擎根据age BETWEEN 20 AND 30定位出所有满足age条件的索引项。每读一个索引项引擎立刻检查这个索引项里保存的city值。如果city不等于‘北京’直接跳过不回表不传给Server层。只有city等于‘北京’的索引项才取出主键ID去回表返回完整行。这个过程中索引项里本身就保存了city的字段值不需要额外访问数据页就能完成过滤。因为二级索引叶子节点存放的是“索引列的所有字段 主键ID”city就在索引里所以引擎在做一次内存判断就能筛掉大量记录。整个设计其实很朴素与其把条件拿到上层去判断不如在最靠近数据的地方判断提前淘汰一批不会进入最终结果的记录。3.2 用具体数据看回表次数变化光讲流程不够直观我用一个示例数据来算一下。假设student表有60万行数据city字段有20个城市值age分布在18到40岁。现在查询age在25到30之间且city等于‘北京’的记录。age 25到30大约覆盖6个年龄段占整个年龄区间的约 6/23 ≈ 26%命中的索引记录大约15.6万条。假设20个城市分布均匀‘北京’的记录占比约5%真正符合city条件的大约是 15.6万 × 5% ≈ 7800条。没有ICP时引擎层需要回表约15.6万次Server层收到15.6万行后再过滤最终留下7800行。有ICP时引擎层在索引扫描阶段就把不符合city的记录全部过滤掉真正回表的只有大约7800行。回表次数从15.6万降到7800直接减少94.5%。这个数字已经足够说明问题了。实际情况中如果city分布不均匀某个高频城市占比达到30%那ICP的收益会缩水到70%如果某个低频城市只占1%收益会更大。所以ICP不是所有查询都带来数量级的提升它的收益取决于条件列在选择率上的“筛选能力”筛选率越狠收益越明显。4. 索引下推能用的边界原理与生效条件4.1 ICP的三大前提ICP不是想用就能用它有几个硬性前提我在看执行计划的时候基本会先对着这几个条件过一遍。第一必须是二级索引。主键索引的叶子节点就是完整数据行没有回表过程也就不存在“下推提前过滤”的意义。所以你在EXPLAIN主键范围扫描时几乎看不到Using index condition。第二WHERE条件里要包含“当前使用的二级索引”中的列。这里强调的是当前使用的索引比如联合索引(a, b, c)WHERE条件里有b和c的过滤条件且优化器最终选择了这个联合索引b和c才能被下推。如果某个条件列不在索引里那就没资格下推只能等回表后再由Server层过滤。第三下推的条件必须能被引擎层理解和执行。那些包含存储函数、非确定性表达式、子查询的条件引擎层无法在索引扫描时安全计算优化器会放弃下推。另外注意一点ICP对访问类型也有适用范围通常集中在range、ref、eq_ref、ref_or_null这几种访问方法。全表扫描时压根没走二级索引自然谈不上ICP。MySQL官方文档还提到NDB引擎有单独的ICP实现但日常生产我们主要关注InnoDB和MyISAM。4.2 哪些条件下推不了我整理过一些实践中常见的“下推失效”场景遇到执行计划里Extra没有出现Using index condition时可以按这个清单排查条件列不在当前使用的索引中。比如索引是(age, city)但WHERE还加了school ‘三中’school列不在索引里这个条件只能回表后过滤。条件里使用了不确定函数。例如WHERE age YEAR(NOW())YEAR(NOW())每年甚至每天都会变引擎无法在索引扫描时预先计算一个稳定边界。前导模糊匹配。LIKE ‘%北京’这种后缀匹配即使city在索引中也无法下推过滤因为B树的索引匹配要求前导确定但LIKE ‘北京%’这种前缀匹配是可以借助ICP的。优化器判定下推收益不大。比如返回结果集本身就非常大回表次数差距不显著优化器可能选择不用ICP。版本或变量限制。MySQL 5.6之前的版本没有ICP5.6之后默认开启但可以用optimizer_switch关闭。分区表在早期版本对ICP有限制8.0之后整体稳定很多。4.3 主键索引、覆盖索引、ICP三者的关系很多人会把ICP和前两个概念搞混这里统一理一下。先看主键索引。InnoDB的表本身就是按主键聚簇存储的主键索引的叶子节点就是完整行。走主键索引查询时引擎扫描到的已经是全量数据不需要“回表读数据”所以ICP这个优化没有用武之地。再看覆盖索引。如果一个SELECT语句所需的列全部包含在二级索引中那么引擎直接从索引返回结果即可连回表都省了EXPLAIN里Extra会显示Using index。这种情况下过滤条件当然也能直接作用于索引列但更准确的叫法是“覆盖索引”而非ICP。覆盖索引是最理想的状态因为它彻底消灭了回表ICP只是减少了回表次数没消灭回表。三者放在一起对比就很清晰主键索引无回表也就不需要ICP覆盖索引无回表也不需要ICP普通二级索引查询需要回表如果索引里刚好有条件列ICP就可以发挥作用。所以在日常优化中如果某个SQL既能用覆盖索引解决又同时出现Using index condition那通常意味着还有进一步优化余地——可以把SELECT的列调整成覆盖索引列让Extra从Using index condition变成Using index。5. 动手验证用EXPLAIN和真实SQL对比有无下推5.1 准备演示表和验证SQL理论讲透了我们来亲手验证一遍。下面的演示在MySQL 8.0环境跑通5.7也完全适用。先建一张演示表CREATE TABLE student ( id INT PRIMARY KEY AUTO_INCREMENT, name VARCHAR(32), age INT, city VARCHAR(32), school VARCHAR(64), KEY idx_age_city (age, city) ) ENGINEInnoDB;插入一批测试数据比如用存储过程生成20万行city随机分布在20个城市里age随机在18到40之间。这一步是模拟一个相对真实的数据分布让索引扫描有意义。验证目标是这条SQLSELECT * FROM student WHERE age BETWEEN 25 AND 30 AND city 北京;注意这里用的是SELECT *所以无法走覆盖索引必须回表。这正是观察ICP的最佳场景。5.2 开启/关闭ICP的EXPLAIN对比先看问题默认状态下的执行计划EXPLAIN SELECT * FROM student WHERE age BETWEEN 25 AND 30 AND city 北京;正常情况下你会发现type是rangekey是idx_age_cityExtra列里会出现Using index condition。有这行字说明当前SQL启用了ICP。然后我们手动关掉ICP开关再做一次对照SET SESSION optimizer_switch index_condition_pushdownoff; EXPLAIN SELECT * FROM student WHERE age BETWEEN 25 AND 30 AND city 北京;这一次type仍然是rangekey也还是idx_age_city但Extra列里原来那行Using index condition消失了变成了Using where。这里的变化非常关键Using where出现在Extra里意味着city条件是在回表之后、由Server层进行过滤的。两条EXPLAIN看起来只有Extra列不同但底层执行路径已经发生了本质变化。验证完记得改回来SET SESSION optimizer_switch index_condition_pushdownon;5.3 结合EXPLAIN ANALYZE看实际行数如果只用EXPLAIN你只能看到“有没有用ICP”看不到行数层面的差异。MySQL 8.0.18之后提供了EXPLAIN ANALYZE可以直接看到实际执行的行数和时间这个用来验证ICP非常直观。EXPLAIN ANALYZE SELECT * FROM student WHERE age BETWEEN 25 AND 30 AND city 北京;输出大致长这样具体数值取决于数据分布和版本- Filter: ((student.age between 25 and 30) and (student.city 北京)) (rows... ) (actual time... rows... loops...) - Index range scan on student using idx_age_city (rows... ) (actual time... rows... loops...)如果仔细观察内层的Index range scan你会发现底层索引扫描返回的行数actual rows远大于最终Filter输出的行数。开关ICP以后这个底层扫描的实际行数会明显变少我实测时差异通常在一个数量级以上。这种定量对比比单纯看EXPLAIN更有说服力也方便你在优化时评估ICP带来的实际收益。如果觉得EXPLAIN ANALYZE不方便还可以用状态变量粗略统计。比如对比开关状态下执行同一SQL时Handler_read_next等状态值的变化这也是一种可行的观测手段不过多数情况直接用执行计划就够了。6. 常见误区、排查与面试要点6.1 三个容易混淆的概念从业久了你会发现数据库相关的知识误区往往不在原理本身而在几个相似概念之间。ICP这里最容易混的就是下面这三个。Using index condition不等于Using index。前者只是把条件下推到引擎层减少回表次数但最终数据还是要回表取后者是覆盖索引生效压根不需要回表。两个Extra字段虽然只差了一个condition单词性能层级完全不同。ICP不等于索引条件都能定位。ICP做的是“扫描过程中的过滤”不是“减少索引扫描范围”。age定位到的扫描区间没变索引上定位到的记录还是那15.6万条只是其中不符合city的记录不会继续回表了。严格来说ICP减少了回表次数和数据传输量但没有改变索引访问的起始范围和扫描记录数。ICP不改变最终结果。它只是把WHERE条件的执行位置提前了该过滤的条件一条都不会少该返回的数据一行都不会多。所以判断ICP是否生效不能看返回结果只能看执行计划和实际执行开销。6.2 生产环境中ICP不生效的排查思路遇到一个明明应该走联合索引、却没有Using index condition的慢查询我一般按这个顺序排查。第一步确认版本。MySQL 5.6以下是不会有ICP的这个话题可以直接跳过。5.6及以上默认开启但如果线上有人调整过optimizer_switch也有可能被关闭用SELECT optimizer_switch看一眼确认。第二步确认当前SQL用的是哪棵索引。EXPLAIN里的key列如果是主键索引ICP大概率不会出现这是正常的。如果key确实是某个二级索引但Extra只有Using where那就继续往下看。第三步看WHERE条件里的列是否完整存在于这棵索引中。用联合索引(age, city)查city没问题但如果条件里带的school列也要过滤school不在索引中该条件无法下推最终Server层仍然要进行一次过滤。这时候Extra会出现Using index condition加其他信息同时存在的情况要注意区分下推的到底是谁。第四步检查是否有覆盖索引在起作用。如果EXPLAIN里Extra是Using index说明优化器选择了覆盖索引直接返回数据不需要ICP。这种情况下查询性能通常已经不差也不用强求ICP。第五步注意优化器决策。有时候优化器会根据统计信息评估下推收益不大主动放弃ICP。这种情况多见于结果集本身很大、筛选条件不够强或者表特别小、回表成本可忽略。这时候与其纠结ICP不如优先改进索引设计或SQL写法。6.3 面试怎么回答索引下推关于索引下推面试官问到的概率不低尤其在工作三到五年的MySQL问题里。答得好不好其实看的不是背没背概念而是能不能把这个优化放在执行链路里讲清楚。我建议的回答思路分四层。第一层给定义ICP是MySQL 5.6引入的优化核心是将部分WHERE条件下推到存储引擎层在二级索引扫描过程中完成过滤。第二层讲收益减少回表次数减少Server层与引擎层之间的行传输某些高隔离级别场景下还能减少不必要的锁操作。第三层讲原理二级索引叶子节点保存索引列和主键引擎可以在访问索引时直接判断条件典型的适用场景是联合索引里一个字段定位范围后索引中后续字段无法参与定位但仍然可以通过ICP参与过滤。第四层将边界只有二级索引能用条件列必须在索引中条件本身要能被引擎安全计算覆盖索引下不需要ICP。这四层答完面试官基本就能判断你对这个知识点是真理解过还是只背了八股。如果再追问“ICP和覆盖索引哪个更优”你补一句“覆盖索引完全消灭回表ICP只是减少回表能用覆盖索引优先用覆盖索引”基本上这一题就稳了。我个人的体会是索引下推之所以值得认真学是因为它逼着你去搞明白一条SQL真正执行时Server层和存储引擎层之间到底是怎么协作的。把这个协作关系搞清楚了再看执行计划里的Extra列你的敏感度会和以前完全不一样。许多慢查询病根不在SQL写法而在引擎层多做了太多无用功。ICP恰恰是相对容易理解、又真实影响生产性能的一个典型优化手段。
返回列表