缓慢变化维度
概述
维度属性(用户手机号、商品价格)会随时间变化,而分析需要"当时的值"而不是"现在的值"。缓慢变化维度(Slowly Changing Dimension,SCD)解决这个问题。本文覆盖 SCD Type 1~6 的实现方案、拉链表的设计与 SQL 写法,以及全量/增量策略的选择。
一、问题背景
订单表里用户属于哪个城市、商品什么价格,如果维度属性变了,历史订单该按新值算还是旧值算?
用户 u001 在 2026-01 属于"北京",2026-06 迁到"上海"
问:1 月订单按北京统计还是上海统计?| 需求 | 方案 |
|---|---|
| 只要最新值 | SCD Type 1(覆盖) |
| 保留历史,按发生时点统计 | SCD Type 2(拉链) |
| 只记录当前值 + 上一次值 | SCD Type 3(加一列) |
二、SCD 类型详解
2.1 Type 1:覆盖旧值
直接更新维度属性,不保留历史:
| user_id | city |
|---|---|
| u001 | 上海(北京被覆盖) |
| 优点 | 缺点 |
|---|---|
| 简单,空间省 | 历史订单的维度关系失真 |
适用:属性变更无分析价值(如用户头像 URL)。
2.2 Type 2:历史拉链
同一自然键保留多行,用 start_date / end_date 标记有效期:
| user_id | city | start_date | end_date | is_current |
|---|---|---|---|---|
| u001 | 北京 | 2026-01-01 | 2026-05-31 | N |
| u001 | 上海 | 2026-06-01 | NULL | Y |
| 优点 | 缺点 |
|---|---|
| 完整保留历史,可按时间回溯 | 行数膨胀,更新逻辑复杂 |
适用:绝大多数业务维度(用户、商品、门店),是数仓最常用的方案。
2.3 Type 3:当前值 + 历史值
只保留上一版本:
| user_id | city_current | city_previous |
|---|---|---|
| u001 | 上海 | 北京 |
| 优点 | 缺点 |
|---|---|
| 支持"当前 vs 上一版本"对比 | 只能回溯一层,多版本失效 |
适用:只需对比两期变化的场景。
2.4 Type 4:迷你维度
把变化频繁的少量属性拆到单独的"迷你维度"表:
用户事实表 ──► 用户迷你维度(年龄、会员等级等高频变化属性)
+ 用户静态维度(性别、注册地)适用:属性变化频繁(如会员等级),拆出降低拉链膨胀。
2.5 Type 6:Type 1 + 2 + 3 混合
同时保留当前值、历史值与"当时值":
| user_id | city | city_original | start_date | end_date | is_current |
|---|---|---|---|---|---|
| u001 | 上海 | 北京 | 2026-06-01 | NULL | Y |
| 优点 | 缺点 |
|---|---|
| 灵活性最高 | 维护最复杂,容易出错 |
适用:业务既有"看当前"又有"看历史"的混合需求。
2.6 类型对比表
| 类型 | 保留历史 | 复杂度 | 典型用途 |
|---|---|---|---|
| Type 1 | 否 | 低 | 无分析价值的属性 |
| Type 2 | 全历史 | 中 | 用户/商品/门店维度(默认选择) |
| Type 3 | 上一版本 | 低 | 两期对比 |
| Type 4 | 按属性拆分 | 高 | 高频变化属性 |
| Type 6 | 混合 | 高 | 复杂业务 |
三、拉链表设计
3.1 表结构
sql
CREATE TABLE dim_user (
user_id BIGINT, -- 自然键
sk_id BIGINT, -- 代理键(拉链版本主键)
user_name STRING,
city STRING,
start_date STRING, -- 生效日期
end_date STRING, -- 失效日期,当前行用 '9999-12-31'
is_current STRING -- Y/N,便于过滤当前版本
);3.2 更新流程(Type 2 拉链)
1. 取当日源数据与当前拉链的当前版本(is_current=Y)
2. 逐行对比业务属性:
- 属性变化 → 旧行 end_date = 昨天,is_current = N
新增一行(start_date=今天,end_date=9999-12-31,is_current=Y)
- 属性不变 → 跳过
3. 新出现的用户 → 直接新增当前行3.3 SQL 实现示例(Hive)
sql
-- 关闭旧版本
UPDATE dim_user
SET end_date = '2026-08-16', is_current = 'N'
WHERE user_id IN (SELECT user_id FROM ods_user WHERE dt = '2026-08-16')
AND is_current = 'Y'
AND (city <> (SELECT city FROM ods_user WHERE dt = '2026-08-16')
OR user_name <> ...);
-- 插入新版本与新增用户
INSERT INTO dim_user
SELECT user_id, sk_id+1, user_name, city,
'2026-08-16', '9999-12-31', 'Y'
FROM ods_user WHERE dt = '2026-08-16';3.4 查询用法
sql
-- 当前版本:一眼看最新
SELECT * FROM dim_user WHERE is_current = 'Y';
-- 历史回溯:订单日期落在哪个版本区间
SELECT o.order_id, u.city
FROM dwd_order_detail o
JOIN dim_user u
ON o.user_id = u.user_id
AND o.dt >= u.start_date
AND o.dt < u.end_date;四、全量 / 增量策略
4.1 两种同步方式
| 方式 | 说明 | 适用 |
|---|---|---|
| 全量快照 | 每天整表覆盖重导 | 数据量小(<百万级) |
| 增量更新 | 只处理变更数据 | 数据量大,需效率 |
4.2 拉链表的增量更新(Merge)
ODS 增量(今天变更的用户)
└─► 关闭旧版本(对匹配自然键且属性变化的当前行)
└─► 插入新版本
└─► 插入新增用户关键:以 user_id(自然键)匹配,而不是按全表扫描。
4.3 全量与增量对比
| 维度 | 全量 | 增量(拉链) |
|---|---|---|
| 实现难度 | 低 | 中 |
| 存储开销 | 高(每天整份) | 低(只存变更) |
| 回溯能力 | 需保留历史快照 | 天然支持 |
| 使用建议 | 小维表 | 大维表、主数据 |
五、实践建议
| 场景 | 推荐方案 |
|---|---|
| 用户主数据 | Type 2 拉链(is_current + 分区日期) |
| 商品维度 | Type 2(价格变化是分析重点) |
| 门店/组织 | Type 2 拉链 |
| 属性变更无意义 | Type 1 覆盖 |
| 需要新旧对比 | Type 3 或 Type 6 |
注意事项:
- **代理键(sk_id)**必须独立于业务键,避免业务键变更导致关联断裂。
- 拉链表更新放在业务低峰,避免大表 UPDATE 锁竞争。
- 定期校验:当前版本行数与源系统一致,
is_current=Y每自然键唯一。 - 变更日期字段建议记录
update_time,方便对账与问题排查。