接到一个数据建模需求,最怕的不是不会写SQL,而是上来就按业务方口述的几个字段开始建表。我见过太多人一听说“做个用户主题模型”就立刻画ER图、定主键,结果做到一半发现底层数据对不上,业务口径又改了,整个模型推倒重来。数据建模这条路上的核心矛盾从来不是技术,而是“没想清楚就动手”。这行干了这些年,我越来越确认一件事:建模工作七成在建模之前。
这篇文章,我按自己的实战经验把数据建模从需求拆解、分层规划、方法论选择,到具体建模实操和上线后的治理优化整个链路梳理一遍。内容偏数仓方向,适合刚入行的数据开发、正在做数据建模相关毕业设计或面试准备的读者,也适合那些已经被混乱的报表和口径折磨到不行、想系统搭建数仓模型的同学。文的实操部分,我尽量把每一步的为什么讲透,这样你拿过去能直接用,用的时候也知道自己是站在哪个岔路口做的选择。
1. 接到建模需求先别急着建表:业务口径比ER图更重要
我见过太多新人拿到需求的第一反应是:打开Navicat,开始设计表结构。这个顺序是错的。数据建模的第一步,是先把业务方的“话”翻译成“可计算的定义”。翻译错了,后面所有结构都白搭。
1.1 同一个指标,三种口径,三套数字
举一个实际例子。假设业务方说“我要看用户转化率”,你能直接建表吗?不能。你先要搞清楚几个问题:
- 用户指什么?是注册用户、启动过App的用户,还是下过单的用户?
- 转化指什么?指从注册到首次下单,还是从浏览到加购?
- 分母是什么?是当天的活跃用户,还是当天有曝光行为的用户?
- 时间口径是什么?自然日、工作日,还是业务自定义的“昨日21点到今日21点”?
同一个“用户转化率”,不同业务方可能给出三个完全不同的口径定义。你在建模文档里写“转化率=转化用户数/活跃用户数”,这个字段模型就埋下了一颗雷。等到了报表层,A部门说你这个数不对,B部门说你这个口径跟我们不一致,光是吵架就能耗掉你一周。
建模之前的对齐工作,业内一般叫“指标口径梳理”。你需要把每个指标拆成:原子指标、业务限定、统计周期、聚合粒度。原子指标是“加购次数”“支付金额”这种不可再拆的度量;业务限定是“仅限安卓端”“排除测试订单”这种过滤条件;统计周期是“自然日”“近30天”;聚合粒度是“按用户”“按店铺”“按商品SPU”。
我习惯在动手建模前先输出一份指标口径清单,表格长这样:
| 指标名称 | 原子指标 | 业务限定 | 统计周期 | 聚合粒度 | 导出公式 |
|---|---|---|---|---|---|
| 支付金额 | 订单实付金额 | 剔除测试订单、剔除退款订单 | 自然日 | 用户 | SUM(实付金额) |
| 成交用户数 | 用户ID | 且有支付成功订单 | 自然日 | 用户 | COUNT(DISTINCT user_id) |
| 客单价 | 支付金额 | 同上 | 自然日 | 用户 | 支付金额/成交用户数 |
这份清单让业务方确认签字,后面所有模型设计和指标开发都以此为准。别嫌这一步繁琐,它替你省掉的是未来无数个“这个数怎么对不上”的深夜。
1.2 主题域划分:先有坐标系,才能给表“定位”
口径理清之后,下一步不是建表,而是划分主题域。主题域是数据分析的最高层级分类,它决定了你的数仓从宏观上是如何组织的。
怎么理解主题域?把企业想象成一棵大树,主题域就是主要枝干。电商公司一般可以拆成:用户域、商品域、交易域、营销域、流量域、会员域、供应链域。每个域内部做高内聚,域与域之间通过“公共键”做关联。
为什么要先划分主题域?因为如果不先定这个“坐标系”,后面每来一张表,你都可能纠结它该放哪。订单表既跟用户有关又跟商品有关,如果你没有域的概念,就可能导致用户域放了一张包含商品明细的大宽表,交易域又隔三差五建出几张字段重叠的“伪明细表”。时间一长,整个数仓变成一锅粥,数据血缘乱成一团。
主题域划分完,还要做一件事:总线矩阵。总线矩阵描述的是“哪些业务过程,跟哪些公共维度相关”。它是一个二维矩阵:行是业务过程(比如“下单”“支付”“退款”),列是公共维度(比如“日期”“用户”“商品”“店铺”),交叉点打钩表示该业务过程涉及这个维度。
这张矩阵的作用,是保证你后续每个事实表都能和既有维度表对齐。想象一下,如果你做交易分析时发现用户维度表里还没有覆盖“新老客标志”,而营销域又依赖这个标志去圈人,链路就断了。有了总线矩阵,你就可以在建事实表时一眼看出:日期维度是否齐备、用户维度是否能关联上、商品维度是否覆盖到。
1.3 这一步的实操输出物
不要觉得建模前的分析阶段是“虚”的。好的分析阶段应该能输出三样东西,缺一不可:
- 指标口径清单——上面举例的Excel或者在线表格,所有参与方确认过。
- 主题域划分文档——树状图或者列表形式,标清楚每个域的边界和责任人。
- 总线矩阵——业务过程 x 公共维度的关系矩阵。
这三样东西一出来,你的模型骨架就已经在脑子里成形了。接下来去画逻辑模型、物理模型,你会发现顺得不可思议,因为每个表的定位都是清晰的。这就像盖楼之前先出建筑图纸,你可以说图纸阶段“什么都没盖”,但没有图纸直接垒砖,垒出来的大概率是危楼。
2. 分层架构是数据建模的骨架:ODS、DWD、DWS、ADS每层该干什么
很多没系统做过数仓的同学,会把数据建模等同于设计几张表。实际上,在正规的数仓体系里,任何一张模型表都必须先找到自己的“层级坐标”。没有分层的概念,所有表平铺在一起,就谈不上建模了。
2.1 为什么要分层:可管理、可复用、可追溯
我给非数仓方向的读者打个比方。分层就像餐厅后厨的分工:
- ODS层是“食材收货区”,市场上买回来的菜先堆在这里,基本不动。
- DWD层是“清洗切配区”,把菜洗干净、去根、切成标准大小。
- DWS层是“预制菜区”,把配好的料按套餐装好,比如“宫保鸡丁套餐”。
- ADS层是“出菜口”,哪个桌点了菜,直接从这里端出去。
如果后厨没有这些分区,所有菜、肉、调料堆在一个房间,厨师炒菜时要自己去找葱姜蒜,配菜员洗菜砧板和成品切配混在一起,整个厨房的效率就会灾难性地低,甚至食品安全都会出问题。
数仓分层也是同一个逻辑:
- ODS(Operational Data Store):贴源层,存放从业务库同步过来的原始数据,不做或少做加工,保留最细粒度,用于数据追溯和审计。
- DWD(Data Warehouse Detail):明细层,对ODS做清洗、转换、降维、标准化,以业务过程为单位建模,保持明细粒度。
- DWS(Data Warehouse Service):汇总层,面向分析主题做轻度汇总,通常是按维度预先聚合,比如“用户当天的下单金额”这种按天、按用户汇总的宽表。
- ADS(Application Data Store):应用层,面向具体报表或业务取数,结果一般是一张可以直接查询的窄表或宽表。
你去看招聘网站的“数据建模”岗位要求,几乎都要求候选人能说清楚这几层的职责。大数据面试题里出现“数仓分层架构”这类问题,本质上就是考察你有没有全局视角,而不仅仅是会写SQL。
2.2 每层的建模目标、表命名和字段设计要点
分层的意义在于让每层有明确的“加工边界”。我在实际项目中,给每层定了四类约束,方便团队照做。
先看ODS层。这一层的建模目标是“原样接入,留痕可查”。ODS表结构与业务源表保持一致,通常加五个管理字段:etl_time(抽取时间)、data_date(数据日期)、source_system(来源系统)、is_deleted(逻辑删除标记)、etl_version(批号)。设计ODS层最容易犯的错是“顺手就把数据清洗了”,这样做会让数据追溯非常痛苦,出问题时根本分不清是源系统的问题还是ETL的问题。
然后是DWD层。这一层的建模目标是“统一清洗、标准化、维度退化”。DWD表通常按业务过程建模,比如下单、支付、退款、收藏、加购各一张明细表。字段上,需要把ODS中各种异味的枚举值做统一编码,把时间字段统一为yyyy-MM-dd HH:mm:ss格式,把同义字段(比如user_id和uid)合并成同一个命名。一个常见做法是:把“用户昵称”“商品名称”这类描述性文本退化到事实表里,避免每次查询都要去JOIN维表,这就是维度退化,后面第4节会详细说。
DWS层的建模目标是“面向分析主题的公共汇总”。和DWD不同,DWS通常是宽表,按主题把一个用户、一个商品、一个店铺的核心指标聚合到一行。典型设计是dws_user_nd(用户n日汇总表):每个用户一行,行为包含近1日、近7日、近30日的下单次数、下单金额、支付金额、退款金额等。这样做的好处是下游95%的报表都可以直接查这一张表,不用每次从DWD全量跑聚合。
ADS层就没什么建模好讲了,它就是为具体需求服务的“取数表”,设计上以直接满足页面展示为目标。但要注意的是,不要让大量临时取数逻辑沉淀到ADS层。ADS每天被业务查询最多的字段,应该反向推动DWS层补字段,而不是任由ADS层无限膨胀。
各层的命名规范我贴一下,方便你直接抄:
| 分层 | 前缀 | 示例 | 粒度 |
|---|---|---|---|
| ODS | ods_ | ods_trade_order_di | 源系统原始粒度 |
| DWD | dwd_ | dwd_trade_order_detail_di | 业务过程明细粒度 |
| DWS | dws_ | dws_user_order_stats_1d | 主题+维度的汇总粒度 |
| ADS | ads_ | ads_user_gmv_ng | 报表需求粒度 |
命名里后缀di表示增量,df表示全量,1d表示按天累计,nd表示n日累计。这些约定不复杂,但是统一之后,团队沟通成本会低很多。
2.3 各分层之间的质量“守门”
分层不只是物理上一张张表,更重要的是层与层之间要有数据质量校验。我见过最糟糕的情况是:DWD算出来一个用户有300多笔订单,结果ODS源头只有280笔,且没人发现,报表直连DWD,最后业务方拿着错误数据去做了经营决策。
所以我在团队里强制实行“逐层质量校验”:
- ODS到DWD:主键唯一性校验、非空约束校验、枚举值合法校验、时间字段合法性校验。也就是保证明细不重不漏。
- DWD到DWS:关键指标对比,比如DWS的当日支付金额汇总是否等于DWD按日聚合的结果。
- DWS到ADS:抽样对比,随机抽10个维度项,比对最近30天的汇总值是否与DWS一致。
这些校验可以写成自动化脚本挂在调度系统里,每天跑完数据自动执行。一旦校验不通过,要能第一时间告警阻塞下游任务。
分层的作用,本质上是把一个巨大的数据处理问题拆成多个可以在局部做质量管控的小问题。每一层把自己的边界守住,整个数仓的数据质量才有保障。别觉得分层“多了几张中间表,浪费了存储”,坏数据导致决策失误带来的损失,比存储费用高几个数量级。
3. 维度建模和范式建模,为什么数据工程师最终都倒向维度建模
选数据建模方法论,是很多刚接触数仓的人第一个纠结的问题。学校数据库原理课教的是三范式(3NF),要尽量减少冗余,消除传递依赖。结果工作后一看,公司的数仓根本不遵循范式,事实表、维度表满天飞,还能看到大量冗余字段。这里面的原因,值得展开讲清楚。
3.1 范式建模的适用边界:OLTP的救星,OLAP的灾难
范式建模,核心是“降低冗余、保证一致性”。在OLTP(在线事务处理)系统中,每一次订单插入都要更新库存、扣减账户余额,如果数据冗余,就可能导致同一份数据在多处不一致。所以事务库要做范式化,这是为了保证写入的一致性。
但到了数仓场景,你的核心负载变成了OLAP(在线分析处理)。读多写少,查询动辄扫描几亿行,分析维度和度量经常跨多张表聚合。这时候如果还死守3NF,会导致两个现实问题:
- 查询性能差。一次分析要JOIN几十张规范化的小表,MapReduce或Spark在处理这些Shuffle时代价极高,响应时间根本扛不住业务方的“我要实时看数”。
- 业务不可理解。范式模型的ER图有几百张表,产品经理看一眼就头大,更别说自己写SQL去做探索式分析。
我并不是说范式建模一无是处,它在数据仓库架构中也有一席之地,特别是作为企业级数据模型的标准视图。但从实际交付角度看,绝大多数公司的数仓主体是用维度建模搭起来的,因为分析场景的响应速度和易用性胜过了理论上的规范性。
3.2 维度建模为什么“香”:星型模型和雪花模型怎么选
维度建模(Dimensional Modeling)由数据仓库大师Ralph Kimball系统化提出。它的核心思想很直白:用“事实表+维度表”的框架来组织数据,把业务过程拆成可度量的“事实”,每个事实用一组“维度”来描述。
事实表里放的是度量值,比如金额、件数、次数。这些度量有一个重要属性叫“可加性”。金额是完全可以跨任何维度相加的;但有些指标做不了跨维度相加,比如“下单用户数”,如果把两个用户ID相加就是无意义操作。所以你需要给每个度量打标签:完全可加、半可加(可以按时间维度叠加,但不能跨某些维度)、不可加。理清这点,是做汇总表设计时的底层逻辑。
维度表里放的是描述性属性,比如用户的性别、年龄、注册渠道,商品的类目层级、品牌,门店的城市、商圈。维度表通常是“胖”的,也就是冗余度较高。这跟范式建模的理念相反,但为了减少JOIN、提升查询效率,这个牺牲值得。
在具体表结构形态上,有两种流派:
- 星型模型:事实表在中间,维度表在四周,每张维度表只和事实表关联一层。特点:结构简单清晰,查询性能好,是数仓里的绝大多数。
- 雪花模型:某些维度表继续规范化拆成二级维度表。比如商品维度表关联到类目表,类目表再关联到一级类目表。特点:减少了冗余,但查询需要多级JOIN。
我自己的实践经验是:数仓建模几乎无脑选星型。因为以现在的存储成本和计算引擎能力,冗余几个维度的压力根本不算什么,而查询减少JOIN层数带来的性能收益是肉眼可见的。雪花模型让ER图好看,但让查询变慢,也让下游的分析师更容易写错SQL。
3.3 Kimball和Inmon,以及很少用但需要知道的Data Vault
方法论如果往更上层看,还有一个绕不开的对比:Kimball和Inmon。
Inmon主张“自上而下”,先建整个企业范围的标准化的数据仓库(通常用范式建模),再从这个仓库派生出各个数据集市。这种模式适合非常成熟的大企业,有专门的企业数据架构团队,有足够长的建设周期。
Kimball主张“自下而上”,直接按业务过程构建维度模型,每个数据集市就是一个业务过程或一个主题的分析基础,多个数据集市通过“一致性维度”联系起来。
现实是,绝大多数公司的数据团队是拿Kimball的思路起步的。先做交易域,再做用户域、商品域,通过总线矩阵保证各域之间维度一致。这样能快速见效,第一个数据模型两到三周就能上线,业务方马上能看到数据分析价值。对一个KPI压力很大的公司来说,这是最实际的选择。
Data Vault建模是这些年小圈子里比较流行的一种方法,它把模型拆成Hub(中心表)、Link(关联表)、Satellite(卫星表),非常适合数据来源多、近源数据实时性要求高、历史追踪要求严格的场景。但它的模型很碎,查询时需要大量JOIN,不太适合直接用为报表服务。我的意见是:你要知道有这样一个流派,面试问到能说清楚它的基本思想即可。真正在业务数仓里大面积铺开Data Vault的,我几乎没有见过。
方法论没有绝对的“最正确”,只有“最合适”。对数据开发工程师而言,在一家公司从零搭数仓,最顺手的组合是:以维度建模为中心,用Kimball的增量迭代方式推进,ODS和DWD层处理好复杂度,DWS/ADS层面向业务直接产出,这就是我在实操中最常用,也推荐你首选的路径。
4. 从订单主题出发,完整走一遍维度建模实操演练
这节我来完整演示一次维度建模的落地过程。选订单域,是因为订单几乎是所有电商公司最核心的业务过程,而且它牵涉到用户、商品、商家、店铺、日期等多个维度,信息量丰富,很适合用来展示建模思路。
4.1 第一步:定业务过程和粒度
订单域里有多个业务过程:提交订单、支付订单、发货、确认收货、申请退款。不是一个业务过程建一张表就完了,而是要根据分析需求决定建模范围。
这里建议把“下单”“支付”“退款”三个过程分开建模,而不是把三个过程塞进同一张“订单流水大宽表”。原因很简单:三个过程的事实度量不同,粒度和敏感字段不同。下单关心的是订单数、下单金额、商品件数;支付关心的是支付成功金额、支付时长;退款关心的是售中退款、售后退款金额。混在一起,字段爆炸,可读性下降,ETL也会变得极难维护。
有的订单表你看到一张表里出现了order_amount、pay_amount、refund_amount三组金额,同时还有几十个冗余状态字段,就是因为建模时没想清楚粒度问题。正确做法是拆开。
确定好业务过程后,接下来要给事实表声明粒度。粒度就是“一行代表什么”。
- 订单明细事实表:一行是“一个订单中的一个商品子项”;
- 订单支付事实表:一行是“一个订单的一次支付流水”;
- 退款事实表:一行是“一个订单商品项的一次退款申请”;
粒度声明是事实表建模的锚点。只要粒度确定,后续所有指标都必须能落户到这个粒度上。比如你在“明细粒度=一个订单一个商品子项”的事实表上,要算“用户订单数”,那就必须先去重订单ID再计数,否则就会把同一订单里的两个商品项算成两单。这种错误在模型设计期容易埋下,到写SQL时就表现为COUNT和COUNT(DISTINCT)结果的巨大差异。
4.2 第二步:事实表字段设计,度量类型先分清楚
确定粒度之后,开始列事实表的度量字段。订单明细事实表的度量字段大概是这些:
order_id(订单ID,维度外键的组成部分)user_id(用户ID)sku_id(SKU粒度商品ID)spu_id(SPU粒度商品ID)shop_id(店铺ID)order_amount(下单金额,可加)item_count(商品件数,可加)actual_pay_amount(实际支付金额,可加)coupon_amount(优惠券抵扣金额,可加)order_status(订单状态,不可加,通常放在DWD层而非DWS)
字段里可以加一些“退化维度”。比如order_source(订单来源:H5/App/小程序)、pay_type(支付方式:微信/支付宝/银行卡),这些其实是维度属性,但为了减少JOIN,直接把它们退化进事实表。这里注意,退化维度要选择取值有限、相对稳定、维度表本身很薄的那些属性,比如渠道、支付方式、订单类型。像“用户所在城市”这种属性就绝不该退化进订单事实表,因为它的值可以通过用户ID关联,并且用户维度会在很多分析场景里被反复使用,一旦进了订单表反而造成不一致。
度量可加性这里再强调一个坑:订单金额按时间维度有可加性,但是“优惠券抵扣金额”如果以“领取优惠券”为业务过程去分析时就不具备跨时间可加性了,因为一张券可能在生命周期里被多次使用。定义口径时就要把“券分摊金额”和“券抵扣金额”分开建模。我见过因为混合这两个口径导致退款报表里“优惠券金额对不上”的典型事故,排查起来极其痛苦。
4.3 第三步:维度表设计,至少把日期、用户、商品这三张做好
维度建模的核心,除了事实表,就是维度表。一个订单事实表,至少要关联这些维度:日期维度、用户维度、商品维度、店铺维度。这里我把这四张维度表的设计要点都过一遍。
日期维度。日期维度是数仓里最常被忽略但最需要精心设计的表。不要直接让下游用order_time去关联事实表,而是建一张dim_date表,一行代表一天,包含:
date_id(日期,格式yyyy-MM-dd,主键)day_of_week(星期几)week_begin_date(本周开始日期)month_id(所属月份)quarter_id(所属季度)is_workday(是否工作日)is_holiday(是否节假日)
为什么这么设计?因为在分析场景里,你需要做“今年中秋和去年中秋对比”“第X周环比第X-1周”这类分析,如果没有一张标准的日期维度表,你就需要在SQL里写一长串日期计算逻辑,每张报表写一遍,非常痛苦。有了它,所有日历语义统一。
用户维度。用户维度表一行代表一个用户,要包含用户的静态属性和分区属性。静态属性:用户ID、性别、注册时间、注册渠道、会员等级;分区属性:年龄区间、城市等级、新老客状态。这里要注意“新老客状态”这类属性,它其实是会随时间变化的,比如一个用户在1月是“新客”,但他到了6月就是“老客”了。如果你把它当着静态字段存在一行用户维表里,就会出现历史回溯不准确的问题。
这个问题的标准解法叫“多变维度的处理策略”。常见策略有三种:
- 直接覆盖:不保留历史,最简单的做法,适合变化频率低或不需要追溯的口径。
- 拉链表:保留历史全量,记录每条记录的有效起始时间和结束时间。
- 快照表:每天保留当时维度的全量快照,适合需要精确还原历史的场景。
在实际数仓场景中,用户维度我会拉链表和快照结合:在主用户维表(dim_user)里只放不变的属性或可覆盖的属性,然后保留一张用户历史拉链表用于回溯分析。商品维度类似,类目归属很容易变化(类目调整是电商常有的事),如果不加处理,历史订单会全部归到新类目下,导致同比分析失真。所有维度的缓慢变化处理,都需要在建模设计期业务确认好:哪些维度属性变了,历史要跟着变;哪些要保留历史口径。
商品维度。商品维度通常比较“深”。一个SKU会关联到品牌、类目层级、SPU、商家。类目层级要处理好一级类目、二级类目、三级类目。我建商品维度时,会直接冗余出多个字段:category_1_name、category_2_name、category_3_name,这样下游要按一级类目分组时,直接GROUP BY一个字段就行,不用去递归JOIN类目表。
店铺维度。相对简单,包含店铺ID、店铺名称、开店时间、店铺等级、主营类目。注意店铺维度和商家维度是两个概念:一家公司(商家)可以拥有多个店铺,分析营销活动时会按商家维度看,而看流量转化时会按店铺维度。粒度不要混。
维度表最重要的设计原则是一致性:同一个“用户ID”,在任何事实表里只能关联同一张用户维度表;同一个“商品ID”同理。这是总线矩阵的落地。如果订单域里用dim_user_basic关联用户,而营销域里又用了另一张dim_member_info,两边对“新客”的定义不同,那这两个域的表永远无法拉通分析。
4.4 第四步:一张DWS宽表是怎么从DWD加工出来的
事实表、维度表建模完成,相当于原料备好了,但业务方看数不会直接查DWD和维表的JOIN结果,那样查询效率低、SQL门槛高。我们要在DWS层做主题汇总。
以“用户订单主题”为例。我们要建一张dws_user_order_1d表,一行代表一个用户某一天的订单汇总。字段包括:
user_iddate_idorder_count(下单次数,去重订单ID)order_sku_count(下单商品件数)order_amount(下单金额)pay_count(支付次数)pay_amount(支付金额)refund_amount(退款金额)
这张表的加工SQL核心思路是:
INSERT OVERWRITE TABLE dws_user_order_1d SELECT user_id, TO_CHAR(order_time, 'yyyy-MM-dd') AS date_id, COUNT(DISTINCT order_id) AS order_count, SUM(item_count) AS order_sku_count, SUM(order_amount) AS order_amount, SUM(CASE WHEN pay_time IS NOT NULL THEN 1 ELSE 0 END) AS pay_count, SUM(actual_pay_amount) AS pay_amount, SUM(refund_amount) AS refund_amount FROM dwd_trade_order_detail_di GROUP BY user_id, TO_CHAR(order_time, 'yyyy-MM-dd');有了这张1日汇总表,再要“近30天用户下单金额”就很容易了:
SELECT user_id, SUM(order_amount) AS order_amount_30d FROM dws_user_order_1d WHERE date_id >= DATE_SUB(CURRENT_DATE, 30) GROUP BY user_id;如果当时没有做DWS汇总,而是每次从DWD里取30天全量明细聚合,同样的查询可能要扫几十亿行。DWS的价值,就是把高成本的预计算提前完成,让下游每一次查询都轻量。
4.5 事务事实表和周期快照事实表
订单事实是典型的“事务事实表”:每个事务一行,当事件发生时记录一行,之后不更新。事务事实表用来统计“发生了什么”,比如支付了多少笔、金额多少。
但很多分析场景还需要回答“截至某个时刻的累计状态”。比如“今天有多少人持有有效优惠券”“当前库存还有多少”。这类瞬时状态型的指标,事务事实表做不了。那就需要周期快照事实表:按固定时间间隔(每天/每小时)记录当时的完整状态。一行代表“某用户截至今天累计消费金额”“某商品今天的库存快照”。
订单域里也要用快照:用户累计消费金额表、累计下单次数表。这类表的价值在于计算会员等级、用户分层的RFM模型时,不用再从DWD做全量历史累计。
事务事实表和周期快照事实表不要混用:我看到有人把“累计到昨天的金额”加到当天事务记录后面,这样把累计逻辑和新增逻辑放在同一行,导致回溯又重新计算时结果不一致。正确做法是:事务表管过程,快照表管状态,两者分开建模;要做“存量+增量”时再去JOIN。
5. 宁可慢一点也要避开的建模坑:字段口径、粒度、NULL和命名
前面讲的是建模“正向设计”的路径。这一节我想说一些更细的坑。这些坑几乎每个数据团队都踩过,但绝大多数不会写进官方文档、更不会出现在教科书里。
5.1 同一个字段,两张表里两种含义,这是最隐蔽的坑
我曾接手过一个项目,用户域里有个字段叫is_new_user,在A表里定义为“该用户是否为当天首次启动App”,在B表里定义为“该用户是否为近30天首次下单”。两张表都叫is_new_user,结果数据产品在同一个看板里同时选了这两个字段做交叉分析,算出来“新用户居然有90%都下单了,而且很多老客也被算成新客”,差点做出错误运营决策。
这个问题就叫“同名字段不同口径”。根治办法有两个层次:
- 建模层:字段命名要带业务限定。比如
is_new_start_user(是否新启动用户)、is_new_purchase_user(是否新成交用户),不要用模糊的is_new_user。 - 口径层:建立公共指标字典,每个指标有唯一编码,在数仓元数据里挂上口径说明。指标一旦确定,所有系统都使用统一指标标识,字段命名保持一致。
这两个办法我都踩过才明白,第二个比第一个更重要。因为只要指标字典清晰,即使字段名有歧义也能通过元数据找回真实含义。反之,指标字典混乱,字段名再规范也治标不治本。
5.2 粒度问题:不要把明细和汇总混在同一张表
我见过最让人揪心的表结构是:一张“订单主题宽表”,里面既有订单明细级别字段(order_id、sku_id),又有用户汇总级别字段(user_30d_order_amount、user_total_amount)。
这张表的设计者本意是“宽表一张表搞定所有需求”,下游查询时也方便,不用JOIN。但灾难在于:如果你按SKU维度聚合时,user_30d_order_amount会被重复累加多次。例如一个用户买了5个SKU的订单,这个字段就在行里重复出现5次,SUM时被加5遍,产生错误的汇总。
一旦有下游把这张表当成聚合表用,数据就会错得一塌糊涂,而且很难查出来。建模时必须保证一个表只有一个粒度,明细就是明细,汇总就是汇总。如果你真的需要一个“既有明细又有汇总的报表查询”,那请把汇总指标拆到另一个ADS层表里,在查询端通过子查询去关联,而不是物理上合并成一张宽表。任何需要“同时看多个粒度”的分析,都应当是通过多张正确粒度的表来组合完成。
5.3 日期字段的类型、时区和NULL,三个细节点
日期字段的坑每天都在发生,而且很烦人:
- 类型不统一:有的表
order_time是string类型,存的是2024-05-20 10:30:00,有的表是timestamp类型。用来做过滤时字符串比较和日期函数转换来回切换,极容易出错。我强烈建议:数仓公共层的所有业务时间字段统一为timestamp类型,所有分区字段统一为string类型且格式固定为yyyy-MM-dd。 - 时区不统一:业务库可能记录的是北京时间,埋点日志可能记录的是UTC时间。如果建模时不统一转成北京时间,统计出来的“当天订单量”就会出现偏差,尤其是凌晨零点到八点这两个时段之间差异明显。建模时就应该在明细DWD层把所有时间字段统一转换为业务标准时区,后面一切基于该标准时间。
- 时间字段为NULL:很多订单有“下单时间”但没“支付时间”,因为订单最终未支付。下游在计算“支付时长=支付时间-下单时间”时就会碰到大量NULL。处理NULL的策略要在建模期就定清楚:是保留NULL,让下游通过
COALESCE处理,还是直接置为1970-01-01这种祭天值?我推荐保留NULL,并且用字段注释写清楚“该字段在下单未支付时为空”。用祭天值会污染计算,而且很难分辨是“真的没有”还是“值为1970”。
5.4 金额字段相关:为什么建模里头一定要用“分”或精度可控的小数
关于金额字段,很多从业务库同步过来的数据是DECIMAL(10, 2),表示到“分”。进入数仓后,有人图省事直接转成DOUBLE,这是个大坑。
浮点数在计算机中不是精确存储的,当你把0.1和0.2相加时,可能得到0.30000000000000004。金额如果用浮点数做累加,累计到千万级别时会产生无法忽略的误差,对账时财务那边一毛钱都不放过,你会被“金额差一分”折磨到怀疑人生。
正确做法是:
- 金额字段尽量保持
DECIMAL类型,如果必须转DOUBLE,至少保留足够精度,并且禁止在浮点数上直接做等值比较。 - 如果需要用整数计算,建议金额统一转换成分(整数),也就是乘以100后存储为
BIGINT。这样既避免浮点误差,SUM也快速准确。下游需要展示元时再除以100。
顺带说一句,Hive/Spark里对DECIMAL的SUM结果类型转换规则在不同版本间有细微差异,最好在开发自测时就拿一个千万级数据量样本做“金额总和”测试,避免上线后发现精度异常。
5.5 主键唯一性校验,写进调度里而不是靠自觉
数据建模的上线只是开始。模型要持续可靠,必须有“关卡”守护。我负责的DWD层所有事实表都配置了主键唯一性检查:
SELECT order_id, COUNT(*) AS cnt FROM dwd_trade_order_detail_di WHERE data_date = '${bizdate}' GROUP BY order_id HAVING COUNT(*) > 1;如果这个查询返回了结果,调度系统就立刻阻断下游任务并发出告警。有了这张“安全网”,模型里的主键重复问题就能第一时间暴露,而不是等报表数据发出去几天后,由业务方发现总数对不上才回头排查。
6. 模型上线只是一个开始:血缘、资产盘点和公共模型优化
很多开发者的习惯是:模型调通了、报表能看到数了,就认为建模工作完事了。其实上线才是最需要盯的时候,因为真实运行中你才会发现模型被用得对不对、怎样被用得不好。
6.1 数据血缘:模型出问题时,靠它快速定位
数仓模型一张接一张,层与层之间通过ETL任务串联。当某张报表出现一个异常值时,你必须能顺着链路倒回去查:ADS这张表的这个字段,是从DWS的哪个字段算来的?那张DWS表的这个字段,又是从DWD哪几张表聚合来的?
没有数据血缘,你只能在全公司数千张表里大海捞针。有血缘,你可以几分钟内定位“源头在哪一层、哪张表、哪个字段、哪一段SQL逻辑”。现在主流的调度平台、元数据平台都支持自动解析SQL血缘,建议在建仓初期就把依赖关系收集起来。我现在维护的每个模型,都要求在元数据平台里能看到完整血缘链路。当模型做了字段调整,我还可以通过血缘自动找到所有下游,批量通知他们,避免悄无声息地在某个报表里埋雷。
6.2 资产盘点:别让小众临时查询绑架了DWS/ADS模型
建数仓一年以后,你会有几百张DWS和ADS宽表。这里最大的风险是模型膨胀——“每来一个需求建一张表”,一年多后表数量暴增,但真正被高频查询的表没几张。
我的经验是每季度做一次“模型资产盘点”。从元数据平台拉出每张表的查询频率、读取行数、产出耗时、对应下游任务数量,列一个清单出来。按“高频高价值”“低频高价值”“高频无价值”“低频无价值”四象限分类。
对“低频无价值”的表,可以直接下线或者合并;对“高频无价值”的表,要看看它查的内容是不是DWS或公共层缺失某些核心字段,如果是,就去补公共模型,而不是忍着一张笨重的个性化宽表继续跑。这个工作我称它为“数据模型的还债”,因为前期的设计缺失总有一天要用维护成本来偿还,越早还,利息越低。
6.3 公共层模型的横向合并与纵向拆分
公共层模型优化的两个方向,我分享下实操中的经验。
第一,横向合并。当多个DWS表都包含“用户ID+日期+支付金额”这种相同粒度相同业务过程的组合,可以考虑合并成一张宽表,这样下游只需要查一张表,跨指标计算就不用JOIN多张DWS。常见做法是把用户域的下单、支付、退款、加购、收藏指标合并到一张“用户交易汇总宽表”里。
SELECT user_id, date_id, SUM(order_amount) AS order_amount, SUM(pay_amount) AS pay_amount, SUM(refund_amount) AS refund_amount, SUM(fav_count) AS fav_count FROM ( -- 这里是DWD各明细表的联合 ) t GROUP BY user_id, date_id;合并的前提是粒度一致、口径一致。粒度都是“用户-天”,口径都是经过指标字典确认的,才可以合并。反过来说,如果仅因分析师个人喜好,把粒度不一致的指标硬塞一起,那就会重新掉进5.2讲的坑。
第二,纵向拆分。当一张DWS表的字段超过80个甚至100个,下游查询却经常只看其中少数列时,可以考虑拆成“用户核心交易表”“用户流量行为表”“用户营销触达表”等子集,避免每次查询都要读取一堆用不上的列,减少I/O和扫描成本。
当然纵向拆分也不是拆得越碎越好,拆太碎会导致下游跨表JOIN太多,查询反而变慢。一般以50到80个字段为一个主题的健康区间,超过或者字段间主题关系太强时就不拆。
6.4 任务稳定性和数据产出的“最后一道防线”
模型优化调整之后,必须有回归验证。我一般保留一批“数据质量基线SQL”,比如某个汇总表里的核心指标必须等于另一张独立来源表的对应统计值。模型改动之后自动跑一遍基线,只要基线通过,才允许合入生产。
真正的交付不是“我建好了表”,而是“表里的数据每天稳定产出、口径清晰、下游用得放心”。数据建模这份工作,很多功夫在SQL之外,但最终又都会体现在数据质量和模型效率上。这也是整个大数据从业链路里最有成就感的部分:你建设的不只是一张张表,而是整个团队做数据决策时最值得依赖的路基。那些在业务方开月会时不用再为一个数字的出入吵来吵去的安稳时刻,就是数据建模工作最好的回报。