ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

MySQL执行计划中filtered=100的真相与优化实践

MySQL执行计划中filtered=100的真相与优化实践 先问一个问题你用EXPLAIN看 MySQL 执行计划的时候看到filtered 100心里是什么感觉我见过不少同学把这列当作“健康指数”觉得 100 分就是最优甚至有人专门截图说“我的 SQL 被优化得非常干净”。实际上filtered这一列在 MySQL 官方文档里写得很清楚它不是分数而是一个估算的百分比描述的是存储引擎返回的行里经过 Server 层 WHERE 条件过滤后预计还能剩下多少行。今天这篇文章我想把filtered 100的前因后果一次性讲透它到底代表什么、什么时候是好事、什么时候是陷阱、以及如何和rows列一起分析真实的行数消耗。我会从执行计划的基础定义讲起再结合几个我实际排查过的慢查询场景最后用EXPLAIN ANALYZE做一次“估算 vs 实际”的对照验证。无论你是刚接触 MySQL 的开发者还是已经写过不少 SQL 但没太关注过filtered的同学这篇内容应该都能帮你少踩几个执行计划分析的坑。1. filtered 到底是什么执行计划里的“剩余行比例”1.1 从一条 EXPLAIN 输出看 filtered 的位置先看一个最常见的执行计划输出EXPLAIN SELECT * FROM orders WHERE status PAID;输出列很多但核心几列通常是这样的idselect_typetabletypepossible_keyskeyrowsfilteredExtra1SIMPLEordersALLNULLNULL10000100.00Using wherefiltered在rows列后面看起来不太起眼但它的作用非常关键rows告诉你的是存储引擎层预计扫描多少行filtered告诉你的是这 10000 行里经过查询条件过滤之后预计还剩百分之多少。也就是说这里是100.00意思是优化器预计扫描到的 10000 行全部都会保留下来不会继续淘汰。我在最初接触这个字段时也有一个误区以为filtered100表示这条查询条件把数据“过滤得很干净”。后来看官方文档才意识到它表达的是“过滤过程中没有被减少”和“过滤效果好坏”完全是两回事。1.2 计算公式rows × filtered / 100真正要关注的结果是下面这个公式预估返回行数 rows * filtered / 100套到刚才的例子10000 * 100 / 100 10000也就是说优化器认为这条 SQL 最终会返回 10000 行而它又是全表扫描没有任何索引可用。这个信号其实很中性具体是好事还是坏事要看你的 WHERE 条件长什么样。再看一个过滤比例比较低的情况tabletypekeyrowsfilteredusersrefidx_status500020.00这里rows是 5000filtered是 20那么预计返回行数就是5000 * 20 / 100 1000优化器认为有 1000 行会满足条件而另外 4000 行会被过滤掉。这个数字会在优化器的成本模型里直接影响下一步连接策略、排序策略、是否走临时表等决策。1.3 filtered 是怎么算出来的统计信息与可选择性filtered是优化器基于表统计信息做出来的估算也就是来自information_schema.statistics或mysql.innodb_table_stats里的索引基数、表行数、数据分布等数据。如果某个列的可选择性很高比如用户表里的id、订单表里的唯一订单号那么优化器会认为等值条件可以过滤掉绝大多数行filtered就会很低。反过来如果一个列的选择性很差或者根本没有可用的统计信息比如一个status列里 90% 的数据都是同一个值优化器也会很诚实地给出接近 100 的filtered。这里有一个非常重要的细节filtered是Server 层的概念和存储引擎没有直接关系。MySQL 的架构大致分成两层存储引擎层负责从 InnoDB 表里根据索引或全表扫描把原始行数据读出来。Server 层负责处理 WHERE 条件、JOIN、GROUP BY、ORDER BY 等操作。所以filtered描述的是存储引擎返回的那些行在 Server 层经手一轮条件过滤之后还剩下多少。哪怕 InnoDB 在索引下推Index Condition PushdownICP阶段已经过滤了一部分行filtered反映的依然是 Server 层那一轮的“损耗比例”。2. filtered 100 的几种常见场景别被“满分”骗了2.1 无 WHERE 条件的全表扫描100 是必然结果先看最简单的场景SELECT * FROM users;没有 WHERE没有过滤存储引擎扫出来多少行最终就返回多少行filtered自然是 100。这种场景下看filtered其实没什么意义重点应该放在type列是不是ALL上。如果一张大表经常出现这种查询你要思考的就不是执行计划里的百分比而是为什么会产生全表扫描以及业务上是否真的需要把整张表都捞出来。我见过一个比较典型的案例有同事为了“省事”在一个后台列表接口里没加任何条件就关联了三张大表三张表的type全是ALLfiltered全是 100。执行计划看起来“数值统一”但实际运行直接让连接数膨胀到了几十万行最后把数据库 CPU 打满了。这个例子说明filtered100本身并不背锅真正的问题是访问路径。2.2 WHERE 条件已完全下推给索引100 反而代表高效filtered100不一定都是坏事。如果查询条件里的列全部被索引覆盖且优化器认为通过索引直接就能定位到最终行那么过滤动作发生在索引查找阶段Server 层返回的行就是最终结果此时filtered会显示为 100。举个例子SELECT id, name FROM users WHERE id 100;如果id是主键执行计划通常是typekeyrowsfilteredconstPRIMARY1100.00这里filtered100是完全正常的按照主键等值查找扫到 1 行这 1 行必然满足条件。你并不会因为它是 100 就担心有问题因为rows1基数已经足够小。类似的还有SELECT order_id, amount FROM payments WHERE order_id 12345;如果order_id上有二级索引且查询字段都在索引里覆盖索引执行计划可能显示typerefrows是该订单下的付款记录数filtered100。这表示索引条件下已经完成了条件判断回表次数也已经被压缩到了最小。所以判断filtered100好不好必须结合rows和访问类型一起看不能单看一个百分比。2.3 过滤性差的列100 只是说明“基本都能过”还有一种高频场景WHERE 条件里的列区分度很低比如状态、是否删除标记等。SELECT * FROM logs WHERE level INFO;如果INFO级别日志占全表的 95% 以上优化器在估算时会认为这个条件基本不会淘汰多少行filtered就会接近甚至等于 100。这时候你可能会问为什么明明有 WHERE 条件filtered还是 100因为它不是“没有过滤”而是“过滤了等于没过滤”。可选择性太差时优化器会在成本模型里判定即便使用索引也可能要读取大量数据数据和全表扫描差不多。所以你在执行计划里经常能看到typeALL配合filtered100这属于优化器的合理选择而不是统计信息出错。2.4 连接查询里驱动表 filtered100 的隐患连表查询时filtered的意义需要结合“驱动表”和“被驱动表”来看。在嵌套循环连接Nested Loop Join里MySQL 会先选一张表当驱动表然后拿驱动表的每一行去被驱动表里查找匹配行。如果驱动表本身没有 WHERE 条件或条件过滤性很差filtered100那就意味着每一行驱动表数据都会进入连接探测流程。举个例子SELECT * FROM users u LEFT JOIN orders o ON u.id o.user_id;如果users表有 10 万行filtered100那意味着 10 万行都会去orders里查找。此时优化器的关键评估点是orders.user_id有没有索引。有索引的话10 万次索引查找还能接受没有索引的话每次探测都可能变成全表扫描SQL 会慢到难以忍受。因此连接查询里看到驱动表filtered100不要急着下结论先检查被驱动表的连接列索引是否合理。3. rows 和 filtered 连起来读真正的预计返回行数3.1 预计返回行数的计算逻辑单独看rows或filtered都有点“盲人摸象”两个字段合起来才是优化器眼中的预估返回行数。下面用一个表来做对比场景typerowsfiltered预计返回行数全表扫描无条件ALL20000100%20000单列索引等值结果集大ref800050%4000复合索引精确定位ref30100%30无索引条件区分度低ALL5000090%45000这里能明显看出filtered100并不稀奇真正有价值的判断标准是“扫描多少行、最终留下多少行”。如果扫描行数多返回行数也多那这条 SQL 本身的数据体量就大你需要考虑的不是优化索引而是业务层面是否需要一次取这么多数据。3.2 预估和实际差得远统计信息过期filtered和rows都是估算值它们是否可信完全取决于统计信息是否新鲜。MySQL 的 InnoDB 引擎通过采样来估算索引基数而不是每次数据变更都精确统计。如果一张表经历了大范围的删除或插入却没有及时更新统计信息执行计划里就可能出现严重失真的rows和filtered。我之前排查过一个案例某张订单表历史数据有 5000 万行业务清理任务删掉了 80% 的数据但执行计划里rows依然是 5000 万级别的旧估算值filtered也不是真实水平导致优化器选了一个错误的索引。解法很简单ANALYZE TABLE orders;执行完再跑一次EXPLAINrows和filtered都回到了正常范围。这个操作对 InnoDB 来说是轻量级的它只会重新采样统计信息不会锁表重建数据日常维护中可以放心使用。3.3 filtered100 且 rows 很大时优先检查访问类型如果说filtered100是一盏信号灯那真正危险的组合是下面这个type ALL rows 几十万甚至上百万 filtered 100这意味着优化器判断全表扫描出的每一行都会进入后续操作没有任何“提前淘汰”。如果业务上实际返回的行数确实也很大那可能是查询本身要处理的数据量就大比如导出报表如果业务上实际返回只有几十行那说明优化器的估算和执行计划已经不一致了常见原因包括缺少合适的索引MySQL 不得不全表扫描。WHERE 条件里的列虽然有索引但优化器由于数据分布原因认为走索引更慢。统计信息过期导致成本评估严重偏离实际。遇到这种组合我的习惯是先看possible_keys再看key。如果possible_keys有值但key是 NULL或者key选择了明显不合适的索引就要考虑使用FORCE INDEX临时验证或者通过ANALYZE TABLE刷新统计信息。4. 实测记录从 filtered100 到 filtered15 的优化过程4.1 原始 SQL 和执行计划光说理论可能不够直观我分享一个真实的排查过程。有一张订单明细表order_items结构简化为CREATE TABLE order_items ( id BIGINT PRIMARY KEY AUTO_INCREMENT, order_id BIGINT NOT NULL, product_name VARCHAR(100) NOT NULL, quantity INT NOT NULL, price DECIMAL(10,2) NOT NULL, KEY idx_order_id (order_id) ) ENGINEInnoDB;业务上有个页面要查某个订单下所有以 “Mac” 开头的商品明细SQL 是这样SELECT * FROM order_items WHERE order_id 12345 AND product_name LIKE Mac%;EXPLAIN结果如下typepossible_keyskeyrowsfilteredExtrarefidx_order_ididx_order_id12000100.00Using where当时同事看到filtered 100很高兴觉得“过滤条件完全走索引了不需要优化”。但实际这条 SQL 在测试环境跑了 1.2 秒。我让他先搞清楚一个事实order_id 12345这个订单本身有 12000 条明细而product_name LIKE Mac%只匹配其中 1800 条。优化器输出的 12000 行和 100% 过滤比例实际上是说通过order_id索引读出了 12000 行并且认为剩下的 WHERE 条件基本不会继续淘汰行。问题恰恰出在这里另外 10200 行被读出来后又丢掉了虽然不影响最终结果却白白消耗了大量 IO 和 CPU。4.2 排查链路先从访问类型看起我没有急着让同事改 SQL而是带着他做了一遍排查先看type这里是ref说明已经用了order_id的等值索引访问路径不算差。再看possible_keys只有idx_order_id说明product_name没有可用索引。看Extra里的Using where这说明product_name的过滤发生在 Server 层在拿到全部 12000 行之后才开始过滤。最后算一遍“扫描行 vs 实际返回行”扫描 12000 行实际返回 1800 行有 85% 的行被无意义地读取。到这里优化方向已经很明确了需要让product_name的过滤也尽可能提前最好在索引层面就完成。4.3 添加复合索引后的执行计划我们添加了一个复合索引ALTER TABLE order_items ADD INDEX idx_order_product (order_id, product_name);再次跑EXPLAIN结果变成typepossible_keyskeyrowsfilteredExtrarefidx_order_id, idx_order_productidx_order_product1800100.00Using where注意看filtered依然还是 100但rows从 12000 降到了 1800。这就是我反复强调的一点优化复合索引后最有价值的变化往往是rows的下降而不是filtered的变化。filtered100完全可以在优化后依旧出现因为它表示“最终没有额外过滤损耗”。这条 SQL 最终执行时间从 1.2 秒降到了 30 毫秒左右。测试环境数据量还不算大放到生产环境的上千万行数据里差距会更加明显。4.4 为什么优化后 filtered 还是 100针对这个案例很多人会问filtered为什么没有变化原因是复合索引(order_id, product_name)把product_name LIKE Mac%也下推到了索引条件里。优化器认为通过这个复合索引定位出来的 1800 行本身就是最终结果。Server 层不再需要二次过滤所以filtered依然是 100。这个例子非常典型它说明判断 SQL 性能不能只看单个百分比。真正核心的指标是rows是否足够小。索引是否覆盖了 WHERE 条件和查询列。实际返回行数和估算行数是否接近。5. EXPLAIN ANALYZE 上场用真实行数验证 filtered5.1 MySQL 8.0.18 的基本用法上面的优化前案例里我们通过公式推算“实际返回约 1800 行”但EXPLAIN给出的只是估算值。如果你用的是 MySQL 8.0.18 及以上版本还有一个更直接的工具EXPLAIN ANALYZE。它的用法很简单把普通EXPLAIN换成EXPLAIN ANALYZE即可EXPLAIN ANALYZE SELECT * FROM order_items WHERE order_id 12345 AND product_name LIKE Mac%;它会真正执行这条 SQL并输出一棵执行计划树附带每个节点的真实耗时、真实行数、循环次数。这就给了我们一把“实测的尺子”用来验证之前的估算是否靠谱。5.2 解读输出中的 actual rows 与 filtered 估算的关系上面那条 SQL 在优化前的EXPLAIN ANALYZE输出大致如下- Filter: (order_items.product_name like Mac%) (cost1200 rows12000) (actual time0.05..4.5 rows1800 loops1) - Index lookup on order_items using idx_order_id (order_id12345) (cost800 rows12000) (actual time0.02..2.8 rows12000 loops1)这里的信息非常好看第一层Filter节点actual rows1800这就是最终返回行数。第二层Index lookup节点actual rows12000这就是通过索引实际读出来的行数。优化器估算的rows12000和实际读取行数完全一致但它对过滤能力的判断和真实结果有差距它预估过滤后还是 12000实际过滤后剩 1800。把这段输出和EXPLAIN里的filtered100对照你就知道问题出在哪了优化器对product_name LIKE Mac%的过滤性估算过于乐观导致filtered偏向 100。而EXPLAIN ANALYZE用真实执行数据告诉你实际过滤比例只有 15%。5.3 估算和实际行数差异大时怎么办第一步是重新采集统计信息ANALYZE TABLE order_items;如果ANALYZE TABLE之后EXPLAIN里的filtered依然没变化放弃单纯依赖执行计划的念头直接用真实数据做判断。此时可以采取的行动包括添加复合索引把过滤条件下推到索引层。修改 SQL拆成两步查询先缩小结果集再关联。使用FORCE INDEX暂时验证某个索引的效果。如果表数据量巨大且历史数据不常用考虑分区或归档。EXPLAIN ANALYZE虽然会真正执行 SQL但它适用于大部分查询场景尤其是排查慢查询时它能给出最接近真相的执行信息。不过要注意在写入量很大的生产库上跑它要谨慎毕竟它会真的执行语句如果涉及UPDATE/DELETE建议改成等价的SELECT来核对执行计划。6. 容易被误解的 filtered 细节几个坑必须避开6.1 filtered100 ≠ 没有 WHERE 条件这是最常见的误解。filtered100只是说明优化器认为扫描出来的行不会因为 Server 层过滤继续减少并不代表查询语句里没有 WHERE。比如SELECT * FROM employees WHERE department Engineering;如果该部门员工数占全公司 90%优化器完全可能给出filtered100。这时候你如果看到 100 就觉得“没有过滤”那就错了实际上是有过滤的只是过滤效果不明显。6.2 filtered 是成本模型的一部分只影响执行计划选择filtered不参与最终结果集的正确性判断它只影响优化器的成本计算。MySQL 会根据扫描行数、过滤比例、索引访问代价、连接次数等一堆因素计算出每个执行计划的“成本值”然后选择成本最小的那个。所以filtered偏大或偏小直接影响的不是查询结果而是优化器“愿不愿意选某个索引”。如果你发现某条 SQL 的filtered明显和实际不符但执行计划里也找不到更优解时可以先容忍这个偏差因为优化器最终选择的可能已经是最优路径了。6.3 join buffer 和临时表对 filtered 的干扰连表查询或带GROUP BY、ORDER BY的查询执行计划里会出现Using temporary或Using join buffer (Block Nested Loop)等标记。此时filtered的估算难度会更高因为它要叠加连接条件、分组条件、排序条件等多重过滤。实际排查中如果filtered数值异常漂亮比如极低先确认是否真的是索引过滤带来的如果是 join buffer 在内存里完成了大量过滤那重点优化方向应该是控制驱动表行数和被驱动表的连接列索引而不是死磕某个 SQL 片段。6.4 定期更新统计信息应纳入巡检统计信息不准filtered就是“盲猜”。InnoDB 有一套自动采样机制但它的采样频率在高频写入的场景下不一定跟得上数据变化。我的习惯是把下面两条 SQL 写进每周的巡检脚本里-- 针对数据变动频繁的大表 ANALYZE TABLE orders; ANALYZE TABLE order_items; ANALYZE TABLE users;也可以查information_schema.tables看TABLE_ROWS和实际的COUNT(*)差距如果偏差超过 30%就尽快执行ANALYZE TABLE刷新。注意ANALYZE TABLE在 InnoDB 里是只读操作不会重建表结构也不会长时间锁表大表执行速度通常是秒级或分钟级可以安排到生产环境的低峰期操作。最后再分享一个执行计划分析习惯在我自己日常排查性能问题时看到filtered 100的第一反应是“冷静”不是“开心”。我会先把这个字段和rows、type、Extra放在一起读一遍rows大不大type是ALL还是ref还是eq_refExtra里有没有Using temporary、Using filesort最终返回行数到底是多少这个流程帮我在不少慢查询里快速定位了真正的问题。比如说filtered很低并不等于 SQL 快它只是说明“过滤发生在 Server 层”filtered100也不等于 SQL 没问题它只是说明“扫描行没有被继续淘汰”。真正能让执行计划分析产生价值的是你对整条数据链路的理解而不是某一行括号里的小数字。希望这篇内容对你以后读执行计划有点帮助。如果你手头也有一个“明明感觉 SQL 不慢但 EXPLAIN 看起来很怪”的案例不妨先用EXPLAIN ANALYZE验证一下估算再回头对比filtered和实际行数差异多半能找到线索。
RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进