物化改写原理与最佳实践
本文面向刚接触数据库内核的读者,用少量关系代数概念解释 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为改写后补在物化表之上的计算,例如过滤、字段选择或表达式计算。
只有当下面的关系成立时,改写才安全:
C 也叫补偿计算。例如物化保存了某个月的全部订单,而查询只需要其中一天的数据,优化器可以扫描物化表后再补一个日期过滤条件。
flowchart LR
A["物化结果 M"] --> B["补偿计算 C<br/>过滤 / 字段裁剪 / 表达式"]
B --> C["查询结果 Q"]
这个原则包含三个基础要求:
- 行必须足够:物化结果必须覆盖查询需要的行,不能先丢掉查询所需的数据。
- 列必须足够:查询输出和补偿计算需要的字段,必须能从物化结果中取得或推导。
- 语义必须安全: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。
用户查询:
普通执行方式需要先展开 View,再执行其中的 Join。Default Raw 改写会在 View 展开阶段直接用该 View 的默认 RAW 物化替换 View 的计算:
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;
两者逻辑相同,整段计算可以替换为物化表扫描:
flowchart LR
A["扫描 orders"] --> B["JOIN customers"]
B --> C["字段选择"]
C -. "整段替换" .-> D["扫描 rp_order_customer"]
这里的“逻辑相同”不要求 SQL 文本逐字一致。例如表别名不同、条件顺序不同,只要规范化后的关系计算等价,仍然可能命中。
4.3 字段裁剪:查询只需要物化中的部分字段
物化定义保存了较多字段:
用户只查询其中两个字段:
物化已经包含查询需要的所有行和字段,优化器只需在物化表之上增加字段选择:
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 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_date 和 status 必须存在于物化结果中。即使它们不出现在查询的最终输出里,优化器也需要读取这些字段才能完成过滤。
相反,如果物化只保存 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 查询增加了物化中没有的字段
物化定义:
用户查询增加了 order_date:
物化表没有保存 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 查询,推荐采用下面的方式:
- 把稳定的业务 Join 和字段定义为视图;
- 在该视图上创建明细投影;
- 明细投影的扫描字段覆盖视图的全部输出字段;
- 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_date 和 status 必须包含在视图输出及明细投影的扫描字段中。
编写 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 推荐顺序
从改写速度和稳定性考虑,优先级可以概括为:
- 固定 BI 查询优先“查询 View + View 全字段明细投影”;
- 无法直接查询 View 时,使查询 SQL 与视图逻辑保持一致,尤其保持 Left Join 结构稳定;
- 为特定查询设计视图时,使 Join 一致、视图 Filter 相同或更宽松,并保留补偿字段;
- 已知目标投影时,使用
Context includeHint 缩小候选范围。