数据仓库承诺提供历史分析,但维度数据却不愿保持静止。客户搬迁、产品变更类别、销售员调到新区域。这些变化看似简单,直到你问:历史报表应该反映旧状态还是新状态? 传统数据库通过更新处理变化——用新值覆写旧值。这对专注于当前状态的事务系统有效,但会破坏使数据仓库有价值的历史背景。当销售员从加州调到伊利诺伊州时,他们过去的销售额应该出现在加州还是伊利诺伊州的区域报表中? 缓慢变化维度(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 的分类系统提供了处理维度变化的结构化方法。
类型系统概述
类型 0:保留原始值
- 永不改变
- 保留原始值
- 示例:出生日期 类型 1:覆写
- 用新值替换旧值
- 不维护历史
- 最简单的方法 类型 2:添加行
- 为每次变化创建新记录
- 保留完整历史
- 最常见的方法 类型 3:添加属性
- 在列中存储有限历史
- 仅跟踪前一个值
- 简单查询 类型 4:添加历史表
- 独立的历史表
- 当前和历史数据分离
- 审计式跟踪 类型 6:组合方法(1+2+3)
- 混合方法
- 多种跟踪方法
- 最大灵活性 类型 7:双键
- 代理键和自然键
- 灵活的查询选项
- 复杂实现 每种类型代表不同的优先级。类型 1 优先考虑简单性而非历史。类型 2 优先考虑历史而非简单性。类型 6 试图以复杂性为代价提供两者。
选择因素
正确的类型取决于具体需求:
历史准确性
- 过去状态有多重要?
- 法规要求?
- 需要审计轨迹? 查询模式
- 当前状态查询?
- 历史状态查询?
- 两种类型都需要? 数据量
- 变化多久发生一次?
- 存储限制?
- 性能要求? 复杂度容忍度
- 团队专业知识?
- 维护负担?
- ETL 复杂性可接受? 没有单一类型适合所有场景。许多数据仓库对不同维度使用不同类型,甚至对同一维度内的不同属性使用不同类型。
类型 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 在数据模型层级强制执行该规则。
类型 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 适用于特定场景:
错误更正
- 数据输入中的错字
- 不正确的初始值
- 数据质量修正
- 错误不需要历史 无关紧要的变化
- 不影响分析的变化
- 外观更新
- 标准化变化
- 示例:“St.”改为”Street” 仅当前状态
- 不需要历史报告
- 当前快照足够
- 存储限制
- 简单报告需求 供应商的电话号码可能使用类型 1。历史电话号码很少对分析重要。当号码改变时,覆写它。报表始终显示当前联系信息。
简单性的代价
类型 1 的简单性伴随着重大限制:
历史丢失
- 无法回答”2005 年的值是什么?”
- 审计轨迹被破坏
- 合规问题
- 不可逆的数据丢失 聚合影响
- 预先计算的聚合变得不正确
- 必须重新计算摘要
- 示例:“按州销售”追溯变化
- 性能影响 报告混淆
- 历史报表显示当前值
- “加州销售”在搬迁后显示零
- 业务用户困惑
- 对数据的信任受损
聚合重新计算问题
类型 1 产生级联更新:
场景
- 聚合表:按供应商州的销售
- 加州总计:$5,000,000
- 伊利诺伊州总计:$2,000,000 类型 1 更新后
- 供应商从 CA 搬到 IL
- 聚合表现在不正确
- 必须重新计算:
- 从加州减去
- 加到伊利诺伊州 问题
- 昂贵的重新计算
- 影响多个聚合表
- ETL 复杂性增加
- 违背聚合的目的 预先计算的聚合存在是为了性能。类型 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 有几种实现模式:
版本号
- 顺序版本列
- 易于理解
- 容易找到最新版本 生效日期
- 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 的威力来自事实表集成:
历史准确性
- 事实链接到正确的历史维度
- 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 仍有缺点:
维度大小增长
- 每次变化都会添加一行
- 频繁变化的维度变得很大
- 存储影响
- 索引维护开销 ETL 复杂性
- 必须检测变化
- 关闭旧记录(设置 End_Date)
- 创建新记录
- 更新 Current_Flag
- 比类型 1 更复杂 自然键查询
- 每个自然键有多行
- 必须指定时间段或当前标志
- 连接变得更复杂
- 重复结果的风险 追溯变化
- 添加新属性很困难
- 不同属性的生效日期不同
- 可能需要重新处理事实
- 昂贵的操作
追溯变化问题
类型 2 最大的挑战出现在维度模型变化时:
情况
- 维度跟踪 Supplier_State
- 业务添加需求:跟踪 Sales_Rep
- Sales_Rep 的生效日期与 State 不同 问题
- 现有行有 State 变化
- 需要添加 Sales_Rep 变化
- 生效日期不一致
- 必须创建新的时间切片 影响
- 现有事实记录指向旧键
- 必须更新事实表
- 昂贵的操作
- 潜在的数据不一致 这种场景使得类型 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 |
| -------------- | --------------- | --------------- | ---------------- |
| 123 | ABC | Acme Supply Co | CA |
| 表结构改变以容纳历史。Original_State 保留第一个值。Current_State 保存最新值。 |
有限历史权衡
类型 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 适用于特定的分析需求:
前后分析
- 比较原始与当前
- 迁移跟踪
- 示例:“从城市搬到乡村的客户” 固定转换
- 预期的一次变化
- 示例:产品发布状态(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 限制
固定结构产生问题:
历史丢失
- 第三次变化覆写第二次
- 无法重建完整历史
- 审计轨迹不完整 架构变化
- 添加更多历史需要 ALTER TABLE
- 需要应用程序变化
- 对生产环境造成干扰 时间查询不可能
- 无法询问”日期 X 的值是什么?”
- 仅两个时间点可用
- 有限的分析价值 事实表模糊性
- 哪个状态适用于给定事务?
- 必须将事务日期与 Effective_Date 比较
- 比类型 2 更复杂
混合列方法
类型 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 |
| -------------- | --------------- | --------------- | ---------------- |
| 123 | ABC | Acme Supply Co | CA |
| 124 | ABC | Acme & Johnson Supply Co | IL |
| 当前表仅包含活动记录。历史表包含所有版本,包括当前版本。 |
数据库审计模式
类型 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 有意义
类型 4 适合特定场景:
双重视角报告
- 需要当前和历史视图
- 频繁在视角之间切换
- 示例:按当前区域与历史区域的销售 性能优化
- 当前表保持小型
- 快速当前状态查询
- 历史表可以分区
- 独立索引策略 变化数据捕获
- 与 CDC 系统集成
- 自然适合 CDC 输出
- 审计轨迹要求
- 合规需求
类型 4 挑战
双表方法产生复杂性:
事实表开销
- 每个维度两个键列
- 增加存储
- 更复杂的 ETL
- 潜在的不一致 同步风险
- 当前和历史表必须保持一致
- 历史表应包含当前记录
- ETL 失败可能导致分歧
- 验证开销 查询混淆
- 用户必须了解使用哪个表
- 错误的表选择给出错误结果
- 文档至关重要
- 需要培训 有限的工具支持
- BI 工具期望单一维度表
- 可能无法很好地处理双重引用
- 需要自定义查询逻辑
- 报告复杂性
维护策略
类型 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 将每种技术应用于不同方面:
类型 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 称为”具有单一版本覆盖的不可预测变化”:
不可预测变化
- 变化不规则发生
- 无法预测何时或多久发生一次
- 缓慢变化维度的典型特征 单一版本覆盖
- 当前值”覆盖”在所有历史行上
- 创建当前状态的单一版本
- 无需日期逻辑即可访问
- 简化常见查询 “覆盖”指的是在所有行中更新的 Current_State 列,无论您检查哪个历史行,都提供当前状态的单一、一致视图。
实现复杂性
类型 6 是最复杂的 SCD 类型:
ETL 挑战
- 检测变化(类型 2)
- 创建新行(类型 2)
- 更新旧行的 End_Date(类型 2)
- 更新 Current_Flag(类型 2)
- 更新实体的所有行的 Current_State(类型 1)
- 最复杂的更新逻辑 存储开销
- 冗余的 Current_State 列
- 存储在每个历史行中
- 增加维度大小
- 比单独类型 2 更多存储 更新性能
- 每次变化更新多行
- 必须更新所有历史行
- 对于有许多版本的实体可能很慢
- 索引维护开销
何时类型 6 有意义
类型 6 在特定场景中证明其复杂性是合理的:
频繁的当前状态查询
- 大多数查询需要当前状态
- 偶尔需要历史状态
- 查询简单性值得存储成本 业务用户查询
- 非技术用户撰写查询
- 日期逻辑太复杂
- 偏好简单的列引用
- 自助 BI 工具 法规要求
- 必须维护完整历史(类型 2)
- 必须报告当前状态(类型 1)
- 两项要求都是强制性的
- 合规证明复杂性合理
类型 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 增加了灵活性:
查询选项
- 历史:在代理键上连接
- 当前:在自然键 + 当前标志上连接
- 时间点:在自然键 + 日期范围上连接
- 不需要重写查询 引用完整性
- 代理键可以有外键约束
- 自然键提供逻辑关系
- 支持两种视角 追溯变化
- 添加属性不会破坏事实
- 新时间切片不需要事实更新
- 对维度变化更具弹性 多个日期视角
- 事实有 Order_Date、Ship_Date、Invoice_Date
- 可以在不同日期上连接
- 示例:“订购时的供应商州”vs”发货时”
追溯变化优势
类型 7 最大的优势出现在维度演变时:
场景
- 维度跟踪 Supplier_State
- 业务添加 Sales_Rep 属性
- Sales_Rep 有不同的变化日期 类型 2 问题
- 必须创建新时间切片
- 现有事实指向旧键
- 必须更新事实表
- 昂贵的操作 类型 7 解决方案
- 将 Sales_Rep 添加到维度
- 创建新时间切片
- 事实仍有自然键
- 不需要事实表更新
- 连接逻辑处理新结构 自然键提供稳定性。即使维度的时间切片改变,事实仍然可以使用自然键和日期逻辑正确连接。
类型 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 适合特定场景:
演变的维度
- 维度结构经常变化
- 定期添加新属性
- 追溯变化常见
- 事实表更新昂贵 多个日期视角
- 事实有多个日期
- 不同日期需要不同的维度状态
- 示例:订单日期 vs 发货日期
- 灵活性值得复杂性 高级用户
- 团队了解 SCD 概念
- 可以撰写正确的连接逻辑
- 数据质量流程成熟
- BI 工具支持复杂连接
类型 7 最佳实践
类型 7 需要仔细实现:
全面文档
- 解释双键目的
- 提供查询模板
- 记录连接模式
- 包含反模式(不该做什么) ETL 验证
- 验证两个键匹配
- 检查自然键存在
- 验证代理键指向正确版本
- 自动数据质量检查 索引策略
- 在事实表中索引两个键
- 在维度中索引自然键
- 索引 Start_Date 和 End_Date
- 监控查询性能 查询模板
- 提供标准查询
- 当前状态模板
- 历史状态模板
- 时间点模板
- 防止常见错误 考虑替代方案
- 类型 2 可能就足够
- 类型 6 提供类似灵活性
- 更简单的方法通常更好
- 复杂性必须合理
类型 7 vs 类型 6
两者都提供灵活性,但通过不同机制:
类型 6
- 在维度中存储当前状态
- 变化时更新所有行
- 简单查询(无日期逻辑)
- 较高存储开销
- 更复杂的 ETL 类型 7
- 在事实中存储自然键
- 不需要维度更新
- 复杂查询(需要日期逻辑)
- 较低存储开销
- 更简单的 ETL,复杂的查询 决策因素
- 需要查询简单性?→ 类型 6
- 维度经常变化?→ 类型 7
- 存储受限?→ 类型 7
- 非技术用户?→ 类型 6 类型 7 以查询简单性换取 ETL 简单性和对维度变化的弹性。正确的选择取决于哪种复杂性对您的团队更易于管理。
选择正确的类型
不同的维度和属性需要不同的 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 类型:
每个维度不同类型
- 客户:类型 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 类型需要规划:
类型 1 到类型 2
- 添加 Start_Date、End_Date、Current_Flag
- 将 Start_Date 设置为最早已知日期
- 将 End_Date 设置为 NULL
- 未来变化使用类型 2
- 历史数据丢失(不可避免) 类型 2 到类型 6
- 添加 Current_State 列
- 从当前行填充
- 更新 ETL 以维护两者
- 向后兼容 类型 2 到类型 1
- 删除历史行
- 仅保留当前行
- 移除时间列
- 不可逆(先备份)
要避免的反模式
常见的 SCD 错误:
对所有内容使用类型 2
- 并非所有属性都需要历史
- 不必要的复杂性
- 浪费存储
- 解决方案:适当混合类型 对所有内容使用类型 1
- 失去有价值的历史
- 无法回答时间问题
- 合规问题
- 解决方案:识别关键历史属性 不一致的实现
- 不同的日期格式
- 混合 NULL vs 高日期
- 不一致的标志值
- 解决方案:在仓库中标准化 忽略自然键
- 仅使用代理键
- 无法跨版本查询
- 难以追踪实体历史
- 解决方案:维护自然键 过度工程
- 类型 2 足够时使用类型 6 或类型 7
- 没有好处的复杂性
- 维护负担
- 解决方案:从简单开始,仅在需要时增加复杂性
实用建议
实际实现的指导方针:
从类型 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 策略可能需要调整。在类型之间迁移的灵活性、记录决策的纪律,以及在可能时选择简单性而非复杂性的智慧,比任何特定的技术选择更能决定长期成功。