🏬 04 · Hive 与数仓建模:四层分层 + SQL 实践
> 数仓岗的重头戏:**分层架构(ODS/DWD/DWS/ADS)** 与 **建模方法(星型/雪花/维度建模)**。
> 本节用 **sqlite3** 构建迷你数仓,手写 SQL 完成**清洗→明细→汇总→应用**四层。
📋 本节要解决的问题
| # | 问题 | 方法 |
|---|---|---|
| 1 | 数仓为什么分层? | 解耦/复用/成本/质量 |
| 2 | 四层各做什么? | ODS/DWD/DWS/ADS |
| 3 | 怎么建模? | 星型/雪花/宽表 |
| 4 | 怎么用 SQL 落地? | sqlite3 四层 ETL |
%matplotlib inline
import matplotlib
import matplotlib.pyplot as plt
matplotlib.rcParams['font.sans-serif'] = ['Microsoft YaHei', 'SimHei', 'PingFang SC', 'Noto Sans CJK SC', 'Arial Unicode MS']
matplotlib.rcParams['axes.unicode_minus'] = False
import numpy as np
import pandas as pd
import sqlite3
import os
rng = np.random.default_rng(42)
1️⃣ 数仓分层(必背框架)
前置概念:数据库 vs 数据仓库(OLTP vs OLAP)
| 维度 | 数据库(OLTP) | 数据仓库(OLAP) |
|---|---|---|
| 目标 | 高频事务:增删改单条记录 | 复杂分析:聚合扫亿级行 |
| 典型操作 | 查订单详情、扣库存 | 算 GMV、留存、转化漏斗 |
| 读写字节 | 读/写几行 | 扫全表/大范围分区 |
| 设计 | 三范式(防冗余) | 维度建模(允许冗余换速度) |
| 代表 | MySQL、PostgreSQL | Hive、Doris、ClickHouse |
事务库不适合直接跑分析(锁冲突 + 全表扫描拖垮线上业务),
所以要把数据 **T+1 同步到数仓**里做分析——这就是"分层"的起点。
print('''数仓四层:
ODS(原始层) :贴源存储,原样保留(保留全量/增量,可回溯);
DWD(明细层) :清洗+标准化+维度退化,明细粒度(最细粒度事实);
DWS(汇总层) :按主题汇总(日/周维度宽表,供报表/应用);
ADS(应用层) :面向业务应用/报表/算法特征(指标结果)。
分层的价值:解耦(上游变化不传导)、复用(一致口径)、
成本(去重存储)、质量(逐层校验)。
''')数仓四层:
ODS(原始层) :贴源存储,原样保留(保留全量/增量,可回溯);
DWD(明细层) :清洗+标准化+维度退化,明细粒度(最细粒度事实);
DWS(汇总层) :按主题汇总(日/周维度宽表,供报表/应用);
ADS(应用层) :面向业务应用/报表/算法特征(指标结果)。
分层的价值:解耦(上游变化不传导)、复用(一致口径)、
成本(去重存储)、质量(逐层校验)。
2️⃣ 建一张"订单事实表"进 ODS(sqlite3)
if os.path.exists('mini_wh.db'):
os.remove('mini_wh.db')
conn = sqlite3.connect('mini_wh.db')
cur = conn.cursor()
cur.execute('''CREATE TABLE ods_orders (
order_id TEXT, user_id TEXT, item_id TEXT, city TEXT,
amount REAL, status TEXT, ts TEXT
)''')
orders = [
('o1', 'u1', 'i_phone', '北京', 5999.0, 'paid', '2024-05-01 10:00:00'),
('o2', 'u2', 'i_phone', '上海', 5999.0, 'paid', '2024-05-01 10:05:00'),
('o3', 'u1', 'i_laptop', '北京', 8999.0, 'refunded', '2024-05-02 09:00:00'),
('o4', 'u3', 'i_tablet', '广州', 3999.0, 'paid', '2024-05-02 11:00:00'),
('o5', 'u2', 'i_bag', '上海', 299.0, 'paid', '2024-05-03 14:30:00'),
('o6', 'u4', 'i_keyboard', None, 199.0, 'paid', '2024-05-03 15:00:00'),
]
cur.executemany('INSERT INTO ods_orders VALUES (?,?,?,?,?,?,?)', orders)
conn.commit()
n = cur.execute('SELECT COUNT(*) FROM ods_orders').fetchone()[0]
print(f'ODS 层订单数: {n}')
print('➡️ ODS 几乎原样存,仅做分区/压缩;可以看到脏数据(city=None)等后续清洗')ODS 层订单数: 6
➡️ ODS 几乎原样存,仅做分区/压缩;可以看到脏数据(city=None)等后续清洗
3️⃣ DWD:清洗 + 标准化(脏数据处理)
cur.execute('''CREATE TABLE dwd_orders AS
SELECT order_id, user_id, item_id,
COALESCE(city, '未知') AS city,
amount,
CASE WHEN status = 'refunded' THEN 0 ELSE 1 END AS is_valid,
substr(ts, 1, 10) AS dt
FROM ods_orders''')
conn.commit()
rows = cur.execute('SELECT * FROM dwd_orders').fetchall()
for r in rows:
print(r)
print('\n➡️ DWD:补空值、状态转成可聚合标志、抽日期分区字段')('o1', 'u1', 'i_phone', '北京', 5999.0, 1, '2024-05-01')
('o2', 'u2', 'i_phone', '上海', 5999.0, 1, '2024-05-01')
('o3', 'u1', 'i_laptop', '北京', 8999.0, 0, '2024-05-02')
('o4', 'u3', 'i_tablet', '广州', 3999.0, 1, '2024-05-02')
('o5', 'u2', 'i_bag', '上海', 299.0, 1, '2024-05-03')
('o6', 'u4', 'i_keyboard', '未知', 199.0, 1, '2024-05-03')
➡️ DWD:补空值、状态转成可聚合标志、抽日期分区字段
4️⃣ DWS:按主题日汇总(宽表雏形)
cur.execute('''CREATE TABLE dws_daily_orders AS
SELECT dt, city,
COUNT(*) AS order_cnt,
COUNT(DISTINCT user_id) AS user_cnt,
SUM(amount) AS gmv,
SUM(is_valid) AS valid_cnt
FROM dwd_orders
GROUP BY dt, city''')
conn.commit()
rows = cur.execute('SELECT * FROM dws_daily_orders ORDER BY dt, city').fetchall()
print('DWS 日汇总(dt, city, 订单数, 用户数, GMV, 有效单):')
for r in rows:
print(' ', r)
print('➡️ DWS 就是"主题宽表":一次计算多指标,供多处复用')DWS 日汇总(dt, city, 订单数, 用户数, GMV, 有效单):
('2024-05-01', '上海', 1, 1, 5999.0, 1)
('2024-05-01', '北京', 1, 1, 5999.0, 1)
('2024-05-02', '北京', 1, 1, 8999.0, 0)
('2024-05-02', '广州', 1, 1, 3999.0, 1)
('2024-05-03', '上海', 1, 1, 299.0, 1)
('2024-05-03', '未知', 1, 1, 199.0, 1)
➡️ DWS 就是"主题宽表":一次计算多指标,供多处复用
5️⃣ ADS:应用层指标(Top 城市 / 全量汇总)
top_city = cur.execute('''
SELECT city, SUM(gmv) AS gmv FROM dws_daily_orders
GROUP BY city ORDER BY gmv DESC LIMIT 3''').fetchall()
print('GMV Top3 城市:')
for c, g in top_city:
print(f' {c}: {g:.0f} 元')
total = cur.execute('''
SELECT SUM(order_cnt), SUM(user_cnt), SUM(gmv), SUM(valid_cnt)
FROM dws_daily_orders''').fetchone()
distinct_users = cur.execute('SELECT COUNT(DISTINCT user_id) FROM dwd_orders').fetchone()[0]
print(f'全量: 订单 {total[0]}, 用户(SUM) {total[1]}, 用户(去重) {distinct_users}, '
f'GMV {total[2]:.0f} 元, 有效单 {total[3]}')
print('➡️ 教学点:DWS 按(天,城市)分组 COUNT(DISTINCT user_id) 后 SUM 会重复计数,'
'跨组去重需单独统计(如按天去重)')
assert total[1] >= 4 and distinct_users == 4
print('✅ ADS 指标正确(去重用户数=4:u1,u2,u3,u4)')GMV Top3 城市:
北京: 14998 元
上海: 6298 元
广州: 3999 元
全量: 订单 6, 用户(SUM) 6, 用户(去重) 4, GMV 25494 元, 有效单 5
➡️ 教学点:DWS 按(天,城市)分组 COUNT(DISTINCT user_id) 后 SUM 会重复计数,跨组去重需单独统计(如按天去重)
✅ ADS 指标正确(去重用户数=4:u1,u2,u3,u4)
# 可视化:四层各层的行数与粒度(自下而上加工)
import matplotlib.pyplot as plt
fig, ax = plt.subplots(figsize=(9, 4.2))
layers = ['ODS(原始订单)', 'DWD(清洗明细)', 'DWS(日×城市汇总)', 'ADS(Top城市)']
# 实际行数来自上面建的表
cnt = [
cur.execute('SELECT COUNT(*) FROM ods_orders').fetchone()[0],
cur.execute('SELECT COUNT(*) FROM dwd_orders').fetchone()[0],
cur.execute('SELECT COUNT(*) FROM dws_daily_orders').fetchone()[0],
len(top_city),
]
bars = ax.bar(layers, cnt, color=['#D9E2F3', '#E2EFDA', '#FBE5D6', '#F8CBAD'],
ec=['#2F5597', '#548235', '#ED7D31', '#C00000'], width=0.55)
for b, v in zip(bars, cnt):
ax.text(b.get_x() + b.get_width() / 2, v + 0.15, str(v), ha='center', fontsize=12, weight='bold')
ax.set_ylabel('行数'); ax.set_title('四层粒度变化:明细行 → 汇总行 → 指标(数据越上层越小、越直接可用)')
plt.show()6️⃣ 建模方法:星型 / 雪花 / 宽表
print('''事实表 + 维度表:
星型模型:事实表居中,维度表直接连(冗余维度,查询快);
雪花模型:维度表再规范化拆分(省空间,查询多 join);
宽表(维度退化):把常用维度字段直接冗余进事实表(DWS 常用,最快)。
选型口诀:查询性能优先 -> 星型/宽表;存储与一致性优先 -> 雪花。
数仓工具:Hive(离线)、Presto/StarRocks/Doris(即席/实时)、Iceberg/Hudi(湖仓)。
''')事实表 + 维度表:
星型模型:事实表居中,维度表直接连(冗余维度,查询快);
雪花模型:维度表再规范化拆分(省空间,查询多 join);
宽表(维度退化):把常用维度字段直接冗余进事实表(DWS 常用,最快)。
选型口诀:查询性能优先 -> 星型/宽表;存储与一致性优先 -> 雪花。
数仓工具:Hive(离线)、Presto/StarRocks/Doris(即席/实时)、Iceberg/Hudi(湖仓)。
7️⃣ 面试高频:数仓治理
缓慢变化维(SCD)与拉链表
维度属性会变化(用户搬家了城市从北京→上海)。两种处理:
• **Type 1(覆盖)**:直接 UPDATE 新值。简单,但**丢失历史**;
• **Type 2(版本)**:旧行改 `end_date` 封版,插新行 `start_date` 生效,`end_date=9999-12-31` 表示当前有效。
可回答"3 月份这个用户在哪个城市"——历史可溯。
**拉链表**就是 Type 2 的落地形式:`user_id, 属性, start_date, end_date` 四列,
每天用"昨天的闭链 + 今天的新开链"两条 SQL 完成更新。适合**变化缓慢、量大**的维表(用户/商品)。
下面用 sqlite3 演示一次完整的拉链更新。
# 拉链表演示:u1 的 city 从 北京 -> 上海(2024-04-01 生效)
cur.execute('CREATE TABLE IF NOT EXISTS d_user_zip (user_id TEXT, city TEXT, start_date TEXT, end_date TEXT)')
cur.execute('DELETE FROM d_user_zip')
cur.executemany('INSERT INTO d_user_zip VALUES (?,?,?,?)', [
('u1', '北京', '2024-01-01', '9999-12-31'),
('u2', '上海', '2024-01-01', '9999-12-31'),
])
conn.commit()
def zipper_update(user, new_city, eff_date):
# 1) 闭链:当前有效记录的 end_date 改为 生效日前一天
cur.execute("UPDATE d_user_zip SET end_date = ? WHERE user_id=? AND end_date='9999-12-31'",
(eff_date, user))
# 2) 开链:插入新版本
cur.execute('INSERT INTO d_user_zip VALUES (?,?,?,?)', (user, new_city, eff_date, '9999-12-31'))
conn.commit()
zipper_update('u1', '上海', '2024-04-01')
rows = cur.execute('SELECT * FROM d_user_zip ORDER BY user_id, start_date').fetchall()
print('拉链表(user, city, 起始日, 截止日):')
for r in rows:
print(' ', r)
# 验证:2024-03-15 查 u1 应在北京,2024-05-01 查 u1 应在上海
q = lambda d: cur.execute(
"SELECT city FROM d_user_zip WHERE user_id='u1' AND ? >= start_date AND ? <= end_date", (d, d)
).fetchone()[0]
assert q('2024-03-15') == '北京' and q('2024-05-01') == '上海'
print('\n✅ 按时间点查询历史正确:3月15日在北京,5月1日在上海')拉链表(user, city, 起始日, 截止日):
('u1', '北京', '2024-01-01', '2024-04-01')
('u1', '上海', '2024-04-01', '9999-12-31')
('u2', '上海', '2024-01-01', '9999-12-31')
✅ 按时间点查询历史正确:3月15日在北京,5月1日在上海
Hive 本身是什么(面试常混)
Hive 不是数据库,而是**把 SQL 翻译成分布式任务的引擎**:
• 表结构(schema)存在 **Metastore**(元数据服务,常配 MySQL);
• 数据存在 HDFS/对象存储上(就是普通文件);
• 查询时 HiveSQL → 解析器 → 优化器 → 执行计划(早期 MR,现 Tez/Spark 引擎);
• **分区(partition)**=按目录切分(如 `dt=2024-05-01/`),查询带分区条件可跳过无关目录(裁剪);
• **分桶(bucket)**=按 key 哈希切文件,利于采样与 map-side join。
print('''1) 指标口径:同一指标全局唯一(命名/计算/来源统一);
2) 数据质量:完整性/准确性/一致性/及时性 四大维度 + 质量监控;
3) 元数据管理:表血缘、字段字典、变更管理;
4) 权限与安全:行列级权限、脱敏、审计;
5) 数据倾斜:大 key 打散/两阶段聚合/倾斜检测;
6) 缓慢变化维(SCD):维度属性变化如何处理(Type1 覆盖/Type2 加版本)。
''')1) 指标口径:同一指标全局唯一(命名/计算/来源统一);
2) 数据质量:完整性/准确性/一致性/及时性 四大维度 + 质量监控;
3) 元数据管理:表血缘、字段字典、变更管理;
4) 权限与安全:行列级权限、脱敏、审计;
5) 数据倾斜:大 key 打散/两阶段聚合/倾斜检测;
6) 缓慢变化维(SCD):维度属性变化如何处理(Type1 覆盖/Type2 加版本)。
📝 自测清单
• [ ] 能画出 ODS/DWD/DWS/ADS 四层与职责
• [ ] 能用 SQL 完成一层 ETL(清洗/聚合)
• [ ] 能对比星型/雪花/宽表
• [ ] 能答 6 个数仓治理高频点
• [ ] 能解释指标口径统一与数据质量四维度