Skip to content

拉链表(SCD2)怎么设计与回刷 ​

难度与前置

难度:中级
前置:已读 数仓分层、分区策略;知道维表快照是什么。
读完能做什么:判断要不要上拉链,定好区间与写入契约,按日关旧开新,并能量化验收、安全回刷。

适用场景 ​

需要回答「某一天当时是什么状态」的维度属性,且变化不频繁,例如:

  • 渠道结算主体 / 合作状态
  • 用户等级、商户类目
  • 剧本上下架、运营归属

不适用:

  • 只关心最新值 → 用 SCD1(覆盖)即可
  • 每次事件都要完整流水 → 用变更明细/事件表,不要硬做拉链
  • 一天内多次变更却只存到「日」精度 → 时间粒度不够,历史顺序会糊

设计前先写死三个答案:

  1. 业务主键是什么
  2. 哪些属性变化要开新版本
  3. 下游按日还是按时点追溯(决定 valid_from 用日期还是时间戳)

真实场景(一条) ​

投放主题里,渠道维表要服务两件事:

  1. 日报:按「入账业务日当天」的结算主体汇总消耗
  2. 对账:渠道中途改了结算主体,要按变更前后拆开核对

若维表只有最新快照,历史日报重跑会把旧消耗算到新主体上。用 SCD2 拉链表后,任意 biz_date 都能取到「当天有效」的渠道属性。

怎么做(步骤清单) ​

1. 选定 SCD 类型(一句话) ​

类型做法何时用
SCD1原地覆盖纠错、不关心历史
SCD2(拉链)关旧链 + 开新链要按历史时点还原
SCD3加「上一次」列只关心最近一次变更

本文只落地 SCD2。

2. 定版本字段与时间区间 ​

推荐字段:

字段含义
业务主键如 channel_id
受管属性如 settle_entity、coop_status
valid_from本版本生效时刻
valid_to本版本失效时刻
is_current是否当前版(可选,须与 valid_to 规则一致)
src_updated_at源端更新时间(便于排障)

时间区间建议左闭右开:[valid_from, valid_to)

查询某时点:

sql
valid_from <= biz_time
AND biz_time < valid_to

当前版统一:valid_to = '9999-12-31'(或时间戳上限),且 is_current = true。

TIP

相邻版本应首尾相接。用闭区间两端都包含交界日,容易同一天命中两行。

3. DDL 骨架 ​

sql
CREATE TABLE IF NOT EXISTS dim_ad_channel_zipper (
  channel_id      STRING  COMMENT '业务主键',
  channel_name    STRING,
  settle_entity   STRING  COMMENT '结算主体(受管属性)',
  coop_status     STRING  COMMENT '合作状态(受管属性)',
  valid_from      STRING  COMMENT '生效日 yyyy-MM-dd,含',
  valid_to        STRING  COMMENT '失效日 yyyy-MM-dd,不含;当前版=9999-12-31',
  is_current      BOOLEAN COMMENT '是否当前版',
  src_updated_at  TIMESTAMP,
  etl_ts          TIMESTAMP
)
COMMENT '渠道维 SCD2 拉链表';

日级精度示例用 STRING 日期;若一天多变,改成 TIMESTAMP 并保持同一套左闭右开。

4. 日增量:识别变更 → 关旧 → 开新 ​

输入:昨日当前链(is_current = true)+ 今日源端维表快照。

对每个变化键做两件事(同一作业内完成):

  1. 关旧:当前版 valid_to = '${biz_date}',is_current = false
  2. 开新:插入新行,valid_from = '${biz_date}',valid_to = '9999-12-31',is_current = true

未变化主键:不动。
源端删除:按业务约定——闭链保留最后状态,或闭链并开一条「已失效」状态;禁止默认静默丢历史。

比较受管属性判断是否变化,不要只看 updated_at。

text
读 is_current 行 C
读今日快照 S
对每个 key:
  若 key 仅在 S → 开新链
  若 key 仅在 C → 按删除语义闭链
  若受管属性不同 → 闭 C 且开新
  否则 → 跳过

最小可跑示意(先算出变更集 tmp_changed,再拼「未变当前版 + 已闭旧版 + 新开链」后 INSERT OVERWRITE 全表;生产可改成按主键合并写入):

sql
-- tmp_changed: 今日相对 is_current 有受管属性变化的主键(示意)
-- CREATE TEMP TABLE tmp_changed AS
-- SELECT c.channel_id, ...新属性...
-- FROM dim_ad_channel_zipper c
-- JOIN src_ad_channel_snapshot s ON c.channel_id = s.channel_id
-- WHERE c.is_current = true
--   AND (c.settle_entity <> s.settle_entity OR c.coop_status <> s.coop_status);

-- 关旧:当前版闭到 biz_date(不含当日新开)
-- 开新:valid_from = biz_date,valid_to = 9999-12-31
INSERT OVERWRITE TABLE dim_ad_channel_zipper
SELECT channel_id, channel_name, settle_entity, coop_status,
       valid_from, valid_to, is_current, src_updated_at, etl_ts
FROM (
  -- 1) 未变更的当前版:原样保留
  SELECT c.*
  FROM dim_ad_channel_zipper c
  LEFT JOIN tmp_changed x ON c.channel_id = x.channel_id
  WHERE c.is_current = true AND x.channel_id IS NULL

  UNION ALL
  -- 2) 变更键:旧当前版闭链
  SELECT
    c.channel_id, c.channel_name, c.settle_entity, c.coop_status,
    c.valid_from,
    '${biz_date}' AS valid_to,
    false AS is_current,
    c.src_updated_at,
    current_timestamp() AS etl_ts
  FROM dim_ad_channel_zipper c
  INNER JOIN tmp_changed x ON c.channel_id = x.channel_id
  WHERE c.is_current = true

  UNION ALL
  -- 3) 变更键:开新链(属性取自今日快照/变更集)
  SELECT
    x.channel_id, x.channel_name, x.settle_entity, x.coop_status,
    '${biz_date}' AS valid_from,
    '9999-12-31' AS valid_to,
    true AS is_current,
    x.src_updated_at,
    current_timestamp() AS etl_ts
  FROM tmp_changed x

  UNION ALL
  -- 4) 历史非当前版:不动
  SELECT *
  FROM dim_ad_channel_zipper
  WHERE is_current = false
) u;

新增主键、删除语义可在同作业加两段 UNION ALL;关键是关旧与开新必须同次提交,避免只开不关。

5. 回刷(幂等)清单 ​

  1. 明确影响日:biz_date = D(以及是否连带重算 D 之后)
  2. 回滚「valid_from = D 的新开链」以及「因 D 被错误改写的 valid_to」
  3. 从可信快照重放 D 日关旧开新
  4. 跑验收 SQL 后再级联下游按日重跑

禁止:只插新行不闭旧链;禁止对同一 biz_date 重复关开导致零长度区间。
建议约束:业务主键 + valid_from 唯一。

6. 查询模板 ​

sql
-- 某业务日当天有效的渠道属性(左闭右开)
SELECT
  channel_id,
  settle_entity,
  coop_status
FROM dim_ad_channel_zipper
WHERE valid_from <= '${biz_date}'
  AND '${biz_date}' < valid_to;

-- 只取当前版
SELECT *
FROM dim_ad_channel_zipper
WHERE is_current = true;

事实表关联时:用事实表的业务日去贴拉链区间,不要永远 Join is_current。

常见坑 ​

  1. 交界日重叠:旧 valid_to 与新 valid_from 双边闭区间,同一天两行命中。统一左闭右开。
  2. 只看 updated_at 就开链:无意义碰触更新产生空版本。必须比较受管属性。
  3. 回刷只插不修:断链、重叠或多条 is_current。按影响范围重放。
  4. 把拉链当流水明细:高频事件进拉链会版本爆炸。事件进 DWD,维表才拉链。
  5. 下游永远 Join is_current:历史重跑被「今天」污染。按业务日贴区间。

如何验收(可量化) ​

  • [ ] 文档写清:主键、受管属性、时间精度、左闭右开、删除语义
  • [ ] 任一主键同一时刻最多一个有效版本
  • [ ] 相邻版本无重叠、无非预期断隙(用下方 SQL D 核对:同主键排序后上一版 valid_to = 下一版 valid_from)
  • [ ] 每个主键至多一条 is_current = true,且 valid_to 为上限值
  • [ ] 抽样变更前后两天,还原结果与源日快照一致
  • [ ] 同一 biz_date 重跑两遍,版本行数与关键属性不变
sql
-- A. 同一主键同一天命中多个版本(应为 0 行)
SELECT channel_id, COUNT(*) AS cnt
FROM dim_ad_channel_zipper
WHERE valid_from <= '${biz_date}'
  AND '${biz_date}' < valid_to
GROUP BY channel_id
HAVING COUNT(*) > 1;

-- B. 多个当前版(应为 0 行)
SELECT channel_id, COUNT(*) AS cnt
FROM dim_ad_channel_zipper
WHERE is_current = true
GROUP BY channel_id
HAVING COUNT(*) > 1;

-- C. 历史版倒挂或零长度(应为 0 行)
SELECT *
FROM dim_ad_channel_zipper
WHERE valid_to <> '9999-12-31'
  AND valid_from >= valid_to;

-- D. 非预期断隙/重叠:同主键按 valid_from 排序后,上一版 valid_to 应等于下一版 valid_from(应为 0 行)
SELECT
  a.channel_id,
  a.valid_from AS prev_from,
  a.valid_to   AS prev_to,
  b.valid_from AS next_from,
  b.valid_to   AS next_to
FROM dim_ad_channel_zipper a
JOIN dim_ad_channel_zipper b
  ON a.channel_id = b.channel_id
 AND a.valid_from < b.valid_from
LEFT JOIN dim_ad_channel_zipper mid
  ON mid.channel_id = a.channel_id
 AND mid.valid_from > a.valid_from
 AND mid.valid_from < b.valid_from
WHERE mid.channel_id IS NULL          -- b 是 a 的相邻下一版
  AND a.valid_to <> b.valid_from;     -- 不相等即断隙或重叠错位

选型不确定时,先读 快照表 vs 拉链何时用哪个。

相关面试题 ​

题库小程序链接待定;先用站内锚点占位。

  • [ ] 题:如何证明任一主键同一时刻最多一个有效版本?(考察点:点查唯一 + SQL A 类校验为 0)→ 占位:#todo-interview-scd-zipper-point-unique
  • [ ] 题:相邻版本断隙/重叠怎么验收?(考察点:上一版 valid_to = 下一版 valid_from)→ 占位:#todo-interview-scd-zipper-adjacent-bounds
  • [ ] 题:is_current 与 valid_to 上限如何约束?(考察点:至多一条 current 且 valid_to 为上限)→ 占位:#todo-interview-scd-zipper-is-current
  • [ ] 题:同 biz_date 重跑两遍,拉链应满足什么?(考察点:版本行数与关键属性不变)→ 占位:#todo-interview-scd-zipper-rerun-stable

改写说明:结构参考 Wood 原创 SCD/拉链文,已改写成可执行清单;未包含未公开公司数据与内部表名。