ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

数据仓库理论与实战:从分层建模到ETL调度与数据质量

数据仓库理论与实战:从分层建模到ETL调度与数据质量

简介:这份PDF资料面向数据仓库初学者与有一定经验但缺乏体系化认知的从业者,围绕数仓建模、工具链与平台建设展开,帮助读者从写SQL的层面进阶到数据平台应用视角。内容覆盖数仓初识、架构发展史、建模方法论、分层建设、指标体系、事实表与维度表设计规范、开发规范等建模主线,并系统梳理Hive、Spark-SQL、Impala、Presto等查询引擎,Sqoop、Flume、NiFi等同步工具,以及调度、元数据、监控、报表、数据湖、实时数仓与数据资产等模块,末尾以三家公司的数仓建设与实时数仓案例收口。资源包共1个PDF文件,约254KB,轻量便于随时查阅。目前已有405人学习下载,适合希望系统补齐数仓知识体系、对照实战案例查漏补缺的读者。

1. 数据仓库到底在解决什么问题:从两张报表对不上说起

月初对账,业务方甩过来两张报表:一张来自订单系统,显示上月成交 1200 万;另一张来自财务系统,显示 980 万。两边都觉得自己是对的,吵到老板那里,最后发现是口径不一致——订单系统算的是下单金额,财务系统算的是实际收款金额,中间还有退款、优惠券分摊、跨月结算没对齐。这种场景在稍微有点规模的公司里几乎每个月都要上演一次。数据仓库要解决的核心问题就是这个:把散落在各个业务系统里的数据,按统一的口径、统一的粒度、统一的时间基准重新组织一遍,让所有人查同一份数据时得到同一个答案。

这套「数据仓库理论与实战」的资源,面向的就是正在或准备从事数仓方向的工程师。它不讲虚的,从数仓的分层架构、维度建模、缓慢变化维、事实表设计,一路讲到 ETL 调度、数据质量校验、指标口径管理。适合两类人:一类是刚转行做数仓、被分层和建模绕晕的新手;另一类是在业务里摸爬滚打了一阵、想回头把理论体系补扎实的熟手。下面我按自己拆这套资料时的理解,把关键环节和容易翻车的地方捋一遍。

2. 分层架构与维度建模:把业务过程翻译成表结构

2.1 为什么非要分 ODS、DWD、DWS、ADS 四层

很多人第一次看到数仓分层图会觉得多此一举——数据直接从业务库抽过来写报表不就行了?我一开始也这么想,直到有一次上游订单表加了个字段,下游十几张报表全炸了。分层的本质是解耦:ODS 层保持和源系统同构,只做抽取不做清洗;DWD 层做清洗、去重、脱敏、维度退化,形成明细事实;DWS 层按主题做轻度聚合,比如「用户日粒度消费汇总」;ADS 层才是面向具体报表的宽表或指标表。这样上游变字段,只需要改 DWD 到 ODS 的映射,DWS 和 ADS 基本不动。

常见做法是 ODS 按天分区全量或增量落地,DWD 用拉链表或快照表保存历史变化,DWS 按维度组合预聚合,ADS 直接对接 BI 工具。参数上,分区字段一般用dt(日期)或hour(小时),存储格式选 Parquet 或 ORC,压缩用 Snappy 兼顾速度和体积。

2.2 维度建模:星型模型和雪花模型怎么选

维度建模是数仓理论里最核心也最容易学歪的部分。星型模型就是一张事实表周围挂一圈维度表,维度表不再往下拆;雪花模型是把维度表再规范化,比如商品维度拆成商品表、类目表、品牌表。理论课上通常说雪花模型节省存储、星型模型查询快,但实际项目里我几乎没见过纯雪花模型——因为 BI 工具做关联查询时,多一层 join 就多一层性能损耗和口径歧义。

选型判断标准很简单:如果维度属性经常需要单独维护(比如类目树由运营单独管理),那就拆出去;如果只是商品的一些固定属性(颜色、尺码),直接冗余在商品维度表里。下面是一个典型的订单事实表建表语句,用 Hive 风格写:

CREATE TABLE dwd_order_detail ( order_id STRING COMMENT '订单ID', user_id STRING COMMENT '用户ID', sku_id STRING COMMENT '商品SKU', order_amount DECIMAL(18,2) COMMENT '下单金额', pay_amount DECIMAL(18,2) COMMENT '实付金额', discount_amount DECIMAL(18,2) COMMENT '优惠金额', order_status INT COMMENT '订单状态', create_time STRING COMMENT '创建时间', pay_time STRING COMMENT '支付时间', dt STRING COMMENT '分区日期' ) PARTITIONED BY (dt STRING) STORED AS PARQUET;

这段建表里几个关键点:金额字段用DECIMAL而不是FLOAT,避免浮点精度丢失;dt作为分区字段放在最后,Hive 会按日期切分目录;order_status用整型编码而不是字符串,节省空间也方便枚举。事实表设计时还要注意粒度——是订单粒度还是订单明细粒度?如果一张表里既有订单级别的优惠金额,又有明细级别的商品数量,那就粒度混了,聚合时必然翻车。

2.3 缓慢变化维:拉链表不是唯一解

缓慢变化维(SCD)是数仓面试必问、实战必踩的坑。Type 1 直接覆盖,不保留历史;Type 2 加start_date和end_date保留版本;Type 3 加一列「上一版本值」。很多人一上来就无脑上 Type 2 拉链表,结果发现用户维度每天新增几百万条,存储扛不住,查询还要过滤end_date = '9999-12-31'。

我的经验是:核心维度(用户、商品、门店)用 Type 2,因为要追溯历史归属;普通维度(比如页面来源、渠道分类)用 Type 1 覆盖就行,没人关心三个月前这个渠道叫什么名字。拉链表的更新逻辑一般用「全量对比 + 增量合并」:先取当天最新快照,和昨天拉链表里end_date = '9999-12-31'的记录做对比,有变化的关掉旧链、开新链,没变化的保持原样。这个逻辑写起来不复杂,但边界条件多——比如同一天内维度变了两次怎么办?一般约定取当天最后一次为准。

3. ETL 调度与数据质量:让数据按时、按质到达

3.1 调度依赖怎么配才不出环

ETL 调度是数仓的血液循环系统。常见工具是 Airflow、DolphinScheduler、DataWorks 这类,核心概念是 DAG(有向无环图)。配置依赖时最容易犯的错是「隐式依赖」——A 任务读了 B 任务产出的表,但调度上没配依赖,结果 A 跑的时候 B 还没跑完,读到空数据。这种问题在测试环境很难发现,因为测试时数据量小、跑得快,上了生产就随机翻车。

正确做法是:每个任务显式声明上游任务和上游表,调度系统根据表血缘自动推导依赖。如果调度工具不支持表级血缘,那就手动配任务依赖,并且加一个「数据就绪检查」节点——检查上游分区是否存在、行数是否大于 0。下面是一个 Airflow 的依赖配置片段:

from airflow import DAG from airflow.operators.bash import BashOperator from datetime import datetime, timedelta default_args = { 'owner': 'data_team', 'retries': 2, 'retry_delay': timedelta(minutes=5), 'depends_on_past': False, } with DAG( 'dwd_order_detail', default_args=default_args, schedule_interval='0 2 * * *', # 每天凌晨2点跑 start_date=datetime(2024, 1, 1), catchup=False, ) as dag: check_ods = BashOperator( task_id='check_ods_partition', bash_command='hive -e "SELECT count(1) FROM ods_order WHERE dt=\'{{ ds }}\'"', ) build_dwd = BashOperator( task_id='build_dwd_order_detail', bash_command='hive -f /scripts/dwd_order_detail.sql --dt {{ ds }}', ) check_ods >> build_dwd

这里schedule_interval用 cron 表达式控制每天凌晨 2 点执行,catchup=False表示不回补历史任务,retries=2给两次重试机会。check_ods先确认上游分区有数据,再触发build_dwd。参数上,{{ ds }}是 Airflow 内置的模板变量,代表执行日期,这样脚本不用硬编码日期。

3.2 数据质量校验:别等业务方来投诉

数据质量校验要覆盖几个维度:完整性(分区是否存在、行数是否骤降)、唯一性(主键是否重复)、一致性(金额合计是否对得上)、及时性(任务是否按时完成)。我一般会在 DWD 层和 DWS 层之间加一个质量检查任务,跑几条 SQL 断言,不通过就阻断下游并告警。

-- 检查主键重复 SELECT order_id, count(1) AS cnt FROM dwd_order_detail WHERE dt = '${dt}' GROUP BY order_id HAVING cnt > 1; -- 检查金额异常(实付大于下单金额) SELECT count(1) FROM dwd_order_detail WHERE dt = '${dt}' AND pay_amount > order_amount; -- 检查行数波动(与昨天相比下降超过30%) SELECT today.cnt AS today_cnt, yesterday.cnt AS yesterday_cnt, (today.cnt - yesterday.cnt) / yesterday.cnt AS diff_rate FROM (SELECT count(1) AS cnt FROM dwd_order_detail WHERE dt = '${dt}') today, (SELECT count(1) AS cnt FROM dwd_order_detail WHERE dt = date_sub('${dt}', 1)) yesterday WHERE abs((today.cnt - yesterday.cnt) / yesterday.cnt) > 0.3;

这三条断言分别对应唯一性、一致性和完整性。实际部署时会把它们包在一个脚本里,任何一条返回非空就触发告警。注意date_sub的用法依赖具体 SQL 引擎,Hive 用date_sub,Spark SQL 也支持,但 Presto 要用date_add('day', -1, ...),跨引擎迁移时要改。

3.3 增量抽取的三种姿势

从业务库抽数据到 ODS,常见三种方式:全量抽取(每天拉整张表)、增量抽取(按update_time拉变化数据)、CDC(变更数据捕获,读 binlog)。全量简单但压力大,小表可以,大表别碰;增量抽取要处理「更新丢失」——如果一条记录在两次抽取之间被更新了两次,按update_time > 上次抽取时间只能拿到最后一次,中间状态丢了;CDC 最准但架构复杂,需要维护 binlog 消费位点。

我一般这样选:配置表、维度表用全量;订单、支付这类流水表用增量 + 幂等写入(按主键 merge);用户资料这种频繁更新的表用 CDC。增量抽取的 SQL 模板:

SELECT * FROM source_order WHERE update_time >= '${last_extract_time}' AND update_time < '${current_extract_time}';

last_extract_time和current_extract_time由调度系统传入,一般取上一个调度周期的起止时间。注意时区问题——如果业务库用的是 UTC 时间,而数仓用东八区,那update_time比较时会差 8 小时,这个坑我见过不止一次。

4. 避坑与排查:那些年数仓翻过的车

4.1 现象:报表数字对不上,但两边 SQL 看起来一样

原因:最常见的是「空值处理不一致」。比如SUM(amount)遇到NULL会跳过,但SUM(COALESCE(amount, 0))会把NULL当 0 算。如果两张报表一张用了COALESCE一张没用,结果就差在那些NULL记录上。另一个原因是 join 类型不同——LEFT JOIN和INNER JOIN在维度缺失时行数不一样。

解决:统一在 DWD 层做空值填充,金额类字段默认 0,字符串类字段默认空串或'UNKNOWN'。所有报表都从同一张 DWS 表出,不允许直接查 DWD。

4.2 现象:任务昨天还好好的,今天突然 OOM

原因:数据倾斜。某个热门商品的订单量是普通商品的几千倍,按sku_id做 group by 时,一个 reduce 任务扛了绝大部分数据。或者分区字段选得不好,某一天的数据量是平时的十倍。

解决:先看执行计划里哪个 stage 耗时最长,确认倾斜键。常用手段是「加盐」——给倾斜键拼一个随机后缀,打散到多个 reduce,再二次聚合。或者单独把倾斜键拎出来处理,剩下的走正常逻辑。

4.3 现象:拉链表查询越来越慢

原因:拉链表只增不删,历史版本越积越多,查询时WHERE end_date = '9999-12-31'过滤不掉旧数据,全表扫描。

解决:按start_date做分区,查询时带上分区条件;或者定期归档超过一年的历史版本到冷存储。另外,拉链表的主键要建索引或 Z-Order 优化,否则每次关联都是全表扫。

4.4 现象:调度任务显示成功,但下游读到空数据

原因:任务写了数据但没提交事务,或者写到了临时目录没 move 到正式分区。Hive 的INSERT OVERWRITE如果中途失败,可能留下一个空分区。

解决:在任务最后加一个「分区修复」步骤,检查目标分区行数是否大于 0,不满足就抛异常。另外,写入时用INSERT OVERWRITE而不是INSERT INTO,避免重复数据累积。

4.5 现象:指标口径改了,但历史数据没重跑

原因:业务方说「从今天起退款金额要从成交额里扣掉」,你改了 SQL,但只跑了今天的分区,历史分区还是旧口径。结果做同比时发现口径不一致。

解决:建立「指标版本」概念,每次口径变更记录变更日期和影响范围,历史数据要么重跑要么在报表层标注口径切换点。重跑成本高的话,至少在 ADS 层加一个metric_version字段,让业务方知道数据是怎么算出来的。

5. 指标口径管理与血缘追溯:一个具体技巧

数仓做到后面,最头疼的不是技术,是「口径管理」。同一个「活跃用户」,运营说打开 App 就算,产品说要有浏览行为才算,财务说要有消费才算。如果没有一套机制来管理这些定义,数仓工程师就会变成「取数机器」,每天被各种临时需求淹没。

我自己的做法是建一张「指标字典表」,把每个指标的口径、计算逻辑、责任人、变更记录都落进去。这张表本身也是数仓的一部分,用 Hive 表存,每天同步到 BI 工具供业务方查询。结构大概这样:

字段类型说明
metric_codeSTRING指标唯一编码,如active_user_dau
metric_nameSTRING指标中文名
definitionSTRING口径描述,如「当日有浏览行为的去重用户数」
sql_logicSTRING核心计算逻辑,可粘贴 SQL 片段
ownerSTRING责任人
versionINT版本号,每次口径变更递增
effective_dateSTRING生效日期
update_timeSTRING最后更新时间

有了这张表,业务方来问「活跃用户怎么算的」,直接甩链接;口径要改,先改字典表再改 SQL,变更记录自动留痕。配合调度系统的表血缘功能,还能做到「改了 DWD 的某个字段,自动通知所有下游指标责任人」。

血缘追溯这块,常见做法是用 SQL 解析工具(比如 Apache Calcite 或开源的 SQLLineage)从 ETL 脚本里提取表级和字段级依赖,写入图数据库。查询时输入一张表名,就能看到它上游依赖哪些表、下游影响哪些报表。我一般会在调度系统里集成这个能力,每次发布新任务时自动更新血缘图。

最后说一个我自己的习惯:每次上线新的 ETL 任务前,强制走一遍「空跑 + 对账」。空跑是把dt设成一个历史日期,看任务能不能正常产出;对账是拿新任务的产出和旧口径的结果做 diff,差异超过阈值就人工确认。这个习惯帮我拦住了至少三次重大口径事故。希望帮到你。

本文还有配套的精品资源,点击获取

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进