从一次重复刷数事故说起:用 PostgreSQL 维度建模与 SCD 实现可重跑的数据管道
【免费下载链接】data-engineer-handbookThis is a repo with links to everything you'd ever want to learn about data engineering项目地址: https://gitcode.com/GitHub_Trending/da/data-engineer-handbook
做增量管道的人都踩过同一个坑:任务在凌晨失败,重跑一次,结果维表里同一实体出现两行,下游报表数字翻倍。data-engineer-handbook 这门中级数据工程训练营把这类问题当成第一周的核心主题——用 PostgreSQL 完成维度数据建模(Dimensional Data Modeling):累积表怎么保证幂等、SCD Type 2(缓慢变化维,记录维度属性历史版本)怎么做全量回填和增量更新,全部用真实 SQL 演练,而不只是讲概念。
这套材料位于仓库的 intermediate-bootcamp/materials/1-dimensional-data-modeling/ 目录,配套的建表脚本、管道查询、练习题(homework)一应俱全。下面以一个"数据翻倍"的故障为切入点,逐层拆解背后的两个关键机制,最后给出可落地的环境搭建与排障清单。
故障复盘:为什么重跑会把数据刷成两份
假设有一张players维表,主键是(player_name, current_season),每天用上游流水增量写入。某天上游把 2022 赛季的数据重发了一遍,你的INSERT没有判断实体是否已存在——PostgreSQL 因为主键冲突会直接报错,如果主键里漏了赛季字段,就会静默插入重复行。
问题出在管道缺少两个设计:
- 幂等写入:同一份输入重跑任意多次,表的状态不变。
- 历史版本隔离:维度属性变化时,旧值不能覆盖,要另起一行并打上有效区间(
start_date/end_date),否则回溯分析全错。
训练营的解法分两步:先用"累积表 + 全外连接"重写维表刷新逻辑,再用"运行长度编码"一次性生成 SCD 历史表。
机制一:累积表(Cumulative Table)与幂等刷新
累积表的设计思想是:每个实体只保留一行"当前快照",历史指标以数组形式堆叠在行内(比如一个赛季一条数据的seasons数组)。DDL 见 lecture-lab/players.sql:
CREATE TYPE season_stats AS ( season Integer, pts REAL, ast REAL, reb REAL, weight INTEGER ); CREATE TABLE players ( player_name TEXT, height TEXT, college TEXT, seasons season_stats[], -- 历史指标以数组堆叠,不膨胀行数 scoring_class scoring_class, is_active BOOLEAN, current_season INTEGER, PRIMARY KEY (player_name, current_season) );刷新时用"上赛季快照 + 本赛季流水"做一次全外连接,关键技巧是COALESCE兜底和数组拼接:
WITH last_season AS ( SELECT * FROM players WHERE current_season = 1997 ), this_season AS ( SELECT * FROM player_seasons WHERE season = 1998 ) INSERT INTO players SELECT COALESCE(ls.player_name, ts.player_name) AS player_name, COALESCE(ls.height, ts.height) AS height, -- 新数据并入历史数组;老球员无新数据时数组不变 COALESCE(ls.seasons, ARRAY[]::season_stats[]) || CASE WHEN ts.season IS NOT NULL THEN ARRAY[ROW(ts.season, ts.pts, ts.ast, ts.reb, ts.weight)::season_stats] ELSE ARRAY[]::season_stats[] END AS seasons, ... FROM last_season ls FULL OUTER JOIN this_season ts -- 全外连接:新球员和消失球员都覆盖 ON ls.player_name = ts.player_name;完整脚本在 lecture-lab/pipeline_query.sql。这段逻辑之所以幂等,是因为输出完全由"上一快照 + 当期流水"函数式决定——不依赖UPDATE、不依赖自增,重跑只是覆盖同一批主键。
为什么用 FULL OUTER JOIN 而不是 INSERT ... ON CONFLICT
| 方案 | 对"新实体" | 对"已存在实体" | 对"本期内消失实体" |
|---|---|---|---|
裸INSERT | 可行 | 主键冲突或重复 | 丢失 |
UPDATE+INSERT两阶段 | 可行 | 可行 | 需额外DELETE |
FULL OUTER JOIN单语句重写 | 可行 | 幂等 | 保留(is_active置 false) |
训练营选第三种,本质是把"差异计算"下沉到一条 SQL 里,配合主键约束形成最后一道防线。
机制二:用运行长度编码一次性生成 SCD Type 2 历史表
SCD Type 2 表长这样(见 lecture-lab/players_scd_table.sql):每个(player_name, scoring_class)的连续区间占一行,用start_season/end_date标记有效期。
回填(backfill)的难点是把"逐季变化的属性流"压缩成区间。训练营的查询(lecture-lab/scd_generation_query.sql)用的是经典两步窗口函数套路:
WITH streak_started AS ( SELECT player_name, current_season, scoring_class, -- 与上一季属性不同(或本季是首季)=> 标记为一段"连续段"的起点 LAG(scoring_class) OVER (PARTITION BY player_name ORDER BY current_season) <> scoring_class OR LAG(scoring_class) OVER (PARTITION BY player_name ORDER BY current_season) IS NULL AS did_change FROM players ), streak_identified AS ( SELECT player_name, scoring_class, current_season, -- 对"段起点"做累加,得到全局递增的段编号 SUM(CASE WHEN did_change THEN 1 ELSE 0 END) OVER (PARTITION BY player_name ORDER BY current_season) AS streak_identifier FROM streak_started ) SELECT player_name, scoring_class, MIN(current_season) AS start_date, MAX(current_season) AS end_date FROM streak_identified GROUP BY 1, 2, 3;拆开看只有两行核心逻辑:LAG找出属性变化的边界,SUM对边界打点累加成段编号。之后按(player_name, scoring_class, 段编号)分组取MIN/MAX就是区间端点。这套"运行长度编码"(RLE)思路可以直接搬到自己项目里做 SCD 全量回填,性能上只多了两个窗口扫描,不需要自连接迭代。
增量更新:四类记录的拆分
全量回填跑一次就够了,日常管道要的是增量:拿上赛季 SCD 状态合并本赛季流水。训练营把结果集严格拆成四块再UNION ALL(lecture-lab/incremental_scd_query.sql):
| 记录类型 | 判定条件 | 处理方式 |
|---|---|---|
historical_scd | end_season < 当前季 | 原样保留,区间不动 |
unchanged_records | 属性与上赛季相同 | 区间右端延伸到当前季 |
changed_records | scoring_class或is_active变化 | 旧行关段(end_season截断)+ 新行开段,用ARRAY[ROW(...), ROW(...)]一次展开两行 |
new_records | LEFT JOIN后上赛季无记录 | 开新段,起讫均为当前季 |
值得注意的工程细节:changed_records没有写两条INSERT,而是把"关旧段"和"开新段"两行打包进一个ROW数组再UNNEST,保证单语句原子完成,中途失败不会留下半截区间。这是手写增量 SCD 时最容易翻车的地方——拆成两条语句,失败重跑就会多出幽灵行。
环境搭建:Docker 一键起 PostgreSQL 的完整步骤
动手前先按 1-dimensional-data-modeling/README.md 把环境搭起来。仓库自带 Docker Compose 编排(Postgres 14 + PGAdmin),示例数据通过data.dump在容器首次启动时自动恢复。
需要 clone 仓库时使用:
git clone https://gitcode.com/GitHub_Trending/da/data-engineer-handbook cd />连上后展开Servers→postgres→Schemas→public,应能看到players、player_seasons等基表,然后就可以依次执行lecture-lab/下的查询验证结果。
排障清单:这些坑材料里都有对应解法
端口 5432 被占用:本机已有 PostgreSQL 或别的容器占了端口。macOS 用lsof -i :5432,Windows 用netstat -ano | findstr :5432找到 PID 杀掉,或者直接改.env里的HOST_PORT。
表没加载出来:Docker 场景下数据是随/docker-entrypoint-initdb.d注入的,如果中途改了data.dump想重置,必须docker compose down -v清掉卷再make up——make restart会重建容器,但make stop只停不删,数据卷还在,改了的 dump 不会重新执行。
PGAdmin 改完.env不生效:PGAdmin 的登录账号存在自己的数据卷里,改了PGADMIN_EMAIL后要删掉 pgadmin 容器重新make up。
SCD 区间断链:如果你自己改写了streak_identifier逻辑,验证方法是按player_name排序后检查相邻行end_date + 1 == 下一行 start_season。断链几乎都来自边界条件漏掉了LAG ... IS NULL(首行永远是段起点)。
分析查询里的除零:lecture-lab/analytical_query.sql 里"最近赛季得分 / 首个赛季得分"用了CASE WHEN (seasons[1]::season_stats).pts = 0 THEN 1 ...兜底,PostgreSQL 对整型除零直接报错而不是返回 NULL,写比率指标时务必加这层防护。
选型建议与下一步
- 小团队起步:PostgreSQL + 这套 SQL 模式足够支撑日粒度的维度建模,数组堆叠历史 +
UNNEST展开的组合(对照 lecture-lab/unnest_query.sql)避免了事实表爆炸,单库内闭环,不引入调度依赖。 - 规模上去了:同样的幂等与 SCD 思路可以直接平移到 Spark 作业——仓库 3-spark-fundamentals/ 目录里的
players_scd_job.py及其单测(src/tests/test_player_scd.py)就是同一模式在 PySpark 上的实现,可以对照 SQL 版逐行看差异。 - 想检验自己:第一周作业要求你把同一套东西套用到
actor_films数据集上,从零写出累积表 DDL、回填查询和增量查询(题目见 homework/homework.md),比单纯看教程的收获大得多。 - 踩坑提醒:累积表"每个实体一行"的约束意味着回溯多赛季明细要靠数组展开,如果下游大量需求是"按赛季 JOIN 事实表",说明累积表选型偏了,应改用标准星型事实表——这正是该材料包第二周(2-fact-data-modeling/)要解决的问题。
维度建模的价值不在 SQL 技巧本身,而在于它给了"管道重跑"一个明确的收敛点。把累积表和 SCD 增量这两段模式吃透,之后无论是换 Spark 还是换数仓,幂等管道的骨架都成立。
【免费下载链接】data-engineer-handbookThis is a repo with links to everything you'd ever want to learn about data engineering
项目地址: https://gitcode.com/GitHub_Trending/da/data-engineer-handbook
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考