跳到主要内容

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 1Type 2Type 3Type 4Type 6
历史保留无完整仅上一版完整(另表)完整
表结构变化不变加拉链字段加列拆表加列+拉链
行数不变膨胀不变不变(历史另表)膨胀
还原任意时点❌✅❌✅(跨表)✅
查询复杂度低中低中中
存储成本低中低中高
常用程度★★★★★★★★★★★★★★★★★★

九、选型决策树​

这个维度属性的历史,未来需要还原吗?
├── 否 → ✅ Type 1 覆盖(最简单)
└── 是 → 需要还原到什么程度?
├── 只需「上一次」→ Type 3 加列
├── 需要任意时点,但当前查询占主导
│ ├── 历史查询很少 → Type 4 历史表
│ └── 想兼顾简单 + 完整 → ✅ Type 6 混合
└── 标准选择 → ✅ Type 2 拉链(完整历史)

实战经验:

  1. 别用 Type 1 处理真正的「变化」——Type 1 只适合「纠错」,不适合「状态迁移」
  2. Type 2 是历史保留的标准答案,绝大多数场景默认它
  3. Type 6 是生产最爱——既保留历史,又不让「查当前状态」变复杂
  4. 一个维度表里,不同字段可以用不同类型:user_name(纠错用 Type 1)、city(历史用 Type 2)

十、常见坑点​

  1. 该留历史的用了 Type 1——「用户等级」直接覆盖,导致历史会员分析报废
  2. Type 2 查询忘了带时间条件——直接 JOIN dim_user ON user_id 不做时点过滤,会把同一用户的多个历史版本全 JOIN 出来,行数爆炸
  3. 拉链 end_date 语义不清——到底用 9999-12-31 还是 NULL?全表必须统一,否则比较逻辑出错
  4. 宽表里硬做 Type 2——宽表 × 维度版本 = 笛卡尔爆炸,见 宽表设计全指南 的「SCD 在宽表中的难题」

十一、小结​

需求选哪种
只是纠错,不要历史Type 1
要完整历史(标准)Type 2 拉链
只关心上一次Type 3
历史拆出去Type 4
既要当前又要历史(生产实用)Type 6

核心记住 Type 2:拉链是 SCD 的灵魂。它的完整 SQL 实现(全量/增量/快照/拉链对比)见下一篇文章 拉链表设计原理与 SQL 实现。


📌 本文是 dwsql.com 数仓建模方法论系列第 3 篇。上篇见 事实表完全指南,下篇直接落地拉链表的完整实现。

📱关注公众号

「数据仓库技术」文章同步更新,不错过每一篇干货

微信公众号二维码
💬加群交流

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

交流微信二维码

你可能还想看