专业书籍精读 · Fundamentals of Data Engineering · 第 8 章
Fundamentals of Data Engineering · Ch 8 · Queries, Modeling, and Transformation · Reis & Housley · 2022
上一章把订单、支付、点击这些数据搬进了公司的数据仓库。可搬进来的只是一堆原料,就像超市后仓刚卸下的货。这一章讲的是怎么把原料变成货架上能直接拿走的商品:怎么提问(查询)、怎么定规矩(建模)、怎么加工(转换)。数据真正产生价值,就在这一步。
老板问「上个月卖了多少钱」,市场部报一个数,财务部报另一个数,差了好几个百分点。没人算错——市场部把退货也算了进去,财务部只算已到账的。数字打架,往往不是算错,而是大家对「一笔订单」的理解不一样。这一章最重要的一件事,就是先把这些词的意思定死。
多数团队的做法是:先和业务一起把关键名词定义清楚,用「流水账 + 名册」的方式组织数据,把加工步骤写成可以反复运行的代码统一管理;只有真需要秒级答案的地方才在流上直接计算。一句诚实的代价:前期定口径、建模型要花不少时间和沟通,跳过它会更快出第一张报表,但之后会在一次次数字打架里加倍还回来。
数据的价值不在搬进来,而在加工好:查询要会「少翻」,建模要先把口径定死、历史别改写,转换要写成能重做的菜谱。
想看一条查询的一生、列裁剪与分区裁剪、流上的三种窗口与水位线、范式化、Inmon · Kimball · Data Vault · 宽表四种建模、缓慢变化维、分布式 join 与 upsert 的代价,以及 dbt、Trino、Flink 的实证? → 切到精读版
你以为数据工程的价值在「搬得快、存得多」,其实价值在这一章:查询、建模、转换把原始数据变成能回答业务问题的东西。本章要建立三条认知:查询要会看执行计划、尽量少扫数据;数据模型是把业务语言固化成结构,口径先于技术;转换是一段段可以重跑的代码,批与流只是运行节奏不同。
SELECT 金额 FROM 订单 WHERE 日期 = '2024-09-01'。Part II「生命周期详解」第四章。上接第 7 章「采集」(数据已进入存储),下启第 9 章「为分析与 ML 服务数据」。它对应生命周期里的「转换」阶段——作者称这是数据开始为业务创造价值的地方。现实里对应数仓建模、dbt 项目、Spark 批作业、Flink 流作业。
设想一家电商:订单表 30 亿行、三年历史,约 2 TB,每天新增 500 万行;分析师每天跑几百条查询,云数仓按扫描量或算力计费。不懂本章的典型后果:
SELECT * 不带日期条件,就把 2 TB 全扫一遍,而真正需要的只是最近 7 天的三列。作者先把查询(query)放回 SQL 的大家族:定义结构的 DDL(如 CREATE)、操作数据的 DML(如 SELECT、INSERT)、管权限的 DCL、管事务的 TCL。一条查询提交后经历四步:解析(检查语法、转成内部表示)→ 规划与优化(查询优化器决定先读哪张表、用哪种 join、能否跳过哪些数据)→ 执行 → 返回结果。同一条 SQL,不同执行计划的耗时可以差几个数量级。
书中给的提速清单:优化 join——常用的连接可预先算好存成表;警惕多对多 join 让行数爆炸;用 CTE(公用表表达式,给子查询起名分步写)提高可读性。看执行计划——用 EXPLAIN 看优化器打算怎么做、哪一步最贵。避免全表扫描——只选需要的列(列裁剪),用分区与聚簇让引擎跳过无关数据块(剪枝)。了解提交与一致性——不同数据库的事务和一致性模型不同,决定你能否读到别人刚写的数据。清理死记录——更新和删除常留下旧版本行,要定期 vacuum。利用结果缓存——同一条查询重复跑,很多云数仓能直接返回缓存结果。
以开头那张表为例:100 列只读 3 列,扫描量降到约 3%;三年数据只查最近 7 天,再降到约 0.6%——两者叠加,2 TB 变成约 0.4 GB。
对流数据,作者介绍了两种架构思路。快速跟随者(fast-follower):用 CDC 把业务库实时复制到一个分析型数据库,查询打在副本上,不碰生产库。Kappa 架构:把事件流平台本身当作主存储,保留足够长的历史,需要时从头重放来算。
流上的聚合要先划窗口(window):固定窗口(tumbling,如每 5 分钟一段、互不重叠)、滑动窗口(sliding,如每分钟计算一次「过去 5 分钟」,窗口相互重叠)、会话窗口(session,按用户活动切,空闲超过一定时长就关闭)。由于数据会迟到,引擎用水位线(watermark)判断「某个时间点之前的数据大致到齐了」,可以关窗出结果;比水位线还晚的数据要另行处理。流还常要和其他数据拼:用维表补全(enrichment)每条事件;两条流互相 join 时,引擎必须把双方最近一段数据缓存起来,等另一边的匹配到达,这段缓存的保留时长就是成本与准确度的权衡。
作者对数据模型(data model)的定义很朴素:数据如何对应真实世界。好模型要反映业务的概念和规则——「客户」是谁、「一笔订单」何时算成立。书里强调,许多组织跳过建模直接堆数据,结果是数据湖变沼泽、指标互相矛盾。建模分三层:概念模型(业务实体与关系,常画成 ER 图)→ 逻辑模型(字段、类型、主外键)→ 物理模型(具体落在哪个数据库、怎么分区)。
建模第一步是定粒度(grain):一行代表什么——一个订单,还是订单里的一个商品?粒度要尽量细,因为细粒度能汇总成粗粒度,反过来不行。然后是范式化(normalization):Codd 提出的一套去冗余规则,第一范式要求每个字段不可再分,第二范式去掉对部分主键的依赖,第三范式去掉非主键字段之间的传递依赖。范式化让每个事实只存一份、改一处即可,代价是查询要多次 join。
Inmon:数仓之父 Bill Inmon 把数仓定义为「面向主题、集成、非易失、随时间变化」的数据集合,支撑管理决策。做法是先建一个高度范式化(第三范式)的企业级数仓作为唯一真相,再从中导出面向各部门的数据集市(data mart)。自上而下,一致性强,前期投入大。
Kimball:自下而上,直接为分析建星形模型(star schema)。中心是事实表(fact table):一行一个业务事件,存数值与指向维表的键,只追加、不修改,又窄又长;周围是维度表(dimension table):描述「谁、什么、哪里、何时」,又宽又短,日期维表几乎必备。多个事实表共用的维度叫一致性维度(conformed dimension),保证跨主题口径一致。维度会变,于是有缓慢变化维(SCD):Type 1 直接覆盖、不留历史;Type 2 新增一行并标注生效起止时间,历史完整保留;Type 3 加一列存「上一个值」,只保留一层历史。
Data Vault:Dan Linstedt 提出,把数据拆成三类表——Hub(只存业务键,如客户编号)、Link(只存 Hub 之间的关系)、Satellite(存描述属性和变化历史)。只插入、不更新,源系统变化时只需加表,适合来源多、变化频繁、要求可审计的企业;代价是表多,查询前通常还要再建一层面向分析的模型。
宽表(wide denormalized table):把事实和维度全部拍平成一张可能有上千列的大表。过去这样做存储冗余、更新麻烦;如今列式存储下,空列几乎不占空间、只读用到的列,宽表查询快、分析师上手容易。作者的态度是务实的:宽表适合快速迭代,但随意加列会让业务逻辑失去结构,仍需治理。
作者区分了查询和转换(transformation):查询是取数回答问题,转换则把结果持久化,供下游反复使用,通常被编排成管道定时运行。
分布式 join。当一张表小到能放进每个节点的内存,用广播 join:把小表复制给所有节点,大表原地不动。两张都很大时,用洗牌哈希 join(shuffle hash join):两边都按 join 键重新分发到各节点,网络开销大得多。所以常见的优化是先过滤、先聚合,把一边缩小到可广播。
SQL 还是代码。SQL 是声明式的,能表达的复杂转换远比人们以为的多,应作为默认;但复杂的过程式逻辑、机器学习特征处理等,用 Spark 等代码框架更合适。用 Spark 时作者提醒:尽早过滤、尽量用内置 API,少写 Python 自定义函数(UDF),因为它要把数据在 JVM 与 Python 之间来回搬。
更新模式。①全量截断重载:清空再全部重算,简单但贵。②只插入:新版本作为新行追加,读的时候取最新一行;注意别对列式库逐行插入。③删除:硬删除真删,软删除只打标记;合规场景要真删。④upsert / merge:按主键有则更新、无则插入。列式存储里的更新往往要重写整个文件块,频繁的小 merge 代价很高,要攒批。schema 也会随业务变化,最好在设计上允许加列。
视图、物化视图与联邦查询。视图只存查询定义,每次现算;物化视图把结果存下来、定期或增量刷新,以存储换速度。联邦查询(federated query)让一个引擎直接查询外部数据源,数据虚拟化更进一步,不搬数据、在查询时跨多个系统取数——灵活,但会把查询压力传给源系统。
流式转换。流式转换在数据流动途中持续加工,常由多步组成有向无环图(DAG)。微批(如 Spark Streaming 按秒到分钟攒一批)与真流式(如 Flink 逐条处理)之争,作者的回答是:先问业务要多快——「秒级」够用就微批,要求毫秒级再上真流式,并用测试验证。
表 1 · 批分析建模四派
| 方法 | 适合 | 代价 |
|---|---|---|
| Inmon(3NF 企业数仓 + 集市) | 大型企业、强调单一真相与跨部门一致 | 前期建模周期长,见效慢 |
| Kimball(星形模型) | 以分析和 BI 为中心,按业务过程逐步交付 | 一致性维度没管好,集市间口径会分裂 |
| Data Vault | 来源多、变化频、需审计追溯 | 表数量多,查询前还要再建分析层 |
| 宽表 | 列式云数仓、快速迭代、分析师自助 | 冗余大,随意加列会让业务逻辑散架 |
表 2 · 缓慢变化维与更新模式
| 做法 | 好处 | 代价 |
|---|---|---|
| SCD Type 1(覆盖) | 简单,维表最小 | 历史被改写,旧报表会变 |
| SCD Type 2(新增行) | 完整历史,按当时状态归因 | 维表变大,join 要带时间条件 |
| 截断重载 | 逻辑最简单、结果自洽 | 数据量大时慢且贵 |
| 只插入 + 取最新 | 写入便宜,天然留痕 | 读时要去重,存储增长 |
| upsert / merge | 表里只有最新状态 | 列式存储中重写文件块,频繁小合并很贵 |
表 3 · 查询与计算的几组选择
| 选择 | 何时选 | 代价 |
|---|---|---|
| 视图 vs 物化视图 | 数据小、要求最新用视图;重复的重查询用物化 | 视图每次现算;物化要刷新,可能过时 |
| 联邦查询 vs 先采集 | 偶尔跨源查询、试探性分析 | 压力落到源系统,性能不可控 |
| 微批 vs 真流式 | 秒级够用选微批;毫秒级选真流式 | 真流式状态管理与运维更复杂 |
判据:先定粒度和口径,再选建模方法;以分析为主、用列式云数仓时,Kimball 星形或宽表通常是起点,来源多又要审计时才考虑 Data Vault;凡涉及历史归因的维度,默认用 Type 2。
面试题「设计一个电商数仓」的好答案会这样落地:原始层原样保留采集数据;中间层清洗、去重、统一口径;对外层按 Kimball 建订单事实表与客户、商品、日期维表,客户维用 Type 2;所有转换写成 SQL 模型、纳入版本控制并带数据测试,按依赖顺序每小时增量运行;按日期分区、按客户聚簇;风控指标单独走 Flink 的滑动窗口。每一步都对应本章一个概念。
开源实证
① 本章是生命周期里创造价值的一段:查询回答问题,建模固化口径,转换把结果持久化供下游复用。
② 查询经历解析、优化、执行、返回;用 EXPLAIN 看计划,列裁剪 + 分区裁剪能把扫描量降几个数量级。
③ 流上查询靠窗口(固定、滑动、会话)与水位线;流与流 join 要缓存一段数据等对方。
④ 建模先定粒度,越细越好;范式化去冗余,分析场景常主动反范式。
⑤ Inmon 自上而下 3NF + 集市,Kimball 星形事实表 + 维表,Data Vault 拆 Hub / Link / Satellite,宽表靠列式存储重新流行。
⑥ 缓慢变化维:Type 1 覆盖丢历史,Type 2 加行保历史,Type 3 加列留一层。
⑦ 分布式 join:小表广播、大表洗牌;先过滤先聚合。更新模式各有代价,列式库怕频繁小 merge。
⑧ 视图现算、物化视图以存储换速度、联邦查询不搬数据但压源;微批与真流式按业务延迟需求选。