缓慢变化维度:管理数据仓库中的历史数据

  1. 历史数据问题
  2. SCD 类型分类
  3. 类型 0:保留原始值
  4. 类型 1:覆写
  5. 类型 2:添加行
  6. 类型 3:添加属性
  7. 类型 4:添加历史表
  8. 类型 6:组合方法
  9. 类型 7:双键策略
  10. 选择正确的类型
  11. 结论

数据仓库承诺提供历史分析,但维度数据却不愿保持静止。客户搬迁、产品变更类别、销售员调到新区域。这些变化看似简单,直到你问:历史报表应该反映旧状态还是新状态?

传统数据库通过更新处理变化——用新值覆写旧值。这对专注于当前状态的事务系统有效,但会破坏使数据仓库有价值的历史背景。当销售员从加州调到伊利诺伊州时,他们过去的销售额应该出现在加州还是伊利诺伊州的区域报表中?

缓慢变化维度(SCD)解决了这个挑战。与快速变化的事务数据(客户 ID、数量、价格)不同,维度属性如位置、名称和类别的变化频率较低但不可预测。SCD 中的"缓慢"并不意味着变化罕见,而是指它们不是正常事务流程的一部分。

Ralph Kimball 的《数据仓库工具包》推广了处理这些变化的分类系统:类型 0 到 7。每种类型代表历史准确性、数据复杂性和查询性能之间的不同权衡。类型 1 牺牲历史以换取简单性。类型 2 以复杂性为代价保留完整历史。类型 6 结合多种方法以获得最大灵活性。

挑战不仅限于技术实现。SCD 必须维护事实表和维度表之间的引用完整性。销售事实表链接到供应商维度。当供应商搬迁时,历史销售记录必须仍然连接到正确的供应商信息,而不破坏数据库关系。

本文探讨每种 SCD 类型,检视其权衡,并提供针对不同场景选择正确方法的指导。

历史数据问题

在探索解决方案之前,理解问题可以揭示为什么维度变化会产生复杂性。

供应商搬迁场景

考虑一个跟踪供应商关系的数据仓库:

📊 初始状态

供应商维度

  • 供应商:Acme Supply Co
  • 位置:加州
  • 供应商代码:ABC

事实表

  • 2005 年销售额:$500,000
  • 2006 年销售额:$750,000

业务问题 "我们 2005 年在加州的供应商销售额是多少?"

答案似乎很明显:$500,000。但当 Acme 在 2007 年搬迁到伊利诺伊州时,问题变得模糊。报表应该显示:

  1. 当前状态:加州销售额为零(Acme 现在在伊利诺伊州)
  2. 历史状态:$500,000(Acme 在 2005 年时在加州)
  3. 两者皆可:取决于查询

不同的业务需求需要不同的答案。税务报告需要历史准确性——销售发生在加州,无论当前位置如何。战略规划可能需要当前状态——了解当前供应商分布比过去位置更重要。

引用完整性挑战

数据库关系使事情变得更加复杂:

🚫 更新问题

场景

  • 事实表引用 Supplier_Key = 123
  • 供应商 123 是"Acme Supply Co, CA"
  • 供应商搬迁到伊利诺伊州

选项 1:就地更新

  • 将供应商 123 改为"Acme Supply Co, IL"
  • 历史事实现在显示伊利诺伊州
  • 损失:历史准确性

选项 2:创建新记录

  • 添加供应商 124 为"Acme Supply Co, IL"
  • 保留供应商 123 为"Acme Supply Co, CA"
  • 问题:新事务使用哪个键?
  • 问题:如何查询"所有 Acme 事务"?

两种选项都无法满足所有需求。更新会破坏历史。新记录会破坏自然键关系。SCD 提供了结构化的方法来解决这个困境。

为什么"缓慢变化"很重要

缓慢变化和快速变化数据之间的区别至关重要:

🔄 变化频率比较

快速变化(事务型)

  • 每笔订单中的客户 ID
  • 每笔销售中的产品 ID
  • 数量、价格、日期
  • 变化:每天数百万次
  • 策略:存储每笔事务

缓慢变化(维度型)

  • 客户地址
  • 产品类别
  • 供应商位置
  • 变化:每月数十次
  • 策略:取决于业务需求

事务数据变化是预期的且经过设计。维度变化是需要特殊处理的例外。"缓慢"限定词表示这些变化发生在正常事务流程之外,使它们更难预测和管理。

SCD 类型分类

Ralph Kimball 的分类系统提供了处理维度变化的结构化方法。

类型系统概述

📋 SCD 类型一览

类型 0:保留原始值

  • 永不改变
  • 保留原始值
  • 示例:出生日期

类型 1:覆写

  • 用新值替换旧值
  • 不维护历史
  • 最简单的方法

类型 2:添加行

  • 为每次变化创建新记录
  • 保留完整历史
  • 最常见的方法

类型 3:添加属性

  • 在列中存储有限历史
  • 仅跟踪前一个值
  • 简单查询

类型 4:添加历史表

  • 独立的历史表
  • 当前和历史数据分离
  • 审计式跟踪

类型 6:组合方法(1+2+3)

  • 混合方法
  • 多种跟踪方法
  • 最大灵活性

类型 7:双键

  • 代理键和自然键
  • 灵活的查询选项
  • 复杂实现

每种类型代表不同的优先级。类型 1 优先考虑简单性而非历史。类型 2 优先考虑历史而非简单性。类型 6 试图以复杂性为代价提供两者。

选择因素

正确的类型取决于具体需求:

🎯 决策因素

历史准确性

  • 过去状态有多重要?
  • 法规要求?
  • 需要审计轨迹?

查询模式

  • 当前状态查询?
  • 历史状态查询?
  • 两种类型都需要?

数据量

  • 变化多久发生一次?
  • 存储限制?
  • 性能要求?

复杂度容忍度

  • 团队专业知识?
  • 维护负担?
  • ETL 复杂性可接受?

没有单一类型适合所有场景。许多数据仓库对不同维度使用不同类型,甚至对同一维度内的不同属性使用不同类型。

类型 0:保留原始值

某些属性根据定义永不改变。

不可变属性

类型 0 适用于具有持久值的属性:

✅ 类型 0 特性

定义

  • 属性永不改变
  • 原始值永久保留
  • 不需要特殊处理

常见示例

  • 出生日期
  • 原始信用评分
  • 账户创建日期
  • 初始分类

实现

  • 标准列
  • 无更新逻辑
  • 最简单的方法

类型 0 实际上不是"缓慢变化"维度——它是"永不变化"维度。此分类的存在是为了明确识别永远不应更新的属性,防止意外修改。

日期维度

日期维度通常对大多数属性使用类型 0:

CREATE TABLE Date_Dimension (
    Date_Key INT PRIMARY KEY,
    Full_Date DATE,
    Day_Of_Week VARCHAR(10),
    Month_Name VARCHAR(10),
    Quarter INT,
    Year INT,
    Is_Holiday BOOLEAN
);

📅 日期维度稳定性

为什么类型 0 有效

  • 2006 年 1 月 1 日永远是 2006 年 1 月 1 日
  • 给定日期的星期几永不改变
  • 季度和年份是固定的

例外:Is_Holiday

  • 假日指定可能会改变
  • 宣布新假日
  • 可能需要类型 1 或类型 2

日期维度展示了类型 0 的简单性。一旦填充,它们很少需要更新。这种稳定性使它们成为事实表连接和基于时间分析的理想选择。

业务价值

类型 0 提供清晰度和保护:

✅ 类型 0 优势

清晰度

  • 明确标记不可变数据
  • 记录业务规则
  • 防止混淆

保护

  • 无意外更新
  • 保证数据完整性
  • 审计合规

性能

  • 无版本检查
  • 简单查询
  • 最小存储

在设计期间识别类型 0 属性可以防止未来问题。当业务规则声明"这永不改变"时,类型 0 在数据模型层级强制执行该规则。

类型 1:覆写

最简单的方法:用新值替换旧值。

类型 1 如何运作

类型 1 就地更新记录:

初始状态:

Supplier_Key Supplier_Code Supplier_Name Supplier_State
123 ABC Acme Supply Co CA

搬迁后:

Supplier_Key Supplier_Code Supplier_Name Supplier_State
123 ABC Acme Supply Co IL
UPDATE Supplier
SET Supplier_State = 'IL'
WHERE Supplier_Code = 'ABC';

旧值消失。新值取而代之。没有历史,没有复杂性。

何时类型 1 有意义

类型 1 适用于特定场景:

✅ 良好的类型 1 使用案例

错误更正

  • 数据输入中的错字
  • 不正确的初始值
  • 数据质量修正
  • 错误不需要历史

无关紧要的变化

  • 不影响分析的变化
  • 外观更新
  • 标准化变化
  • 示例:"St."改为"Street"

仅当前状态

  • 不需要历史报告
  • 当前快照足够
  • 存储限制
  • 简单报告需求

供应商的电话号码可能使用类型 1。历史电话号码很少对分析重要。当号码改变时,覆写它。报表始终显示当前联系信息。

简单性的代价

类型 1 的简单性伴随着重大限制:

🚫 类型 1 限制

历史丢失

  • 无法回答"2005 年的值是什么?"
  • 审计轨迹被破坏
  • 合规问题
  • 不可逆的数据丢失

聚合影响

  • 预先计算的聚合变得不正确
  • 必须重新计算摘要
  • 示例:"按州销售"追溯变化
  • 性能影响

报告混淆

  • 历史报表显示当前值
  • "加州销售"在搬迁后显示零
  • 业务用户困惑
  • 对数据的信任受损

聚合重新计算问题

类型 1 产生级联更新:

⚠️ 聚合维护

场景

  • 聚合表:按供应商州的销售
  • 加州总计:$5,000,000
  • 伊利诺伊州总计:$2,000,000

类型 1 更新后

  • 供应商从 CA 搬到 IL
  • 聚合表现在不正确
  • 必须重新计算:
    • 从加州减去
    • 加到伊利诺伊州

问题

  • 昂贵的重新计算
  • 影响多个聚合表
  • ETL 复杂性增加
  • 违背聚合的目的

预先计算的聚合存在是为了性能。类型 1 变化强制重新计算,抵消了性能优势。这使得类型 1 不适合用于聚合表中的维度。

实现考量

类型 1 需要仔细规划:

💡 类型 1 最佳实践

记录决策

  • 明确标记类型 1 属性
  • 解释为什么不需要历史
  • 获得业务签核

审计轨迹替代方案

  • 考虑数据库审计日志
  • 独立审计表
  • 折衷:简单更新,外部历史

混合方法

  • 某些属性使用类型 1
  • 其他属性使用类型 2
  • 同一维度,不同策略

类型 1 与其他类型结合使用效果最佳。对历史真正不重要的属性使用类型 1,对重要的属性使用类型 2。

类型 2:添加行

最常见的 SCD 方法:为每次变化创建新记录。

类型 2 如何运作

类型 2 通过多笔记录保留完整历史:

初始状态:

Supplier_Key Supplier_Code Supplier_Name Supplier_State Version
123 ABC Acme Supply Co CA 0

第一次搬迁后:

Supplier_Key Supplier_Code Supplier_Name Supplier_State Version
123 ABC Acme Supply Co CA 0
124 ABC Acme Supply Co IL 1

每次变化都会创建一个具有新代理键的新行。自然键(Supplier_Code)保持不变,但代理键(Supplier_Key)会改变。

实现变化

类型 2 有几种实现模式:

🔧 类型 2 实现选项

版本号

  • 顺序版本列
  • 易于理解
  • 容易找到最新版本

生效日期

  • Start_Date 和 End_Date 列
  • 精确的时间跟踪
  • 支持时间点查询

当前标志

  • 布尔值指示当前记录
  • 快速当前状态查询
  • 通常与日期结合

生效日期实现:

Supplier_Key Supplier_Code Supplier_Name Supplier_State Start_Date End_Date
123 ABC Acme Supply Co CA 2000-01-01 2004-12-22
124 ABC Acme Supply Co IL 2004-12-22 NULL

NULL End_Date 表示当前记录。某些实现使用高日期值(9999-12-31)而不是 NULL,以避免在查询中处理 null。

当前标志实现:

Supplier_Key Supplier_Code Supplier_Name Supplier_State Effective_Date Current_Flag
123 ABC Acme Supply Co CA 2000-01-01 N
124 ABC Acme Supply Co IL 2004-12-22 Y

Current_Flag 启用快速筛选:WHERE Current_Flag = 'Y' 仅返回当前记录。

事实表集成

类型 2 的威力来自事实表集成:

✅ 类型 2 优势

历史准确性

  • 事实链接到正确的历史维度
  • 2003 年的事务链接到 Supplier_Key 123(CA)
  • 2005 年的事务链接到 Supplier_Key 124(IL)
  • 报表显示准确的历史状态

无聚合更新

  • 聚合保持正确
  • "按州销售"不会追溯变化
  • 预先计算的摘要保持有效
  • 维持性能

无限历史

  • 跟踪每次变化
  • 完整审计轨迹
  • 支持合规要求
  • 可能的时间旅行查询

当事实表存储 Supplier_Key 时,它会捕获事务时的维度状态。2003 年的销售引用 Supplier_Key 123(加州)。即使供应商搬迁,该关系也永不改变。历史报表自动显示正确状态。

查询模式

类型 2 支持多种查询模式:

当前状态查询:

SELECT 
    s.Supplier_Name,
    s.Supplier_State,
    SUM(f.Sales_Amount) as Total_Sales
FROM Fact_Sales f
JOIN Supplier s ON f.Supplier_Key = s.Supplier_Key
WHERE s.Current_Flag = 'Y'
GROUP BY s.Supplier_Name, s.Supplier_State;

历史状态查询:

SELECT 
    s.Supplier_Name,
    s.Supplier_State,
    SUM(f.Sales_Amount) as Total_Sales
FROM Fact_Sales f
JOIN Supplier s ON f.Supplier_Key = s.Supplier_Key
WHERE f.Sale_Date BETWEEN s.Start_Date AND s.End_Date
GROUP BY s.Supplier_Name, s.Supplier_State;

时间点查询:

SELECT 
    s.Supplier_Name,
    s.Supplier_State
FROM Supplier s
WHERE s.Supplier_Code = 'ABC'
  AND '2003-06-15' BETWEEN s.Start_Date AND COALESCE(s.End_Date, '9999-12-31');

类型 2 挑战

尽管很受欢迎,类型 2 仍有缺点:

⚠️ 类型 2 挑战

维度大小增长

  • 每次变化都会添加一行
  • 频繁变化的维度变得很大
  • 存储影响
  • 索引维护开销

ETL 复杂性

  • 必须检测变化
  • 关闭旧记录(设置 End_Date)
  • 创建新记录
  • 更新 Current_Flag
  • 比类型 1 更复杂

自然键查询

  • 每个自然键有多行
  • 必须指定时间段或当前标志
  • 连接变得更复杂
  • 重复结果的风险

追溯变化

  • 添加新属性很困难
  • 不同属性的生效日期不同
  • 可能需要重新处理事实
  • 昂贵的操作

追溯变化问题

类型 2 最大的挑战出现在维度模型变化时:

🚫 追溯变化场景

情况

  • 维度跟踪 Supplier_State
  • 业务添加需求:跟踪 Sales_Rep
  • Sales_Rep 的生效日期与 State 不同

问题

  • 现有行有 State 变化
  • 需要添加 Sales_Rep 变化
  • 生效日期不一致
  • 必须创建新的时间切片

影响

  • 现有事实记录指向旧键
  • 必须更新事实表
  • 昂贵的操作
  • 潜在的数据不一致

这种场景使得类型 2 不适合经常进行结构变化的维度。当模型演变时,维护类型 2 的成本可能变得过高。

最佳实践

类型 2 需要纪律才能发挥最佳效果:

💡 类型 2 最佳实践

需要代理键

  • 事实中永不使用自然键
  • 代理键启用版本控制
  • 简化键管理

一致的日期处理

  • 标准化使用 NULL 或高日期
  • 记录惯例
  • 在 ETL 中强制执行

索引策略

  • 为当前查询索引 Current_Flag
  • 为时间查询索引 Start_Date 和 End_Date
  • 为查找索引自然键

ETL 变化检测

  • 将来源与当前维度比较
  • 有效检测变化
  • 批次更新以提高性能

类型 2 是大多数缓慢变化维度的默认选择。其历史准确性和查询灵活性的结合使其适用于广泛的场景。

类型 3:添加属性

类型 3 通过额外列跟踪有限历史。

类型 3 如何运作

类型 3 不是添加行,而是添加列:

初始状态:

Supplier_Key Supplier_Code Supplier_Name Current_State
123 ABC Acme Supply Co CA

添加类型 3 跟踪后:

Supplier_Key Supplier_Code Supplier_Name Original_State Effective_Date Current_State
123 ABC Acme Supply Co CA 2004-12-22 IL

表结构改变以容纳历史。Original_State 保留第一个值。Current_State 保存最新值。

有限历史权衡

类型 3 提供固定的历史深度:

📊 类型 3 特性

跟踪内容

  • 原始值
  • 当前值
  • 一个转换日期
  • 无中间变化

限制

  • 仅两个状态可见
  • 第二次搬迁会覆写第一次变化
  • 无法跟踪多次转换
  • 固定列结构

优势

  • 简单查询
  • 无行倍增
  • 固定表大小
  • 易于理解

查询简单性

类型 3 启用直接的查询:

-- 当前状态分析
SELECT 
    Current_State,
    SUM(Sales_Amount) as Total_Sales
FROM Supplier s
JOIN Fact_Sales f ON s.Supplier_Key = f.Supplier_Key
GROUP BY Current_State;

-- 原始状态分析
SELECT 
    Original_State,
    SUM(Sales_Amount) as Total_Sales
FROM Supplier s
JOIN Fact_Sales f ON s.Supplier_Key = f.Supplier_Key
GROUP BY Original_State;

-- 转换分析
SELECT 
    Original_State,
    Current_State,
    COUNT(*) as Suppliers_Moved
FROM Supplier
WHERE Original_State != Current_State
GROUP BY Original_State, Current_State;

无日期范围检查。无当前标志。简单的列引用。

何时类型 3 有意义

类型 3 适用于特定的分析需求:

✅ 良好的类型 3 使用案例

前后分析

  • 比较原始与当前
  • 迁移跟踪
  • 示例:"从城市搬到乡村的客户"

固定转换

  • 预期的一次变化
  • 示例:产品发布状态(Beta → 已发布)
  • 示例:客户层级(标准 → 高级)

简单报告

  • 业务只需要两个视图
  • 当前和原始就足够
  • 不需要中间状态

前一个值变化

另一种方法跟踪最近的变化:

Supplier_Key Supplier_Code Supplier_Name Previous_State Change_Date Current_State
123 ABC Acme Supply Co CA 2004-12-22 IL

第二次搬迁后:

Supplier_Key Supplier_Code Supplier_Name Previous_State Change_Date Current_State
123 ABC Acme Supply Co IL 2008-02-04 NY

Previous_State 现在显示 IL(不是 CA)。此变化跟踪最后一次转换,失去了更早的历史。

类型 3 限制

固定结构产生问题:

🚫 类型 3 问题

历史丢失

  • 第三次变化覆写第二次
  • 无法重建完整历史
  • 审计轨迹不完整

架构变化

  • 添加更多历史需要 ALTER TABLE
  • 需要应用程序变化
  • 对生产环境造成干扰

时间查询不可能

  • 无法询问"日期 X 的值是什么?"
  • 仅两个时间点可用
  • 有限的分析价值

事实表模糊性

  • 哪个状态适用于给定事务?
  • 必须将事务日期与 Effective_Date 比较
  • 比类型 2 更复杂

混合列方法

类型 3 通常与其他类型结合:

💡 类型 3 混合策略

场景

  • 使用类型 2 获得完整历史
  • 为常见查询添加类型 3 列
  • 示例:类型 2 维度中的 Current_State 列

优势

  • 类型 2 提供完整历史
  • 类型 3 列简化频繁查询
  • 两种方法的优点

权衡

  • 冗余数据
  • ETL 必须维护两者
  • 存储开销

类型 3 很少单独使用。其有限的历史使其不适合作为唯一的跟踪机制。与类型 2 结合,它在维护完整历史的同时提供查询便利性。

类型 4:添加历史表

类型 4 将当前和历史数据分离到不同的表中。

类型 4 如何运作

类型 4 使用两个表:一个用于当前状态,一个用于历史:

Supplier 表(当前):

Supplier_Key Supplier_Code Supplier_Name Supplier_State
124 ABC Acme & Johnson Supply Co IL

Supplier_History 表:

Supplier_Key Supplier_Code Supplier_Name Supplier_State Create_Date
123 ABC Acme Supply Co CA 2003-06-14
124 ABC Acme & Johnson Supply Co IL 2004-12-22

当前表仅包含活动记录。历史表包含所有版本,包括当前版本。

数据库审计模式

类型 4 类似于数据库审计表:

🔍 类型 4 特性

结构

  • 当前表:每个实体一行
  • 历史表:所有版本
  • 两个表共享键结构
  • 历史包含时间戳

与审计表的相似性

  • 跟踪所有变化
  • 不可变历史
  • 基于时间戳
  • 独立存储

与审计表的差异

  • 历史表是可查询的维度
  • 事实表可以引用两者
  • 维度模型的一部分
  • 不仅用于审计

事实表集成

类型 4 的独特功能:事实可以引用两个表:

CREATE TABLE Fact_Sales (
    Sale_ID INT PRIMARY KEY,
    Sale_Date DATE,
    Current_Supplier_Key INT,  -- 引用 Supplier
    Historical_Supplier_Key INT, -- 引用 Supplier_History
    Amount DECIMAL(10,2)
);

✅ 双重引用优势

当前状态查询

  • 连接到 Supplier 表
  • 始终显示当前信息
  • 快速查询(较小的表)
  • 不需要日期筛选

历史状态查询

  • 连接到 Supplier_History 表
  • 显示事务时的状态
  • 完整审计轨迹
  • 时间准确性

灵活性

  • 每次查询选择适当的表
  • 不需要重写查询
  • 两种视图都可用

查询模式

类型 4 启用不同的分析视角:

当前状态分析:

SELECT 
    s.Supplier_State,
    SUM(f.Amount) as Total_Sales
FROM Fact_Sales f
JOIN Supplier s ON f.Current_Supplier_Key = s.Supplier_Key
GROUP BY s.Supplier_State;

此查询显示按当前供应商州分组的所有销售,无论销售何时发生。

历史状态分析:

SELECT 
    sh.Supplier_State,
    SUM(f.Amount) as Total_Sales
FROM Fact_Sales f
JOIN Supplier_History sh ON f.Historical_Supplier_Key = sh.Supplier_Key
GROUP BY sh.Supplier_State;

此查询显示按销售时供应商州分组的销售。

ETL 考量

类型 4 需要仔细的 ETL 设计:

⚠️ 类型 4 ETL 复杂性

变化检测

  • 将来源与当前表比较
  • 检测变化
  • 更新当前表
  • 插入历史表

键管理

  • 为变化生成新代理键
  • 用新键更新当前表
  • 在历史表中插入新行
  • 用两个键更新事实表

事实表加载

  • 必须填充两个键列
  • 当前键:最新维度键
  • 历史键:事务时的维度键
  • 需要时间查找

何时类型 4 有意义

类型 4 适合特定场景:

✅ 良好的类型 4 使用案例

双重视角报告

  • 需要当前和历史视图
  • 频繁在视角之间切换
  • 示例:按当前区域与历史区域的销售

性能优化

  • 当前表保持小型
  • 快速当前状态查询
  • 历史表可以分区
  • 独立索引策略

变化数据捕获

  • 与 CDC 系统集成
  • 自然适合 CDC 输出
  • 审计轨迹要求
  • 合规需求

类型 4 挑战

双表方法产生复杂性:

🚫 类型 4 问题

事实表开销

  • 每个维度两个键列
  • 增加存储
  • 更复杂的 ETL
  • 潜在的不一致

同步风险

  • 当前和历史表必须保持一致
  • 历史表应包含当前记录
  • ETL 失败可能导致分歧
  • 验证开销

查询混淆

  • 用户必须了解使用哪个表
  • 错误的表选择给出错误结果
  • 文档至关重要
  • 需要培训

有限的工具支持

  • BI 工具期望单一维度表
  • 可能无法很好地处理双重引用
  • 需要自定义查询逻辑
  • 报告复杂性

维护策略

类型 4 需要持续维护:

💡 类型 4 最佳实践

一致性检查

  • 验证历史包含当前记录
  • 验证键关系
  • 定期对账
  • 自动验证

清晰命名

  • 明显的表名(当前 vs 历史)
  • 描述性键列名称
  • 数据字典中的文档

ETL 原子性

  • 在单一事务中更新两个表
  • 失败时回滚
  • 防止部分更新

性能调整

  • 按日期分区历史表
  • 每个表不同的索引
  • 如需要归档旧历史

类型 4 以复杂性为代价提供灵活性。当真正需要双重视角且团队可以管理额外的 ETL 和维护负担时,它效果最佳。

类型 6:组合方法

类型 6 结合类型 1、2 和 3(1 + 2 + 3 = 6)以获得最大灵活性。

类型 6 如何运作

类型 6 使用类型 2 的行版本控制,加上类型 3 的当前值列和类型 1 的覆写策略:

初始状态:

Supplier_Key Row_Key Supplier_Code Supplier_Name Current_State Historical_State Start_Date End_Date Current_Flag
123 1 ABC Acme Supply Co CA CA 2000-01-01 9999-12-31 Y

第一次搬迁后:

Supplier_Key Row_Key Supplier_Code Supplier_Name Current_State Historical_State Start_Date End_Date Current_Flag
123 1 ABC Acme Supply Co IL CA 2000-01-01 2004-12-22 N
123 2 ABC Acme Supply Co IL IL 2004-12-22 9999-12-31 Y

注意:

  • 类型 2:创建新行(Row_Key 2)
  • 类型 3:添加 Current_State 列
  • 类型 1:Row_Key 1 中的 Current_State 覆写为 IL

第二次搬迁后:

Supplier_Key Row_Key Supplier_Code Supplier_Name Current_State Historical_State Start_Date End_Date Current_Flag
123 1 ABC Acme Supply Co NY CA 2000-01-01 2004-12-22 N
123 2 ABC Acme Supply Co NY IL 2004-12-22 2008-02-04 N
123 3 ABC Acme Supply Co NY NY 2008-02-04 9999-12-31 Y

所有行现在显示 Current_State = NY(类型 1 覆写),而 Historical_State 保留每个时间段内当前的值(类型 2 历史)。

三种技术的结合

类型 6 将每种技术应用于不同方面:

🔧 类型 6 机制

类型 1 组件

  • Current_State 列
  • 每次变化时覆写
  • 所有行更新以显示最新值
  • 启用"当前状态"查询

类型 2 组件

  • 每次变化添加行
  • Start_Date 和 End_Date
  • Current_Flag
  • 保留完整历史

类型 3 组件

  • Historical_State 列
  • 存储时间段内当前的值
  • 创建后永不更新
  • 启用"历史状态"查询

查询灵活性

类型 6 支持三种查询模式:

当前状态查询:

SELECT 
    s.Current_State,
    SUM(f.Sales_Amount) as Total_Sales
FROM Fact_Sales f
JOIN Supplier s ON f.Supplier_Key = s.Row_Key
GROUP BY s.Current_State;

显示按当前供应商州分组的所有销售。简单连接,无日期筛选。

历史状态查询:

SELECT 
    s.Historical_State,
    SUM(f.Sales_Amount) as Total_Sales
FROM Fact_Sales f
JOIN Supplier s ON f.Supplier_Key = s.Row_Key
GROUP BY s.Historical_State;

显示按销售发生时当前的州分组的销售。同样简单,不需要日期筛选。

时间点查询:

SELECT 
    s.Supplier_Name,
    s.Historical_State
FROM Supplier s
WHERE s.Supplier_Code = 'ABC'
  AND '2005-06-15' BETWEEN s.Start_Date AND s.End_Date;

检索特定日期时存在的维度状态。

Kimball 的"具有单一版本覆盖的不可预测变化"

Ralph Kimball 将类型 6 称为"具有单一版本覆盖的不可预测变化":

📚 Kimball 的术语

不可预测变化

  • 变化不规则发生
  • 无法预测何时或多久发生一次
  • 缓慢变化维度的典型特征

单一版本覆盖

  • 当前值"覆盖"在所有历史行上
  • 创建当前状态的单一版本
  • 无需日期逻辑即可访问
  • 简化常见查询

"覆盖"指的是在所有行中更新的 Current_State 列,无论您检查哪个历史行,都提供当前状态的单一、一致视图。

实现复杂性

类型 6 是最复杂的 SCD 类型:

⚠️ 类型 6 复杂性

ETL 挑战

  • 检测变化(类型 2)
  • 创建新行(类型 2)
  • 更新旧行的 End_Date(类型 2)
  • 更新 Current_Flag(类型 2)
  • 更新实体的所有行的 Current_State(类型 1)
  • 最复杂的更新逻辑

存储开销

  • 冗余的 Current_State 列
  • 存储在每个历史行中
  • 增加维度大小
  • 比单独类型 2 更多存储

更新性能

  • 每次变化更新多行
  • 必须更新所有历史行
  • 对于有许多版本的实体可能很慢
  • 索引维护开销

何时类型 6 有意义

类型 6 在特定场景中证明其复杂性是合理的:

✅ 良好的类型 6 使用案例

频繁的当前状态查询

  • 大多数查询需要当前状态
  • 偶尔需要历史状态
  • 查询简单性值得存储成本

业务用户查询

  • 非技术用户撰写查询
  • 日期逻辑太复杂
  • 偏好简单的列引用
  • 自助 BI 工具

法规要求

  • 必须维护完整历史(类型 2)
  • 必须报告当前状态(类型 1)
  • 两项要求都是强制性的
  • 合规证明复杂性合理

类型 6 最佳实践

管理类型 6 需要纪律:

💡 类型 6 最佳实践

清晰的列命名

  • Current_State vs Historical_State
  • 明显区分
  • 防止查询错误
  • 自我记录

高效更新

  • 批次 Current_State 更新
  • 使用基于集合的操作
  • 避免逐行更新
  • 优化性能

文档

  • 解释三种技术
  • 提供查询示例
  • 记录 ETL 逻辑
  • 培训用户

考虑类型 2 + 视图

  • 替代方案:类型 2 维度
  • 添加具有当前状态的视图
  • 视图连接到当前行
  • 更简单的 ETL,类似的查询体验

视图替代方案提供类似的查询简单性,而无需更新复杂性:

CREATE VIEW Supplier_With_Current AS
SELECT 
    h.*,
    c.Supplier_State as Current_State
FROM Supplier h
JOIN Supplier c ON h.Supplier_Code = c.Supplier_Code
WHERE c.Current_Flag = 'Y';

此视图使每个历史行都能访问当前状态,而无需冗余存储。

类型 7:双键策略

类型 7 在事实表中放置代理键和自然键,以获得最大查询灵活性。

类型 7 如何运作

类型 7 使用类型 2 维度结构,但在事实中存储两个键:

Supplier 维度(类型 2):

Supplier_Key Supplier_Code Supplier_Name Supplier_State Start_Date End_Date Current_Flag
123 ABC Acme Supply Co CA 2000-01-01 2004-12-22 N
124 ABC Acme Supply Co IL 2004-12-22 2008-02-04 N
125 ABC Acme Supply Co NY 2008-02-04 9999-12-31 Y

具有双键的事实表:

CREATE TABLE Fact_Sales (
    Sale_ID INT PRIMARY KEY,
    Sale_Date DATE,
    Supplier_Key INT,        -- 代理键(历史)
    Supplier_Code VARCHAR(10), -- 自然键
    Amount DECIMAL(10,2)
);

事实存储事务时当前的代理键(Supplier_Key)和识别所有版本实体的自然键(Supplier_Code)。

查询灵活性

类型 7 启用三种查询模式,无需日期逻辑:

历史状态查询:

SELECT 
    s.Supplier_State,
    SUM(f.Amount) as Total_Sales
FROM Fact_Sales f
JOIN Supplier s ON f.Supplier_Key = s.Supplier_Key
GROUP BY s.Supplier_State;

在代理键上连接显示历史状态。2003 年的销售连接到 Supplier_Key 123(CA),显示加州销售。

当前状态查询:

SELECT 
    s.Supplier_State,
    SUM(f.Amount) as Total_Sales
FROM Fact_Sales f
JOIN Supplier s ON f.Supplier_Code = s.Supplier_Code
WHERE s.Current_Flag = 'Y'
GROUP BY s.Supplier_State;

在自然键上连接并使用当前标志显示当前状态。ABC 的所有销售连接到 Supplier_Key 125(NY),显示纽约销售。

时间点查询:

SELECT 
    s.Supplier_State,
    SUM(f.Amount) as Total_Sales
FROM Fact_Sales f
JOIN Supplier s ON f.Supplier_Code = s.Supplier_Code
WHERE f.Sale_Date BETWEEN s.Start_Date AND s.End_Date
GROUP BY s.Supplier_State;

在自然键上连接并使用日期范围显示任何时间点的状态。

相对于类型 2 的优势

类型 7 为类型 2 增加了灵活性:

✅ 类型 7 优势

查询选项

  • 历史:在代理键上连接
  • 当前:在自然键 + 当前标志上连接
  • 时间点:在自然键 + 日期范围上连接
  • 不需要重写查询

引用完整性

  • 代理键可以有外键约束
  • 自然键提供逻辑关系
  • 支持两种视角

追溯变化

  • 添加属性不会破坏事实
  • 新时间切片不需要事实更新
  • 对维度变化更具弹性

多个日期视角

  • 事实有 Order_Date、Ship_Date、Invoice_Date
  • 可以在不同日期上连接
  • 示例:"订购时的供应商州"vs"发货时"

追溯变化优势

类型 7 最大的优势出现在维度演变时:

✅ 处理维度变化

场景

  • 维度跟踪 Supplier_State
  • 业务添加 Sales_Rep 属性
  • Sales_Rep 有不同的变化日期

类型 2 问题

  • 必须创建新时间切片
  • 现有事实指向旧键
  • 必须更新事实表
  • 昂贵的操作

类型 7 解决方案

  • 将 Sales_Rep 添加到维度
  • 创建新时间切片
  • 事实仍有自然键
  • 不需要事实表更新
  • 连接逻辑处理新结构

自然键提供稳定性。即使维度的时间切片改变,事实仍然可以使用自然键和日期逻辑正确连接。

类型 7 挑战

双键方法有重大缺点:

🚫 类型 7 问题

无真正的引用完整性

  • 自然键在维度中不唯一
  • 无法在自然键上创建外键
  • 代理键外键仅确保键存在
  • 不确保正确版本
  • 数据完整性依赖应用程序逻辑

查询复杂性

  • 用户必须了解使用哪个键
  • 错误选择给出错误结果
  • 某些查询需要日期逻辑
  • 比类型 2 更复杂

重复行风险

  • 没有日期筛选的自然键连接返回多行
  • 容易得到错误结果
  • 必须记住 WHERE Current_Flag = 'Y'
  • 或日期范围筛选

性能问题

  • 日期比较比键连接慢
  • 自然键连接可能较慢
  • 更复杂的执行计划
  • 索引策略至关重要

BI 工具限制

  • 工具期望单一连接键
  • 可能无法很好地处理双键逻辑
  • 通常需要自定义 SQL
  • 报告复杂性

引用完整性问题

类型 7 最严重的问题是无法强制执行引用完整性:

🚫 引用完整性缺口

问题

Supplier_Code 在维度中出现多次(每个版本一次)。数据库无法在非唯一列上创建外键约束。

后果

  • 可以插入不存在的 Supplier_Code
  • 可以插入有效代码但错误的 Supplier_Key
  • 可能出现孤立事实
  • 数据质量取决于 ETL

示例

事实有 Supplier_Code = 'ABC' 和 Supplier_Key = 999。Supplier_Key 999 不存在,但 Supplier_Code 'ABC' 存在。Supplier_Key 上的外键会捕获这一点,但不验证自然键关系。

缓解措施

  • ETL 验证至关重要
  • 定期数据质量检查
  • 应用程序层级约束
  • 无法依赖数据库

何时类型 7 有意义

类型 7 适合特定场景:

✅ 良好的类型 7 使用案例

演变的维度

  • 维度结构经常变化
  • 定期添加新属性
  • 追溯变化常见
  • 事实表更新昂贵

多个日期视角

  • 事实有多个日期
  • 不同日期需要不同的维度状态
  • 示例:订单日期 vs 发货日期
  • 灵活性值得复杂性

高级用户

  • 团队了解 SCD 概念
  • 可以撰写正确的连接逻辑
  • 数据质量流程成熟
  • BI 工具支持复杂连接

类型 7 最佳实践

类型 7 需要仔细实现:

💡 类型 7 最佳实践

全面文档

  • 解释双键目的
  • 提供查询模板
  • 记录连接模式
  • 包含反模式(不该做什么)

ETL 验证

  • 验证两个键匹配
  • 检查自然键存在
  • 验证代理键指向正确版本
  • 自动数据质量检查

索引策略

  • 在事实表中索引两个键
  • 在维度中索引自然键
  • 索引 Start_Date 和 End_Date
  • 监控查询性能

查询模板

  • 提供标准查询
  • 当前状态模板
  • 历史状态模板
  • 时间点模板
  • 防止常见错误

考虑替代方案

  • 类型 2 可能就足够
  • 类型 6 提供类似灵活性
  • 更简单的方法通常更好
  • 复杂性必须合理

类型 7 vs 类型 6

两者都提供灵活性,但通过不同机制:

🔍 类型 7 vs 类型 6 比较

类型 6

  • 在维度中存储当前状态
  • 变化时更新所有行
  • 简单查询(无日期逻辑)
  • 较高存储开销
  • 更复杂的 ETL

类型 7

  • 在事实中存储自然键
  • 不需要维度更新
  • 复杂查询(需要日期逻辑)
  • 较低存储开销
  • 更简单的 ETL,复杂的查询

决策因素

  • 需要查询简单性?→ 类型 6
  • 维度经常变化?→ 类型 7
  • 存储受限?→ 类型 7
  • 非技术用户?→ 类型 6

类型 7 以查询简单性换取 ETL 简单性和对维度变化的弹性。正确的选择取决于哪种复杂性对您的团队更易于管理。

选择正确的类型

不同的维度和属性需要不同的 SCD 类型。

决策框架

选择适当的 SCD 类型需要评估多个因素:

🎯 SCD 类型选择标准

历史准确性要求

  • 法规合规需求?
  • 需要审计轨迹?
  • 历史报告至关重要?
  • → 类型 2、类型 4 或类型 6

查询模式

  • 主要是当前状态查询?
  • 主要是历史查询?
  • 两者同等重要?
  • → 影响类型 6 vs 类型 2

变化频率

  • 变化罕见?
  • 变化频繁?
  • 可预测或不可预测?
  • → 影响存储和性能

维度稳定性

  • 结构经常变化?
  • 定期添加新属性?
  • 需要追溯变化?
  • → 不稳定维度使用类型 7

团队能力

  • ETL 专业水平?
  • 查询复杂度容忍度?
  • BI 工具限制?
  • → 经验较少的团队使用更简单的类型

类型选择矩阵

选择 SCD 类型的实用指南:

需求 推荐类型 替代方案 避免
不需要历史 类型 1 类型 0 类型 2+
需要完整历史 类型 2 类型 4、类型 6 类型 1、类型 3
仅前后比较 类型 3 带视图的类型 2 类型 1
当前 + 历史视图 类型 6 类型 7、类型 4 类型 1、类型 3
频繁维度变化 类型 7 类型 2 类型 6
简单查询至关重要 类型 1、类型 6 类型 3 类型 7
存储受限 类型 1、类型 3 类型 2 类型 6
审计合规 类型 2、类型 4 类型 6 类型 1

混合方法

大多数数据仓库使用多种 SCD 类型:

✅ 混合 SCD 类型

每个维度不同类型

  • 客户:类型 2(地址变化重要)
  • 产品:类型 1(描述更新不重要)
  • 日期:类型 0(永不改变)
  • 员工:类型 2(部门调动跟踪)

每个属性不同类型

  • Customer.Address:类型 2
  • Customer.Phone:类型 1
  • Customer.Email:类型 1
  • Customer.CreditLimit:类型 2

优势

  • 独立优化每个维度
  • 平衡复杂性和需求
  • 避免过度工程
  • 实用方法

按行业的常见模式

不同行业有典型的 SCD 模式:

🏢 行业模式

金融服务

  • 账户维度:类型 2
  • 客户维度:类型 2
  • 产品维度:类型 2
  • 原因:法规合规、审计要求

零售

  • 产品维度:类型 1 或类型 2
  • 商店维度:类型 2
  • 客户维度:类型 1 或类型 2
  • 原因:平衡历史与简单性

医疗保健

  • 患者维度:类型 2
  • 提供者维度:类型 2
  • 诊断维度:类型 0
  • 原因:历史准确性至关重要

制造业

  • 供应商维度:类型 2
  • 产品维度:类型 2
  • 工厂维度:类型 1
  • 原因:供应链跟踪

性能考量

SCD 类型影响查询和 ETL 性能:

⚠️ 性能影响

类型 1

  • 快速查询(无日期逻辑)
  • 快速更新(简单 UPDATE)
  • 必须重新计算聚合
  • 最佳查询性能

类型 2

  • 中等查询性能
  • 日期范围检查增加开销
  • 维度随时间增长
  • 索引策略至关重要

类型 6

  • 简单查询(无日期逻辑)
  • 慢速更新(多行)
  • 较大的维度大小
  • 以更新速度换取查询速度

类型 7

  • 复杂查询(日期逻辑)
  • 快速更新(无维度变化)
  • 自然键连接可能较慢
  • 取决于索引质量

存储考量

不同类型有不同的存储特性:

💾 存储影响

类型 1

  • 最小存储
  • 每个实体一行
  • 不随时间增长
  • 最有效

类型 2

  • 随变化增长
  • 每个版本一行
  • 可预测的增长率
  • 监控维度大小

类型 3

  • 固定大小
  • 额外列
  • 无行倍增
  • 中等存储

类型 6

  • 最大存储
  • 类型 2 行 + 冗余列
  • 最高开销
  • 以存储成本换取查询简单性

迁移策略

变更 SCD 类型需要规划:

💡 SCD 类型迁移

类型 1 到类型 2

  • 添加 Start_Date、End_Date、Current_Flag
  • 将 Start_Date 设置为最早已知日期
  • 将 End_Date 设置为 NULL
  • 未来变化使用类型 2
  • 历史数据丢失(不可避免)

类型 2 到类型 6

  • 添加 Current_State 列
  • 从当前行填充
  • 更新 ETL 以维护两者
  • 向后兼容

类型 2 到类型 1

  • 删除历史行
  • 仅保留当前行
  • 移除时间列
  • 不可逆(先备份)

要避免的反模式

常见的 SCD 错误:

🚫 SCD 反模式

对所有内容使用类型 2

  • 并非所有属性都需要历史
  • 不必要的复杂性
  • 浪费存储
  • 解决方案:适当混合类型

对所有内容使用类型 1

  • 失去有价值的历史
  • 无法回答时间问题
  • 合规问题
  • 解决方案:识别关键历史属性

不一致的实现

  • 不同的日期格式
  • 混合 NULL vs 高日期
  • 不一致的标志值
  • 解决方案:在仓库中标准化

忽略自然键

  • 仅使用代理键
  • 无法跨版本查询
  • 难以追踪实体历史
  • 解决方案:维护自然键

过度工程

  • 类型 2 足够时使用类型 6 或类型 7
  • 没有好处的复杂性
  • 维护负担
  • 解决方案:从简单开始,仅在需要时增加复杂性

实用建议

实际实现的指导方针:

💡 实用 SCD 建议

从类型 2 开始

  • 大多数维度的默认选择
  • 广为理解
  • 功能的良好平衡
  • 可以演变为其他类型

谨慎使用类型 1

  • 仅当历史真正不重要时
  • 记录决策
  • 获得业务签核
  • 考虑审计表作为备份

最初避免类型 6 和类型 7

  • 实现复杂
  • 维护复杂
  • 仅在类型 2 证明不足时添加
  • 需要成熟的团队

记录所有内容

  • 每个维度使用哪种类型
  • 为什么选择该类型
  • 查询模式和示例
  • ETL 逻辑和边缘案例

监控和调整

  • 跟踪维度增长
  • 监控查询性能
  • 收集用户反馈
  • 愿意变更类型

结论

缓慢变化维度代表数据仓库的基本挑战之一:在历史准确性与实际实现限制之间取得平衡。在 SCD 类型之间的选择不是寻找"最佳"方法——而是将技术与业务需求、团队能力和系统限制相匹配。

通过 SCD 类型的旅程揭示了重要的教训:

简单性有代价:类型 1 的简单性以失去历史为代价。在设计期间看似微小的权衡,当业务需求演变时会成为重大限制。"我们 2005 年在加州的销售额是多少?"这个问题在供应商搬迁且类型 1 覆写其位置后变得无法回答。历史数据一旦失去,就无法恢复。

历史需要结构:类型 2 的受欢迎程度源于其平衡的方法。完整的历史保留、合理的查询复杂性和广为理解的实现使其成为默认选择。为变化创建新行、维护时间列和使用代理键的模式已在无数数据仓库中证明了自己。

灵活性需要复杂性:类型 6 和类型 7 提供最大灵活性,但需要复杂的 ETL 流程和仔细的查询设计。只有当更简单的方法证明不足时,复杂性才是合理的。从类型 2 开始并随着需求的出现演变到更复杂的类型可以防止过早优化。

情境很重要:没有单一的 SCD 类型适合所有场景。客户地址可能需要类型 2 以获得历史准确性,而电话号码使用类型 1 以获得简单性。产品类别可能需要类型 2,而产品描述使用类型 1。在单一数据仓库内——甚至在单一维度内——混合类型的能力实现了实用的解决方案。

权衡是不可避免的:每种 SCD 类型代表竞争关注点之间的权衡:

  • 历史准确性 vs. 查询简单性
  • 存储效率 vs. 时间精确度
  • ETL 复杂性 vs. 查询灵活性
  • 当前状态访问 vs. 历史状态访问

理解这些权衡能够做出明智的决策,而不是盲目遵循模式。

实现纪律:成功的 SCD 实现需要纪律。一致的日期处理、清晰的命名惯例、全面的文档和强大的 ETL 流程将有效的实现与有问题的实现区分开来。技术模式不如其应用的严谨性重要。

SCD 类型分类系统提供了讨论维度变化策略的词汇。类型 0 到类型 7 不是严格的规则,而是工具包。艺术在于为每种情况选择正确的工具,在适当时组合技术,并保持纪律以一致地实现所选方法。

现代数据仓库通常使用类型 2 作为基础,为历史不重要的属性添加类型 1,仅在特定需求证明额外复杂性合理时才使用类型 6 或类型 7。这种务实的方法平衡了历史准确性、查询性能、存储效率和可维护性的竞争需求。

成功的最终衡量标准不是您选择哪种 SCD 类型,而是您的维度模型是否能让业务准确回答其问题。当历史报表反映实际历史状态,当当前报表显示当前状态,并且两者之间的区别清晰且有意时,无论采用哪种特定类型,SCD 实现都是成功的。

随着数据仓库的演变和业务需求的变化,SCD 策略可能需要调整。在类型之间迁移的灵活性、记录决策的纪律,以及在可能时选择简单性而非复杂性的智慧,比任何特定的技术选择更能决定长期成功。

分享到