异步 MV 透明改写静默丢弃 WHERE 子句过滤谓词,导致查询返回错误结果

Viewed 8

版本

Apache Doris 4.0.2-rc02

可复现性

在以下条件下 100% 可复现:

  1. 异步 MV 定义中无 WHERE 子句
  2. 查询含 WHERE 子句(如分区列上的范围谓词)
  3. CBO 选择了该 MV 进行透明改写

Bug 描述

当带 WHERE 子句的查询被透明改写为使用一个无 WHERE 子句的异步物化视图时,MV 改写逻辑会静默丢弃查询的过滤谓词。改写后的计划中不包含 LogicalFilter 节点,导致:

  1. MV 扫描读取所有分区而非仅匹配 WHERE 条件的分区
  2. 分区裁剪失效 — 无谓词可用于裁剪
  3. 查询返回错误结果 — 包含无关分区数据(如历史日期数据)

这是一个静默数据正确性缺陷:不抛出错误、不记录警告,EXPLAIN 计划看似合法(仅缺失本应存在的 Filter 节点)。

复现步骤

环境搭建(脱敏 Schema)

-- 事实表
CREATE TABLE fact_table_a (
    date_key INT,
    key_col VARCHAR(32),
    measure_a DECIMAL(18,2),
    measure_b DECIMAL(18,2)
) DUPLICATE KEY(date_key, key_col)
PARTITION BY (date_key)
DISTRIBUTED BY HASH(key_col) BUCKETS 24
PROPERTIES ("replication_num" = "3");

-- 维度表
CREATE TABLE dim_table_b (
    key_col VARCHAR(32),
    attr_1 INT,
    attr_2 VARCHAR(64),
    attr_3 VARCHAR(64),
    attr_4 VARCHAR(64),
    attr_5 VARCHAR(64)
) DUPLICATE KEY(key_col)
DISTRIBUTED BY HASH(key_col) BUCKETS 24
PROPERTIES ("replication_num" = "3");

-- 异步物化视图(无 WHERE 子句)
CREATE MATERIALIZED VIEW mv_table_ab
BUILD DEFERRED REFRESH AUTO ON MANUAL
PARTITION BY (date_key)
DISTRIBUTED BY RANDOM BUCKETS 24
PROPERTIES ("grace_period" = "0")
AS
SELECT fa.date_key, db.attr_1, db.attr_2, db.attr_3,
       count(1) AS cnt,
       sum(fa.measure_a) AS total_a,
       sum(fa.measure_b) AS total_b
FROM fact_table_a fa
LEFT JOIN dim_table_b db ON fa.key_col = db.key_col
GROUP BY fa.date_key, db.attr_1, db.attr_2, db.attr_3;

-- 刷新 MV
REFRESH MATERIALIZED VIEW mv_table_ab AUTO;

触发 Bug

-- 带 WHERE 子句的查询
SELECT date_key, attr_1, attr_2, count(1)
FROM fact_table_a fa
LEFT JOIN dim_table_b db ON fa.key_col = db.key_col
WHERE date_key > 20260301
GROUP BY date_key, attr_1, attr_2
LIMIT 50;

-- 期望:仅 date_key > 20260301 的行
-- 实际(MV 改写后):包含所有 date_key 值的行(包括 20250101)

通过 EXPLAIN 验证

EXPLAIN SELECT date_key, attr_1, attr_2, count(1)
FROM fact_table_a fa
LEFT JOIN dim_table_b db ON fa.key_col = db.key_col
WHERE date_key > 20260301
GROUP BY date_key, attr_1, attr_2
LIMIT 50;

Bug 在 EXPLAIN 中的表现

  1. MV 扫描节点显示 partitions=N/N(全部分区)而非裁剪后的子集
  2. MV 扫描节点无 PREDICATES: 行对应 WHERE 条件
  3. MV 扫描与聚合之间无 LogicalFilter / VFILTER 节点
  4. VAGGREGATE 显示 sum(cnt)(上卷)但其前方无 Filter

正确行为(禁用 MV 改写)

SET SESSION enable_materialized_view_rewrite = false;

EXPLAIN SELECT ... WHERE date_key > 20260301 ...;
-- 显示:PREDICATES: date_key > 20260301
-- 显示:partitions=裁剪数/N(仅匹配分区)

影响评估

场景 影响
WHERE 条件作用于分区列 结果错误 — 包含历史数据
WHERE 条件作用于非分区列 结果错误 — 包含未过滤行
WHERE + GROUP BY = MV GROUP BY 聚合 + Filter 均丢失(Bug #3 + #4)
WHERE + GROUP BY 为 MV GROUP BY 子集 Filter 丢失(Bug #3),聚合通过上卷保留
无 WHERE 正确 — 无需谓词补偿
大 grace_period + 过期分区 额外过期数据风险(Bug #5 + #6)

以下是glm5.1结合代码诊断后的结论,作为参考

根因分析

Bug 链涉及 Nereids MV 改写引擎中 4 个互相关联的缺陷:

Bug 1:SplitPredicate.equals() 复制粘贴错误(Predicates.java:392)

// Predicates.java 第 391-393 行
SplitPredicate that = (SplitPredicate) o;
return Objects.equals(equalPredicateMap, that.equalPredicateMap)
    && Objects.equals(rangePredicateMap, that.residualPredicateMap)  // BUG:应为 that.rangePredicateMap
    && Objects.equals(residualPredicateMap, that.residualPredicateMap);

影响equals()this.rangePredicateMapthat.residualPredicateMap 比较,而非 that.rangePredicateMap。后果:

  • 违反 equals()/hashCode() 契约(hashCode 正确使用了 rangePredicateMap
  • 可能混淆范围谓词与残差谓词
  • 可导致改写逻辑中 SplitPredicate 比较失败

修复:将第 392 行改为 Objects.equals(rangePredicateMap, that.rangePredicateMap)

Bug 2:compensateRangePredicate() keySet 副作用(Predicates.java:212-213)

// Predicates.java 第 212-213 行
Set<Expression> queryRangeSet = querySplitPredicate.getRangePredicateMap().keySet();
queryRangeSet.remove(BooleanLiteral.TRUE);  // BUG:永久修改底层 HashMap!

影响keySet() 返回 HashMap 键的直接视图。对其调用 .remove()永久移除底层 rangePredicateMap 中的 TRUE 键。后续对 getRangePredicateMap() 的调用将缺失此谓词,导致在重复调用时静默丢失谓词。

修复:先使用 new HashSet<>(querySplitPredicate.getRangePredicateMap().keySet()) 创建防御性副本再执行 .remove()

Bug 3:doRewrite() Filter 丢失短路逻辑(AbstractMaterializedViewRule.java:274-275)

// AbstractMaterializedViewRule.java 第 274-275 行
if (compensatePredicates.isAlwaysTrue()) {
    rewrittenPlan = mvScan;  // BUG:未创建 LogicalFilter!
}

影响:当 compensatePredicates 判定为"恒真"(三个谓词 Map 均为空)时,改写计划完全跳过创建 LogicalFilter 节点。仅在 MV 的谓词已覆盖查询谓词时此逻辑正确。但 Bug #1/#2 导致补偿 Map 错误地为空时,查询的 WHERE 子句被静默丢弃。

触发条件:MV 无 WHERE 子句,查询有 WHERE 子句 → compensateRangePredicate() 计算差异 → 因 Bug #1/#2 结果为空或不正确 → isAlwaysTrue() 返回 true → 无 Filter。

修复:即使 isAlwaysTrue(),仍需验证查询原始谓词已被 MV 谓词覆盖;若未覆盖,始终用未补偿谓词创建 LogicalFilter

Bug 4:聚合改写丢弃范围谓词(AbstractMaterializedViewAggregateRule.java:128)

// AbstractMaterializedViewAggregateRule.java 第 128 行
ImmutableMap.of(),  // BUG:范围谓词被故意不传入 rewriteExpression

影响:执行 groupBy 相等的聚合改写(查询 GROUP BY 等于 MV GROUP BY)时,rewriteExpression()ImmutableMap.of() 作为范围谓词 Map 被调用。这意味着范围补偿谓词(如 data_date > X)在此改写路径中永远不会被应用,即使它们已被正确计算。

修复:传入实际的 compensatePredicates.getRangePredicateMap() 而非 ImmutableMap.of()

附加缺陷(P1)

Bug 5:宽限期绕过(MTMVRewriteUtil.java:69-72)

// MTMVRewriteUtil.java 第 69-72 行
long gracePeriodMills = mtmv.getGracePeriod();
for (Partition partition : allPartitions) {
    if (gracePeriodMills > 0 && currentTimeMills <= (partition.getVisibleVersionTime()
        + gracePeriodMills) && !forceConsistent) {
        res.add(partition);  // 绕过:将过期分区视为有效
        continue;
    }

影响:较大的 grace_period 值(如 360000ms = 6 分钟)使所有分区通过资格检查,绕过 isMTMVPartitionSyncanalyzeGracePeriod() 无上限校验——任何正数 Long 值均可被接受。

修复:为 grace_period 添加上限校验,并将 forceConsistent 暴露为 Session 变量。

Bug 6:PartitionCompensator 使用被绕过的分区列表(PartitionCompensator.java:93-94)

// PartitionCompensator.java 第 93-94 行
Collection<Partition> mvValidPartitions = cascadesContext.getStatementContext()
    .getMvCanRewritePartitionsMap().get(new BaseTableInfo(mtmv));

影响mvValidPartitions 来自宽限期绕过逻辑。当所有分区均被视为有效时,无需 union 补偿 → 可能提供过期或不完整数据。

修复建议概要

Bug 文件 行号 修复
#1 Predicates.java 392 that.residualPredicateMapthat.rangePredicateMap
#2 Predicates.java 212 new HashSet<>(...) 包装 keySet() 再执行 .remove()
#3 AbstractMaterializedViewRule.java 274 即使 isAlwaysTrue(),仍需验证查询谓词已被覆盖;未覆盖的谓词应作为 LogicalFilter 补充
#4 AbstractMaterializedViewAggregateRule.java 128 传入 compensatePredicates.getRangePredicateMap() 而非 ImmutableMap.of()
#5 MTMVRewriteUtil.java 69-72 MTMVPropertyUtil.analyzeGracePeriod() 中为 grace_period 添加上限校验;将 forceConsistent 暴露为 Session 变量
#6 PartitionCompensator.java 93-94 使用严格同步检查的分区列表,而非宽限期绕过后的列表

关联问题

  • 此 Bug 影响 SplitPredicate 类,该类是 MV 改写中所有谓词补偿的核心
  • keySet() 副作用 Bug(Bug #2)具有级联影响:TRUErangePredicateMap 中移除后,对同一 StructInfo 的所有后续操作均缺失此谓词
  • Bug #4(聚合改写中的 ImmutableMap.of())可能是为简化 groupBy 相等路径而刻意为之,但它错误地假设范围谓词已被 LogicalFilter 处理——而 Bug #3 阻止了该 Filter 的创建

源码引用(Doris 4.0.2-rc02)

  • fe/fe-core/src/main/java/org/apache/doris/nereids/rules/exploration/mv/Predicates.java
  • fe/fe-core/src/main/java/org/apache/doris/nereids/rules/exploration/mv/AbstractMaterializedViewRule.java
  • fe/fe-core/src/main/java/org/apache/doris/nereids/rules/exploration/mv/AbstractMaterializedViewAggregateRule.java
  • fe/fe-core/src/main/java/org/apache/doris/mtmv/MTMVRewriteUtil.java
  • fe/fe-core/src/main/java/org/apache/doris/nereids/rules/exploration/mv/PartitionCompensator.java
  • fe/fe-core/src/main/java/org/apache/doris/mtmv/MTMVPropertyUtil.java
  • fe/fe-core/src/main/java/org/apache/doris/qe/SessionVariable.java
0 Answers