拉链表详解
缓慢变化维
首先,要理解“为什么”要有这些解决方式。
- 维(Dimension):是数据分析的角度或上下文,例如客户、产品、员工、地区等。它们是分析的基础(例如,按地区分析销售额,按产品分析销量)。
- 缓慢变化(Slowly Changing):维度的属性不是一成不变的,但它们变化的频率远低于事实数据(如销售交易记录)。例如:
- 客户的住址变化了。
- 产品的名称或类别修改了。
- 员工从销售部调到了市场部。
核心问题:当维度表中的一条记录(比如某个客户)的某个属性发生变化时,在数据仓库中应该如何保存这个变化的历史,以及如何将其与过去的事实数据正确关联?
为了解决这个问题,产生了三种主流的缓慢变化维(SCD)处理方式。
三种解决方式详解
这三种方式可以理解为三种不同的“历史记录管理策略”。
方式一:重写覆盖(Type 1 - Overwrite)
- 做法:直接用新值覆盖旧值。不保留任何历史痕迹。
- 比喻:就像修改一份电子文档。你打开文件,把错误的地方改掉,然后保存。旧的版本就消失了,只留下最新的、正确的版本。
- 何时使用:
- 修正之前的数据错误(如错别字)。
- 变化本身没有历史分析价值,或者业务上根本不关心历史值(例如,客户的电话号码变了,但分析从不基于旧号码)。
- 优点:实现简单,容易理解,节省空间。
- 缺点:丢失所有历史信息。无法知道这个维度成员过去是什么样子的。如果基于这个维度做历史分析,结果会是错误的(因为用了当前的值去关联了过去的事实)。
示例:
初始客户表:
| 客户ID | 客户姓名 | 所在城市 |
|---|---|---|
| 1001 | 张三 | 北京 |
后来,张三搬到了上海。采用 SCD Type 1 方式后,表变为:
| 客户ID | 客户姓名 | 所在城市 |
|---|---|---|
| 1001 | 张三 | 上海 |
说明:
北京这个历史信息被永久覆盖了,无法追溯。
方式二:添加新行(Type 2 - Add New Row)
- 做法:不覆盖旧记录,而是插入一条新的维度记录。为了区分同一客户的不同版本,通常会采用以下技术手段:
- 增加一个唯一的代理键(
Surrogate Key),作为表的主键,与原始业务键(如客户ID)分离。 - 增加
生效日期、失效日期和是否当前标志等字段来标识哪条记录在哪个时间段有效。
- 增加一个唯一的代理键(
- 比喻:就像保存文档的多个版本。V1.0是初稿,V1.1是第一次修改稿,V2.0是最终稿。所有版本都保存在服务器上,你可以查看任何一个历史版本。
- 何时使用:这是最常用、最重要的方式。当需要完整、准确地跟踪历史变化,并用于历史分析时。
- 优点:完整保留了所有历史状态,可以精确地反映过去某个时间点的维度情况,保证历史分析的准确性。
- 缺点:维度表会变得非常大(因为存储了所有历史版本),ETL 处理逻辑比 Type 1 复杂。
示例:
初始客户表(注意增加了代理键和有效日期):
| 代理键 | 客户ID | 客户姓名 | 所在城市 | 生效日期 | 失效日期 | 是否当前 |
|---|---|---|---|---|---|---|
| 501 | 1001 | 张三 | 北京 | 2020-01-01 | 9999-12-31 | Y |
张三搬家后,我们新增了一行,并更新旧行的失效日期和当前标志:
| 代理键 | 客户ID | 客户姓名 | 所在城市 | 生效日期 | 失效日期 | 是否当前 |
|---|---|---|---|---|---|---|
| 501 | 1001 | 张三 | 北京 | 2020-01-01 | 2023-05-31 | N |
| 502 | 1001 | 张三 | 上海 | 2023-06-01 | 9999-12-31 | Y |
说明:在分析2023年6月之前的销售数据时,事实表通过代理键关联到
501这条记录(北京);分析之后的数据时,则关联到502这条记录(上海)。历史得到了完美保留。
方式三:添加新列(Type 3 - Add New Column)
- 做法:不新增行,而是在原有行上增加新的列来保存上一次的旧值。通常只能保存有限次的历史变化(最常见的是只保存上一次变化)。
- 比喻:就像只保留上一版的纸质文件。你手头有最新版的文件,而抽屉里只保留了上一版的文件,再之前的版本都扔掉了。
- 何时使用:当业务上只关心当前值和上一次的值,或者变化次数非常有限时。这种场景相对较少。
- 优点:既可以知道当前值,又可以知道上一个历史值,且不需要增加行记录。
- 缺点:无法保存完整的变更历史(只能保存最近一次或有限的几次),表结构会因为增加新列而发生变化,灵活性差。
示例:
初始客户表:
| 客户ID | 客户姓名 | 当前城市 | 上一个城市 |
|---|---|---|---|
| 1001 | 张三 | 北京 | NULL |
张三从北京搬到上海后:
| 客户ID | 客户姓名 | 当前城市 | 上一个城市 |
|---|---|---|---|
| 1001 | 张三 | 上海 | 北京 |
说明:如果他再从上海搬到广州,那么“当前城市”变为“广州”,“上一个城市”则变为“上海”,而最初的“北京”这个值就被覆盖丢失了。
总结与对比
为了更直观,用一个表格来总结三种方式的特性:
| 特性 | Type 1 (重写覆盖) | Type 2 (新增行) | Type 3 (新增列) |
|---|---|---|---|
| 历史保留 | 不保留任何历史 | 完整保留所有历史 | 部分保留(通常只保留上一次) |
| 实现复杂度 | 简单 | 复杂 | 中等 |
| 存储空间 | 小 | 大(记录数增长) | 中等(列增长) |
| 分析能力 | 只能基于当前状态分析,历史分析会失真 | 可准确进行历史时间点分析 | 可分析当前和上一次的状态 |
| 常见应用 | 错误修正、无业务价值的变化 | 客户属性、产品属性、部门划分等绝大多数场景 | 偶尔需要对比本次和上次值的场景 |
拉链表
什么是拉链表
拉链表是一种设计表结构的方法,旨在高效、准确地记录数据在不同时间点上的所有状态变化。它通过开始日期和结束日期这两个字段,像“拉链”一样清晰地勾勒出每条数据的生命周期。
核心要解决的问题:有一张会变化的表(如用户表),如何既能查到任何一天的准确数据快照,又避免每天全量存储(浪费空间)?
思路一:先修改再插入 (Update then Insert)
这是最经典、最符合直觉的事务型数据库实现方式。
- 核心步骤:
- 修改 (UPDATE):找到需要失效的旧记录(即当前有效
is_current='Y'且业务上发生变化的记录),将其end_date更新为前一天,is_current更新为'N'。 - 插入 (INSERT):将变化后的新数据作为一条新记录插入,其
start_date为当天,end_date为'9999-12-31',is_current为'Y'。
- 修改 (UPDATE):找到需要失效的旧记录(即当前有效
- 比喻:就像公司的人事流程。
- 先修改:先给老员工办离职手续,在他的档案上写下离职日期(UPDATE旧记录)。
- 再插入:再为新员工创建一份新档案,入职日期从今天开始(INSERT新记录)。
- 适用场景:
- 传统关系型数据库(如 MySQL, PostgreSQL),支持事务(Transaction)。
- 数据量不是特别巨大的场景。
- 优点:
- 逻辑清晰,符合人类“先结束旧的,再开始新的”的思维习惯。
- 节省空间,直接在原记录上修改,不会产生冗余数据。
- 缺点:
- 依赖事务:必须将UPDATE和INSERT放在一个数据库事务中,要么全部成功,要么全部失败,否则会导致数据不一致(比如只UPDATE了旧记录,但INSERT失败了,那么这个数据就“消失”了)。
- 性能瓶颈:UPDATE操作是随机IO,在大数据量下非常耗时,容易成为ETL流程的瓶颈。
SQL伪代码示例:
sql
-- 在一个事务中执行
BEGIN TRANSACTION;
-- 1. 让旧的当前记录失效
UPDATE dim_table
SET end_date = '2023-10-27', is_current = 'N'
WHERE business_key = 'xxx' AND is_current = 'Y';
-- 2. 插入新的当前记录
INSERT INTO dim_table (business_key, attributes, start_date, end_date, is_current)
VALUES ('xxx', 'new_value', '2023-10-28', '9999-12-31', 'Y');
COMMIT TRANSACTION;
思路二:先删除再插入 (Delete then Insert)
这种思路是为了解决“先修改再插入”在大数据环境下的性能问题而演变来的。
- 核心步骤:
- 标记 (SELECT):先查询出需要被“失效”的所有旧记录。
- 删除 (DELETE):从目标表中删除这些即将被失效的旧记录。
- 插入 (INSERT):将所有需要失效的旧记录(修改了
end_date和is_current后) 和 全新的记录 一起重新插入到目标表中。
- 比喻:就像整理书架。
- 你决定重新整理某一格的书。
- 先删除:先把这一格里所有的书都拿出来(DELETE old records)。
- 再插入:然后把需要保留的旧书和新买的书,按照新的分类顺序一起放回去(INSERT old + new records)。
- 适用场景:
- 大数据平台(如 Hive, Spark, 早期对UPDATE支持不好或性能极差)。
- 更强调批量处理能力而非事务性的环境。
- 优点:
- 性能更高:避免了耗时的单条UPDATE操作,全部转换为批量的DELETE和INSERT,这对基于HDFS(一次写入,多次读取)的系统非常友好。
- 简化逻辑:只需要做两次批量操作,逻辑更简单。
- 缺点:
- 操作风险更高:DELETE操作是危险的,如果逻辑有误,可能误删数据。
- 依然不是原子操作:DELETE和INSERT是分开的,如果过程失败,需要有一套补偿机制来保证数据可重跑。
SQL伪代码示例 (Hive):
sql
-- 1. 先将所有需要变化的数据计算出来,存入临时表
INSERT OVERWRITE TABLE temp_dim_table
SELECT ... -- 所有未变化的记录
UNION ALL
SELECT ... -- 所有需要失效的旧记录(已设置好失效日期)
UNION ALL
SELECT ... -- 所有新增的记录
-- 2. 用临时表覆盖原表
INSERT OVERWRITE TABLE dim_table
SELECT * FROM temp_dim_table;
思路三:覆盖插入 (Insert Overwrite)
这是现代大数据生态中最主流、最优雅的实现方式,可以看作是“先删除再插入”的优化和封装。
- 核心步骤:
- 全量计算:基于昨天的全量拉链表和今天的增量变化数据,通过一个完整的SQL查询,计算出今天结束时拉链表应该有的全部数据。这个结果集包含三部分:
- 未变化的的历史数据:原样保留。
- 已关闭的历史数据:发生变化的数据,其旧版本已被标记为失效(
end_date设为昨天)。 - 新开启的数据:发生变化数据的新版本和全新插入的数据。
- 覆盖写入:将计算得到的整个结果集,一次性覆盖写入(INSERT OVERWRITE) 到目标表中。
- 全量计算:基于昨天的全量拉链表和今天的增量变化数据,通过一个完整的SQL查询,计算出今天结束时拉链表应该有的全部数据。这个结果集包含三部分:
- 比喻:就像印刷报纸。
- 报社不会去擦改昨天已经印好的报纸。
- 而是基于昨天的旧报纸内容和今天收到的新消息,重新排版、印刷今天全新的报纸,然后发行出去覆盖掉旧的。
- 适用场景:
- 所有大数据计算引擎(Spark, Hive, Flink SQL等)。
- 数据湖仓一体(Delta Lake, Iceberg, Hudi)环境。
- 优点:
- 性能最佳:只有批量读和批量写,没有UPDATE和DELETE,完美契合分布式计算和列式存储(Parquet/ORC)。
- 原子性:
INSERT OVERWRITE操作本身在大数据框架内通常是原子的,要么成功生成新版本,失败则原数据毫发无损。 - 易于回溯和重跑:数据以快照形式存储,如果某天逻辑出错,只需用正确的代码和原始数据重跑即可。
- 缺点:
- 需要存储多份全量数据(但可通过生命周期管理自动清理旧版本)。
SQL伪代码示例 (Spark SQL):
sql
INSERT OVERWRITE dim_table
SELECT
-- 1. 处理发生变化的数据:选出旧版本并让其失效
old.dw_sk,
old.business_key,
old.attributes,
old.start_date,
CURRENT_DATE - 1 AS end_date, -- 失效日期是昨天
'N' AS is_current
FROM dim_table old -- 历史拉链表
JOIN incremental_table inc ON old.business_key = inc.business_key
WHERE old.is_current = 'Y'
UNION ALL
SELECT
-- 2. 处理发生变化的数据:插入新版本
new_dw_sk, -- 生成新的代理键
inc.business_key,
inc.attributes, -- 新的属性值
CURRENT_DATE AS start_date, -- 生效日期是今天
'9999-12-31' AS end_date,
'Y' AS is_current
FROM incremental_table inc
UNION ALL
SELECT
-- 3. 处理未变化的数据:原样选出
old.*
FROM dim_table old
LEFT JOIN incremental_table inc ON old.business_key = inc.business_key
WHERE inc.business_key IS NULL -- 左连接为空的,表示今天没变化
;
总结对比
| 特性 | 先修改再插入 (Update-Insert) | 先删除再插入 (Delete-Insert) | 覆盖插入 (Insert Overwrite) |
|---|---|---|---|
| 核心操作 | UPDATE + INSERT | DELETE + INSERT | 全量SELECT + Overwrite INSERT |
| 数据库类型 | 传统事务型数据库 (OLTP) | 早期大数据系统 (Hive) | 现代大数据引擎 (Spark) |
| 性能 | 差 (随机IO) | 中 (批量操作) | 优 (全量扫描,顺序IO) |
| 原子性 | 依赖事务保证 | 难保证 | 操作本身原子 |
| 风险 | 中 | 高 (误删风险) | 低 |
| 思维模式 | 过程式编程 | 过程式编程 | 声明式编程 (我更关心结果) |
演进趋势:随着数据量爆炸式增长,技术选型正明显地从 “先修改再插入” 向 “覆盖插入” 迁移,后者已成为大数据领域处理拉链表的事实标准。
前两种实现思路都是需要在事务表中去实现。

309

被折叠的 条评论
为什么被折叠?



