3、维度建模实战:缓慢变化维(SCD)类型1、2、3的实现与性能权衡
说到维度建模,有个绕不开的话题——缓慢变化维,简称 SCD。
你想想看,现实世界里的数据不是一成不变的。客户的地址会变,产品的分类会调,员工的部门会换。这些变化怎么处理?直接覆盖?保留历史?还是只记最新?
这就是 SCD 要解决的问题。我个人习惯把 SCD 分成三种主流类型:类型1(覆盖)、类型2(拉链表)、类型3(有限历史)。今天咱们就掰开揉碎,把它们的实现方式和性能权衡讲清楚。
核心观点:没有银弹。选哪种类型,取决于你的业务场景——是要查历史快照,还是只看当前值?
3.1 类型1:直接覆盖,简单粗暴
类型1 的做法很简单:当维度属性发生变化时,直接更新原记录,不保留任何历史痕迹。
举个例子,客户的手机号变了,我就把表里的 phone_number 字段改成新值。旧值?丢了。
-- 类型1 更新示例
UPDATE dim_customer
SET phone_number = '138-0000-9999',
update_time = CURRENT_TIMESTAMP
WHERE customer_id = 1001;
这种方式的优点很明显:实现简单,查询快。不需要额外的字段,也不需要复杂的逻辑。事实表关联维度表时,永远拿到的是最新值。
但缺点也致命:你再也回不去了。如果业务需要分析“客户变更手机号之前的订单”,类型1 就无能为力了。
我的经验:类型1 适合那些“变了就变了,历史不重要”的属性。比如客户的 VIP 等级,如果业务只看当前等级,覆盖没问题。但如果是地址、联系方式这种可能涉及历史追溯的,我建议慎用。
3.2 类型2:拉链表,全量历史
类型2 是实战中最常用的方式。它通过增加记录来保存每次变化,而不是覆盖。
具体做法是:给维度表加上 effective_date(生效日期)和 expiration_date(失效日期)两个字段,再加一个 is_current 标志位。每次变化时,把旧记录的失效日期设为当前时间,然后插入一条新记录。
-- 类型2 插入示例(简化版)
-- 1. 关闭旧记录
UPDATE dim_customer
SET expiration_date = '2024-03-15',
is_current = 0
WHERE customer_id = 1001 AND is_current = 1;
-- 2. 插入新记录
INSERT INTO dim_customer (customer_id, name, address, effective_date, expiration_date, is_current)
VALUES (1001, '张三', '新地址', '2024-03-16', '9999-12-31', 1);
这样做的好处是:你可以回溯任意时间点的维度状态。比如查去年1月的订单,只要用 order_date BETWEEN effective_date AND expiration_date 就能关联到当时的客户地址。
注意:类型2 的代价是维度表会不断膨胀。一个客户改10次地址,表里就有10条记录。如果维度表有上亿客户,频繁变更会导致表变得非常大,影响查询性能。
我曾经在一个电商项目中遇到过这种情况:客户维度表每天变更几十万条,半年后表体积超过 500GB,ETL 跑得越来越慢。后来我们做了分区和索引优化,才把查询时间降下来。
3.3 类型3:有限历史,折中方案
类型3 是一种折中。它不保留全部历史,只保留当前值和上一个值(或者有限个历史值)。
实现方式是在维度表里加几个“前值”字段。比如客户地址,除了 current_address,再加一个 previous_address。
-- 类型3 表结构示例
CREATE TABLE dim_customer_scd3 (
customer_id INT PRIMARY KEY,
customer_name VARCHAR(100),
current_address VARCHAR(200),
previous_address VARCHAR(200),
address_change_date DATE
);
当地址变化时,把 current_address 移到 previous_address,新地址写入 current_address。
-- 类型3 更新示例
UPDATE dim_customer
SET previous_address = current_address,
current_address = '新地址',
address_change_date = CURRENT_DATE
WHERE customer_id = 1001;
类型3 的优点是:查询简单,表体积不膨胀。你既能拿到当前值,也能看到上一次的值。适合“对比前后变化”的场景,比如分析客户搬家前后的消费行为。
但缺点也很明显:只能回溯一步。如果客户改了3次地址,中间那次就丢了。
3.4 三种类型的性能权衡
选哪种类型,不只是业务问题,更是性能问题。我整理了一张对比表,方便你直观感受:
| 维度 | 类型1(覆盖) | 类型2(拉链表) | 类型3(有限历史) |
|---|---|---|---|
| 存储空间 | 最小,不增加记录 | 最大,每次变更新增记录 | 中等,增加少量字段 |
| 查询性能 | 最快,直接关联 | 较慢,需要时间范围过滤 | 快,直接关联 |
| ETL复杂度 | 最低,简单 UPDATE | 最高,需要关闭+插入 | 中等,需要字段迁移 |
| 历史追溯能力 | 无 | 全量历史 | 有限(通常1步) |
| 适用场景 | 属性不重要,只看当前 | 需要完整历史快照 | 需要对比前后变化 |
从性能角度看,类型1 和类型3 对查询更友好,因为它们不需要额外的 JOIN 条件或时间过滤。类型2 虽然灵活,但查询时多了 BETWEEN 条件,如果索引没建好,很容易变成全表扫描。
我的建议:如果维度表变更频率很低(比如一个月改几次),类型2 完全没问题。但如果变更频繁(每天几万次),我建议考虑混合策略——把高频变更的属性单独拎出来用类型1,低频但重要的属性用类型2。
3.5 实战中的混合策略
在实际项目中,我很少只用一种 SCD 类型。更常见的做法是:一张维度表里,不同属性用不同的策略。
比如客户维度表:
- 姓名、性别:几乎不变,用类型1 覆盖即可。
- 地址、手机号:可能变更,且需要追溯,用类型2 拉链表。
- VIP等级:只关心当前和上一次,用类型3 保留前值。
这样做的好处是:既满足了业务需求,又控制了表体积和查询复杂度。
避坑指南:我曾经在一个项目中,把所有属性都用了类型2,结果维度表膨胀到 2TB,查询慢到无法忍受。后来重构时,把 80% 的字段改成类型1,只保留 20% 的关键字段用类型2,性能提升了 10 倍。
3.6 本章小结:一张图看懂 SCD 选型
为了让你更直观地理解三种类型的区别和选型逻辑,我画了一张流程图:
这张图的核心逻辑很简单:先问业务是否需要追溯历史。不需要,就用类型1。需要,再看需要几步历史。一步,用类型3;多步,用类型2。
最后说一句:SCD 没有标准答案。我见过有人把类型2 玩出花,也见过类型1 撑起整个数仓。关键是理解每种类型的代价,然后根据你的数据量、变更频率和查询模式做选择。别盲目追求“全量历史”,那往往是性能灾难的开始。