跳转至

物化改写原理与最佳实践

本文面向刚接触数据库内核的读者,用少量关系代数概念解释 Tardis 如何把普通查询透明地改写为物化数据查询。 本文只介绍判断思路和经典场景,不展开具体规则、优化器实现和物化生命周期。

1. 先理解物化改写解决什么问题

假设下面的 SQL 需要扫描订单表、关联客户表,并计算最终结果:

SELECT
    o.order_id,
    c.customer_name,
    o.amount
FROM orders o
JOIN customers c
    ON o.customer_id = c.customer_id;

如果这段计算已经被提前执行,结果也已经保存到一张物理表中,那么查询时就不必再次扫描和关联原始表。 在 Tardis 中:

  • RP(RpGoal)描述需要提前计算的 SQL 逻辑,可以把它理解为物化视图的定义。
  • Materialization 是某次刷新生成的可用物化数据。
  • 物化改写是在不改变查询结果的前提下,用物化表扫描替换原查询中的一段计算。
flowchart LR
    A["原始查询<br/>扫描 orders"] --> B["JOIN customers"]
    B --> C["返回结果"]
    D["改写后的查询<br/>扫描物化表"] --> C

物化改写对用户是透明的:用户仍然查询原来的表或 View,优化器负责决定是否可以使用物化数据。

2. 核心原则:结果必须等价

判断能否改写时,最重要的不是两段 SQL 文本是否相同,而是它们表达的计算逻辑和结果集关系。

记:

  • Q 为用户查询;
  • M 为物化定义;
  • C 为改写后补在物化表之上的计算,例如过滤、字段选择或表达式计算。

只有当下面的关系成立时,改写才安全:

Q 的结果 = C(M 的结果)

C 也叫补偿计算。例如物化保存了某个月的全部订单,而查询只需要其中一天的数据,优化器可以扫描物化表后再补一个日期过滤条件。

flowchart LR
    A["物化结果 M"] --> B["补偿计算 C<br/>过滤 / 字段裁剪 / 表达式"]
    B --> C["查询结果 Q"]

这个原则包含三个基础要求:

  1. 行必须足够:物化结果必须覆盖查询需要的行,不能先丢掉查询所需的数据。
  2. 列必须足够:查询输出和补偿计算需要的字段,必须能从物化结果中取得或推导。
  3. 语义必须安全:Join 类型、空值、聚合粒度等语义必须保持一致,不能只看 SQL 长得相似。

3. 一次物化改写大致经历什么

为了便于理解,可以把改写过程概括为五步:

flowchart TD
    A["解析查询<br/>生成逻辑计划"] --> B["查找相关物化候选"]
    B --> C["规范化查询与物化计划<br/>消除无关的写法差异"]
    C --> D{"能否证明结果等价?"}
    D -- "否" --> E["继续使用原计划"]
    D -- "是" --> F["用物化表扫描替换原计算"]
    F --> G["补上必要的过滤、投影等计算"]
    G --> H["进入后续优化并选择最终计划"]

优化器比较的是 SQL 转换后的逻辑计划,而不是简单比较 SQL 字符串。因此,别名不同、字段顺序不同或一部分 Join 书写顺序不同,都不一定妨碍命中。

“规则能够产生物化改写方案”不等于“最终执行计划一定使用物化”。后续优化还会综合有效性、权限和代价等因素选择最终计划。

4. 经典命中场景

本节示例中的 rp_* 表示物化刷新生成的物理表,仅用于解释改写结果。实际查询不需要、也不应该直接依赖这个物理表名。

4.1 查询 View 时使用 Default Raw

假设有一个 View:

CREATE VIEW order_detail AS
SELECT
    o.order_id,
    o.order_date,
    o.status,
    o.amount,
    c.customer_name
FROM orders o
JOIN customers c
    ON o.customer_id = c.customer_id;

系统为 order_detail 创建了包含其全部字段的 RAW RP,并刷新得到物化表 rp_order_detail

用户查询:

SELECT
    order_id,
    amount
FROM order_detail
WHERE status = 'PAID';

普通执行方式需要先展开 View,再执行其中的 Join。Default Raw 改写会在 View 展开阶段直接用该 View 的默认 RAW 物化替换 View 的计算:

-- 原理示意,不是用户实际提交的 SQL
SELECT
    order_id,
    amount
FROM rp_order_detail
WHERE status = 'PAID';
flowchart LR
    subgraph Before["改写前"]
        A["扫描 orders"] --> B["JOIN customers"]
        B --> C["order_detail"]
        C --> D["status = 'PAID'"]
        D --> E["选择 order_id, amount"]
    end
    subgraph After["改写后"]
        F["扫描 order_detail 的<br/>Default Raw 物化"] --> G["status = 'PAID'"]
        G --> H["选择 order_id, amount"]
    end

这个场景的特点是:

  • 改写对象是被查询的 View;
  • RAW RP 覆盖 View 的全部字段;
  • 物化仍然有效,且不违反查询的权限约束;
  • 命中后无需展开 View 内部的复杂 SQL。

它与后文的通用逻辑匹配不同:Default Raw 更像是“这个 View 已经有一份可直接读取的完整结果”,因此可以尽早替换。

4.2 查询与物化的 SQL 逻辑相同

物化定义:

SELECT
    o.order_id,
    c.customer_name,
    o.amount
FROM orders o
JOIN customers c
    ON o.customer_id = c.customer_id;

用户查询:

SELECT
    o.order_id,
    c.customer_name,
    o.amount
FROM orders o
JOIN customers c
    ON o.customer_id = c.customer_id;

两者逻辑相同,整段计算可以替换为物化表扫描:

-- 原理示意
SELECT
    order_id,
    customer_name,
    amount
FROM rp_order_customer;
flowchart LR
    A["扫描 orders"] --> B["JOIN customers"]
    B --> C["字段选择"]
    C -. "整段替换" .-> D["扫描 rp_order_customer"]

这里的“逻辑相同”不要求 SQL 文本逐字一致。例如表别名不同、条件顺序不同,只要规范化后的关系计算等价,仍然可能命中。

4.3 字段裁剪:查询只需要物化中的部分字段

物化定义保存了较多字段:

SELECT
    order_id,
    customer_id,
    order_date,
    status,
    amount
FROM orders;

用户只查询其中两个字段:

SELECT
    order_id,
    amount
FROM orders;

物化已经包含查询需要的所有行和字段,优化器只需在物化表之上增加字段选择:

-- 原理示意
SELECT
    order_id,
    amount
FROM rp_orders_raw;
flowchart LR
    A["物化字段<br/>order_id, customer_id,<br/>order_date, status, amount"]
    A --> B["Project<br/>只保留 order_id, amount"]
    B --> C["查询结果"]

物化字段比查询多通常没有问题;多余字段可以被裁剪,不会改变结果。

4.4 补充或收紧条件:查询结果是物化结果的子集

物化定义保存 2026 年以来的订单:

SELECT
    order_id,
    customer_id,
    order_date,
    status,
    amount
FROM orders
WHERE order_date >= DATE '2026-01-01';

用户查询的条件更严格:

SELECT
    order_id,
    amount
FROM orders
WHERE order_date >= DATE '2026-07-01'
  AND status = 'PAID';

查询需要的行一定包含在物化结果中,因此可以在物化表之上补充更严格的过滤:

-- 原理示意
SELECT
    order_id,
    amount
FROM rp_orders_2026
WHERE order_date >= DATE '2026-07-01'
  AND status = 'PAID';
flowchart LR
    A["物化结果<br/>2026-01-01 以来的订单"] --> B["补偿过滤<br/>2026-07-01 以来<br/>且 status = 'PAID'"]
    B --> C["查询需要的子集"]

从集合角度看,查询条件必须比物化条件更严格:

满足查询条件的行 ⊆ 满足物化条件的行

还要注意,补偿过滤使用的 order_datestatus 必须存在于物化结果中。即使它们不出现在查询的最终输出里,优化器也需要读取这些字段才能完成过滤。

相反,如果物化只保存 7 月订单,而查询需要 2026 年全年的订单,物化缺少部分结果,不能仅靠物化表回答查询。

4.5 调整 Join 顺序

物化定义:

SELECT
    o.order_id,
    c.customer_name,
    r.region_name
FROM orders o
JOIN customers c
    ON o.customer_id = c.customer_id
JOIN regions r
    ON c.region_id = r.region_id;

用户调整了内连接的书写顺序:

SELECT
    o.order_id,
    c.customer_name,
    r.region_name
FROM regions r
JOIN customers c
    ON c.region_id = r.region_id
JOIN orders o
    ON o.customer_id = c.customer_id;

虽然两棵原始 Join 树的形状不同,但它们连接相同的表、使用相同的连接关系,并产生相同结果。物化匹配前,优化器可以把安全范围内的 Join 树转换成稳定的规范顺序,再进行比较。

flowchart TD
    subgraph Query["查询 Join 树"]
        Q1["regions"] --> Q2["JOIN customers"]
        Q2 --> Q3["JOIN orders"]
    end
    subgraph Materialization["物化 Join 树"]
        M1["orders"] --> M2["JOIN customers"]
        M2 --> M3["JOIN regions"]
    end
    Q3 --> N["规范化后的 Join 树"]
    M3 --> N
    N --> R["扫描 Join 物化"]

改写逻辑可以简单总结为:

  • 内连接通常具有交换性和结合性,因此书写顺序不同仍可能表达同一逻辑;
  • 外连接涉及保留行和空值填充,不能随意交换;
  • AIR 只会在能够保持依赖关系和 Join 语义的安全范围内做规范化。

5. 典型不能命中的场景

不能命中通常不是优化器“没有发现 SQL 很像”,而是无法证明仅靠物化结果可以安全、完整地得到查询结果。

5.1 查询增加了物化中没有的字段

物化定义:

SELECT
    order_id,
    customer_id,
    amount
FROM orders;

用户查询增加了 order_date

SELECT
    order_id,
    order_date,
    amount
FROM orders;

物化表没有保存 order_date,也无法从现有字段推导出它:

flowchart LR
    A["物化字段<br/>order_id, customer_id, amount"] --> B{"能否得到 order_date?"}
    B -- "不能" --> C["拒绝改写<br/>扫描原表"]

如果改写后再回查原表补字段,可能引入重复行、额外 Join 和一致性问题,这已经不是简单、安全的物化替换。因此该物化不能直接回答查询。

5.2 Left Join 右侧收紧条件,不能简单补偿

先看物化定义,它保留全部订单,并关联客户信息:

SELECT
    o.order_id,
    o.customer_id,
    c.customer_name,
    c.region
FROM orders o
LEFT JOIN customers c
    ON o.customer_id = c.customer_id;

用户把右表改成子查询,并把区域条件写入子查询:

SELECT
    o.order_id,
    o.customer_id,
    c.customer_name
FROM orders o
LEFT JOIN (
    SELECT
        customer_id,
        customer_name
    FROM customers
    WHERE region = 'EAST'
) c
    ON o.customer_id = c.customer_id;

对于一个属于 WEST 区域的客户:

  • 物化结果中,该订单已经匹配到客户,customer_name 非空;
  • 查询结果中,右侧子查询先排除了该客户,但 Left Join 仍需保留订单,并把 customer_name 填为 NULL

如果错误地在物化结果外层补 WHERE region = 'EAST',这个订单会被整行删除,而不是被保留并将右表字段置空:

-- 错误的补偿方式,结果与原查询不同
SELECT
    order_id,
    customer_id,
    customer_name
FROM rp_order_customer_left_join
WHERE region = 'EAST';
flowchart TD
    A["WEST 客户对应的订单"] --> B["物化中的 Left Join<br/>已匹配客户"]
    B --> C["物化结果:保留订单<br/>customer_name 非空"]
    A --> D["查询右侧先过滤 EAST"]
    D --> E["查询结果:保留订单<br/>customer_name = NULL"]
    C --> F{"外层过滤能否变成 E?"}
    F -- "不能;只会删除整行" --> G["拒绝改写"]

这里的关键不是“子查询一定不能命中”,而是过滤发生在 Left Join 的哪一侧、哪一个阶段,会改变保留行和空值填充语义

下面两种写法也不能想当然地互换:

-- 写法 A:Join 后过滤,未匹配行和非 EAST 行都会被删除
FROM orders o
LEFT JOIN customers c
    ON o.customer_id = c.customer_id
WHERE c.region = 'EAST'
-- 写法 B:右表先过滤,Left Join 仍保留所有 orders 行
FROM orders o
LEFT JOIN (
    SELECT *
    FROM customers
    WHERE region = 'EAST'
) c
    ON o.customer_id = c.customer_id

这也是物化改写必须保守处理外连接条件的原因。

6. 用一张表记住这些场景

场景 通常能否命中 判断关键
查询 View,存在覆盖全部 View 字段的 Default Raw 可以 可直接用完整 View 结果替代 View 展开
查询与物化 SQL 逻辑相同 可以 规范化后的关系计算等价
查询字段是物化字段的子集 可以 增加字段裁剪即可
查询条件比物化条件更严格 可以 查询行是物化行的子集,且补偿字段齐全
安全范围内调整 Join 顺序 可以 规范化后 Join 关系和语义一致
查询增加物化中没有且无法推导的字段 不可以 物化无法提供完整列
Left Join 右侧先过滤,试图改成物化结果上的外层过滤 不可以 保留行与 NULL 填充语义不同

7. AIR 推荐的投影和视图使用方式

7.1 固定 BI 查询:在视图上创建明细投影,查询视图

对于固定报表、固定看板等 BI 查询,推荐采用下面的方式:

  1. 把稳定的业务 Join 和字段定义为视图;
  2. 在该视图上创建明细投影;
  3. 明细投影的扫描字段覆盖视图的全部输出字段;
  4. BI 查询直接查询视图,在外层选择字段和补充筛选条件。
flowchart LR
    A["来源表"] --> B["固定业务视图"]
    B --> C["覆盖视图全部字段的<br/>明细投影"]
    D["BI 查询直接查询视图"] --> E["Default Raw 改写"]
    C --> E
    E --> F["字段裁剪和条件补偿"]

这种方式可以在 View 展开阶段通过 Default Raw 直接替换视图计算,不需要先完整展开复杂 View,再与大量候选投影逐一进行通用逻辑匹配,因此改写链路最短、改写速度最快,也更容易获得稳定的查询性能。

视图定义:

CREATE VIEW analytics.sales.order_detail AS
SELECT
    o.order_id,
    o.order_date,
    o.customer_id,
    o.status,
    o.amount,
    c.customer_name,
    c.region
FROM source_sales.public.orders o
LEFT JOIN source_crm.public.customers c
    ON o.customer_id = c.customer_id;

BI 查询直接引用该视图:

SELECT
    order_id,
    customer_name,
    amount
FROM analytics.sales.order_detail
WHERE order_date >= DATE '2026-07-01'
  AND status = 'PAID';

如果明细投影只选择视图的部分字段,它就不能表示 View 的完整结果,也不能作为覆盖完整 View 的 Default Raw。此时包含未选字段的查询将无法使用该投影。

7.2 查询 SQL 与视图逻辑一致:保持 Left Join 位置稳定

如果查询没有直接引用视图,但 SQL 与视图定义的逻辑一致,仍然可以通过通用逻辑匹配使用视图上的投影。为了提高改写成功率,推荐保持表集合、Join 类型和 Join 条件一致。

  • Inner Join 通常具有交换性和结合性,在连接关系不变的前提下可以调整顺序;
  • Left Join 涉及保留左表数据和右表字段补 NULL,不要随意交换左右表,也尽量不要调整 Left Join 在 Join 链中的位置;
  • 不要把 Left Join 改成 Inner Join、Right Join,或把右表改成过滤语义不同的子查询;
  • 不要在 ON、外层 WHERE 和右表子查询之间随意移动右表过滤条件。
flowchart TD
    A["查询 SQL 与视图逻辑一致"] --> B{"Join 类型"}
    B -- "Inner Join" --> C["连接关系不变时<br/>可以交换顺序"]
    B -- "Left Join" --> D["保持左右表、Join 位置<br/>和过滤位置稳定"]
    C --> E["规范化后参与物化匹配"]
    D --> E

即使两段 SQL 使用相同的表和关联字段,调整 Left Join 的位置也可能改变哪些行被保留以及哪些字段被填为 NULL。当优化器无法证明结果等价时,就会放弃改写。

7.3 为特定查询创建加速视图:Join 和 Filter 的推荐方式

如果创建视图的目的就是加速某个已知查询,推荐以目标查询为模板定义视图,使视图逻辑与查询逻辑相同,或者让视图结果适度覆盖查询所需的数据。

Join 推荐方式

  • 视图与目标查询使用相同的表、Join 类型和 ON 条件;
  • 最稳妥的方式是保持 Join 顺序也一致;Inner Join 确有需要时可以交换顺序;
  • Left Join 应保持左侧保留表、右侧被关联表和 Join 在整棵 Join 树中的位置一致;
  • 不要为了“可能复用”而加入目标查询不需要的 Join,额外 Join 可能改变行数或增加匹配难度;
  • 视图应具有明确的业务粒度,并显式输出查询结果、补偿过滤和后续关联需要的字段。

Filter 推荐方式

视图中的过滤条件推荐与查询条件相同,或者比查询条件更宽松,使查询结果成为视图结果的子集:

查询结果集 ⊆ 视图及其投影的结果集

例如,视图保存 2026 年以来的订单:

CREATE VIEW analytics.sales.orders_2026 AS
SELECT
    order_id,
    order_date,
    status,
    amount
FROM source_sales.public.orders
WHERE order_date >= DATE '2026-01-01';

目标查询使用更严格的条件:

SELECT
    order_id,
    amount
FROM source_sales.public.orders
WHERE order_date >= DATE '2026-07-01'
  AND status = 'PAID';

优化器可以在视图投影之上补充日期和状态过滤。为了完成补偿,order_datestatus 必须包含在视图输出及明细投影的扫描字段中。

编写 Filter 时还建议:

  • 把长期稳定、所有目标查询都需要的业务范围条件放入视图;
  • 把日期范围、状态等单次查询特有的更严格条件留在查询中;
  • 优先使用字段与常量之间直接、类型一致的比较,避免不必要的函数封装和类型转换;
  • Left Join 右表条件在视图与查询中保持相同位置,不要在右表子查询、ON 和外层 WHERE 之间移动;
  • 除非结果范围本身就是业务定义,不要在加速视图中加入 LIMIT,展示排序使用的 ORDER BY 也应留给最终查询。

如果视图条件比查询更严格,视图已经丢失查询需要的部分数据,不能仅靠投影恢复这些行。

7.4 使用 SQL Hint 缩小候选投影范围

当固定查询已经明确应使用哪些投影时,可以通过 Context include Hint 指定候选投影 ID:

/*+ Context("include"='投影ID1,投影ID2') */
SELECT
    order_id,
    customer_name,
    amount
FROM analytics.sales.order_detail
WHERE order_date >= DATE '2026-07-01';

多个投影 ID 写在同一个字符串中,并使用英文逗号分隔。设置 include 后,物化改写只会在指定投影范围内查找候选,不再考虑列表之外的投影。候选范围越小,需要比较的物化越少,改写效率通常越高。

需要注意:

  • include 限制的是候选范围,并不强制查询一定使用指定投影;
  • 指定投影仍需处于可用状态,并满足行、列、权限和逻辑等价要求;
  • 如果指定错误或遗漏了可用投影,查询可能无法命中物化;
  • 推荐用于候选投影明确且 SQL 形态稳定的固定 BI、报表或接口查询。

7.5 推荐顺序

从改写速度和稳定性考虑,优先级可以概括为:

  1. 固定 BI 查询优先“查询 View + View 全字段明细投影”;
  2. 无法直接查询 View 时,使查询 SQL 与视图逻辑保持一致,尤其保持 Left Join 结构稳定;
  3. 为特定查询设计视图时,使 Join 一致、视图 Filter 相同或更宽松,并保留补偿字段;
  4. 已知目标投影时,使用 Context include Hint 缩小候选范围。