先问一个问题:你用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';输出列很多,但核心几列通常是这样的:
| id | select_type | table | type | possible_keys | key | rows | filtered | Extra |
|---|---|---|---|---|---|---|---|---|
| 1 | SIMPLE | orders | ALL | NULL | NULL | 10000 | 100.00 | Using where |
filtered在rows列后面,看起来不太起眼,但它的作用非常关键:rows告诉你的,是存储引擎层预计扫描多少行;filtered告诉你的,是这 10000 行里,经过查询条件过滤之后,预计还剩百分之多少。
也就是说,这里是100.00,意思是优化器预计:扫描到的 10000 行,全部都会保留下来,不会继续淘汰。
我在最初接触这个字段时也有一个误区,以为filtered=100表示这条查询条件把数据“过滤得很干净”。后来看官方文档才意识到,它表达的是“过滤过程中没有被减少”,和“过滤效果好坏”完全是两回事。
1.2 计算公式:rows × filtered / 100
真正要关注的结果是下面这个公式:
预估返回行数 = rows * filtered / 100套到刚才的例子:
10000 * 100 / 100 = 10000也就是说,优化器认为这条 SQL 最终会返回 10000 行,而它又是全表扫描,没有任何索引可用。这个信号其实很中性,具体是好事还是坏事,要看你的 WHERE 条件长什么样。
再看一个过滤比例比较低的情况:
| table | type | key | rows | filtered |
|---|---|---|---|---|
| users | ref | idx_status | 5000 | 20.00 |
这里rows是 5000,filtered是 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 Pushdown,ICP)阶段已经过滤了一部分行,filtered反映的依然是 Server 层那一轮的“损耗比例”。
2. filtered = 100 的几种常见场景:别被“满分”骗了
2.1 无 WHERE 条件的全表扫描:100 是必然结果
先看最简单的场景:
SELECT * FROM users;没有 WHERE,没有过滤,存储引擎扫出来多少行,最终就返回多少行,filtered自然是 100。
这种场景下看filtered其实没什么意义,重点应该放在type列是不是ALL上。如果一张大表经常出现这种查询,你要思考的就不是执行计划里的百分比,而是为什么会产生全表扫描,以及业务上是否真的需要把整张表都捞出来。
我见过一个比较典型的案例:有同事为了“省事”,在一个后台列表接口里没加任何条件就关联了三张大表,三张表的type全是ALL,filtered全是 100。执行计划看起来“数值统一”,但实际运行直接让连接数膨胀到了几十万行,最后把数据库 CPU 打满了。这个例子说明,filtered=100本身并不背锅,真正的问题是访问路径。
2.2 WHERE 条件已完全下推给索引:100 反而代表高效
filtered=100不一定都是坏事。如果查询条件里的列全部被索引覆盖,且优化器认为通过索引直接就能定位到最终行,那么过滤动作发生在索引查找阶段,Server 层返回的行就是最终结果,此时filtered会显示为 100。
举个例子:
SELECT id, name FROM users WHERE id = 100;如果id是主键,执行计划通常是:
| type | key | rows | filtered |
|---|---|---|---|
| const | PRIMARY | 1 | 100.00 |
这里filtered=100是完全正常的:按照主键等值查找,扫到 1 行,这 1 行必然满足条件。你并不会因为它是 100 就担心有问题,因为rows=1,基数已经足够小。
类似的还有:
SELECT order_id, amount FROM payments WHERE order_id = 12345;如果order_id上有二级索引,且查询字段都在索引里(覆盖索引),执行计划可能显示type=ref,rows是该订单下的付款记录数,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 预计返回行数的计算逻辑
单独看rows或filtered都有点“盲人摸象”,两个字段合起来才是优化器眼中的预估返回行数。
下面用一个表来做对比:
| 场景 | type | rows | filtered | 预计返回行数 |
|---|---|---|---|---|
| 全表扫描,无条件 | ALL | 20000 | 100% | 20000 |
| 单列索引等值,结果集大 | ref | 8000 | 50% | 4000 |
| 复合索引精确定位 | ref | 30 | 100% | 30 |
| 无索引,条件区分度低 | ALL | 50000 | 90% | 45000 |
这里能明显看出,filtered=100并不稀奇,真正有价值的判断标准是“扫描多少行、最终留下多少行”。如果扫描行数多,返回行数也多,那这条 SQL 本身的数据体量就大,你需要考虑的不是优化索引,而是业务层面是否需要一次取这么多数据。
3.2 预估和实际差得远:统计信息过期
filtered和rows都是估算值,它们是否可信,完全取决于统计信息是否新鲜。
MySQL 的 InnoDB 引擎通过采样来估算索引基数,而不是每次数据变更都精确统计。如果一张表经历了大范围的删除或插入,却没有及时更新统计信息,执行计划里就可能出现严重失真的rows和filtered。
我之前排查过一个案例:某张订单表历史数据有 5000 万行,业务清理任务删掉了 80% 的数据,但执行计划里rows依然是 5000 万级别的旧估算值,filtered也不是真实水平,导致优化器选了一个错误的索引。解法很简单:
ANALYZE TABLE orders;执行完再跑一次EXPLAIN,rows和filtered都回到了正常范围。这个操作对 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结果如下:
| type | possible_keys | key | rows | filtered | Extra |
|---|---|---|---|---|---|
| ref | idx_order_id | idx_order_id | 12000 | 100.00 | Using 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,结果变成:
| type | possible_keys | key | rows | filtered | Extra |
|---|---|---|---|---|---|
| ref | idx_order_id, idx_order_product | idx_order_product | 1800 | 100.00 | Using 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 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_ref?Extra里有没有Using temporary、Using filesort?- 最终返回行数到底是多少?
这个流程帮我在不少慢查询里快速定位了真正的问题。比如说,filtered很低并不等于 SQL 快,它只是说明“过滤发生在 Server 层”;filtered=100也不等于 SQL 没问题,它只是说明“扫描行没有被继续淘汰”。真正能让执行计划分析产生价值的,是你对整条数据链路的理解,而不是某一行括号里的小数字。
希望这篇内容对你以后读执行计划有点帮助。如果你手头也有一个“明明感觉 SQL 不慢,但 EXPLAIN 看起来很怪”的案例,不妨先用EXPLAIN ANALYZE验证一下估算,再回头对比filtered和实际行数差异,多半能找到线索。