4. 缓慢变化维度(SCD):SCD类型1、2、3的原理与实现策略

维度数据不是一成不变的。客户的地址会变,产品的分类会调整,员工的部门会调动。这些变化在数仓里怎么处理?这就是SCD要解决的问题。

我刚开始做数仓时,觉得这很简单——直接更新不就完了?后来发现,历史报表对不上账,业务方追着我问为什么上个月的销售额归到了新部门。嗯,从那以后,我再也不敢小看SCD了。

4.1 什么是缓慢变化维度

缓慢变化维度,英文叫Slowly Changing Dimension,简称SCD。它描述的是维度属性随时间发生变更的场景。

举个例子:客户的居住城市从北京变成了上海。如果你直接覆盖,那去年他还在北京时的订单,现在查出来也显示上海。这合理吗?不合理。但如果你不更新,新订单又没法正确归因。这就是矛盾所在。

Kimball老爷子提出了三种经典策略,分别对应不同的业务需求。咱们一个一个来看。

4.2 SCD类型1:直接覆盖

原理:不保留历史,直接更新维度表中的属性值。

说白了,就是“忘掉过去”。新值覆盖旧值,查不到任何历史痕迹。

适用场景:

  • 属性值错误,需要修正(比如电话号码录入错误)
  • 业务上不关心历史变化(比如客户的VIP等级,只看当前)
  • 历史回溯分析无意义

实现SQL:

-- 类型1:直接更新
UPDATE dim_customer
SET city = '上海', update_time = CURRENT_TIMESTAMP
WHERE customer_id = 1001;

注意:类型1一旦执行,历史数据就彻底丢失。我曾经在项目里因为误用类型1,导致财务月报对不上账,花了三天才把历史拉回来。所以,用之前一定想清楚:业务方真的不需要看历史吗?

4.3 SCD类型2:增加新行

原理:不修改已有记录,而是插入一条新行,用生效时间和失效时间来标记版本。

这是最常用的策略。你想想看,业务上大部分场景都需要追溯历史——上个月的销售归到旧部门,这个月的归到新部门,这才合理。

核心字段:

字段 含义
customer_sk 代理键,自增主键,每行唯一
customer_id 业务自然键,用于关联
city 属性值
effective_date 生效日期
expiration_date 失效日期,当前版本设为'9999-12-31'
is_current 是否当前版本,1表示当前

实现SQL:

-- 步骤1:将当前版本标记为过期
UPDATE dim_customer
SET expiration_date = CURRENT_DATE - 1, is_current = 0
WHERE customer_id = 1001 AND is_current = 1;

-- 步骤2:插入新版本
INSERT INTO dim_customer (customer_id, city, effective_date, expiration_date, is_current)
VALUES (1001, '上海', CURRENT_DATE, '9999-12-31', 1);

我的经验:类型2的代理键(SK)一定要用自增整数,不要用自然键。自然键在业务系统里可能会重用,到时候数据就乱了。我在一个电商项目里吃过这个亏,后来全部改成了雪花ID生成器。

4.4 SCD类型3:增加新列

原理:在维度表中增加一列,用来保存变化前的旧值。只保留最近一次变化。

类型3用得不多,但它有个独特的价值:你可以在同一行里同时看到当前值和前值。比如分析客户从哪个城市迁移过来,类型3就非常方便。

实现方式:

-- 假设原表有 city 字段,增加 previous_city 字段
ALTER TABLE dim_customer ADD COLUMN previous_city VARCHAR(100);

-- 更新时,将当前值移到 previous_city,再设置新值
UPDATE dim_customer
SET previous_city = city,
    city = '上海',
    update_time = CURRENT_TIMESTAMP
WHERE customer_id = 1001;
策略 优点 缺点
类型1 实现简单,存储少 丢失历史,无法回溯
类型2 完整保留历史,支持任意时间点回溯 存储膨胀,查询需过滤时间
类型3 可同时查看当前值和前值 只能保留一次变化,扩展性差

4.5 三种策略的选择逻辑

实际项目中怎么选?我一般按这个思路来:

  1. 先问业务方:这个属性变化后,历史报表需不需要重新归因?如果需要,类型2;如果不需要,类型1。
  2. 再看变化频率:如果属性一天变十次,类型2会导致维度表爆炸。这时候考虑拉链表或者只保留关键变化。
  3. 最后看分析需求:如果只需要对比当前和上一个值,类型3就够了,别用类型2增加复杂度。

避坑指南:我曾经在一个金融项目中,把所有维度都设计成类型2。结果半年后,客户维度表从100万行膨胀到800万行,ETL跑一次要40分钟。后来我们做了个评估,发现80%的属性根本不需要历史追溯,改成类型1后,性能提升了5倍。

4.6 实时数仓中的SCD实现

实时场景下,SCD的处理逻辑不变,但实现方式要调整。你不能在Flink SQL里直接跑UPDATE,因为状态后端不支持事务性更新。

实时类型2的实现思路:

-- Flink SQL 示例:使用CDC流处理维度变化
-- 1. 从Kafka读取变更事件
CREATE TABLE cdc_customer (
    customer_id INT,
    city STRING,
    op_type STRING,  -- 'insert', 'update', 'delete'
    ts TIMESTAMP(3)
) WITH (...);

-- 2. 使用双流JOIN或维表JOIN,将变更事件与事实流关联
-- 3. 在维表JOIN时,根据事件时间选择正确的维度版本
SELECT 
    f.order_id,
    f.amount,
    d.city,
    d.effective_date
FROM fact_order f
JOIN dim_customer FOR SYSTEM_TIME AS OF f.proc_time d
ON f.customer_id = d.customer_id
AND f.order_date BETWEEN d.effective_date AND d.expiration_date;

实时场景的注意点:

  • 维表要存在支持版本查询的存储里,比如HBase或MySQL,用有效时间范围做过滤
  • Flink的维表JOIN要用Lookup Join,不要用普通JOIN,否则状态会无限膨胀
  • CDC延迟会导致维度版本和事实时间对不上,建议用事件时间而非处理时间

4.7 SCD核心决策流程

下面这张图总结了SCD策略的选择逻辑,我在项目中一直用它来和业务方对齐需求。

SCD策略选择决策流程 维度属性发生变化 是否需要追溯历史变化? (业务方是否要看历史报表?) 否 类型1:直接覆盖 是 是否需要完整历史? (任意时间点回溯?) 是 类型2:增加新行 否 类型3:增加新列 选择建议 • 类型1:错误修正、不关心历史的属性 • 类型2:需要完整历史追溯、审计合规 • 类型3:只需对比当前值和前值,变化频率低

4.8 混合策略与实战建议

实际项目中,很少只用一种策略。我通常的做法是:

  • 同一个维度表,不同属性用不同策略。比如客户维度:姓名用类型1(错误修正),地址用类型2(需要历史),VIP等级用类型3(只看前后变化)。
  • 类型2的存储优化:如果变化太频繁,考虑拉链表+快照表组合。拉链表存全量变化,快照表存每日全量,查询时按需选择。
  • 实时场景的降级:如果CDC延迟超过阈值,先走类型1保证实时性,离线再补类型2的历史版本。这叫“先实时,后准确”。

最后提醒一句:SCD策略的选择,本质是业务需求和技术成本的权衡。不要为了技术完美而过度设计。我见过一个项目,把所有维度都做成类型2,结果查询性能一塌糊涂,业务方反而用不了。记住,数仓的最终目标是服务业务,不是展示技术。