MySQL执行计划中filtered=100的真相与优化实践
2026/9/19 8:40:01 网站建设 项目流程

先问一个问题:你用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_keyskeyrowsfilteredExtra
1SIMPLEordersALLNULLNULL10000100.00Using where

filteredrows列后面,看起来不太起眼,但它的作用非常关键:rows告诉你的,是存储引擎层预计扫描多少行;filtered告诉你的,是这 10000 行里,经过查询条件过滤之后,预计还剩百分之多少。

也就是说,这里是100.00,意思是优化器预计:扫描到的 10000 行,全部都会保留下来,不会继续淘汰。

我在最初接触这个字段时也有一个误区,以为filtered=100表示这条查询条件把数据“过滤得很干净”。后来看官方文档才意识到,它表达的是“过滤过程中没有被减少”,和“过滤效果好坏”完全是两回事。

1.2 计算公式:rows × filtered / 100

真正要关注的结果是下面这个公式:

预估返回行数 = rows * filtered / 100

套到刚才的例子:

10000 * 100 / 100 = 10000

也就是说,优化器认为这条 SQL 最终会返回 10000 行,而它又是全表扫描,没有任何索引可用。这个信号其实很中性,具体是好事还是坏事,要看你的 WHERE 条件长什么样。

再看一个过滤比例比较低的情况:

tabletypekeyrowsfiltered
usersrefidx_status500020.00

这里rows是 5000,filtered是 20,那么预计返回行数就是:

5000 * 20 / 100 = 1000

优化器认为有 1000 行会满足条件,而另外 4000 行会被过滤掉。这个数字会在优化器的成本模型里,直接影响下一步连接策略、排序策略、是否走临时表等决策。

1.3 filtered 是怎么算出来的:统计信息与可选择性

filtered是优化器基于表统计信息做出来的估算,也就是来自information_schema.statisticsmysql.innodb_table_stats里的索引基数、表行数、数据分布等数据。如果某个列的可选择性很高,比如用户表里的id、订单表里的唯一订单号,那么优化器会认为等值条件可以过滤掉绝大多数行,filtered就会很低。

反过来,如果一个列的选择性很差,或者根本没有可用的统计信息,比如一个status列里 90% 的数据都是同一个值,优化器也会很诚实地给出接近 100 的filtered

这里有一个非常重要的细节:filteredServer 层的概念,和存储引擎没有直接关系。MySQL 的架构大致分成两层:

  • 存储引擎层:负责从 InnoDB 表里根据索引或全表扫描,把原始行数据读出来。
  • Server 层:负责处理 WHERE 条件、JOIN、GROUP BY、ORDER BY 等操作。

所以filtered描述的是:存储引擎返回的那些行,在 Server 层经手一轮条件过滤之后,还剩下多少。哪怕 InnoDB 在索引下推(Index Condition Pushdown,ICP)阶段已经过滤了一部分行,filtered反映的依然是 Server 层那一轮的“损耗比例”。

2. filtered = 100 的几种常见场景:别被“满分”骗了

2.1 无 WHERE 条件的全表扫描:100 是必然结果

先看最简单的场景:

SELECT * FROM users;

没有 WHERE,没有过滤,存储引擎扫出来多少行,最终就返回多少行,filtered自然是 100。

这种场景下看filtered其实没什么意义,重点应该放在type列是不是ALL上。如果一张大表经常出现这种查询,你要思考的就不是执行计划里的百分比,而是为什么会产生全表扫描,以及业务上是否真的需要把整张表都捞出来。

我见过一个比较典型的案例:有同事为了“省事”,在一个后台列表接口里没加任何条件就关联了三张大表,三张表的type全是ALLfiltered全是 100。执行计划看起来“数值统一”,但实际运行直接让连接数膨胀到了几十万行,最后把数据库 CPU 打满了。这个例子说明,filtered=100本身并不背锅,真正的问题是访问路径。

2.2 WHERE 条件已完全下推给索引:100 反而代表高效

filtered=100不一定都是坏事。如果查询条件里的列全部被索引覆盖,且优化器认为通过索引直接就能定位到最终行,那么过滤动作发生在索引查找阶段,Server 层返回的行就是最终结果,此时filtered会显示为 100。

举个例子:

SELECT id, name FROM users WHERE id = 100;

如果id是主键,执行计划通常是:

typekeyrowsfiltered
constPRIMARY1100.00

这里filtered=100是完全正常的:按照主键等值查找,扫到 1 行,这 1 行必然满足条件。你并不会因为它是 100 就担心有问题,因为rows=1,基数已经足够小。

类似的还有:

SELECT order_id, amount FROM payments WHERE order_id = 12345;

如果order_id上有二级索引,且查询字段都在索引里(覆盖索引),执行计划可能显示type=refrows是该订单下的付款记录数,filtered=100。这表示索引条件下已经完成了条件判断,回表次数也已经被压缩到了最小。

所以,判断filtered=100好不好,必须结合rows和访问类型一起看,不能单看一个百分比。

2.3 过滤性差的列:100 只是说明“基本都能过”

还有一种高频场景:WHERE 条件里的列区分度很低,比如状态、是否删除标记等。

SELECT * FROM logs WHERE level = 'INFO';

如果INFO级别日志占全表的 95% 以上,优化器在估算时会认为这个条件基本不会淘汰多少行,filtered就会接近甚至等于 100。

这时候你可能会问:为什么明明有 WHERE 条件,filtered还是 100?因为它不是“没有过滤”,而是“过滤了等于没过滤”。可选择性太差时,优化器会在成本模型里判定:即便使用索引也可能要读取大量数据,数据和全表扫描差不多。所以你在执行计划里经常能看到type=ALL配合filtered=100,这属于优化器的合理选择,而不是统计信息出错。

2.4 连接查询里驱动表 filtered=100 的隐患

连表查询时,filtered的意义需要结合“驱动表”和“被驱动表”来看。

在嵌套循环连接(Nested Loop Join)里,MySQL 会先选一张表当驱动表,然后拿驱动表的每一行去被驱动表里查找匹配行。如果驱动表本身没有 WHERE 条件,或条件过滤性很差,filtered=100,那就意味着每一行驱动表数据都会进入连接探测流程。

举个例子:

SELECT * FROM users u LEFT JOIN orders o ON u.id = o.user_id;

如果users表有 10 万行,filtered=100,那意味着 10 万行都会去orders里查找。此时优化器的关键评估点是orders.user_id有没有索引。有索引的话,10 万次索引查找还能接受;没有索引的话,每次探测都可能变成全表扫描,SQL 会慢到难以忍受。

因此,连接查询里看到驱动表filtered=100,不要急着下结论,先检查被驱动表的连接列索引是否合理。

3. rows 和 filtered 连起来读:真正的预计返回行数

3.1 预计返回行数的计算逻辑

单独看rowsfiltered都有点“盲人摸象”,两个字段合起来才是优化器眼中的预估返回行数。

下面用一个表来做对比:

场景typerowsfiltered预计返回行数
全表扫描,无条件ALL20000100%20000
单列索引等值,结果集大ref800050%4000
复合索引精确定位ref30100%30
无索引,条件区分度低ALL5000090%45000

这里能明显看出,filtered=100并不稀奇,真正有价值的判断标准是“扫描多少行、最终留下多少行”。如果扫描行数多,返回行数也多,那这条 SQL 本身的数据体量就大,你需要考虑的不是优化索引,而是业务层面是否需要一次取这么多数据。

3.2 预估和实际差得远:统计信息过期

filteredrows都是估算值,它们是否可信,完全取决于统计信息是否新鲜。

MySQL 的 InnoDB 引擎通过采样来估算索引基数,而不是每次数据变更都精确统计。如果一张表经历了大范围的删除或插入,却没有及时更新统计信息,执行计划里就可能出现严重失真的rowsfiltered

我之前排查过一个案例:某张订单表历史数据有 5000 万行,业务清理任务删掉了 80% 的数据,但执行计划里rows依然是 5000 万级别的旧估算值,filtered也不是真实水平,导致优化器选了一个错误的索引。解法很简单:

ANALYZE TABLE orders;

执行完再跑一次EXPLAINrowsfiltered都回到了正常范围。这个操作对 InnoDB 来说是轻量级的,它只会重新采样统计信息,不会锁表重建数据,日常维护中可以放心使用。

3.3 filtered=100 且 rows 很大时,优先检查访问类型

如果说filtered=100是一盏信号灯,那真正危险的组合是下面这个:

type = ALL rows = 几十万甚至上百万 filtered = 100

这意味着优化器判断:全表扫描出的每一行都会进入后续操作,没有任何“提前淘汰”。如果业务上实际返回的行数确实也很大,那可能是查询本身要处理的数据量就大,比如导出报表;如果业务上实际返回只有几十行,那说明优化器的估算和执行计划已经不一致了,常见原因包括:

  • 缺少合适的索引,MySQL 不得不全表扫描。
  • WHERE 条件里的列虽然有索引,但优化器由于数据分布原因认为走索引更慢。
  • 统计信息过期,导致成本评估严重偏离实际。

遇到这种组合,我的习惯是先看possible_keys,再看key。如果possible_keys有值但key是 NULL,或者key选择了明显不合适的索引,就要考虑使用FORCE INDEX临时验证,或者通过ANALYZE TABLE刷新统计信息。

4. 实测记录:从 filtered=100 到 filtered=15 的优化过程

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) ) ENGINE=InnoDB;

业务上有个页面要查某个订单下所有以 “Mac” 开头的商品明细,SQL 是这样:

SELECT * FROM order_items WHERE order_id = 12345 AND product_name LIKE 'Mac%';

EXPLAIN结果如下:

typepossible_keyskeyrowsfilteredExtra
refidx_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,而是带着他做了一遍排查:

  1. 先看type:这里是ref,说明已经用了order_id的等值索引,访问路径不算差。
  2. 再看possible_keys:只有idx_order_id,说明product_name没有可用索引。
  3. Extra里的Using where:这说明product_name的过滤发生在 Server 层,在拿到全部 12000 行之后才开始过滤。
  4. 最后算一遍“扫描行 vs 实际返回行”:扫描 12000 行,实际返回 1800 行,有 85% 的行被无意义地读取。

到这里,优化方向已经很明确了:需要让product_name的过滤也尽可能提前,最好在索引层面就完成。

4.3 添加复合索引后的执行计划

我们添加了一个复合索引:

ALTER TABLE order_items ADD INDEX idx_order_product (order_id, product_name);

再次跑EXPLAIN,结果变成:

typepossible_keyskeyrowsfilteredExtra
refidx_order_id, idx_order_productidx_order_product1800100.00Using where

注意看:filtered依然还是 100,但rows从 12000 降到了 1800。这就是我反复强调的一点:

优化复合索引后,最有价值的变化往往是rows的下降,而不是filtered的变化。filtered=100完全可以在优化后依旧出现,因为它表示“最终没有额外过滤损耗”。

这条 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 上场:用真实行数验证 filtered

5.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%') (cost=1200 rows=12000) (actual time=0.05..4.5 rows=1800 loops=1) -> Index lookup on order_items using idx_order_id (order_id=12345) (cost=800 rows=12000) (actual time=0.02..2.8 rows=12000 loops=1)

这里的信息非常好看:

  • 第一层Filter节点:actual rows=1800,这就是最终返回行数。
  • 第二层Index lookup节点:actual rows=12000,这就是通过索引实际读出来的行数。
  • 优化器估算的rows=12000和实际读取行数完全一致,但它对过滤能力的判断和真实结果有差距:它预估过滤后还是 12000,实际过滤后剩 1800。

把这段输出和EXPLAIN里的filtered=100对照,你就知道问题出在哪了:优化器对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 filtered=100 ≠ 没有 WHERE 条件

这是最常见的误解。filtered=100只是说明优化器认为扫描出来的行不会因为 Server 层过滤继续减少,并不代表查询语句里没有 WHERE。

比如:

SELECT * FROM employees WHERE department = 'Engineering';

如果该部门员工数占全公司 90%,优化器完全可能给出filtered=100。这时候你如果看到 100 就觉得“没有过滤”,那就错了,实际上是有过滤的,只是过滤效果不明显。

6.2 filtered 是成本模型的一部分,只影响执行计划选择

filtered不参与最终结果集的正确性判断,它只影响优化器的成本计算。MySQL 会根据扫描行数、过滤比例、索引访问代价、连接次数等一堆因素,计算出每个执行计划的“成本值”,然后选择成本最小的那个。

所以,filtered偏大或偏小,直接影响的不是查询结果,而是优化器“愿不愿意选某个索引”。如果你发现某条 SQL 的filtered明显和实际不符,但执行计划里也找不到更优解时,可以先容忍这个偏差,因为优化器最终选择的可能已经是最优路径了。

6.3 join buffer 和临时表对 filtered 的干扰

连表查询或带GROUP BYORDER BY的查询,执行计划里会出现Using temporaryUsing 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.tablesTABLE_ROWS和实际的COUNT(*)差距,如果偏差超过 30%,就尽快执行ANALYZE TABLE刷新。

注意:ANALYZE TABLE在 InnoDB 里是只读操作,不会重建表结构,也不会长时间锁表,大表执行速度通常是秒级或分钟级,可以安排到生产环境的低峰期操作。

最后再分享一个执行计划分析习惯

在我自己日常排查性能问题时,看到filtered = 100的第一反应是“冷静”,不是“开心”。我会先把这个字段和rowstypeExtra放在一起读一遍:

  • rows大不大?
  • typeALL还是ref还是eq_ref
  • Extra里有没有Using temporaryUsing filesort
  • 最终返回行数到底是多少?

这个流程帮我在不少慢查询里快速定位了真正的问题。比如说,filtered很低并不等于 SQL 快,它只是说明“过滤发生在 Server 层”;filtered=100也不等于 SQL 没问题,它只是说明“扫描行没有被继续淘汰”。真正能让执行计划分析产生价值的,是你对整条数据链路的理解,而不是某一行括号里的小数字。

希望这篇内容对你以后读执行计划有点帮助。如果你手头也有一个“明明感觉 SQL 不慢,但 EXPLAIN 看起来很怪”的案例,不妨先用EXPLAIN ANALYZE验证一下估算,再回头对比filtered和实际行数差异,多半能找到线索。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询