有一次,我在排查一个线上报表“数据少了”的问题。上游接口的数据量是正常的,单独跑那条 SQL 也能查到数据,但只要进了数仓的调度链路,每天都有几个分区的记录对不上。折腾了两天,最后定位到查询优化器的谓词下推上——一个看起来完全合理的 WHERE 条件,被下推到文件扫描层之后,把本不该过滤掉的行吃掉了。从那以后,我对“SQL 谓词下推”这几个字的看法就变了:它确实能让查询快几个数量级,但它绝对不是一个可以闭眼信任的黑盒优化。
这篇文章不打算再讲一遍“谓词下推有多好”的教科书内容,而是想把我在日常项目里遇到过的、以及和团队同学复盘过的那些潜在问题系统梳理一遍。如果你是做数据仓库、数据湖、或者查询引擎相关工作的同学,看完应该能少走不少弯路。
1. 先分清下推的四种层级,再谈它为什么值得冒险
谓词下推,说白了一句话:把 WHERE 和 JOIN ON 里的条件,尽量往数据源方向挪。数据库执行 SQL 时天然有几步:扫描数据、过滤、投影、聚合、Join。如果一个过滤条件在扫描阶段就能把 10 亿行减到 1 万行,谁还愿意让它留在 Join 之后呢?真正让优化器兴奋的,不仅是少算几行,而是它往下推得越深,“扫描-传输-计算”整条链路上的 I/O 和网络带宽都会被砍掉。
不过在聊潜在问题之前,我们得先把“下推”到底发生在哪几层分清楚。我习惯把它分成四层:
| 层级 | 典型实现 | 收益 | 主要风险 |
|---|---|---|---|
| 分区裁剪 | Hive / Spark / Trino 根据分区元数据直接跳过目录 | 减少文件数量,通常几个数量级 | 分区值格式、类型不匹配时漏数据 |
| 文件级裁剪 | Parquet / ORC 的 row group 内嵌 min/max 统计 | 跳过数据块,进一步降低 I/O | 数据乱序导致 min/max 覆盖全表,裁剪失效 |
| 扫描级过滤 | 扫描器在读取每行/每批时即时求值谓词 | 减少向上层引擎传输的数据量 | 表达式重复求值、函数语义和引擎不一致 |
| 引擎内下推 | 把 Filter 下推到 Project/Aggregate 前、把 Join 条件下推到上游算子 | 减少中间结果集,优化 Join 策略 | 基数估算被误导,Join 策略变更反而变慢 |
这四层不是互斥的,一个查询里可能同时用上多层。比如一张按日期分区的订单表,WHERE 里带dt = '2024-06-01',优化器先做分区裁剪,只在对应日期的目录里找文件;然后对每个 Parquet 文件读 row group 的 min/max 统计,发现某些文件里order_amount最大值都小于查询下限,于是整块跳过;剩下的 row group 在扫描时再逐行判断,只把匹配的行传回上层。
在没有这些优化的情况下,同样的查询可能要扫全表一整年的文件,I/O 差距跑个几百倍都很正常。这也是所有优化器都宁可冒险也要下推的根本原因——收益实在太大,大到让人下意识忽略它背后隐藏的默认假设。这些假设一旦被打破,下面的问题就来了。
2. 最容易被忽略的语义改变:同一条件下推后结果集悄悄变了
优化器做谓词下推时,默认谓词是“无副作用、确定性、且在任何执行环境中语义一致”的。这个假设对纯内置简单谓词基本成立,但一旦遇到 UDF、非确定性函数、时间日期函数、跨字符集比较,就很容易翻车。翻车最麻烦的地方在于:两边各跑各的都有道理,可结果就是不一样。
2.1 非确定性函数被“提前执行”或“重复执行”
有一个定时同步任务的场景很典型。任务要把某个来源表每天新增的数据拉进数仓,SQL 里写着:
SELECT * FROM source_logs WHERE event_time >= current_date - INTERVAL '1' DAY;current_date只在查询开始时计算一次,看起来没问题。但当一个查询被拆给多个 worker 并行扫描时,部分引擎会把current_date下推到每个扫描器的过滤条件里,每个扫描器各自调用一次。如果这个过程刚好跨过午夜,不同 worker 拿到的边界可能是 6 月 1 日 0 点和 6 月 2 日 0 点,看到的“增量数据”就不一样,最终写入目标表的结果自然错乱。
更极端的例子是写法本身就不规范的WHERE id = uuid()。这种条件如果被下推到文件层,每个文件甚至每个 row group 都会生成一个新的 UUID,本来想筛某一条记录,结果所有匹配条件全部打散,返回空集或者随机多行。
处理办法有两类。第一类是在引擎注册函数时把非确定性标记出来:Spark Catalyst 里Expression.deterministic返回 false 的表达式不会被这类优化改动,Trino/Hive 的 UDF 声明里也有对应的确定性属性。第二类是把值先算好再拼进 SQL,或者走参数化查询,让字面量参与比较而不是函数参与比较。
注意:很多引擎对窗口函数、聚合函数里的非确定性有额外防护,但简单的 Filter 条件下推往往不会做二次检查,这是这个坑容易踩中的原因。
2.2 时区、类型与 Collation 导致的结果漂移
这类问题我在实际排查里见过最多次。一张表的时间列ts用TIMESTAMP(3)存储,统一是 UTC。业务侧同学习惯写WHERE ts >= '2024-01-01 00:00:00',上层 BI 引擎按会话时区+08:00把它解释成2023-12-31 16:00:00 UTC,下推到存储层时已经是一个绝对时刻;另一个报表引擎则把它当成无时区字符串直接做文本比较。两个入口跑同一条业务语义的 SQL,命中的边界差了一整个时区,数据自然对不上。
字符类型的坑更隐蔽。底层存储对字符串比较可能走二进制排序,上层引擎却走大小写不敏感的 collation。一旦WHERE user_name = 'Alice'被下推到存储层,底层可能把alice也选出来;换成大小写敏感的比较,又可能漏掉真实存在的Alice。这种问题只看行数很难定位,必须把原始字符串拿出来分组对比。
2.3 存储层与计算层的函数方言差异
正则表达式是重灾区。Parquet 扫描器底层很多用 C++ 的正则库,像 RE2 这类实现;上层的 Trino/Presto 用的是 Java 正则。\d、反向引用、非贪婪匹配这些语法在两个环境里的表现并不完全一致。LIKE的转义规则在 Hive、Presto、Spark 之间也有细微差别,当下推把谓词翻译成底层过滤表达式时,原本在引擎里执行是对的,到了存储层语义却微调了,结果就是特定行被错误过滤或者错误保留。
这种差异往往只体现在个别数据上,开发环境的构造数据测不出来,一旦跑全量,行数突然就差了。所以遇到正则、LIKE、自定义函数参与的过滤条件,我会优先怀疑下推语义,而不是怀疑业务数据。
3. 过激下推导致的“假空结果”与错误裁剪
还有一类问题,表现不是慢,而是“空”——查询不报错,执行计划也正常,但结果集就是少一块或者干脆为空。空结果比慢更难排查,因为从执行计划看,谓词确实下推了,裁剪也确实生效了,只是剪错了。
3.1 分区字段类型与格式不匹配
最常见的情况:分区表的分区字段dt是 string,但上游写入的数据用了'20240601'这种紧凑格式。分析同学的习惯是写WHERE dt = '2024-06-01'。优化器严格按字符串字面值对比分区值,发现分区列表里没有'2024-06-01',于是判定没有匹配分区,直接返回空表。不是谓词本身错了,是分区键的“业务语义”和“物理取值”脱节了。
更隐蔽的版本是分区字段是 int,WHERE 里却写字符串;或者查询里对分区列做了CAST,分区裁剪因类型不匹配直接失效,本该裁剪的没裁剪,性能反而变差。排查这类问题,第一步永远是看真实存在的分区值:
SHOW PARTITIONS your_table; SELECT COUNT(*) FROM your_table WHERE dt = '2024-06-01'; SELECT COUNT(*) FROM your_table WHERE dt = '20240601';如果两个查询结果一个有一个没有,基本就是分区值格式问题,而不是数据问题。
3.2 统计信息过期导致的错误裁剪
文件级裁剪依赖文件头里的元数据:每个 Parquet/ORC 文件都记录了列的最小值、最大值、行数等信息。理论上这些统计和文件是一体的,但如果数仓有自己的元数据缓存层,文件被覆盖或替换后缓存没有及时刷新,优化器就会拿着旧统计做判断。
我遇到过一次很典型的现场:某一层 ETL 在 10:00 先写了一个小文件,统计信息显示col_a最大值是 100;10:10 任务重跑,又写入一个大文件,最大值变成 10000。查询如果落在 10:10 之后,却还在用旧的缓存元数据,WHERE col_a > 5000就会把这个本应命中的文件整体跳过,结果返回空。这虽然不完全是“谓词下推”的责任,但下推把“相信元数据”这个动作提前到了扫描之前,让故障面被放大了。
解决方案就三条:文件替换后必须触发元数据刷新;关键表查询前手动执行ANALYZE TABLE或REFRESH TABLE;遇到空结果先看执行计划里 physical input 的字节数是不是 0,是 0 就直接检查文件列表。
3.3 临时文件与未提交目录被误判为空分区
准实时场景里常见。写入引擎先把数据写到分区目录下的临时文件或者 staging 目录,提交时才重命名成正式文件。如果查询刚好落在“目录存在、正式文件还不存在”的窗口,扫描器就会认为该分区没有任何可读文件,谓词裁剪之后返回空。更麻烦的是,部分连接器会主动忽略_temporary这样的临时路径,元数据列表里这个分区的文件数就是 0,优化器直接跳过。
症状非常典型:每隔一段时间,查询会偶发少一个分区的数据,重跑一次又好了。我排查这类问题的固定套路是三步。第一步,到文件系统里看分区目录下的文件列表,确认有.tmp、_temporary这类文件。第二步,手动指定文件路径或者去掉分区过滤条件能查到数据,但带分区条件就空。第三步,把查询调度时间错开写入 commit 窗口,更稳妥的做法是改成“先写独立临时表,再原子切换分区”,从根上消除空窗口期。
4. 性能不升反降的三个隐蔽场景
下推确实是为了提速设计的,但“下推”和“变快”之间不是等号。下面三个场景我都实际遇到过,共同特征是 EXPLAIN 里显示谓词成功下推了,Profile 却显示 CPU 不降反升,查询变慢甚至失败。
4.1 基数估算误差被下推放大:Join 策略被带偏
谓词下推会改变扫描阶段的行数估计,优化器用这个估计去选后续算子的策略,尤其影响 Join 类型。如果过滤列的数据分布不均匀,估算值就可能严重偏离真实值。
我举个例子。一张订单表里user_tier = 'vip'的记录占比在整体统计里只有 0.1%,优化器据此认为过滤后只剩几万行,于是把一对多的 Join 从 SortMergeJoin 换成了 BroadcastJoin,把右表广播到所有节点。结果某次大促当天vip行占比直接涨到 20%,右表实际有上千万行,Broadcast 把内存打满,任务要么 OOM 要么疯狂落盘。关闭下推后,Join 策略回到稳妥的 SortMergeJoin,扫描的数据量虽然大了,但任务至少能跑完。
这种问题的确认方法不复杂:查看执行计划里 Join 的 build 侧大小和 broadcast 标志,再对比关闭下推后的物理计划。如果你经常在分布极度不均的列上做过滤,最好给列生成直方图统计,让基数估计更接近真实。但心里要始终记着一句话:下推不是问题的元凶,是放大器,它会把过时统计造成的决策误差放大成不可承受的代价。
4.2 数据乱序导致文件裁剪失效,还白多了一次逐行求值
Parquet/ORC 的文件级裁剪依赖每列在 row group 上的 min/max。只有当数据按过滤列排序,或者大致聚集时,min/max 的范围才足够窄,才能跳过大量数据块。但很多 ETL 任务写入时是按写入顺序排的,业务查询却按时间戳过滤。这种情况下,每个 row group 的 min/max 可能覆盖全表的所有时间范围,文件级裁剪一个 block 都跳不过去。
谓词照样被下推了,结果却是扫描器要在每个 block 上执行一遍过滤表达式。如果引擎层的过滤是向量化、批量执行的,而存储层过滤是一行一行解释执行的,下推后的总耗时反而比在上层过滤更高。数据量越大,这种感觉越明显。
我的建议是两选一。要么在写入时按常用过滤列做一次显式排序,让 row group 统计信息重新变得可用;要么对无法排序的大表调整 row group 大小,或者直接关闭扫描级过滤,只保留分区裁剪。别指望优化器能“聪明地”判断出 min/max 没有裁剪价值,它只会忠实地继续下推。
4.3 小文件场景下的重复编译与 CPU 空转
谓词下推通常会在连接器层生成表达式树,再编译成扫描器内部的过滤函数。对大文件来说,这个编译成本摊薄后小到可以忽略;但如果一张表有几十万个小文件,比如 Flink 高频率写入或者上游按分钟落盘,每个文件都要重复编译一遍同样的表达式,CPU 就全耗在这上面了。
我之前在一个模拟项目里专门测过:一张表有二十多万个小 Parquet 文件,同样的WHERE type = 'click',开启扫描级下推跑了 47 秒,关闭后只跑 38 秒。原因是下推表达式被编译了几十万次,而关闭下推后,数据读入引擎统一过滤,表达式只编译一次。这种场景下,“少读数据”省下来的 I/O 远远抵不上“重复编译”烧掉的 CPU。所以“下推”不该是一个永远开启的开关,最好能按表规模、文件数量动态选择是否启用扫描级过滤。
5. 混合引擎与外部数据源:跨源契约比想象中脆弱
当查询延伸到外部数据源,比如 Spark 读 JDBC 表、Trino 查 Hive、或者计算引擎直接读 CSV,谓词下推就不再是引擎内部优化,而是要把谓词翻译成另一个系统的表达式。问题在于,两边对谓词的语义理解并不总是一致的。
5.1 Spark JDBC 数据源的方言陷阱
Spark 从 MySQL/PostgreSQL 读数据时,JDBCRelation会把过滤谓词下推成远端 SQL,比如WHERE id > 100。大多数时候这是好事,但函数语义差异会带来很怪的问题。
举个例子,Spark 的字符串比较按 Java 的 UTF-16 字符顺序,MySQL 的VARCHAR比较却受 collation 影响,'a'和'A'可能被当成相等。如果 Spark 把WHERE user_name = 'Alice'下推给 JDBC 源,远端数据库按自己的排序规则过滤,Spark 却以为结果已经精确匹配。还有一个常见问题:远端库存的时间戳精度到微秒,Spark 侧可能只是微秒甚至毫秒精度,下推一个等值条件后,远端按精确值匹配,Spark 全表扫描再过滤的结果反而和下推结果对不上。症状同样是“读出来的行数比预期少”,但单独全表扫描再过滤又没问题。
建议:对 JDBC 源这类链路偏长的场景,优先在 session 配置里关掉谓词下推做一次对照测试。如果确实要保性能,尽量只下推=、>、<、>=、<=这类无函数包裹的裸列比较,避免函数翻译层出偏差。
5.2 Trino/Presto 连接 Hive 时的分区值契约
Trino 查 Hive 表时,谓词下推依赖 Hive Metastore 的分区元数据。一个很经典的问题:分区字段在 Metastore 里是 string,业务侧写入的分区值是'20240101',查询却写WHERE dt = DATE '2024-01-01'。Trino 想把谓词转成 Hive 分区过滤,需要把分区字符串 cast 成 date,转换规则又不是次次都能对齐,结果就是两种:cast 失败导致全部分区被跳过;或者 cast 之后'2024-01-01'和'20240101'永远不相等,返回空结果。最迷惑的是,执行计划里“谓词下推”是成功的,整个查询也没报错。
我的处理习惯是:遇到这类问题先SHOW PARTITIONS,看分区值实际长什么样,然后统一约定“分区值只允许 ISO 格式”,从源头消灭格式分裂。不要指望优化器帮你判断两种格式之间的业务等价性,它不是业务引擎。
5.3 外部表的 Schema 推断错误
CSV/JSON 外部表自动推断列类型时,最常见的坑是把“前 100 行全是数字”的字符串列推断成 bigint。之后查询写WHERE code = '00123',下推到扫描层,扫描层把列当 int 读出123,和字符串'00123'不匹配,该行直接被过滤。反过来的情况也有:字符串列被推断成数值类型,本来应该是精确匹配的编号被粗暴地做了数值比较,结果集产生偏差。
这类问题的根因不是谓词下推,但下推会让错误放大。如果列的类型根本没推断对,全表扫描后在上层过滤至少还有补救机会,下推到扫描层后连补救都省了。我的建议很简单:外部表永远显式声明 schema,不要依赖自动推断。如果实在无法避免,查询前先执行一次类型检查,确认列类型和谓词类型匹配,再考虑下推的问题。
6. 我的下推安全检查单与排查链路
被下推问题缠住的时候,光靠记忆零散排查效率太低。我自己整理了一套固定流程,遇到疑似谓词下推引起的问题,直接按这个链路走。
6.1 排查链路:从 EXPLAIN 到开关对照
第一步,先看执行计划。EXPLAIN里能看到谓词最终出现在哪一层:如果条件出现在 Scan 节点而不是 Filter 节点,说明已经下推到数据源;如果 Scan 和 Filter 里都有,说明是部分下推外加补充过滤,这时要关注补充过滤的逻辑是否和存储层过滤重复,重复过滤一般问题不大,最怕语义冲突。
第二步,对比开启和关闭下推的结果集。Spark 可以用优化器规则开关临时关闭某个下推规则,Trino 可以用 session property 或者连接器属性,Hive 也有对应的参数。临时关掉后重新执行同样的 SQL,把两次的COUNT(*)、SUM(amount)、MAX(ts)对齐一遍。只要有差异,就用分组统计法定位是哪一列、哪一分区出了问题。
第三步,检查物理文件状态。SHOW PARTITIONS看分区值,用 Parquet 工具读 row group 的 min/max,确认统计信息是否真实,确认临时文件是否影响扫描结果。很多时候,问题在现场文件的元数据里已经有了答案,只是被优化器直接跳过了。
第四步,回到 Profile 看耗时。如果下推后 CPU 更高或者任务失败,重点对照第 4 章里那三个隐蔽场景排查。先看 Join 策略,再看文件裁剪是否失效,最后看小文件数量是否导致重复编译。
6.2 我最终留存的谓词下推回归用例
我现在每次给新表、新查询引擎做“下推正确性验收”时,都会跑一组固定 SQL。下面这些用例可以直接抄作业,每一行都用“关闭下推后的结果”作为基准:
| 用例 | SQL 形态 | 我要确认的点 |
|---|---|---|
| 非确定性函数 | WHERE id = uuid()或WHERE dt >= current_date - INTERVAL '1' DAY | 多次运行结果是否稳定,是否被提前/重复执行 |
| 时区边界 | WHERE ts >= TIMESTAMP '2024-01-01 00:00:00 +08:00'且底层存 UTC | 不同时区 session 下结果是否一致 |
| 分区格式 | WHERE dt = '2024-01-01'但分区值是'20240101' | 分区裁剪会不会返回空 |
| Collation | WHERE user_name = 'Alice'且底层有alice | 大小写敏感差异是否被下推放大 |
| NULL 语义 | WHERE col NOT IN (NULL, 'x') | 三值逻辑在存储层的实现是否正确 |
| 类型推断 | 外部表 code 列含'00123'被推断成数字 | 显式声明 schema 后结果是否一致 |
每一行我都用“关下推的结果”当基准,开下推如果结果一致,才继续讨论性能。毕竟对数据查询来说,结果错了,性能毫无意义。
最后分享一个我现在的默认态度:下推优先级永远是“分区裁剪 > 文件级裁剪 > 扫描级 Filter”。分区裁剪依赖的元数据结构最简单,出错面最小;扫描级 Filter 牵涉表达式求值、函数方言、类型转换,风险最高。凡是遇到 UDF、非确定性函数、外部数据源,我默认禁用下推,除非有完整的回归测试背书。这套打法帮我挡住了不少线上“神秘空结果”和“某天突然变慢”的问题,希望对排查 SQL 执行计划的你也有点参考价值。