4. SCD 类型 2
SCD 类型 2,说白了就是「保留历史快照」。每次维度数据发生变化,我们不覆盖,也不加新列,而是直接插入一条新行。这样,旧行就变成了历史记录,新行代表当前状态。
我个人觉得,类型 2 是数仓建模里最实用、也最常用的缓慢变化维处理方式。为什么?因为它能完整还原任意时间点的业务事实。你想想看,如果业务方问「上个月这个客户是什么等级」,类型 2 能直接回答,类型 1 就做不到。
核心思想: 用多行记录同一个业务实体的不同版本,通过生效时间或标志位区分哪个版本是「当前」。
4.1 适用场景
不是所有维度都适合用类型 2。我在项目中遇到过不少团队,一上来就给所有维度表都上类型 2,结果存储暴涨、查询变慢,得不偿失。
适合类型 2 的场景,通常满足以下条件:
- 需要审计或回溯历史:比如客户等级变更、产品分类调整、员工部门调动。业务方需要知道「当时是什么情况」。
- 变化频率适中:一年变几次或几十次,而不是每天变几百次。变化太频繁,行数会爆炸。
- 维度属性对分析有实质影响:比如客户的地理位置、产品的品类归属。这些属性变了,分析结果会跟着变。
举个例子,我在做电商数仓时,客户地址维度用了类型 2。因为客户搬家后,历史订单的配送区域分析必须基于当时的地址,而不是现在的。嗯,这里要注意:如果维度属性变化后对历史分析没影响,比如客户的「最后登录时间」,那就没必要用类型 2,用类型 1 覆盖就行。
| 场景 | 推荐类型 | 原因 |
|---|---|---|
| 客户等级变更,需回溯历史等级 | 类型 2 | 保留每次等级变化,支持按时间点分析 |
| 产品分类调整,历史订单需关联旧分类 | 类型 2 | 事实表关联当时的产品分类,保证一致性 |
| 员工部门调动,需统计历史部门人数 | 类型 2 | 每个部门在任期内的人数统计 |
| 客户手机号更新,历史分析不依赖手机号 | 类型 1 | 直接覆盖,减少存储和复杂度 |
4.2 实现方式
类型 2 的实现,核心就两个东西:有效日期和当前标志。这两个字段配合使用,就能精确控制哪一行是「当前有效」的。
4.2.1 有效日期方式
这种方式用两个日期字段来标记行的有效期:
- effective_date:该行开始生效的日期
- expiration_date:该行失效的日期(通常用 '9999-12-31' 表示当前有效)
我习惯用这种方式,因为它能精确表达时间范围,查询时用 BETWEEN 就能找到任意时间点的版本。
-- 创建客户维度表(有效日期方式)
CREATE TABLE dim_customer (
customer_sk INT PRIMARY KEY, -- 代理键
customer_id VARCHAR(20), -- 业务主键
customer_name VARCHAR(100),
customer_level VARCHAR(20),
customer_address VARCHAR(200),
effective_date DATE NOT NULL, -- 生效日期
expiration_date DATE NOT NULL, -- 失效日期,当前行用 '9999-12-31'
is_current CHAR(1) DEFAULT 'Y' -- 当前标志,辅助快速过滤
);
-- 插入新客户
INSERT INTO dim_customer VALUES (
1001, 'C001', '张三', '普通', '北京市朝阳区',
'2024-01-01', '9999-12-31', 'Y'
);
-- 客户升级,关闭旧行,插入新行
UPDATE dim_customer
SET expiration_date = '2024-06-15', is_current = 'N'
WHERE customer_id = 'C001' AND is_current = 'Y';
INSERT INTO dim_customer VALUES (
1002, 'C001', '张三', 'VIP', '北京市朝阳区',
'2024-06-16', '9999-12-31', 'Y'
);
小技巧: 我一般把 expiration_date 设为 '9999-12-31' 而不是 NULL,这样查询时直接用 BETWEEN 就能覆盖所有情况,不用额外处理 NULL 判断。
4.2.2 当前标志方式
这种方式用一个布尔字段来标记当前行:
- is_current:'Y' 表示当前有效行,'N' 表示历史行
这种方式简单直观,但无法直接知道某行在哪个时间段有效。所以,我通常会把当前标志和有效日期一起用,两者互补。
-- 查询当前所有 VIP 客户
SELECT customer_id, customer_name
FROM dim_customer
WHERE is_current = 'Y' AND customer_level = 'VIP';
-- 查询 2024-03-01 时点的客户等级
SELECT customer_id, customer_name, customer_level
FROM dim_customer
WHERE customer_id = 'C001'
AND effective_date <= '2024-03-01'
AND expiration_date >= '2024-03-01';
注意: 如果只用当前标志,没有日期字段,你就无法查询历史某个时间点的维度状态。我曾经接手过一个项目,只用了 is_current 字段,结果业务方要查半年前的客户等级,我只能靠日志反推,折腾了两天。所以,我建议至少保留一个日期字段。
4.3 核心逻辑流程图
下面这张图展示了类型 2 的核心处理流程。从检测变化开始,到关闭旧行、插入新行,再到关联事实表,每一步都很关键。
4.4 避坑指南
类型 2 虽然强大,但坑也不少。我踩过几个,分享给你:
- 代理键必须自增:不要用业务主键作为维度表主键,否则同一业务实体的多行会冲突。用自增的代理键(surrogate key),事实表关联时用这个代理键。
- 并发更新问题:如果两个 ETL 任务同时更新同一个客户,可能会产生数据不一致。我建议用数据库事务或加锁机制,保证更新和插入是原子操作。
- 存储膨胀:维度变化频繁时,表行数会快速增长。我曾经维护过一个客户维度表,三年积累了 5000 万行。解决方案是定期归档历史数据,或者对变化频率过高的属性单独拉出做「属性变化事实表」。
- 查询性能:如果维度表很大,关联事实表时性能会下降。我一般会在 is_current、effective_date、expiration_date 上建索引,查询当前行时用 is_current='Y' 过滤,能大幅提升性能。
总结一下: 类型 2 是保留历史的标准方案,用有效日期 + 当前标志双保险,能应对绝大多数业务场景。但要注意存储和性能问题,别一上来就无脑用。