数据仓库承诺提供历史分析,但维度数据却不愿保持静止。客户搬迁、产品变更类别、销售员调到新区域。这些变化看似简单,直到你问:历史报表应该反映旧状态还是新状态?
传统数据库通过更新处理变化——用新值覆写旧值。这对专注于当前状态的事务系统有效,但会破坏使数据仓库有价值的历史背景。当销售员从加州调到伊利诺伊州时,他们过去的销售额应该出现在加州还是伊利诺伊州的区域报表中?
缓慢变化维度(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 年搬迁到伊利诺伊州时,问题变得模糊。报表应该显示:
- 当前状态:加州销售额为零(Acme 现在在伊利诺伊州)
- 历史状态:$500,000(Acme 在 2005 年时在加州)
- 两者皆可:取决于查询
不同的业务需求需要不同的答案。税务报告需要历史准确性——销售发生在加州,无论当前位置如何。战略规划可能需要当前状态——了解当前供应商分布比过去位置更重要。
引用完整性挑战
数据库关系使事情变得更加复杂:
🚫 更新问题
场景
- 事实表引用 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 策略可能需要调整。在类型之间迁移的灵活性、记录决策的纪律,以及在可能时选择简单性而非复杂性的智慧,比任何特定的技术选择更能决定长期成功。