05.缓慢变化维:类型 1 / 2 / 3 / 4 / 6
维度不是静止的:用户会换手机号、商品会调分类、员工会换部门。SCD(Slowly Changing Dimension,缓慢变化维)就是回答「维度属性变了,数仓该怎么办」的一套方案。
用户改了城市、商品调了分类、员工换了部门,维度表该怎么处理?这就是 SCD(Slowly Changing Dimension)要回答的问题。它没有标准答案,只有不同策略的权衡——而权衡的关键,是你要不要保留历史。
一、先理解问题本身
维度数据的变化,和事实数据的变化有本质区别:
| 事实数据 | 维度数据 | |
|---|---|---|
| 变化频率 | 频繁、海量 | 缓慢、偶发 |
| 例子 | 每笔订单 | 用户改了地址 |
正因为维度变化「缓慢」,才叫 Slowly Changing Dimension。但「缓慢」不等于「不重要」——如果用户去年在广东、今年搬到北京,你不处理这个变化,按地区统计「今年 GMV」时就会把今年的订单算到广东头上。
处理维度变化的核心矛盾是:保留历史 vs 简单省事。SCD 的六种类型,就是这个矛盾的不同平衡点。
二、六种类型速览
| 类型 | 别名 | 怎么做 | 保留历史? | 一句话 |
|---|---|---|---|---|
| Type 1 | 覆盖 | 直接 UPDATE 旧值 | ❌ | 「改了就是改了」 |
| Type 2 | 拉链/版本行 | 新增一行 + 生效/失效时间 | ✅ 完整 | 「每次变化记一条」 |
| Type 3 | 加列 | 加一列存旧值 | ⚠ 仅最近一个版本 | 「保留上一次」 |
| Type 4 | 快照/历史表 | 历史拆到独立表 | ✅ 完整 | 「历史单放一张表」 |
| Type 6 | 混合 | Type 1 + Type 2 + Type 3 结合 | ✅ | 「既要当前又要历史」 |
Type 5 也存在(Type 1 + Type 4 的变体,主维度表加一个指向历史表的外键),但实践中极少单独讨论,通常归入 Type 4 或 Type 6 一起理解。
三、Type 1:直接覆盖(最常用,也最「危险」)
3.1 做法
维度属性变了,直接 UPDATE 覆盖旧值,不保留任何历史。
变化前:user_id=100, city='广州'
变化后:user_id=100, city='北京' ← 直接覆盖,旧值消失
UPDATE dim_user
SET city = '北京'
WHERE user_id = 100;
3.2 适用场景
- 属性是「纠错」而非「变化」——比如用户名字拼写错误被修正
- 历史对分析不重要,且你确定永远不重要
3.3 代价
历史被永久抹掉。之后想分析「这个用户去年在哪个城市」就做不到了,而且已经跑出来的历史报表会和新数据对不上。
⚠ Type 1 是最常见的「默认选择」,因为它最简单,但也最容易在未来埋雷。用 Type 1 之前,先问一句:这个字段的历史,我确定永远用不上吗?
四、Type 2:拉链(保留完整历史,重点)
4.1 做法
维度变化时不覆盖,而是新增一行,并用「生效时间 + 失效时间」标记每一行的有效区间。这是保留完整历史的标准做法,也是实际建模中最常用的一种。
dim_user(Type 2 拉链)
user_id | city | start_date | end_date | is_current
--------|---------|------------|------------|-----------
100 | 广州 | 2025-01-01 | 2026-03-15 | 0
100 | 北京 | 2026-03-16 | 9999-12-31 | 1 ← 当前有效行
start_date:这一行从哪天开始生效end_date:这一行到哪天失效(当前行用9999-12-31表示「至今有效」)is_current:标记当前有效行,方便查询
4.2 核心 SQL 实现(Hive/Spark)
下面是 Type 2 拉链的经典增量实现,三行搞定「关链 + 开链」:
-- Step 1:更新旧记录,关闭旧链(把当前有效行置为失效)
UPDATE dim_user
SET end_date = '${biz_date}', is_current = 0
WHERE user_id = 100
AND is_current = 1
AND city <> '北京';
-- Step 2:插入新记录,打开新链
INSERT INTO dim_user
SELECT user_id, '北京', '${biz_date}', '9999-12-31', 1
FROM dim_user
WHERE user_id = 100
AND is_current = 0
AND city = '广州'
AND end_date = '${biz_date}';
实际生产里,Hive 不支持行级 UPDATE,常用 FULL OUTER JOIN 对比快照 或
MERGE(Hive 3.0+/Spark/Iceberg)来实现拉链。完整拉链实现见 拉链表设计原理与 SQL 实现。
4.3 查询时的时点正确 (Point-in-Time)
Type 2 最大的价值:能还原任意历史时点的维度状态。查「2026-01-01 那天用户 100 在哪个城市」:
SELECT city
FROM dim_user
WHERE user_id = 100
AND start_date <= '2026-01-01'
AND end_date > '2026-01-01'; -- 广州
4.4 代价
- 行数膨胀:每变化一次就多一行
- 所有关联事实表的查询都要带时间条件,JOIN 变复杂
五、Type 3:加列(只保留「上一次」)
5.1 做法
在维度表里加一列存旧值,通常只保留「当前值 + 上一个值」。
user_id | current_city | previous_city
--------|--------------|--------------
100 | 北京 | 广州
5.2 适用场景
- 你只关心「和上一次相比发生了什么变化」,且最多回溯一个版本
- 典型:跟踪「部门变更」这类低频、浅回溯的场景
5.3 代价
- 只能回溯一个版本,历史深度严重受限
- 变化频繁的属性会需要不断加列(
city、city_prev、city_prev2…),不可维护
Type 3 很少单独使用,通常是 Type 6 的一个组成部分。
六、Type 4:历史表(历史单放)
6.1 做法
主维度表只保留当前状态,所有历史版本拆到一张独立的历史表。
dim_user(当前表,只有最新状态)
user_id | city
--------|------
100 | 北京
dim_user_history(历史表,存所有变化)
user_id | city | effective_date
--------|---------|---------------
100 | 广州 | 2025-01-01
100 | 北京 | 2026-03-16
6.2 适用场景
- 当前状态查询占绝对主导(不想要 Type 2 的行数膨胀)
- 历史查询较少,且可以忍受跨表
6.3 代价
- 当前表和历史表可能不一致(需要额外同步逻辑)
- 跨表查历史时点成本高
七、Type 6:混合(工程实战常用)
Type 6 是 Type 1 + Type 2 + Type 3 的组合,一张表同时具备:
- 当前值(Type 1 的思想)
- 历史版本行(Type 2 的思想)
- 「当前值」快照列(Type 3 的思想)
user_id | current_city | city_snapshot | start_date | end_date | is_current
--------|--------------|---------------|------------|------------|-----------
100 | 北京 | 广州 | 2025-01-01 | 2026-03-15 | 0
100 | 北京 | 北京 | 2026-03-16 | 9999-12-31 | 1
current_city:当前最新值(所有历史行都同步成最新,方便按当前状态过滤)city_snapshot:这一行当时的值(保留历史)- 拉链字段:还原任意时点
为什么 Type 6 工程上最实用:
- 想查「当前在北京的用户」→ 直接
WHERE current_city = '北京',不用管拉链 - 想查「某历史时点」→ 用
start_date/end_date还原 - 想查「某时点的值」→ 用
city_snapshot
一个字段三用,把「当前态」和「历史态」都照顾到了。代价是存储冗余更大(历史行也带着当前值)。
八、六种类型对比总表
| 维度 | Type 1 | Type 2 | Type 3 | Type 4 | Type 6 |
|---|---|---|---|---|---|
| 历史保留 | 无 | 完整 | 仅上一版 | 完整(另表) | 完整 |
| 表结构变化 | 不变 | 加拉链字段 | 加列 | 拆表 | 加列+拉链 |
| 行数 | 不变 | 膨胀 | 不变 | 不变(历史另表) | 膨胀 |
| 还原任意时点 | ❌ | ✅ | ❌ | ✅(跨表) | ✅ |
| 查询复杂度 | 低 | 中 | 低 | 中 | 中 |
| 存储成本 | 低 | 中 | 低 | 中 | 高 |
| 常用程度 | ★★★★★ | ★★★★ | ★★ | ★★★ | ★★★★ |
九、选型决策树
这个维度属性的历史,未来需要还原吗?
├── 否 → ✅ Type 1 覆盖(最简单)
└── 是 → 需要还原到什么程度?
├── 只需「上一次」→ Type 3 加列
├── 需要任意时点,但当前查询占主导
│ ├── 历史查询很少 → Type 4 历史表
│ └── 想兼顾简单 + 完整 → ✅ Type 6 混合
└── 标准选择 → ✅ Type 2 拉链(完整历史)
实战经验:
- 别用 Type 1 处理真正的「变化」——Type 1 只适合「纠错」,不适合「状态迁移」
- Type 2 是历史保留的标准答案,绝大多数场景默认它
- Type 6 是生产最爱——既保留历史,又不让「查当前状态」变复杂
- 一个维度表里,不同字段可以用不同类型:
user_name(纠错用 Type 1)、city(历史用 Type 2)
十、常见坑点
- 该留历史的用了 Type 1——「用户等级」直接覆盖,导致历史会员分析报废
- Type 2 查询忘了带时间条件——直接
JOIN dim_user ON user_id不做时点过滤,会把同一用户的多个历史版本全 JOIN 出来,行数爆炸 - 拉链
end_date语义不清——到底用9999-12-31还是NULL?全表必须统一,否则比较逻辑出错 - 宽表里硬做 Type 2——宽表 × 维度版本 = 笛卡尔爆炸,见 宽表设计全指南 的「SCD 在宽表中的难题」
十一、小结
| 需求 | 选哪种 |
|---|---|
| 只是纠错,不要历史 | Type 1 |
| 要完整历史(标准) | Type 2 拉链 |
| 只关心上一次 | Type 3 |
| 历史拆出去 | Type 4 |
| 既要当前又要历史(生产实用) | Type 6 |
核心记住 Type 2:拉链是 SCD 的灵魂。它的完整 SQL 实现(全量/增量/快照/拉链对比)见下一篇文章 拉链表设计原理与 SQL 实现。
📌 本文是 dwsql.com 数仓建模方法论系列第 3 篇。上篇见 事实表完全指南,下篇直接落地拉链表的完整实现。
「数据仓库技术」文章同步更新,不错过每一篇干货

备注「数据仓库技术」加入社群,每日一道大厂SQL真题

