## 一、承接上文:数据进来了,然后呢
上一篇我们解决了「数据怎么进来」------用 DataX 把业务库的刷卡主数据和用户信息表搬到了 Hive 的 ODS 层。
现在的状态是:ODS 层有几张和业务库一模一样的表,字段还是那副样子,数据还是全量。
这时候如果直接拿 ODS 去做报表,会发生什么?
- 一个「进站人次排行」指标,SQL 里要写一堆
if、case when去清洗脏数据 - 三个指标要写三遍几乎相同的清洗逻辑,改一次口径要改三个脚本
- 每次查询都要扫全量数据,跑一次几分钟
所以第二天的任务很明确:
- DWD 层建模 --- 把 ODS 洗干净、规范好
- DIM 层建模 --- 把维度单独抽出来管理
- DWS 层建模 --- 做轻度聚合,让指标能复用
- ADS 层建模 --- 算出最终的三个指标
- ClickHouse 搭建 --- 给大屏提供亚秒级查询能力
- 宽表写入 CK --- 把 ADS 结果推给 ClickHouse
整条链路是这样一条「越走越窄、越走越快」的路:
ODS(原始) ─► DWD(明细) ─► DIM(维度)
│
└─► DWS(轻聚合) ─► ADS(指标) ─► ClickHouse ─► 大屏
图1:数仓分层数据流转图
二、原理:为什么要分层
这是本篇文章最该先想通的问题。很多人背得出「ODS/DWD/DWS/ADS」,但说不清分层的动机。
2.1 现象与原因
现象:不分层也能跑,把所有逻辑堆在一条 SQL 里,报表照样能出数。
原因:数据量小的时候确实能跑,一旦表变多、口径变复杂,代价会指数级上升:
| 痛点 | 不分层 | 分层之后 |
|---|---|---|
| 口径统一 | 每个脚本各写一遍 | 清洗逻辑只在 DWD 写一次 |
| 复用性 | 无法复用 | DWS 结果被多个 ADS 指标共用 |
| 排查成本 | 一条几百行 SQL,改不动 | 逐层定位,哪层错查哪层 |
| 性能 | 每次查都扫全量明细 | 上层数据已被聚合/裁剪过 |
| 解耦 | 源表加个字段,连锁崩 | 上游变更被 DWD 挡住 |
2.2 分层的本质
说白了就两个词:职责分离 + 空间换时间。
- 职责分离:每一层只干一件事------ODS 负责「原样落地」,DWD 负责「清洗规范」,DIM 负责「维度管理」,DWS 负责「轻聚合」,ADS 负责「出指标」。一层只关心自己的上游。
- 空间换时间 :DWS 把明细算成聚合,ADS 直接读结果,本质是提前算好、多存一份、换取查询变快。
各层的定位对照:
| 分层 | 定位 | 粒度 | 常见存储 |
|---|---|---|---|
| ODS | 原始数据层,贴源不做处理 | 与业务库一致 | Hive 外部表(TextFile) |
| DWD | 明细数据层,清洗+规范化 | 一行 = 一个业务事实 | Hive ORC(分区) |
| DIM | 维度层,一致性维度 | 一行 = 一个维度对象 | Hive ORC(拉链表) |
| DWS | 汇总层,轻度聚合宽表 | 天 + 维度组合 | Hive ORC(分区) |
| ADS | 指标层,面向应用 | 一行 = 一个指标结果 | Hive + ClickHouse |
三、DWD 层建模:把脏数据变成「可用明细」
3.1 核心职责
DWD 只做四件事:清洗、规范化、脱敏、维度退化。
- 清洗:去重、过滤无效记录(金额为负、时间越界)、空值统一
- 规范化:字段类型统一(日期统一格式、金额统一精度)、命名统一(驼峰改下划线)
- 脱敏:手机号、身份证等敏感字段做掩码 ------ 数仓里这一步别省
- 维度退化 :把常用的维度字段(比如线路名、车站名)直接冗余进事实表,避免每次都去 join 维表
最后一条是维度建模里很关键的思想:为了查询性能,主动接受冗余。
3.2 两个必须做的决策
决策一:ETL 还是 ELT?
- ETL:抽取→转换→加载,先用外部工具算好再入库(传统 Hadoop 时代)
- ELT:抽取→加载→转换,先灌进 Hive,再用 SQL 加工(现代数仓主流)
我们选 ELT。原因很实际:Hive 的计算能力就摆在那儿,何必再引一个转换工具?数据先落地再加工,出问题还能回溯原始数据。
决策二:增量还是全量?
| 方案 | 适用场景 | 代价 |
|---|---|---|
| 每日全量 | 小表、维表 | 简单,但数据量一大就崩 |
| 每日增量 | 大事实表(刷卡表) | 必须靠分区,否则重跑没法幂等 |
刷卡表这种每天新增几十万条的事实表,必须用增量 + 分区。
3.3 建表:分区和存储格式不是可选项
sql
CREATE TABLE dwd_swipe_detail (
id BIGINT,
card_no STRING,
line_name STRING,
station_name STRING,
...
)
PARTITIONED BY (dt STRING)
STORED AS ORC
TBLPROPERTIES ('orc.compress' = 'SNAPPY');
这里每一行都在解决性能问题:
PARTITIONED BY (dt):按天分区。查询时只要where dt = 'xxx',Hive 会做分区裁剪,直接跳过其它天的目录,不扫全表。这是 Hive 最重要的优化手段。STORED AS ORC:列式存储 + 压缩。报表通常只查几列,列存只读那几列;ORC 还带谓词下推和统计信息,能进一步减少扫描量。
3.4 最容易踩的坑:重跑不幂等
现象:任务失败后重跑一次,DWD 表数据翻倍了。
原因 :用了 INSERT INTO,它只追加不清空。而采集任务失败重跑是常态。
对策 :永远用 INSERT OVERWRITE 配合分区。
sql
INSERT OVERWRITE TABLE dwd_swipe_detail PARTITION(dt='2026-10-14')
SELECT ... FROM ods_swipe WHERE ...
overwrite + 指定分区 = 只覆盖当天分区 ,重跑多少次结果都一样,这叫幂等 。数仓任务的第一准则:可重跑。
四、DIM 层建模:维度建模的两块基石
4.1 事实表和维度表
维度建模只认两种表:
- 事实表 :记录「发生了什么」,是可累加的数字(刷卡一次、金额多少),行多、增长快
- 维度表 :描述「谁、在哪、什么时候」,是描述性属性(用户、车站、线路),行少、相对稳定
围绕这个划分有两种模型:
| 模型 | 特点 | 取舍 |
|---|---|---|
| 星型模型 | 维度表不拆表,一级扁平 | 冗余换查询性能,join 少、易理解 ← 我们用这个 |
| 雪花模型 | 维度表继续拆子表,规范化 | 省空间,但 join 层级深、查询慢 |
数仓里基本一边倒选星型 。理由跟 DWD 的维度退化一致:分析场景要的是查得快,不是存得省。
4.2 变化维:维度会变,历史要留
这是 DIM 层真正有技术含量的地方。
现象:用户表里的「所在城市」字段,用户搬家后就被覆盖了。结果三个月前那笔刷卡记录,用现在的城市去分析,口径全错。
原因 :维度表的属性是会随时间变化的,而事实表记录的是当时的状态。直接覆盖 = 历史事实和当时的维度对不上了。
对策:按场景选变化维策略:
| 策略 | 做法 | 是否留历史 | 场景 |
|---|---|---|---|
| SCD1 | 直接覆盖 | 否 | 改错了、修正数据 |
| SCD2(拉链表) | 加 start_date / end_date |
是 | 需要按历史状态分析 ← 我们用这个 |
| SCD3 | 加一个「上一个值」列 | 只有一版 | 极少用 |
4.3 拉链表的原理
拉链表看着玄,本质就一句话:用两个日期字段,标记每一条维度记录的有效区间。
user_id | city | start_date | end_date
--------|------|------------|-----------
1001 | 北京 | 2026-01-01 | 2026-10-13 ← 旧版本,被"关闭"了
1001 | 上海 | 2026-10-14 | 9999-12-31 ← 新版本,当前有效
每天的处理逻辑分三步:
- 比对新旧 :把当日新增/变更的维度数据和历史拉链表做
full outer join,按主键找出「有新值的记录」 - 关闭旧版本 :把历史里被更新的那批记录,
end_date改成昨天 - 开新版本 :变更数据作为新记录写入,
start_date= 今天,end_date=9999-12-31(表示仍然有效)
9999-12-31 这个「最大日期」是拉链表的固定套路,用来表示当前生效版本 ,方便查询时写 where end_date = '9999-12-31' 直接拿最新状态。
4.4 一致性维度
DIM 层还有个隐性要求:同一个维度,全公司只能有一份。
比如「车站」这个维度,客流分析要用、线路分析也要用。如果各主题各建一份,同一车站在两张表里编码不同,跨主题的指标就永远对不上。所以公认的维度统一放 DIM 层,全局唯一 ------ 这就是「一致性维度」。
五、DWS 层建模:让指标能复用
5.1 为什么要有 DWS
现象 :三个 ADS 指标里,「进站人次排行」和「每日运输乘客最多的车站」都要按「车站 + 天」统计人次。如果没有 DWS,这两个指标就要各自扫一遍 DWD 明细表。
原因 :ADS 直接怼 DWD,等于重复计算。数据量一大,跑一次十几分钟,还白烧一遍集群资源。
对策:中间加一层 DWS,把「车站 + 天」的聚合结果算好存下来,ADS 直接读。
5.2 核心思想
DWS 的设计有两个原则:
- 以维度为基准 :先想清楚「按什么粒度聚合」,再往里面塞度量。所以 DWS 表名通常是
dws_业务过程_粒度_天。 - 按天累积:一天一个分区,只算增量天,不清历史。
5.3 一个重要提醒:宽表不是越宽越好
DWS 常被叫做「宽表」,但宽是有代价的:
- 列越多,单次扫描的 IO 和内存越大
- 如果一张 DWS 表塞进了所有可能用到的度量,那它就退化成了「另一种明细表」,复用性没提升,成本却上去了
正确做法:按主题域拆多张 DWS 表,每张只服务一组关联指标,让 ADS 能精准取数。
六、ADS 层建模:三个指标的口径落地
ADS 是唯一面向「人」的一层,它的用户是大屏、报表、运营。所以它的设计原则很直接:
一个指标一张表,一次算好,多次消费,结果要小。
下面拿大纲里的三个指标走一遍。指标建模,先把口径说清楚,再谈 SQL------口径不清晰,代码写对了也是错的。
| 指标 | 表名 | 口径定义 | 数据来源 |
|---|---|---|---|
| 指标1:进站人次排行 | ads_in_station_rank |
按车站汇总进站刷卡人次,按人次倒序排名 | DWS 车站-天聚合表 |
| 指标2:每日运输乘客最多的车站区 | ads_station_passenger_top |
每日按车站所属区域汇总运输乘客数,取 Top N | DWS + DIM 车站维度 |
| 指标3:线路单程直达乘客耗时平均 | ads_line_direct_cost_avg |
同一乘客同一线路直达行程(不含换乘)的平均耗时 | DWD 明细表 |
三个指标分别体现了三种典型模式:
- 指标1 是排行类 :在 DWS 上再套一层
row_number() over (order by cnt desc),属于纯复用 - 指标2 是维度下钻类 :需要 join DIM 的车站维度,拿到「所属区域」,属于跨层关联
- 指标3 是明细回溯类 :直达行程要判断「同一乘客、同一线路、进出站配对」,这类逻辑 DWS 聚合不出来,必须回 DWD 明细算
最后一条其实点出了分层的边界:DWS 不是万能的。当指标口径依赖「行程级」的配对和判定时,聚合层的抽象就不够了,只能回到明细层 ------ 这也是为什么 DWD 必须存在、且要保留完整明细。
图2:ADS 三个指标的建表与结果预览
七、ClickHouse 搭建:为什么要再引一个数据库
7.1 现象:Hive 出数了,但大屏还是转圈
现象:ADS 层数据算好了,大屏去查,一个指标要等十几秒甚至几分钟。
原因 :Hive 是为大批量离线计算 设计的------explain 一下就知道,一个简单查询也要起 MapReduce 任务,任务启动开销就是秒级 。它擅长「扫几十亿行算出结果」,不擅长「快速查出一小撮结果」。而大屏是高频、小结果集、要求秒级响应的场景。两者的设计目标根本不匹配。
对策 :引一个 OLAP 数据库,把算好的指标结果同步过去专供查询。这就是 ClickHouse(下称 CK)。
7.2 CK 为什么快(原理)
CK 的快不是玄学,是四件事叠出来的:
① 列式存储
数据按列存而不是按行存。报表查 车站 + 人次 两列时,只需读这两列的数据文件,其它几十列完全不碰。行存做不到这点,它必须把整行读进来。
② 数据有序 + 稀疏主键索引
CK 建表时用 ORDER BY (列) 指定物理排序键 。数据按这个键排好序落盘后,CK 每 8192 行打一个「标记」,记录这一块的最小值/最大值。查询时凭标记就能整块整块地跳过不相关的数据 ------ 这是 CK 查询快的最大功臣,比索引本身还重要。
③ 向量化执行 + SIMD
传统数据库是「取一行、算一行」,函数调用开销大、CPU 流水线利用率低。CK 是「取一批(block)、算一批」,用列存 + SIMD 指令一次处理多个值,同样时间处理的数据量高一个数量级。
④ MergeTree 引擎:类 LSM 的批处理友好结构
MergeTree 是 CK 最核心的表引擎。写入时数据先落成一个不可变的 part ,后台线程再异步 merge 成更大的 part。这个设计把「随机写」变成了「顺序写 + 后台整理」,所以 CK 特别欢迎大批量写入,特别讨厌单条插入 (每插一条就产生一个 part,merge 追不上,会报 too many parts)。
围绕这个特性,CK 还有几个变种引擎:
| 引擎 | 作用 |
|---|---|
MergeTree |
基础引擎 |
ReplacingMergeTree |
按排序键后台去重(注意是最终一致,不是实时) |
SummingMergeTree |
相同排序键的数值列自动求和 |
7.3 搭建要点
sql
CREATE TABLE ads_in_station_rank_ck (
station_name String,
dt Date,
cnt UInt64
)
ENGINE = MergeTree
PARTITION BY dt
ORDER BY (station_name, dt);
搭建时几件必须做的事(很多人第一次装 CK 都栽在这里):
- 关 swap / 调
max_memory_usage:CK 查询吃内存,内存一超就 OOM Kill。单机部署要显式配置内存上限。 PARTITION BY用日期:分区数别太多,分区过细会产生大量小 part,反而拖慢。ORDER BY就是主键 :它的选择直接决定查询性能,按「查询最常用的过滤列」来排,不是按唯一性来排。- 单机 vs 集群 :如果只是给大屏供数、数据量不大,单机 CK 完全够用。集群(分片 + 副本)带来的是吞吐和容灾,同时也带来运维复杂度,别为了「看起来专业」硬上。
八、宽表写入 CK:把第一天的技能复用起来
8.1 方案选择
ADS 结果在 Hive,需求是推到 CK。常见三条路:
| 方案 | 原理 | 评价 |
|---|---|---|
| DataX | clickhousewriter 插件直连 CK |
复用第一天技能,改动最小 ← 选这个 |
| CK 的 Hive 表引擎 | CK 反向读 Hive | 配置依赖多,排错难 |
| Spark 写 JDBC | 自己写代码 | 灵活但重,没必要 |
选 DataX 的理由和第一天一致:它是插件化架构,换目标端只需要换 writer 插件、改 JSON,Reader 和调度逻辑一行不用动。
这正是第一天讲的那个原理在发挥价值 ------ 当初理解「Framework + Plugin」,现在就直接省掉了一整套新工具的学习成本。
8.2 配置改动只有一处
对比第一天的 HDFS 同步,这次只有 writer 段变了:
json
"writer": {
"name": "clickhousewriter",
"parameter": {
"username": "default",
"password": "",
"connection": [{ "jdbcUrl": "jdbc:clickhouse://ck-host:8123/ads_db" }],
"table": ["ads_in_station_rank_ck"],
"batchSize": 10000,
"column": ["station_name", "dt", "cnt"]
}
}
两个关键点:
batchSize要给大(上万级)。原因回到 7.2 的第 ④ 条:CK 最怕小批量写入产生海量 part,批越大吞吐越高。- 列顺序必须和 CK 表定义严格对应。DataX 是按位置写入的,不是按列名匹配 ------ 顺序错了不会报错,只会静默写脏数据,这是最阴的一类 bug。
8.3 幂等:重跑问题在 CK 这边同样存在
CK 的 MergeTree 不支持 INSERT OVERWRITE,重跑就会追加重复数据。
对策二选一:
- 同步前先按分区
ALTER TABLE ... DROP PARTITION,再写入 - 改用
ReplacingMergeTree,查询时用FINAL或自己去重(注意:ReplacingMergeTree 是后台异步去重的,不保证实时,查报表前要确认 merge 已完成)
这也是为什么我把「幂等」在两篇博客里各强调了一次:从 Hive 到 CK,任何一个环节不幂等,重跑就是灾难。
九、小结:把两天的链路串起来
回头看,两天的内容其实是一条完整的链路:
Day1 ODS 采集 ──► Day2 DWD 清洗 ──► DIM 维度
└─► DWS 轻聚合 ──► ADS 指标 ──► CK ──► 大屏
三个最值得记住的原理:
① 分层是为了职责分离
每层只解决一个问题,上游变了被本层挡住。而它的代价(多存几份数据)换来的是复用性和可维护性 ------ 这就是「空间换时间」。
② 维度建模的核心是「冗余换性能」
无论 DWD 的维度退化、DIM 选星型而非雪花、还是 DWS 做成宽表,背后都是同一个决策:分析场景下,查询性能比存储空间更值钱。
③ CK 和 Hive 是互补,不是替代
Hive 擅长「大量数据算一次」,CK 擅长「算好的结果查很多次」。所以正确姿势是:在 Hive 里算,在 CK 里查。理解各自的定位,才不会拿大炮打蚊子。
真正跑通这条链路之后,你会发现最难的从来不是写 SQL,而是想清楚每一层该放什么、每个指标的口径是什么。建模能力,本质上就是这种「想清楚」的能力。