一、引言
电商业务每天都会产生订单、支付、退款、商品、库存、用户行为等数据。如果直接在业务数据库或原始日志上开发指标,同一个"成交金额"可能出现多种口径,重复计算、链路混乱和数据不可追溯也会随之而来。
数仓分层的作用,是按照数据在分析链路中的职责,把原始数据逐步加工成可复用的明细事实、统一维度、公共汇总和应用结果。分层之后,链路可以拆成下面的结构:
lua
业务系统 / 日志系统
|
v
+------------------+
| ODS 原始数据层 | 保存源数据,负责可追溯
+------------------+
|
| 清洗、去重、标准化、业务过程建模
v
+------------------+ +------------------+
| DWD 明细事实层 | <----> | DIM 公共维度层 |
+------------------+ +------------------+
|
| 按公共分析粒度汇总
v
+------------------+
| DWS 公共汇总层 | 沉淀可复用指标
+------------------+
|
| 面向具体产品组装
v
+------------------+
| ADS 应用数据层 | 看板、报表、接口、画像
+------------------+
每一层解决一类问题:
- ODS 回答:"源系统当时产生了什么?"
- DWD 回答:"发生了什么业务事实?"
- DIM 回答:"事实中的人、货、场分别是谁?"
- DWS 回答:"按公共分析对象汇总后表现如何?"
- ADS 回答:"某个数据产品最终需要展示或输出什么?"
二、五层职责总览
| 分层 | 核心职责 | 典型粒度 | 主要加工 | 服务对象 |
|---|---|---|---|---|
| ODS | 接入并保存原始数据 | 与源表或源事件一致 | 采集、落盘、基础格式校验 | DWD、审计、重跑任务 |
| DWD | 构建标准化明细事实 | 一次业务过程或最细事实 | 清洗、去重、关联、状态解析、维度退化 | DWS、明细分析、数据科学 |
| DIM | 建立一致性维度 | 一个维度成员或其历史版本 | 编码统一、属性整合、层级维护、拉链处理 | DWD、DWS、ADS |
| DWS | 建立公共汇总模型 | 主题对象 × 统计周期 | 聚合、派生指标计算、宽表化 | ADS、分析人员、指标平台 |
| ADS | 服务具体应用 | 由消费场景决定 | 指标组装、排名、筛选、展示适配 | BI、运营、风控、数据 API |
这只能作为起点,真正设计分层时,首先要声明 粒度 。一张表如果不能用一句话准确说明"一行代表什么",后续指标很可能发生重复计算。
三、ODS:保留原貌,而不是放任混乱
ODS 是数据进入数仓后的第一站,定义为保存未经处理的原始数据、结构与源系统基本一致的数据准备区,同时承担记录基础数据历史变化的职责。
电商场景的 ODS 数据通常来自:
MySQL / PostgreSQL
├─ order_info 订单主表
├─ order_item 订单明细表
├─ payment_record 支付记录
├─ refund_record 退款记录
├─ product_info 商品信息
└─ user_info 用户信息
App / Web 日志
├─ page_view 页面浏览
├─ product_click 商品点击
├─ add_to_cart 加购
└─ submit_order 提交订单
外部数据
├─ 广告投放数据
├─ 物流轨迹
└─ 客服工单
离线链路一般通过全量或增量同步写入 ODS;实时链路常通过 CDC 读取数据库变更日志,再写入 Kafka。
ODS 表的粒度应尽量与来源一致:
| ODS 表 | 一行代表什么 |
|---|---|
| ods_order_info | 源订单表的一条订单记录或变更记录 |
| ods_order_item | 一个订单中的一个商品明细 |
| ods_payment_record | 一次支付流水或支付状态变更 |
| ods_page_view_log | 用户的一次页面浏览事件 |
| ods_product_info | 源商品表的一条商品记录或变更记录 |
对于 CDC 数据,需要进一步区分两种存储语义:
sql
日志型 ODS:一行代表一次 INSERT / UPDATE / DELETE 变更
快照型 ODS:一行代表某个时间点的最新业务状态
两者不能混用。日志型 ODS 适合追溯变化过程,快照型 ODS 适合读取当前状态。
ODS 可以执行有限的接入处理:
- 增加采集时间、来源系统和批次号;
- 记录 CDC 操作类型、源库位点等元数据;
- 统一无法解析的文件格式;
- 对敏感字段进行加密、脱敏或访问隔离;
- 将异常记录送入隔离区。
ODS 不宜直接承担业务口径计算。例如,"支付状态为 20 表示成功"属于业务语义解析,更适合放在 DWD。
ODS 设计示例:
css
表名:ods_trade_order_item_inc
粒度:订单明细表的一次变更
来源:MySQL trade.order_item
更新:CDC 实时接入,离线按天归档
关键字段:
order_id 订单编号
order_item_id 订单明细编号
product_id 商品编号
sku_id SKU 编号
quantity 购买数量
original_amount 原始金额
discount_amount 优惠金额
actual_amount 应付金额
status 源系统状态码
db_operation I / U / D
source_ts 源库变更时间
ingest_ts 数仓接入时间
dt 归档日期
Hive分区表会按照分区列值组合建立独立目录,查询可以利用分区缩小扫描范围。离线 ODS 常按dt 分区,但应注意:
- dt 是存储组织方式,不等于业务发生时间;
- 补数时,采集日期与业务日期可能不同;
- 实时 ODS 还需要保留source_ts 和ingest_ts ,用于判断延迟和乱序。
四、DWD:把源记录转化为业务事实
DWD 即明细数据层,以业务过程为建模驱动,针对具体业务过程构建最细粒度的明细事实表,并可根据使用特点适当冗余重要维度属性。这里的关键不是"明细"两个字,而是 业务过程 。
电商常见业务过程包括:
- 用户浏览商品;
- 用户加入购物车;
- 用户提交订单;
- 订单支付成功;
- 商家发货;
- 用户确认收货;
- 用户申请退款;
- 退款成功。
一个业务过程通常对应一类事实表。
rust
下单过程 -> dwd_trade_order_item
支付过程 -> dwd_trade_payment
退款过程 -> dwd_trade_refund
浏览过程 -> dwd_traffic_page_view
加购过程 -> dwd_traffic_add_cart
不要把所有过程强行压进一张"大而全"的订单宽表。下单、支付和退款具有不同的发生时间、生命周期和数量关系。合并过早会掩盖粒度差异。
以订单为例,可以存在多种合理粒度:
objectivec
订单粒度:
一行 = 一个订单
订单明细粒度:
一行 = 一个订单中的一个 SKU
事件粒度:
一行 = 一次订单状态变更
支付流水粒度:
一行 = 一次支付尝试
退款明细粒度:
一行 = 一个订单明细的一次退款
假设一个订单购买 3 个 SKU,并发生 2 次支付尝试和 1 次部分退款:
订单主表 1 行
订单明细表 3 行
支付事实表 2 行
退款事实表 1 行或多行
如果直接把支付表与订单明细表关联,可能得到3 × 2 = 6 行,支付金额也会被重复累计。正确做法是先明确目标粒度,再把其他数据聚合到相同粒度后关联。
DWD 主要执行以下处理:
- 数据清洗
- 过滤测试订单;
- 识别无效主键;
- 修正字段类型;
- 处理非法时间和异常金额;
- 标记脏数据原因。
- 去重与幂等
支付网关可能重复回调。可以按支付流水号和版本号保留有效记录:
唯一业务键:payment_id
排序依据:source_update_time、binlog_position
保留规则:最新有效版本
去重规则应写入模型文档,不能只藏在 SQL 的ROW_NUMBER() 中。
- 状态标准化
不同渠道可能使用不同状态码:
yaml
微信支付:SUCCESS
支付宝:TRADE_SUCCESS
银行卡:02
|
v
统一支付状态:PAY_SUCCESS
- 金额拆解
订单商品的金额关系应显式定义:
diff
原价金额
- 商品优惠
- 店铺优惠
- 平台优惠
+ 运费
= 应付金额
如果平台券需要按商品分摊,应规定分摊算法、精度和尾差归属,否则订单级金额无法稳定下钻到商品级。
- 维度键映射
DWD 可以关联 DIM 获取代理键或稳定业务编码,例如:
rust
product_id -> product_sk
user_id -> user_sk
shop_id -> shop_sk
但需要警惕实时链路中的维度延迟:事实已经到达,维度数据可能还没有同步。常见处理方式包括异步重试、临时未知键、维表回补和事实纠正流。
DWD 设计示例:
css
表名:dwd_trade_order_item
粒度:一行代表一个订单中的一个 SKU
事实字段:
item_quantity
original_amount
discount_amount
payable_amount
维度键:
user_id
shop_id
product_id
sku_id
category_id
过程时间:
order_create_time
pay_time
cancel_time
治理字段:
source_system
source_record_id
etl_time
dt
DWD 表中的可加事实必须与粒度匹配。payable_amount 可以在订单明细粒度求和;"用户数"不是明细事实,通常需要在 DWS 中对user_id 去重统计。
五、DIM:让"人、货、场"拥有统一解释
DIM 是公共维度层,用于建立企业一致性维度,降低不同业务线在编码、属性和层级上的口径差异。
电商数仓常见维度包括:
objectivec
人:用户、会员、商家、员工
货:商品、SKU、品牌、类目
场:店铺、渠道、终端、地区
时间:日期、周、月、季度、节假日
活动:营销活动、优惠券、广告计划
维度表保存描述性属性,即事实表保存交易或度量数据,维度表保存用于描述这些事实的数据属性。
DIM 的设计粒度:
objectivec
dim_user_current
一行 = 一个用户的当前信息
dim_product_current
一行 = 一个商品的当前信息
dim_sku_current
一行 = 一个 SKU 的当前信息
dim_product_history
一行 = 一个商品在一段有效时间内的属性版本
dim_date
一行 = 一个自然日
"商品"和"SKU"往往不是同一粒度:
objectivec
商品:某款运动鞋
SKU:黑色、42 码的该款运动鞋
销售事实通常发生在 SKU 粒度,运营分析则可能按商品、品牌或类目汇总。
假设商品 1001 在 5 月 10 日从"运动鞋"类目调整到"户外鞋"。
如果只保存当前类目,那么查询 4 月销售额时,这笔历史交易也可能被归入"户外鞋"。如果分析要求按照交易发生时的类目归属,就需要保存维度历史。
yaml
product_id | category | valid_from | valid_to | current_flag
-----------+----------+------------+------------+-------------
1001 | 运动鞋 | 2026-01-01 | 2026-05-09 | 0
1001 | 户外鞋 | 2026-05-10 | 9999-12-31 | 1
事实表按业务发生时间匹配有效区间:
vbnet
fact.product_id = dim.product_id
AND fact.order_time BETWEEN dim.valid_from AND dim.valid_to
是否采用历史维度取决于业务问题:
- 查询当前商品归属:使用当前快照;
- 还原交易当时归属:使用历史版本;
- 监管或财务审计:通常要求保留历史版本。
订单域和流量域都使用统一的商品、用户和渠道维度后,跨域分析才有稳定基础:
rust
流量事实 + 商品维度 -> 商品访问人数
交易事实 + 商品维度 -> 商品支付金额
访问人数、支付金额
|
v
商品支付转化率
如果两个域各自维护一套类目映射,"访问类目"和"支付类目"就可能无法对齐。
六、DWS:沉淀公共粒度和统一指标
DWS 是公共汇总层,以分析主题对象为驱动,构建公共粒度的汇总事实表,沉淀口径一致、可复用的统计指标。
DWS 不只是"把 DWD 做一次GROUP BY ",它要回答三个问题:
- 按什么分析对象汇总?
- 汇总到什么时间周期?
- 哪些指标值得被多个应用复用?
典型 DWS 粒度包括:
用户 × 日
商品 × 日
店铺 × 日
渠道 × 日
活动 × 日
用户 × 商品 × 日
店铺 × 类目 × 日
表名可以体现粒度:
dws_trade_shop_day
dws_trade_product_day
dws_user_trade_day
dws_traffic_channel_day
dws_shop_category_day
简单示例:
表名:dws_trade_shop_day
粒度:一行代表一个店铺在一个自然日的交易汇总
维度:
stat_date
shop_id
指标:
order_user_count
order_count
order_amount
pay_user_count
pay_order_count
pay_amount
refund_amount
DWS 中最好区分原子指标和派生指标。
原子指标通常可以表示为:
arduino
业务过程 + 度量 + 统计口径
//示例
支付成功 + 订单金额 + 去除测试订单
= 支付金额
派生指标在原子指标上增加时间、维度或计算条件:
店铺日支付金额
= 支付金额 + 店铺维度 + 自然日周期
店铺日客单价
= 店铺日支付金额 / 店铺日支付人数
比值类指标应避免先算比例再汇总。例如,不能直接把各商品转化率求平均得到店铺转化率;正确方式是分别汇总分子和分母:
店铺支付转化率
= 店铺支付用户数 / 店铺访问用户数
一个指标进入 DWS,通常应满足:
- 有明确且稳定的业务定义;
- 被多个报表或应用使用;
- 粒度能够清晰声明;
- 可由 DWD 和 DIM 重算;
- 统计周期与更新策略明确。
只为单个页面临时计算的字段不必全部进入 DWS,否则公共层会变成应用需求的堆积区。
七、ADS:面向应用交付,而不是再次建设公共模型
ADS 面向具体数据产品,存放个性化统计指标和应用结果。
典型消费场景包括:
- 经营分析看板;
- 商品排行榜;
- 店铺诊断报告;
- 用户标签接口;
- 实时大屏;
- 风控规则输入;
- 营销人群圈选;
- 对外数据 API。
ADS 粒度由消费场景决定:
经营日报:
一行 = 日期 × 店铺
商品排行榜:
一行 = 日期 × 类目 × 商品
用户画像:
一行 = 用户
实时大屏:
一行 = 分钟 × 渠道,或一个指标的最新状态
数据 API:
一行 = 接口约定的业务对象
简单示例:
ini
表名:ads_shop_operation_dashboard
粒度:一个店铺在一个自然日的经营结果
字段:
stat_date
shop_id
visitor_count
pay_user_count
pay_order_count
pay_amount
refund_amount
avg_order_value
pay_conversion_rate
shop_rank
//指标统计
avg_order_value
= pay_amount / pay_user_count
pay_conversion_rate
= pay_user_count / visitor_count
ADS 可以为了查询性能预先计算比例、排名和展示标签,但必须保留分子、分母或对应公共指标来源,便于解释和复算。
ADS 不宜:
- 直接清洗原始 CDC 数据;
- 自行解释支付状态码;
- 重新维护商品类目映射;
- 复制一套与 DWS 不同的公共指标口径;
- 成为所有下游需求的永久宽表。
ADS 中的逻辑应以"组装和交付"为主,公共业务语义应尽量在 DWD、DIM 和 DWS 中解决。
八、离线数仓与实时数仓如何共用分层思想
分层是一套逻辑架构,不等同于某一种计算引擎。离线和实时可以采用相同的数据模型,但实现方式不同。
8.1.离线链路
sql
业务库 / 日志
|
v
批量同步到 ODS
|
v
Spark SQL / Hive SQL
|
+--> DWD 明细
+--> DIM 维度
+--> DWS 日汇总
+--> ADS 报表
常见特点:
- 按小时或按天调度;
- 通过分区覆盖保证幂等;
- 允许全分区重算;
- 延迟较高,但逻辑容易复现;
- 适合财务报表、经营日报和历史分析。
8.2.实时链路
lua
数据库 binlog / 用户事件
|
v
CDC / Kafka
|
v
Flink / Spark Streaming
|
+--> 实时 ODS 事件流
+--> 实时 DWD 标准事件
+--> 实时 DIM 查询或广播
+--> 实时 DWS 窗口聚合
+--> 实时 ADS / OLAP / KV
实时数仓还必须处理三个额外问题:
- 事件时间
业务指标通常应按事件发生时间计算,而不是计算引擎收到数据的时间。
- Processing Time:算子处理事件时机器上的系统时间;
- Event Time:事件在业务端实际发生的时间。
支付事件发生在 23:59,但因网络延迟在次日 00:01 到达。如果按处理时间计算,它会进入次日;如果按事件时间计算,它应归属前一日。
2.乱序和迟到数据
Flink 使用 Watermark 表示事件时间的推进,并根据 Watermark 判断窗口何时可以触发。现实系统中可能存在迟到事件,过度等待则会增加结果延迟。因此实时指标需要明确:
允许迟到多久?
窗口何时首次输出?
迟到数据是否更新历史结果?
超过允许迟到范围后进入哪里?
3.有状态计算
实时去重、窗口聚合和订单状态跟踪都需要保存状态。Flink 将窗口聚合、事件模式识别等操作归为有状态计算,并通过 checkpoint、savepoint 等机制支持故障恢复。Spark Structured Streaming 同样支持事件时间窗口和 Watermark,并用 Watermark 清理超过迟到阈值的历史状态。
8.3.离线与实时口径必须汇合
实时结果追求低延迟,离线结果追求完整性。两条链路如果各写一套业务逻辑,最终很容易出现:
实时大屏支付金额:1000 万
次日离线报表金额:980 万
造成差异的常见原因包括:
- 实时窗口尚未接收迟到数据;
- 实时维度尚未更新;
- 离线链路过滤了测试订单;
- 退款回冲时间口径不同;
- 去重键或状态判断不一致。
较稳妥的做法是:
- 统一业务主键和事件定义;
- 统一指标口径与维度编码;
- 实时结果允许修正;
- 离线链路负责最终校准;
- 建立实时与离线结果的日终对账。
九、表格式、分区和分层不是一回事
数仓分层描述的是数据职责,Hive、Iceberg 等表格式解决的是数据如何存储、查询和演进。
sql
逻辑分层:ODS / DWD / DIM / DWS / ADS
计算引擎:Spark / Flink
消息系统:Kafka
表格式:Hive Table / Iceberg
查询引擎:Trino / Spark SQL / Flink SQL
服务存储:ClickHouse / Doris / Elasticsearch / Redis
这些技术可以组合,但不能把某种表格式等同于某一层。
传统 Hive 分区表常按日期组织数据,Iceberg 支持通过元数据执行 Schema Evolution 和 Partition Evolution;旧数据可以继续使用原分区布局,新数据采用新布局,不需要立即重写全部数据文件。
这类能力可以降低表结构演进和回溯重算的成本,但不会自动解决业务粒度、指标口径和分层边界问题。
十、分层设计步骤
以一个电商主题为例,可以按下面的顺序设计。
第一步:识别业务过程。
rust
浏览 -> 加购 -> 下单 -> 支付 -> 发货 -> 收货 -> 退款
每个过程是否产生可记录的业务事实?
第二步:声明事实粒度。
objectivec
订单明细事实:一个订单中的一个 SKU
支付事实:一次支付流水
退款事实:一次退款明细
第三步:确定事实和维度。
事实:
数量、金额、次数、时长
维度:
用户、商品、店铺、渠道、地区、日期、活动
第四步:设计 ODS 接入和追溯策略。
- 全量还是增量;
- 快照还是变更日志;
- 数据保留周期;
- 分区字段;
- CDC 主键和位点;
- 删除事件如何表达。
第五步:设计 DWD 标准化规则。
- 唯一业务键;
- 去重顺序;
- 状态映射;
- 金额关系;
- 测试数据识别;
- 维度缺失处理;
- 数据质量规则。
第六步:建设一致性 DIM。
- 维度业务主键;
- 代理键是否需要;
- 当前值还是历史版本;
- 属性来源优先级;
- 层级和编码标准。
第七步:提炼 DWS 公共粒度。
从多个应用需求中寻找稳定交集,例如:
店铺 × 日
商品 × 日
用户 × 日
渠道 × 日
第八步:按消费场景建立 ADS。
最后再处理:
- 页面字段;
- 排名;
- 比率;
- 数据接口结构;
- 查询性能;
- 服务存储选型。
十一、小结
ODS、DWD、DIM、DWS、ADS 解决的是数据从产生到消费过程中的职责划分:
vbnet
ODS:保留来源,支持追溯
DWD:统一明细事实
DIM:统一分析视角
DWS:复用公共指标
ADS:服务具体应用
设计分层时,表名和技术选型排在粒度之后。先确定业务过程,再声明"一行代表什么",随后定义事实、维度、时间口径和更新方式。只要粒度稳定、口径统一、依赖清晰,即使底层从 Hive 迁移到 Iceberg,从 Spark 批处理扩展到 Flink 实时计算,数仓模型仍然可以延续。