从 TQuel 看 PostgreSQL 时态代数:为什么保留 valid-time 会破坏优化规则

2026-07-21 33 预计阅读时间: 1 分钟
来源: postgr.es AI 摘要 Original link

Disclaimer: This article is an AI-assisted summary. Read it together with the original source when precision matters. The summary may omit context, version differences, or edge cases and is not official documentation.

预计阅读时间:14 分钟

随着 PostgreSQL 可能在 19 版本加入 UPDATE/DELETE FOR PORTION OF,时态数据支持正在从单个语法功能走向更完整的查询模型。真正棘手的问题并不是“如何保存历史”,而是:时态关系运算能否像普通关系运算一样被查询优化器安全地重排?

Paul Jungwirth 阅读 Richard Snodgrass 的 TQuel 论文后,重新审视了时态关系代数中的几个关键假设,尤其是笛卡尔积、差集和 valid-time 的语义。这些讨论对 PostgreSQL 的时态连接、集合运算和未来的专用执行节点都有直接参考价值。

TQuel 与 PostgreSQL 的距离

TQuel 是 Ingres 查询语言 Quel 的时态扩展,而 Ingres 正是 PostgreSQL 的前身之一。它和 SQL:2011 的 PERIOD 有一个重要区别:TQuel 的有效时间不是一个单独的连续区间,而是由多个 chronon 组成的集合。

可以把它理解成:某个元组在哪些离散时间点成立。这个集合不要求连续,因此更接近 PostgreSQL 的 multirange

-- 示例:一个事实在两个不连续的时间段内有效
SELECT '{[2024-01-01,2024-02-01),[2024-03-01,2024-04-01)}'::daterange[];

在实际建模中,可以使用 datemultirange 表示这种时间集合:

CREATE TABLE account_status_history (
    account_id bigint NOT NULL,
    status text NOT NULL,
    valid_during datemultirange NOT NULL
);

INSERT INTO account_status_history
VALUES (
    42,
    'active',
    '{[2024-01-01,2024-02-01),[2024-03-01,2024-04-01)}'::datemultirange
);

-- 查询某一天是否处于有效时间内
SELECT account_id, status
FROM account_status_history
WHERE valid_during @> DATE '2024-03-15';

TQuel 的另一个不同点是:它为每个属性保存 valid-time,而不是只为整个元组保存一个时间范围。这样的表示更接近“每个事实独立变化”的模型,也让关系代数更加纯粹,但会增加实现和使用复杂度。

好消息是,SQL:2011 风格的元组时间可以转成 TQuel 所需的形式:把元组的 valid-time 复制到每个属性上。Snodgrass 将这种关系称为 homogeneous relation。这意味着,TQuel 的部分理论仍然可能适用于 PostgreSQL 的整体元组时间模型,但需要验证相关运算是否仍然保持封闭性。

Snapshot reducibility:先过滤历史,再执行普通查询

论文中一个非常有用的结果是:对于 homogeneous relation,很多 TQuel 运算具备 snapshot reducibility

直观地说,如果最终只关心某个时间点 t 的结果,那么可以采用两种策略:

  • 先执行完整的时态运算,再取时间点 t 的快照。
  • 先从每张基础表取出时间点 t 的快照,再执行普通关系运算。

第二种方式通常更适合数据库执行器,因为它允许把时间过滤条件尽早下推到扫描阶段。用关系代数表示,就是类似下面的性质:

σ(R ×̂ S) = σ(R) × σ(S)

这里的 σ 表示在某个时间点取快照,×̂ 表示时态笛卡尔积,右侧则是普通笛卡尔积。

在 PostgreSQL 中,可以先用视图封装快照逻辑,让现有应用继续运行普通 SQL:

CREATE VIEW current_account_status AS
SELECT account_id, status
FROM account_status_history
WHERE valid_during @> CURRENT_DATE;

SELECT a.account_id, a.status, p.plan_name
FROM current_account_status AS a
JOIN account_plan AS p USING (account_id)
WHERE p.enabled;

对于应用迁移,这个性质很重要。历史表可以先建立起来,已有查询不必立即改写成复杂的时态语法。应用只需要查询按时间过滤的视图,后续再逐步引入时态连接、时态聚合和历史查询。

关键反例:笛卡尔积不一定能分配到差集

普通关系代数中,开发者和优化器经常依赖各种等价变换。但 TQuel 的 Theorem 13 指出,至少有一个熟悉的恒等式在时态语义下不成立:

Q ×̂ (R −̂ S) ≢ (Q ×̂ R) −̂ (Q ×̂ S)

在 SQL 中,差集对应 EXCEPT。因此,这个问题会影响以笛卡尔积为基础定义的连接变换。

考虑下面三个 TQuel 风格关系:

Q = { ('a', {[0,20)}) }
R = { ('b', {[0,20)}) }
S = { ('b', {[5,10)}) }

左侧先计算 R −̂ S,得到:

R −̂ S = { ('b', {[0,5), [10,20)}) }
Q ×̂ (R −̂ S)
= { ('a', {[0,20)}, 'b', {[0,5), [10,20)}) }

右侧则先做两个笛卡尔积,再计算差集:

Q ×̂ R = { ('a', {[0,20)}, 'b', {[0,20)}) }
Q ×̂ S = { ('a', {[0,20)}, 'b', {[5,10)}) }

(Q ×̂ R) −̂ (Q ×̂ S)
= { ('a', {}, 'b', {[0,5), [10,20)}) }

b 的时间结果看起来相同,但 a 的 valid-time 被消成了空集。原因在于,差集比较的不只是普通属性值,还会受到输入 valid-time 的影响;在右侧,两个完整笛卡尔积元组的时间属性不完全相同,导致差集的行为与普通关系代数不同。

这个反例对查询优化器有直接含义:不能因为某个变换在普通关系代数中成立,就默认它在时态关系上也成立。任何时态连接重排、差集下推或笛卡尔积分配,都需要明确的语义证明或反例测试。

valid-time 到底应该保留吗?

问题的根源之一,是时态运算是否应该把输入元组的 valid-time 当作普通属性原样带入结果。

使用普通区间表示时,可以观察到类似现象:如果笛卡尔积保留两个输入的原始时间范围,并额外计算交集作为结果时间,那么右侧差集可能因为“时间属性不相等”而无法执行预期的减法。

如果运算只保留运算结果的 valid-time,而不把输入时间范围作为普通结果属性,代数结果就能恢复一致:

Q ×̂ (R −̂ S)
= { ('a', 'b', [0,5)), ('a', 'b', [10,20)) }

(Q ×̂ R) −̂ (Q ×̂ S)
= { ('a', 'b', [0,5)), ('a', 'b', [10,20)) }

这带来一个重要的设计取舍:保留输入 valid-time 很有用,但不一定适合作为默认语义。

保留它可以支持一些实际场景,例如:

  • 根据预订持续时间计算折扣。
  • 在过滤时判断一条记录的有效时长。
  • 在连接条件中使用两个事实的原始有效时间。
  • 对时态聚合进行按时间长度加权。

但对关系运算本身而言,valid-time 更像是元组属性的限定条件,而不是普通业务列。可以把一个带时间的元组想象成一组普通元组,每个 chronon 对应一个时间点;时态运算的结果应当由这些快照结果重新组合,而不是机械地携带输入时间字段。

这也解释了 SQL:2011 PERIOD 设计中的一个矛盾:PERIOD 不是普通 SQL 值,不能像普通列一样自由地 SELECT、放入视图或子查询、传递给函数、返回、GROUP BY。从可组合性看,这很不方便;但从关系代数语义看,把 valid-time 当作“限定条件”而不是普通属性,反而更容易保持运算规律。

可以如何验证时态恒等式

如果正在实现 PostgreSQL 时态扩展,建议把每个候选优化规则先写成可执行的性质测试,而不是直接交给 planner。下面是一个最小的 PostgreSQL 实验,使用 datemultirange 检查两个时间集合的差集:

WITH q AS (
    SELECT 'a'::text AS value,
           '{[2024-01-01,2024-01-21)}'::datemultirange AS valid_time
), r AS (
    SELECT 'b'::text AS value,
           '{[2024-01-01,2024-01-21)}'::datemultirange AS valid_time
), s AS (
    SELECT 'b'::text AS value,
           '{[2024-01-06,2024-01-11)}'::datemultirange AS valid_time
)
SELECT
    r.valid_time - s.valid_time AS r_minus_s,
    q.valid_time * (r.valid_time - s.valid_time) AS lhs_time,
    (q.valid_time * r.valid_time)
      - (q.valid_time * s.valid_time) AS rhs_time
FROM q, r, s;

这里的 * 是 PostgreSQL multirange 的交集运算。这个查询只演示时间集合的核心行为;真实扩展还需要定义元组相等、重复行、NULL、属性级 valid-time,以及笛卡尔积输出模式。

可以进一步用 relsim 这类关系计算器枚举小规模关系,自动寻找反例:为每个关系生成少量元组和时间片,分别计算恒等式左右两侧,然后比较规范化后的结果。对 planner 来说,这类测试比几个手工例子更可靠,因为时态语义的边界往往藏在空时间集、断裂区间、重复元组和不同属性时间组合中。

给 PostgreSQL 实现者的落地建议

当前可以把工作拆成三个层次:

  1. 先做快照查询。 使用 rangemultirange 存储历史,并通过 valid_during @> $1 过滤到指定时间点。这个阶段可以继续使用普通执行计划。
  2. 再定义时态运算语义。 明确连接、差集、投影、聚合是否保留输入时间,以及结果时间如何计算。不要让 SQL 实现中的偶然行为成为隐式规范。
  3. 最后接入优化器。 在引入 CustomScan、专用执行节点或 planner hook 之前,为每条变换规则准备证明、反例和回归测试。

一个实用的判断标准是:如果把时态关系展开成每个 chronon 一条普通关系记录,某个运算的结果与展开后再计算的结果不一致,那么这个运算就需要特别谨慎。valid-time 可以被暴露给用户查询,但不应未经设计就被当成普通属性参与所有关系代数变换。

结语

TQuel 的价值不只在于它是一套 1990 年代的时态查询语言。它提醒我们,时态数据库的难点集中在代数语义:哪些规则仍然成立,哪些规则必须放弃,以及 valid-time 在结果中究竟扮演属性、限定条件还是内部计算依据。

对于 PostgreSQL,snapshot reducibility 提供了一个务实的起点:先把历史表按时间过滤,再复用成熟的普通 SQL 计划。更复杂的时态连接和集合运算则需要逐条验证。尤其是差集与笛卡尔积相关的变换,不能仅凭普通关系代数的直觉放进优化器。

采用时态模型时,建议保留一份明确的语义清单:时间是连续区间还是 multirange,作用于元组还是属性,是否保留输入时间,如何处理重复和 NULL,以及每条优化规则的证明或反例。这样,未来从 SQL 扩展走向专用执行器时,性能优化才不会悄悄改变查询结果。


相关推荐