KEEL · 龙骨 · A CURRICULUM FOR THE AI ERA
05 · 数仓分层与建模 — keel 龙骨
前四章把数据搬对了、算对了。到了落地这一步,同一个业务问题(「每个状态多少单、金额多少」)在链路的哪个位置回答,代价能差三个数量级。这一章把这件事量出来。
前四章把数据搬对了、算对了。到了落地这一步,同一个业务问题(「每个状态多少单、金额多少」)在链路的哪个位置回答,代价能差三个数量级。这一章把这件事量出来。
现场
第 00 章的分析表 orders_analytics 只有一行一行按主键去重后的最新状态。运营要的却是「按状态汇总」。如果每次都去分析表上 group by,数据量涨上去以后报表会越来越慢;如果预先算好存在一张小表里,查询快到不像话——但那张小表就一定会过期。
这不是「选哪个」的问题,是分几层、每层各管一件事的问题。
加工链路
flowchart TD
subgraph SRC["业务库 labcdc"]
O["表 public.orders<br/>当前状态 28000 行"] -->|"逻辑槽 cdc_slot<br/>解码"| CH["变更流水<br/>INSERT 30000 / UPDATE 10500 / DELETE 2000"]
end
subgraph ODS["ODS 贴源层"]
ODS_T["ods_orders_cdc<br/>42500 行 · append-only<br/>带 lsn / seq / op"]
end
subgraph DWD["DWD 明细层"]
DWD_T["dwd_orders_current<br/>28000 行 · 按 order_id 取最新<br/>剔掉已删除"]
end
subgraph DWS["DWS 汇总层"]
DWS_T["dws_orders_by_status<br/>3 行 · 按 status 预聚合"]
end
CH --> ODS_T
ODS_T -->|"distinct on + 剔删除"| DWD_T
DWD_T -->|"group by status"| DWS_T
ODS_T -. "同一问题要现算:20.9 ms" .-> SLOW["扫 42500 行 + 排序去重"]
DWD_T -. "直接聚合:4.8 ms" .-> MID["扫 28000 行"]
DWS_T -. "查预聚合:0.021 ms" .-> FAST["扫 3 行"]
ODS_T -. "DDL 不同步 / 改口径" .-> REBUILD["用 seq 顺序回放重建 DWD/DWS"]
style O fill:#e8f0fe,stroke:#3b5bdb,color:#1a1a2e
style CH fill:#e8f0fe,stroke:#3b5bdb,color:#1a1a2e
style ODS_T fill:#fff4e6,stroke:#e8590c,color:#1a1a2e
style DWD_T fill:#e6fcf5,stroke:#0ca678,color:#1a1a2e
style DWS_T fill:#f3f0ff,stroke:#7048e8,color:#1a1a2e
style SLOW fill:#ffe3e3,stroke:#c92a2a,color:#1a1a2e
style MID fill:#fff4e6,stroke:#e8590c,color:#1a1a2e
style FAST fill:#e6fcf5,stroke:#0ca678,color:#1a1a2e
style REBUILD fill:#f3f0ff,stroke:#7048e8,color:#1a1a2e
精确定义
ODS(贴源层)存「原样」。它不做清洗、不去重、不建业务语义,保留变更的顺序和信息(本机保留 lsn、seq、op)。它的价值是可回放:任何一层的口径写错了,都能从 ODS 重新算,而不必去业务库重新抽。
**DWD(明细层)**是「清洗后的事实」。本机把它定义成「按主键取最新、剔除已删除」,也就是把变更流折叠成当前状态。真实数仓里这一层往往保留更细的粒度(每个订单的每次状态迁移各一行),因为汇总层会随需求变,明细层不该轻易丢。
**DWS(汇总层)**是「按主题预聚合」。它回答固定的那几类问题,行数极少。
**ADS(应用层)**是对着具体报表/接口做的输出层,本机没建——它和 DWS 的边界是「DWS 服务多个报表,ADS 服务一个」。
维度建模与宽表:维度建模是把事实(订单、支付)和维度(客户、商品、时间)分开,靠 join 还原;宽表是把需要的维度字段直接冗余进事实表,查询时不用 join。取舍点在于查询频次和变更频次:维度变化慢、查询频繁,宽表省下的是每次查询的 join;维度变化频繁,宽表就要反复回刷。
一次完整运行
原始输出见 lab/evidence/stream-data-pipeline/06-warehouse.txt。造 3 万下单、1.05 万次改状态、2 千次删除:
ODS ods_orders_cdc : 42500 行(INSERT 30000 / UPDATE 10500 / DELETE 2000)
DWD dwd_orders_current : 28000 行
DWS dws_orders_by_status: 3 行
三层的行数比 42500 : 28000 : 3。同一个查询「每个状态多少单、金额多少」在三层各跑一次 EXPLAIN ANALYZE:
| 查询对象 | 计划形态 | Execution Time |
|---|---|---|
| ODS(要自己去重 + 剔删除) | Seq Scan 42500 行 → Sort → Unique → GroupAggregate |
20.877 ms |
| DWD(直接聚合) | Seq Scan 28000 行 → HashAggregate |
4.790 ms |
| DWS(读预聚合) | Seq Scan 3 行 |
0.021 ms |
DWS 相对 ODS 快了约 1000 倍(20.877 / 0.021)。DWD 相对 ODS 快了约 4.4 倍,省下的是「排序去重」和「多出来那 14500 行」。DWS 再快,是因为它把 group by 从查询时挪到了写入时。
顺带一个反直觉的对照——明细点查:
DWD: Bitmap Index Scan on dwd_orders_current_order_id_idx Execution Time: 0.073 ms
ODS: Index Scan using ods_orders_cdc_oid_seq ... Execution Time: 0.066 ms
两者几乎一样快(差 0.007 ms)。因为 ODS 上也有 (order_id, seq desc) 索引,还原单条并不比查宽表慢。分层带来的收益集中在「聚合」和「全表扫描」,不在「点查」。 如果你的负载全是按主键点查,多敷一层 DWD 不划算。
宽表/预聚合换来的是什么
拿本机的数字说话:
- 预聚合把一次聚合查询从 20.877 ms 压到 0.021 ms,但代价是数据会过期。第 06 章会实测:物化视图灌进 20 万新数据后仍然报旧值(100 万 vs 120 万),要跑一次 488 ms 的刷新才能对齐。预聚合不是「算得更快」,是「把计算挪到写入路径上」。
- 宽表省下的是 join。本机的 DWD 已经是「一张表答一个业务问题」,没有 join,所以这个收益在实验里没有直接体现——真实系统里 join 的成本往往比扫描还大,宽表的价值才显出来。
两者共同的代价是写入路径变长:每写一条明细,可能要多写几张预聚合表。写多了,写入延迟和失败面都变大。
回填与重跑
ODS 保留 seq(单调递增)和 op,这让重跑变得确定:DWD 的构建 SQL 是
create table dwd_orders_current as
with latest as (
select distinct on (order_id) * from ods_orders_cdc order by order_id, seq desc
)
select order_id, customer_id, status, amount, updated_at, seq as src_seq
from latest where op <> 'DELETE';
它只依赖 ODS 的内容,不依赖任何外部状态,所以同样的 ODS 一定算出同样的 DWD。改口径时改这条 SQL、重建 DWD、再重建 DWS;要回到某个历史时点,把 ods_orders_cdc 过滤到 seq <= N 再跑一遍即可。
这正是「ODS 贴源」的核心价值:增量管道的正确性难以证明,但一条可重放的基线可以随时验证。 上游 DDL 变了、某天数据被误删、口径要调——只要有 ODS,这些都是重跑一次的问题。
生产边界
- 教学替身 vs 真实依赖:本机全程在一个 PostgreSQL 实例里,三层表都在同一个库。真实环境 ODS 常落在对象存储/Hive 表上、DWD/DWS 落在 MPP 数仓(第 06 章),跨系统搬运本身又是一条管道。
- 要盯的指标:各层的行数比(突变说明去重或删除逻辑出问题)、DWS 的刷新延迟与最近刷新时间、重建 DWD 的耗时(决定你能否承受一次口径变更)。
- 失败策略:DWS 刷新失败要保留旧版本可用,别让报表直接空;重建期间要有「双写/切换」而不是「先删后建」,否则重建窗口内报表不可查。
- 别把 ODS 当归档:ODS 保留全量变更,体积会持续涨。要定保留策略,但保留窗口必须覆盖你最长的回填需求(比如「最近 90 天口径随时可能重算」)。
动手
- 跑
exp06_warehouse.sh,确认三层的行数是 42500 / 28000 / 3,并把三条EXPLAIN ANALYZE的Execution Time抄下来。 - 给 ODS 加一个
seq <= N的过滤,重建 DWD/DWS,验证「按历史时点回填」能得到当时的口径。 - 断言题:给 DWD 的
order_id加唯一索引,重建后确认没有重复的order_id(去重逻辑正确)。
自测
- ODS 为什么要保留
op和seq?去掉它们,回填还能做吗? - 为什么 DWS 比 ODS 快约 1000 倍,而 DWD 相对 ODS 只快 4 倍?
- 本机实测点查上 DWD 和 ODS 几乎一样快,这说明分层的收益集中在哪类查询上?
- 预聚合「把计算挪到写入路径」是什么意思?它换来的和付出的是什么?
- 口径要改,你从哪一层开始重算?为什么不是从业务库重新抽?
↓ 下一步:06 章 · OLAP 选型与管道运维 —— 预聚合的另一条路:列存和物化视图,以及上线后该盯什么。