2. 数据仓库中的时间维度:缓慢变化维度(SCD)类型、有效时间与事务时间

聊 Point-in-Time Join 之前,咱们得先把时间维度这件事掰扯清楚。我见过不少团队,PIT Join 写得挺溜,结果数据一跑出来全是错的——问题根源往往不在 Join 语法上,而在对时间语义的理解出了偏差。

说白了,数据仓库里的时间不是只有一种。你想想看,一个客户今天改了地址,明天又改回来,那这条记录到底哪个时间才是“对的”?这就引出了我们今天要聊的两个核心概念:有效时间和事务时间,以及它们背后的实现机制——缓慢变化维度。

2.1 缓慢变化维度(SCD)的三种主流类型

SCD 这个概念,最早是 Kimball 老爷子提出来的。我刚开始做数仓那会儿,觉得这东西太理论了,直到有一次上线后业务方跑来质问:“为什么上个月报表里的客户地址和这个月不一样?”——嗯,从那以后我再也不敢小看 SCD 了。

SCD 的核心问题很简单:维度属性变了,历史数据怎么办?

常见的做法有三种:

类型 处理方式 典型场景
SCD Type 1 直接覆盖,不保留历史 客户邮箱、联系电话(不重要属性)
SCD Type 2 新增记录,保留完整历史 客户地址、产品分类(重要属性)
SCD Type 3 增加字段,保留有限历史 客户等级变化(仅需保留上一次)

Type 1 最简单,但也最危险。我见过有人把客户主地址也做成 Type 1,结果审计的时候发现上个月的报表和这个月对不上,查了半天才发现是地址被覆盖了。Type 1 适合那些“错了就错了,不需要追溯”的属性。

Type 2 是 PIT Join 最常打交道的类型。每次属性变化,就插入一条新记录,同时带上生效时间和失效时间。这样你就能回答“某年某月某日,这个客户的信息是什么”。

Type 3 用得少一些,但某些场景很实用。比如客户等级,你只需要知道“当前等级”和“上一个等级”,Type 3 加两个字段就够了,不用像 Type 2 那样整行复制。

核心原则:PIT Join 主要解决的是 Type 2 SCD 与事实表之间的关联问题。如果你的维度全是 Type 1,那根本不需要 PIT Join——直接关联主键就行。

2.2 有效时间 vs 事务时间:两个容易搞混的概念

这两个概念,我当年花了整整一个项目周期才真正搞明白。先给个定义:

  • 有效时间(Valid Time):现实世界中,这个事实“真正发生”的时间。
  • 事务时间(Transaction Time):数据被记录到数据库中的时间。

举个例子你就懂了。假设今天是 2024-03-15,你收到一份销售单据,上面写着“2024-03-10 成交”。那么:

  • 有效时间 = 2024-03-10(实际成交日)
  • 事务时间 = 2024-03-15(录入系统的日期)

为什么这个区分这么重要?因为 PIT Join 里,你选择用哪个时间做关联,结果可能完全不同。

注意:很多新手在做 PIT Join 时,默认用事务时间关联维度。这在大部分场景下是对的,但如果你遇到“补录数据”或“延迟上报”的情况,事务时间关联会导致维度快照取错。我踩过这个坑,补录的数据关联到了错误的维度版本,整整花了三天排查。

2.3 时间维度的建模方式

实际项目中,时间维度的建模通常有两种思路:

方式一:在维度表中直接加时间字段

-- Type 2 SCD 维度表示例
CREATE TABLE dim_customer (
    customer_sk      BIGINT PRIMARY KEY,  -- 代理键
    customer_id      VARCHAR(20),         -- 业务主键
    customer_name    VARCHAR(100),
    customer_address VARCHAR(200),
    valid_from       DATE,                -- 有效开始时间
    valid_to         DATE,                -- 有效结束时间(NULL 表示当前有效)
    is_current       BOOLEAN,             -- 是否当前版本
    created_at       TIMESTAMP,           -- 事务时间(记录创建时间)
    updated_at       TIMESTAMP            -- 事务时间(记录更新时间)
);

方式二:单独维护时间维度表

有些团队喜欢把时间单独抽出来,做成一张时间维度表。这样做的好处是统一管理,但会增加 Join 的复杂度。

我的建议:对于大多数场景,直接在维度表中维护 valid_from / valid_to 就够了。单独抽时间维度表,除非你的业务时间逻辑极其复杂(比如多个时区、多个日历体系),否则没必要增加复杂度。

2.4 时间维度与 PIT Join 的关系

到这里,你应该能感受到:PIT Join 本质上就是在解决“有效时间”和“事务时间”之间的映射问题。

事实表里记录的是业务事件的发生时间(有效时间),而维度表里记录的是属性变化的时间区间。PIT Join 要做的,就是把事实表的有效时间,映射到维度表对应的那个版本上。

我画了一张图,帮你理清这几个概念之间的关系:

时间维度与 PIT Join 核心概念关系图 事实表 订单 ID: 1001 有效时间: 2024-03-10 事务时间: 2024-03-15 维度表 (Type 2 SCD) 客户 SK: 1001 地址: 北京市朝阳区 有效区间: [2024-01-01, 2024-06-30] 事务时间: 2024-01-01 PIT Join 关联 事实表有效时间 ∈ 维度表有效区间 有效时间 (Valid Time) 业务事件真实发生的时间 例如:订单成交日期、客户变更日期 事务时间 (Transaction Time) 数据被记录到系统的时间 例如:ETL 加载时间、数据录入时间 PIT Join 的核心任务 将事实表的有效时间,映射到维度表对应的版本快照上 同时保证事务时间的一致性,避免数据回溯问题

2.5 实际项目中的避坑指南

最后,分享几个我在项目中踩过的坑:

  • 坑一:有效时间用 DATE 类型,事务时间用 TIMESTAMP。 别混用。我见过有人把有效时间也存成 TIMESTAMP,结果关联的时候精度对不上,白白浪费半天排查。
  • 坑二:Type 2 SCD 的 valid_to 不要用 9999-12-31。 用 NULL 表示“当前有效”更安全。9999-12-31 在跨年计算时容易出 bug。
  • 坑三:事务时间不要作为 PIT Join 的关联条件。 除非你明确知道自己在做什么。事务时间只用于数据加载顺序的控制,不是业务语义的一部分。

一个小技巧:在做 PIT Join 之前,先检查维度表的 Type 2 版本是否完整。我习惯写一个 SQL 检查每个业务主键的 valid_from / valid_to 是否有重叠或断层。这一步能避免 80% 的 PIT Join 问题。