# 缓慢变化维 SCD Type 2 详解

0 阅读14分钟

数仓开发核心技能:缓慢变化维概念、SCD Type 1/2/3 对比、拉链表设计、SQL 实现、生产环境案例、数据一致性保障,从 0 到 1 掌握维度变化追踪


📌 前言

真实生产问题

问题场景:

某电商公司数仓开发遇到的问题:

问题 1:用户维度变化无法追踪
- 用户小明 3 月 1 日是普通会员
- 3 月 15 日升级为 VIP 会员
- 4 月 1 日降级为普通会员
- 数仓只记录当前状态(普通会员)
- 分析 3 月 VIP 用户消费情况时,小明被遗漏

问题 2:历史数据对不上
- 3 月报表:VIP 用户 10 万人,GMV 5000 万
- 4 月重算 3 月数据:VIP 用户 8 万人,GMV 4000 万
- 业务质疑:为什么数据不一致?

问题 3:商品类目调整,历史归属错误
- 商品 A 原来属于"手机"类目
- 5 月调整为"数码配件"类目
- 分析上半年"手机"类目销量时,商品 A 被排除
- 实际应该包含(因为销售时属于手机类目)

缓慢变化维(SCD)解决:

- 记录维度历史变化
- 每条事实关联当时的维度版本
- 历史数据可追溯、可重算
- 业务分析更准确

优化后效果:

- 数据准确性:90% → 99.9%
- 历史可追溯:支持任意时间点快照
- 业务信任度:大幅提升
- 重算成本:无需重算,直接查询

📚 缓慢变化维概念深度解析

什么是缓慢变化维?

定义:

缓慢变化维(Slowly Changing Dimension,SCD)
指维度表中属性值会随时间变化的维度

示例:
- 用户维度:会员等级、所在城市、注册时间
- 商品维度:价格、类目、供应商
- 门店维度:店长、面积、地址

对比:
- 快速变化维:每分钟都在变化(不适合 SCD)
- 缓慢变化维:几天/几周变化一次(适合 SCD)
- 静态维度:几乎不变(如日期维度)

为什么需要 SCD?

场景:用户会员等级变化

不用 SCD(只记录当前状态):
┌─────────┬──────────┬────────────┬─────────────┐
 user_id  会员等级  修改时间    当前状态    
├─────────┼──────────┼────────────┼─────────────┤
 001      普通会员  2026-04-01             
└─────────┴──────────┴────────────┴─────────────┘

问题:
- 无法知道 3 月时用户是什么等级
- 分析 3  VIP 用户消费时,数据错误

使用 SCD Type 2(记录历史):
┌─────────┬──────────┬────────────┬────────────┬────────────┐
 user_id  会员等级  生效时间    失效时间    当前状态   
├─────────┼──────────┼────────────┼────────────┼────────────┤
 001      普通会员  2026-01-01  2026-02-28           
 001      VIP 会员  2026-03-01  2026-03-31           
 001      普通会员  2026-04-01  9999-12-31           
└─────────┴──────────┴────────────┴────────────┴────────────┘

优点:
 可以查询任意时间点的状态
 历史事实关联正确的维度版本
 数据可追溯、可审计

SCD Type 1/2/3 对比

Type 1:覆盖(不保留历史)

原理:直接覆盖旧值

示例:
更新前:
┌─────────┬──────────┐
│ user_id │ 城市     │
├─────────┼──────────┤
│ 001     │ 北京     │
└─────────┴──────────┘

用户搬到上海,更新后:
┌─────────┬──────────┐
│ user_id │ 城市     │
├─────────┼──────────┤
│ 001     │ 上海     │  ← 直接覆盖
└─────────┴──────────┘

优点:
✓ 简单,实现成本低
✓ 存储空间小

缺点:
✗ 丢失历史信息
✗ 无法追溯过去

适用场景:
- 属性变化不重要(如手机号)
- 只需要当前状态
- 存储空间有限

Type 2:新增行(保留完整历史)⭐推荐

原理:每次变化新增一行,用生效/失效时间标识

示例:
初始:
┌─────────┬──────────┬────────────┬────────────┐
 user_id  城市      生效时间    失效时间   
├─────────┼──────────┼────────────┼────────────┤
 001      北京      2026-01-01  9999-12-31 
└─────────┴──────────┴────────────┴────────────┘

用户搬到上海,更新后:
┌─────────┬──────────┬────────────┬────────────┐
 user_id  城市      生效时间    失效时间   
├─────────┼──────────┼────────────┼────────────┤
 001      北京      2026-01-01  2026-03-31   失效
 001      上海      2026-04-01  9999-12-31   新增
└─────────┴──────────┴────────────┴────────────┘

优点:
 保留完整历史
 可以查询任意时间点状态
 事实表关联准确

缺点:
 数据量增长快
 实现复杂度较高

适用场景:⭐推荐
- 关键维度(用户/商品/门店)
- 需要历史分析
- 数据准确性要求高

Type 3:新增列(保留有限历史)

原理:增加"原值"列,保留上一次的值

示例:
初始:
┌─────────┬──────────┬────────────┐
│ user_id │ 当前城市 │ 原城市     │
├─────────┼──────────┼────────────┤
│ 001     │ 北京     │ NULL       │
└─────────┴──────────┴────────────┘

用户搬到上海,更新后:
┌─────────┬──────────┬────────────┐
│ user_id │ 当前城市 │ 原城市     │
├─────────┼──────────┼────────────┤
│ 001     │ 上海     │ 北京       │
└─────────┴──────────┴────────────┘

用户搬到广州,更新后:
┌─────────┬──────────┬────────────┐
│ user_id │ 当前城市 │ 原城市     │
├─────────┼──────────┼────────────┤
│ 001     │ 广州     │ 上海       │ ← 只保留上一次
└─────────┴──────────┴────────────┘

优点:
✓ 保留最近一次变化
✓ 存储增长可控

缺点:
✗ 只能保留一次变化
✗ 无法追溯更早历史

适用场景:
- 只需要对比当前和上一次
- 变化频率低
- 存储空间有限

三种类型对比:

特性Type 1Type 2Type 3
历史保留完整一次
存储增长
实现复杂度
查询复杂度
推荐使用⭐⭐⭐⭐⭐⭐⭐

🔧 拉链表设计详解

表结构设计

用户维度表(SCD Type 2):

CREATE TABLE dim_user_scd (
    user_id BIGINT COMMENT '用户 ID',
    user_name STRING COMMENT '用户名',
    gender STRING COMMENT '性别',
    city STRING COMMENT '城市',
    member_level STRING COMMENT '会员等级',
    register_date DATE COMMENT '注册日期',
    start_date DATE COMMENT '生效日期',
    end_date DATE COMMENT '失效日期',
    is_current INT COMMENT '是否当前版本 1=是 0=否',
    version INT COMMENT '版本号',
    create_time TIMESTAMP COMMENT '记录创建时间',
    update_time TIMESTAMP COMMENT '记录更新时间'
)
PARTITIONED BY (dt STRING)
STORED AS ORC;

字段说明:
- start_date:该记录生效日期
- end_date:该记录失效日期(9999-12-31 表示永不过期)
- is_current:是否为当前版本(1=当前,0=历史)
- version:版本号(从 1 递增)

数据示例:

┌─────────┬──────────┬────────────┬────────────┬────────────┬──────────┐
 user_id  城市      生效日期    失效日期    is_current  version  
├─────────┼──────────┼────────────┼────────────┼────────────┼──────────┤
 001      北京      2026-01-01  2026-03-31  0           1        
 001      上海      2026-04-01  9999-12-31  1           2        
└─────────┴──────────┴────────────┴────────────┴────────────┴──────────┘

数据加载流程

全量加载(第一天):

-- 初始化拉链表
INSERT OVERWRITE TABLE dim_user_scd PARTITION (dt='2026-01-01')
SELECT 
    user_id,
    user_name,
    gender,
    city,
    member_level,
    register_date,
    '2026-01-01' AS start_date,  -- 生效日期为当天
    '9999-12-31' AS end_date,    -- 永不过期
    1 AS is_current,              -- 当前版本
    1 AS version,
    CURRENT_TIMESTAMP AS create_time,
    CURRENT_TIMESTAMP AS update_time
FROM ods_user_info
WHERE dt = '2026-01-01';

增量更新(后续每天):

-- 步骤 1:找出变化的数据
WITH today_data AS (
    -- 今天源数据
    SELECT * FROM ods_user_info WHERE dt = '2026-04-01'
),
yesterday_dim AS (
    -- 昨天维度表(当前版本)
    SELECT * FROM dim_user_scd 
    WHERE dt = '2026-03-31' AND is_current = 1
),
changed_data AS (
    -- 找出变化的数据(城市或会员等级变化)
    SELECT 
        t.user_id,
        t.user_name,
        t.gender,
        t.city,
        t.member_level,
        t.register_date,
        y.start_date,
        y.end_date,
        y.version
    FROM today_data t
    JOIN yesterday_dim y ON t.user_id = y.user_id
    WHERE t.city != y.city 
       OR t.member_level != y.member_level
)

-- 步骤 2:失效旧记录
INSERT INTO TABLE dim_user_scd PARTITION (dt='2026-04-01')
SELECT 
    user_id,
    user_name,
    gender,
    city,
    member_level,
    register_date,
    start_date,
    DATE_SUB('2026-04-01', 1) AS end_date,  -- 失效日期为昨天
    0 AS is_current,                         -- 不再是当前版本
    version,
    create_time,
    CURRENT_TIMESTAMP AS update_time
FROM changed_data;

-- 步骤 3:插入新记录
INSERT INTO TABLE dim_user_scd PARTITION (dt='2026-04-01')
SELECT 
    user_id,
    user_name,
    gender,
    city,
    member_level,
    register_date,
    '2026-04-01' AS start_date,              -- 生效日期为今天
    '9999-12-31' AS end_date,                -- 永不过期
    1 AS is_current,                         -- 当前版本
    version + 1 AS version,                  -- 版本号 +1
    CURRENT_TIMESTAMP AS create_time,
    CURRENT_TIMESTAMP AS update_time
FROM changed_data;

-- 步骤 4:复制未变化的数据
INSERT INTO TABLE dim_user_scd PARTITION (dt='2026-04-01')
SELECT 
    y.user_id,
    y.user_name,
    y.gender,
    y.city,
    y.member_level,
    y.register_date,
    y.start_date,
    y.end_date,
    y.is_current,
    y.version,
    y.create_time,
    y.update_time
FROM yesterday_dim y
LEFT JOIN changed_data c ON y.user_id = c.user_id
WHERE c.user_id IS NULL;  -- 未变化的数据

🔧 查询实战技巧

1. 查询当前状态

-- 查询用户当前状态
SELECT * FROM dim_user_scd
WHERE dt = '2026-04-01'
  AND is_current = 1
  AND user_id = '001';

-- 或使用 end_date
SELECT * FROM dim_user_scd
WHERE dt = '2026-04-01'
  AND end_date = '9999-12-31'
  AND user_id = '001';

2. 查询历史状态(时间点快照)

-- 查询 2026-03-15 时的用户状态
SELECT * FROM dim_user_scd
WHERE dt = '2026-04-01'  -- 使用最新分区
  AND start_date <= '2026-03-15'
  AND end_date >= '2026-03-15'
  AND user_id = '001';

-- 结果:
-- user_id=001, city=北京, start_date=2026-01-01, end_date=2026-03-31

3. 查询变化历史

-- 查询用户所有历史变化
SELECT 
    user_id,
    city,
    member_level,
    start_date,
    end_date,
    version
FROM dim_user_scd
WHERE dt = '2026-04-01'
  AND user_id = '001'
ORDER BY version;

-- 结果:
-- version=1: 北京,普通会员,2026-01-01 ~ 2026-03-31
-- version=2: 上海,VIP 会员,2026-04-01 ~ 9999-12-31

4. 事实表关联拉链表

-- 订单事实表关联用户维度(关联当时的状态)
SELECT 
    o.order_id,
    o.user_id,
    o.pay_amount,
    o.order_date,
    u.city,              -- 下单时的城市
    u.member_level       -- 下单时的会员等级
FROM dwd_order_fact o
JOIN dim_user_scd u 
  ON o.user_id = u.user_id
  AND o.order_date >= u.start_date
  AND o.order_date <= u.end_date
WHERE o.dt = '2026-04-01'
  AND u.dt = '2026-04-01';

-- 关键:订单日期在维度生效/失效时间范围内

5. 统计不同时期 VIP 用户消费

-- 统计 3 月份 VIP 用户的 GMV
SELECT 
    SUM(o.pay_amount) AS gmv,
    COUNT(DISTINCT o.user_id) AS vip_user_count
FROM dwd_order_fact o
JOIN dim_user_scd u 
  ON o.user_id = u.user_id
  AND o.order_date >= u.start_date
  AND o.order_date <= u.end_date
  AND u.member_level = 'VIP 会员'  -- 关联时的会员等级
WHERE o.dt = '2026-04-01'
  AND u.dt = '2026-04-01'
  AND o.order_date >= '2026-03-01'
  AND o.order_date <= '2026-03-31';

-- 结果准确:使用下单时的会员等级,不是当前等级

🏭 生产环境完整案例

案例:电商用户维度拉链表

业务背景:

- 日均新增用户:10 万
- 日均用户变化:5 万(城市/会员等级)
- 历史数据:3 年
- 数据量:约 5 亿条记录

表结构设计:

CREATE TABLE dim_user_scd (
    user_sk BIGINT COMMENT '代理键(自增)',
    user_id STRING COMMENT '用户 ID(业务键)',
    user_name STRING COMMENT '用户名',
    gender STRING COMMENT '性别',
    age INT COMMENT '年龄',
    city STRING COMMENT '城市',
    province STRING COMMENT '省份',
    member_level STRING COMMENT '会员等级',
    member_score INT COMMENT '会员积分',
    register_date DATE COMMENT '注册日期',
    first_order_date DATE COMMENT '首单日期',
    start_date DATE COMMENT '生效日期',
    end_date DATE COMMENT '失效日期',
    is_current INT COMMENT '是否当前版本',
    version INT COMMENT '版本号',
    create_time TIMESTAMP COMMENT '创建时间',
    update_time TIMESTAMP COMMENT '更新时间'
)
PARTITIONED BY (dt STRING)
STORED AS ORC
TBLPROPERTIES ('orc.compress' = 'SNAPPY');

ETL 脚本(Hive SQL):

-- 文件:etl_dim_user_scd.sql
-- 执行:hive -f etl_dim_user_scd.sql -d dt=2026-04-01

USE ecommerce;

-- 设置参数
SET hive.exec.dynamic.partition=true;
SET hive.exec.dynamic.partition.mode=nonstrict;
SET hive.exec.max.dynamic.partitions=1000;

-- 临时表:今天源数据
DROP TABLE IF EXISTS tmp_user_today;
CREATE TABLE tmp_user_today AS
SELECT * FROM ods_user_info WHERE dt = '${dt}';

-- 临时表:昨天维度(当前版本)
DROP TABLE IF EXISTS tmp_user_yesterday;
CREATE TABLE tmp_user_yesterday AS
SELECT * FROM dim_user_scd 
WHERE dt = DATE_SUB('${dt}', 1) 
  AND is_current = 1;

-- 临时表:变化数据
DROP TABLE IF EXISTS tmp_user_changed;
CREATE TABLE tmp_user_changed AS
SELECT 
    t.user_id,
    t.user_name,
    t.gender,
    t.age,
    t.city,
    t.province,
    t.member_level,
    t.member_score,
    t.register_date,
    t.first_order_date,
    y.start_date,
    y.version
FROM tmp_user_today t
JOIN tmp_user_yesterday y ON t.user_id = y.user_id
WHERE t.city != y.city 
   OR t.member_level != y.member_level
   OR t.member_score != y.member_score;

-- 步骤 1:失效旧记录
INSERT INTO TABLE dim_user_scd PARTITION (dt='${dt}')
SELECT 
    -1 AS user_sk,  -- 由后续统一生成
    user_id,
    user_name,
    gender,
    age,
    city,
    province,
    member_level,
    member_score,
    register_date,
    first_order_date,
    start_date,
    DATE_SUB('${dt}', 1) AS end_date,
    0 AS is_current,
    version,
    create_time,
    CURRENT_TIMESTAMP AS update_time
FROM tmp_user_changed;

-- 步骤 2:插入新记录
INSERT INTO TABLE dim_user_scd PARTITION (dt='${dt}')
SELECT 
    -1 AS user_sk,
    user_id,
    user_name,
    gender,
    age,
    city,
    province,
    member_level,
    member_score,
    register_date,
    first_order_date,
    '${dt}' AS start_date,
    '9999-12-31' AS end_date,
    1 AS is_current,
    version + 1 AS version,
    CURRENT_TIMESTAMP AS create_time,
    CURRENT_TIMESTAMP AS update_time
FROM tmp_user_changed;

-- 步骤 3:复制未变化数据
INSERT INTO TABLE dim_user_scd PARTITION (dt='${dt}')
SELECT 
    y.user_sk,
    y.user_id,
    y.user_name,
    y.gender,
    y.age,
    y.city,
    y.province,
    y.member_level,
    y.member_score,
    y.register_date,
    y.first_order_date,
    y.start_date,
    y.end_date,
    y.is_current,
    y.version,
    y.create_time,
    y.update_time
FROM tmp_user_yesterday y
LEFT JOIN tmp_user_changed c ON y.user_id = c.user_id
WHERE c.user_id IS NULL;

-- 步骤 4:新增用户(昨天不存在,今天新增)
INSERT INTO TABLE dim_user_scd PARTITION (dt='${dt}')
SELECT 
    -1 AS user_sk,
    t.user_id,
    t.user_name,
    t.gender,
    t.age,
    t.city,
    t.province,
    t.member_level,
    t.member_score,
    t.register_date,
    t.first_order_date,
    '${dt}' AS start_date,
    '9999-12-31' AS end_date,
    1 AS is_current,
    1 AS version,
    CURRENT_TIMESTAMP AS create_time,
    CURRENT_TIMESTAMP AS update_time
FROM tmp_user_today t
LEFT JOIN tmp_user_yesterday y ON t.user_id = y.user_id
WHERE y.user_id IS NULL;

-- 清理临时表
DROP TABLE IF EXISTS tmp_user_today;
DROP TABLE IF EXISTS tmp_user_yesterday;
DROP TABLE IF EXISTS tmp_user_changed;

性能优化:

-- 1. 使用 Map Join(小表)
SET hive.auto.convert.join=true;
SET hive.auto.convert.join.noconditionaltask.size=268435456;

-- 2. 开启并行执行
SET hive.exec.parallel=true;
SET hive.exec.parallel.thread.number=8;

-- 3. 压缩输出
SET hive.exec.compress.output=true;
SET mapreduce.output.fileoutputformat.compress.codec=org.apache.hadoop.io.compress.SnappyCodec;

-- 4. 合理设置 Reduce 数
SET hive.exec.reducers.bytes.per.reducer=256000000;
SET hive.exec.reducers.max=100;

⚠️ 常见坑点与解决方案

坑点 1:数据量爆炸

问题:

拉链表数据量增长过快
- 每天 5 万变化 × 365 天 × 3 年 = 5.4 亿条
- 存储空间不足
- 查询性能下降

解决:

-- 方案 1:定期合并历史版本(保留最近 N 版)
-- 只保留最近 10 个版本,更早的合并为一条
INSERT OVERWRITE TABLE dim_user_scd PARTITION (dt='2026-04-01')
SELECT 
    user_id,
    FIRST(user_name) AS user_name,
    FIRST(gender) AS gender,
    FIRST(city) AS city,
    FIRST(member_level) AS member_level,
    MIN(start_date) AS start_date,
    CASE 
        WHEN version <= 10 THEN end_date
        ELSE '9999-12-31'
    END AS end_date,
    CASE 
        WHEN version <= 10 THEN is_current
        WHEN version = 10 THEN 1
        ELSE 0
    END AS is_current,
    CASE 
        WHEN version <= 10 THEN version
        WHEN version = 10 THEN 10
        ELSE 10
    END AS version,
    MIN(create_time) AS create_time,
    MAX(update_time) AS update_time
FROM (
    SELECT *, ROW_NUMBER() OVER (PARTITION BY user_id ORDER BY version DESC) AS rn
    FROM dim_user_scd WHERE dt = '2026-03-31'
) t
WHERE rn <= 10
GROUP BY user_id, CASE WHEN version <= 10 THEN version ELSE 10 END;

-- 方案 2:冷热分离
-- 热数据(最近 1 年):保留完整 SCD
-- 冷数据(1 年前):只保留快照(每年 1 条)

-- 方案 3:分区裁剪
-- 按 dt 分区,查询时指定分区
SELECT * FROM dim_user_scd WHERE dt = '2026-04-01';

坑点 2:时区问题

问题:

生效/失效时间边界不清晰
- 用户 3 月 31 日 23:59 变化
- 应该算 3 月还是 4 月?

解决:

-- 使用日期而非时间戳
start_date DATE  -- 2026-03-31
end_date DATE    -- 2026-04-01

-- 查询时使用 >= 和 <(而非 <=)
WHERE order_date >= start_date AND order_date < end_date

-- 或使用日期函数
WHERE TO_DATE(order_date) BETWEEN start_date AND end_date

坑点 3:数据不一致

问题:

事实表和维度表对不上
- 订单关联不到用户维度
- 用户维度有,订单没有

解决:

-- 方案 1:数据对账(每天执行)
SELECT '订单有但维度无' AS issue, COUNT(1) AS cnt
FROM dwd_order_fact o
LEFT JOIN dim_user_scd u 
  ON o.user_id = u.user_id
  AND o.order_date >= u.start_date
  AND o.order_date <= u.end_date
WHERE o.dt = '2026-04-01'
  AND u.dt = '2026-04-01'
  AND u.user_id IS NULL

UNION ALL

SELECT '维度有但订单无', COUNT(1)
FROM dim_user_scd u
LEFT JOIN dwd_order_fact o 
  ON u.user_id = o.user_id
  AND u.start_date <= o.order_date
  AND u.end_date >= o.order_date
WHERE u.dt = '2026-04-01'
  AND o.dt = '2026-04-01'
  AND o.order_id IS NULL;

-- 方案 2:使用 LEFT JOIN(不丢失订单)
SELECT 
    o.order_id,
    COALESCE(u.city, '未知') AS city,
    COALESCE(u.member_level, '未知') AS member_level
FROM dwd_order_fact o
LEFT JOIN dim_user_scd u 
  ON o.user_id = u.user_id
  AND o.order_date >= u.start_date
  AND o.order_date <= u.end_date;

📋 最佳实践清单

表设计

  • 使用 SCD Type 2(保留完整历史)
  • 包含 start_date/end_date 字段
  • 包含 is_current 标识
  • 包含 version 版本号
  • 使用 ORC 格式 + Snappy 压缩

ETL 开发

  • 全量初始化(第一天)
  • 增量更新(后续每天)
  • 变化检测(对比关键字段)
  • 数据对账(验证一致性)
  • 异常处理(失败重试)

查询优化

  • 使用分区裁剪(指定 dt)
  • 使用 is_current 过滤当前版本
  • 使用 Map Join(小表)
  • 避免全表扫描

数据质量

  • 每天对账(事实 vs 维度)
  • 监控数据量波动
  • 监控变化率(异常告警)
  • 定期清理临时表

📌 总结

核心要点

概念要点推荐使用
SCD TypeType 1/2/3Type 2⭐⭐⭐⭐⭐
关键字段start_date/end_date/is_current必备
更新策略增量更新每天执行
查询技巧时间范围关联事实关联维度

实践原则

1. 关键维度用 SCD Type 2
   用户/商品/门店等核心维度

2. 非关键维度用 SCD Type 1
   手机号/邮箱等次要属性

3. 每天对账验证
   确保事实表和维度表一致

4. 定期优化性能
   冷热分离、分区裁剪

💡 缓慢变化维是数仓开发的核心技能,建议深入理解并掌握!


👋 感谢阅读!


🔗 系列文章

  • [01-SQL 窗口函数从入门到精通](./01-SQL 窗口函数从入门到精通.md)
  • [02-Spark 性能优化 10 个技巧](./02-Spark 性能优化 10 个技巧.md)
  • 03-数据仓库分层设计指南
  • 04-维度建模实战
  • [05-Flink 实时数仓实战](./05-Flink 实时数仓实战.md)
  • [06-Kafka 消息队列实战指南](./06-Kafka 消息队列实战指南.md)
  • [07-Hive 性能优化实战](./07-Hive 性能优化实战.md)
  • [08-Linux 大数据开发必备工具](./08-Linux 大数据开发必备工具.md)
  • 09-缓慢变化维 SCD Type 2 详解(本文)
  • [下一篇:Flink 时间语义与 Watermark 详解](./10-Flink 时间语义与 Watermark.md)