有一次在项目复盘时,我和几位同事聊起一个很有意思的话题:一个数仓项目做得成功还是失败,最早其实不取决于用了什么引擎、写了多少条SQL,而是最开始有没有想清楚“这个数仓到底要承载什么”。数据仓库这个词已经被用得太滥了,一提到数仓改造,大家的第一反应就是上分层,一上分层就是ODS、DWD、DWS、ADS四件套,可真正能把每一层为什么存在、层与层之间的边界是什么、哪些地方必须做牺牲讲清楚的开发,其实不多。这个系列叫“数据仓库的思考”,第一篇我不打算堆概念,而是把我在实际项目里验证过的方法、反复权衡过的取舍,以及踩过之后才知道疼的坑整理出来。适合正在搭数仓但还没形成自己方法论的同学,也适合已经被报表口径不一致折磨到怀疑人生的分析同学。如果看完能让你重新审视手头的设计文档,那这篇内容的作用就算达到了。
1. 数仓到底解决什么问题
1.1 一个业务分析师眼里的“数仓不清爽”
很多数仓项目都是从一个看板需求开始的。业务方提需求时通常很简单,一句“我要看当月的GMV”扔过来,听起来毫无歧义,可同一个指标在不同部门手里往往有三种定义:一种是支付成功后才算,一种是下单就算,还有一种是剔除退款后再算。如果数仓从一开始没有把这些口径前置到模型设计里,数据就会在报表层各算各的,分析同学拿着同一张表能产出两套结论。
我见过不止一个项目,报表层堆了一堆临时SQL,每个SQL里都写了一遍GMV的计算逻辑,改一个口径要通知所有人同步改。这就是典型的数仓没有发挥作用,因为数仓的核心目标本来就不是“把数据搬过来”,而是把散落的、混乱的、各说各话的数据,变成一套被统一解释过的数据资产。
1.2 数仓和业务库不是一回事
把数仓和业务数据库混为一谈,是初学者最容易犯的错误。业务库是给前台系统用的,它的核心目标是支撑一笔笔交易快速完成,读写路径短,并发能力强,数据冗余少,这就是OLTP,翻译成人话就是“收银台系统”。数仓是给分析决策用的,核心目标是把历史数据攒起来,做交叉分析、趋势分析和多维统计,数据要宽、要全、要能对同一指标形成统一口径,这就是OLAP,类比成人话就是“库房里的月度盘点”。
两者的差异决定了设计方式完全不同。业务库追求的是三范式,表拆得越细越好,避免更新异常;数仓追求的是查询效率和口径一致,经常需要主动做冗余,把维度信息直接塞进宽表。如果在数仓里死守范式,一个订单表拆成七八张表,每次分析都要来回join,别说性能扛不住,分析同学改一个口径都得心惊胆战。
数仓还有一个容易忽略的特征:它承载的是历史数据,所以要强调时间维度。同样一张订单表,业务库里可能只保留近三个月的订单,数仓里却要保留上线以来所有历史明细。因此,数仓里的每个核心事实表几乎都离不开日期分区,“按天分区”这个看似普通的动作,本质是在替业务库补上历史回溯的能力。
1.3 数仓的价值最终落在三件事上
把各种复杂概念剥掉以后,我认为数仓的价值主要落在三件事上。
第一是统一口径。同一个指标在数仓里只能有一个定义,最好只存在于一个模型里,下游要用就从那个模型取,而不是各写一遍。这也是从模型层面消灭“报表打架”的关键。
第二是整合多源数据。业务库可能有两套订单系统,一套在自建平台上,一套在第三方系统中,两边字段命名都不一样。数仓要做的就是把这两份数据接进来,完成字段映射、清洗、去重,最终输出一张可以被业务直接理解的订单主表。跨系统整合这件事,在业务库层面很难做,它是数仓的天然主场。
第三是提升查询效率。业务侧的明细数据量动辄上亿条,直接对明细不做任何处理的报表查询,在大多数存储引擎上都撑不住。数仓通过分层预计算、汇总表、宽表等手段,把常见查询需要的统计结果提前算好,让报表查询从“扫描全表”变成“读结果”,响应速度自然就上来了。
如果建好的数仓在这三个价值点上都没有明显贡献,那这个数仓大概率只是把业务库的数据换了个地方存放,意义不大。
2. 数仓分层设计:不要为了分层而分层
2.1 常见四层架构到底在做什么
现在绝大多数数仓项目都会采用分层的思路,最常见的就是ODS、DWD、DWS、ADS四层。很多人把这四层挂在嘴边上,但不一定清楚每一层的职责边界。
| 层级 | 中文习惯叫法 | 核心定位 | 典型处理动作 |
|---|---|---|---|
| ODS | 操作数据存储层 | 原样接入业务数据,保留最原始状态 | 抽取、落地、简单清洗 |
| DWD | 明细数据层 | 统一口径、统一字段、建模加工 | 维度退化、事实清洗、缓慢变化维处理 |
| DWS | 汇总数据层 | 面向分析主题做汇总 | 按维度预聚合、生成指标 |
| ADS | 应用数据层 | 面向具体报表和应用输出 | 生成应用表、只读结果 |
我在实际项目里最常强调的一句话是:ODS层要克制,不要在这里做太多业务上的聪明处理。ODS的作用是保留现场,让上游数据一旦出问题,数仓还有地方可以回溯原始数据。如果ODS层就做了大量过滤、字段改名、类型转换,一旦上线以后发现业务方想要的某个字段其实被当时“自作聪明”地清掉了,补数据的成本会非常大。
DWD是数仓的核心。能不能做好数仓,主要看DWD做得实不扎实。这里说的扎实,不是指表建得多么复杂,而是指维度、事实、粒度和口径都被定义清楚了。举例来说,订单明细层如果能把“订单状态”这个枚举值统一成一套字典,把“下单时间、支付时间、完成时间”这些关键时间戳都保留下来,那么下游无论怎么加工,都在同一套基础上玩,不会各说各话。
DWS开始做汇总,通常按主题划分,比如交易主题、用户主题、流量主题。汇总不是简单地把明细count一下,而是要提前约定好统计维度和统计粒度,比如“按天、按商品类目、按店铺”的成交金额,形成一个聚合模型。这里容易踩的坑是汇总层做得太粗,业务方想多拆一个维度都得重新跑一遍底层明细,性能优势体现不出来。
ADS则是面向最终报表的,这一层可以容忍一定程度的“脏乱差”,逻辑可以写得直白一些,因为它的目标就是让报表开发同学看得懂、查得快。
2.2 分层设计里的几条取舍原则
分层不是目的,控制复杂度才是。我见过不少项目,分层分得很标准,模型却异常臃肿,一个指标要经过五六层加工才能算出来,出了问题根本不知道在哪一层丢的。所以分层设计至少要守住几条原则。
第一,层与层之间不要随意跨层引用。数仓最怕的就是ODS层直接跳过去被报表引用,或者DWS层又回头去依赖ODS。跨层引用会让血缘关系变得混乱,也容易让公共DWD层失去意义。尽量保证依赖方向是从下往上走的,DWD依赖ODS,DWS依赖DWD,ADS依赖DWS,形成一条清晰的生产链路。
第二,不要为了分层而增加不必要的层。很多团队把“分层多”理解为“架构先进”,于是一个指标文档里出现了五层六层的加工逻辑。实际上每多一层,就多一份调度成本、多一份出错可能、多一份血缘维护成本。如果你的项目规模并不大,业务口径也不复杂,完全可以压缩成ODS、DWD、ADS三层,把汇总逻辑并入ADS里,效果反而更好。
第三,公共层必须有人负责。无论是DWD还是DWS,一旦被多个下游共用了,就必须有一个明确的owner去响应修改需求。不然就会出现大家都往公共层塞字段,塞了大半年后公共层膨胀成一张什么都有的大表,谁都不敢动,所有人只能继续在ADS里各搞一套。公共层的演进需要纪律,而纪律背后必须有人承担责任。
重要提示:判断一个分层是否合理的直观方法,是看一个普通的数据需求从提出到上线,需要改动多少层。如果每次加一个报表都要动三层以上的模型,分层很可能已经过度了。
3. 建模方法论:维度建模为主,但别生搬硬套
3.1 事实表与维度表的几组核心概念
维度建模是数仓领域流传最广的一套方法,核心就两样东西:事实表和维度表。事实表记录发生了什么,通常是数值、时间、外键,比如订单金额、下单时间、商品ID;维度表记录业务对象是什么,通常是文本描述,比如商品名称、类目、品牌、店铺。
一个最简单的星型模型就是一张事实表连接若干张维度表,查询时通过外键把维度信息关联进来。之所以推荐这个形式,是因为它符合业务同学理解世界的方式,也就是“先选维度,再看指标”。可维度建模的门槛不在表结构,而在事实表的粒度设计。
事实表一般分三类。事务事实表记录每一笔业务事件,比如每一条订单、每一笔支付、每一次点击;周期快照事实表记录某个时间点的状态,比如每天的用户余额快照、库存快照;累积快照事实表则记录一个业务流程从开始到结束的多个里程碑时间点,比如一笔订单从下单、支付、发货到完成的完整时间线。选错事实表类型是建模阶段最常见的坑。
比如统计退款,如果只建了下单事务事实表,那么每一笔订单就是一行,退款状态挂在订单字段上。这么做导致的问题是,再次申请退款、部分退款这些情况表现得很别扭。反而可以设计一张退款事务事实表,每发生一次退款就记一行,和订单事实表分开建模,统计起来会轻松很多。
3.2 建模时最容易翻车的四个场景
我在不同项目中反复遇到四类建模错误,几乎每次都是引发线上问题后才被重视。
第一个是把维度表做成雪花型,过度规范化。维度表本来应该是扁平的描述性信息,有人为了“复用类目信息”,把商品维度拆成商品表、类目表、品牌表三层,每层都有主键,查询时得一路join到品牌名。这种模型在报表查询中几乎都是负收益,因为维度查询本身不需要那么严格的范式。我个人的建议是维度表尽量做成宽维度,文字描述字段直接冗余在同一张表里。哪怕是类目层级变化,也完全可以通过缓慢变化维处理,而不是靠拆表。
第二个忽略退化维度。订单号、发票号、流水号这些大概率不会单独建一张维度表的字段,很多人就直接丢掉了。可实际分析中,这类字段却是排查问题、核对数据的关键钥匙。DWD层在加工事实表时,尽量把这些退化维度直接保留成普通字段,不要为了“看起来规范”把它们删掉。
第三个是不区分事实表的粒度,把指标和维度混在一起建表。比如把“订单金额”和“支付金额”塞在同一张事实表里,并且认为这是同一个粒度。实际上订单金额来自下单事件,支付金额来自支付事件,两者时间可能不同、状态可能不同,混在一起以后想按小时分析支付趋势就会很别扭。正确做法是明确事实表粒度,一张事实表只回答一个业务过程的问题。
第四个是缓慢变化维策略选得太随意。SCD Type 1直接覆盖旧值,适合不关注历史变化的属性;Type 2保留一条新记录并启用有效期,适合需要追踪历史状态的属性;Type 3只保留最近一次变化,通常用于临时性需求。很多人不管什么字段都上Type 2,导致维度表无限膨胀,历史版本越积越多,查询成本快速上升。我通常会和业务确认一个原则:只有影响业务决策的属性才值得做Type 2,其他属性用Type 1覆盖即可。
3.3 宽表、星型、雪花型怎么选
建模到后期会面对一个现实问题:底层模型是不是越宽越好。结论是不一定。宽表的好处是查询快、不用join,适合业务方自助取数;坏处是改动成本高,加一个新字段可能要刷全量历史数据,而且宽表里字段动辄上百,没人记得每个字段的准确含义。
我更推荐的做法是:在DWD层保持星型模型,把维度退化和公共维度管理好,保证明细层能被多种需求复用;在DWS或ADS层面向具体场景适当做宽表,把高频查询的字段预先组装到一起。这样既保住了底层模型的灵活性,又照顾了应用层的查询体验。
注意:宽表不是“无脑把字段堆一起”。宽表里的每个字段最好都是被真实报表验证过的,没有实际使用场景的字段就不要提前加入宽表,否则只会让表越来越重、查询越来越慢、解释成本越来越高。
4. 技术实现里的细节:分区、存储、调度
4.1 存储格式和压缩策略怎么选
数仓天然面对大表,存储格式选不对,所有下游都会跟着遭殃。现在主流的数仓引擎基本都支持列式存储格式,比如ORC、Parquet这类。列式存储的好处是查询时只需要读取相关列,不用像行式存储一样把整行数据都读出来,尤其适合汇总统计类查询。
压缩策略则需要在压缩比和解压速度之间找平衡。偏向于省空间的gzip/zlib压缩比高,但解压会消耗更多CPU;偏向于查询性能的snappy或zstd解压快,压缩比略低一些。我的经验是:如果存储成本还能接受,明细层和汇总层优先选snappy这类解压速度快的压缩方式,因为下游查询频繁,CPU开销往往比存储成本更敏感。只有在很少被直接查询、主要用于回溯或归档的场景里,才考虑更高压缩比的格式。
还有一个细节经常被忽略:格式转换不要反复做。数据从ODS进到DWD时如果已经转成列式格式,就不要因为中间换了一个计算引擎又转回行式格式。每多一次转换,就多一次全量读写,时间成本和失败概率都会上升。
4.2 分区设计要兼顾裁剪和文件大小
分区是数仓表设计里性价比最高的优化手段。一个按天分区的明细表,查询某一天的数据时,引擎只需要扫描一个分区目录,而不是全表;反之,如果一个表没有合理分区,几亿行数据每次被全量扫描,再好的存储格式也扛不住。
分区字段通常选择日期,比如dt。业务上还会用省份、店铺ID做二级分区,但要控制分区数量。如果分区粒度太细,比如按小时分区,每天会产生大量小文件,小文件一多,元数据压力变大,查询反而不稳定。我个人的习惯是:绝大多数事实表按天分区就够了,只有在实时性要求非常高的场景下才按小时分区;维度表则基本不做时间分区,而是保留全量快照。
分区设计和表生命周期管理是绑在一起的。ODS层很多原始日志不需要永久保留,按业务要求设置保留90天或180天即可;DWD层明细通常需要保留更长时间,但也不是无限膨胀。把每个表的数据保留周期提前定好,写入表注释,由调度平台定时清理过期分区,能省下大量的存储成本,也能避免查询时扫到大量无用历史数据。
下面是一个通用明细表的DDL示例,我习惯在建表时就带上分区键和注释:
CREATE TABLE dwd_trade_order_di ( order_id string COMMENT '订单ID', user_id string COMMENT '用户ID', product_id string COMMENT '商品ID', order_amount decimal(10,2) COMMENT '订单金额', order_status string COMMENT '订单状态,字典:00-待支付,01-已支付...', order_time string COMMENT '下单时间', pay_time string COMMENT '支付时间' ) PARTITIONED BY (dt string COMMENT '数据日期分区') STORED AS ORC;这里把订单状态直接以字典形式注释清楚,是避免下游理解不一致的重要手段。很多人建表时不写字段注释,过了两个月自己都记不清某个字段到底是什么意思,这在数仓里是要命的。
4.3 调度与重跑逻辑,先保证幂等
数仓的数据加工离不开调度系统,但调度设计里最容易被忽视的是幂等性。简单说,一个任务跑一次和跑十次,最终落表的结果应该完全一样。做不到幂等的任务,重跑一次就会产生重复数据,而重复数据是数据质量问题里最常见、最难查的一类。
要保证幂等,首先要处理好写入方式。增量表重跑时,应该先删除目标分区再写入,而不是一路append;全量表重跑时,应该先清空再覆盖。无论哪种方式,都不能让下游在任务执行中间读到半成品,最好的做法是先写临时目录,成功后原子切换到正式分区。
其次是处理好上游延迟。调度任务经常因为上游数据没准备好而失败,如果没有合理的依赖机制,任务就会拿到不完整的数据跑出一个“假正确”的结果。我的经验是:核心任务必须显式声明依赖上游任务,而不是单纯依赖时间触发;如果上游真的延迟了,宁可等,也不要带着不完整数据硬跑。数仓领域有一句老话,数据不怕晚,就怕错。晚一点,业务方顶多催一下;错了,重新核对口径的代价是双倍起步。
5. 数据质量与元数据:数仓的隐形底线
5.1 数据质量校验的三道防线
很多人觉得数据质量是事后补救的事,报表出来错了再追查。但真正成熟一点的数仓项目,会把质量校验嵌到数据加工链路的多个环节里。
第一道防线在源端。数仓接入业务库数据时,要监控接入是否完整。最典型的场景是:上游业务库某一天抽数连接超时,导致ODS层缺失当天的订单数据,但任务显示成功。这种问题用人工很难第一时间发现,建议在ODS层的每个核心接入任务后面,加一个“行数校验”和“日期连续性校验”,发现缺失就立即告警。
第二道防线在加工过程。DWD层在做清洗时,要内置一些质量规则,比如关键字段不能为空、枚举值必须在指定范围内、金额字段不能为负数、主外键关联不上的孤儿记录要单独标记出来。与其让这些坏数据混进明细层,不如在加工时就把它们拦下来,并生成一份异常记录供排查。
第三道防线在产出后。每次DWS汇总表跑完,可以自动执行几条对比SQL,比如“今天的结果与昨天的差值波动超过阈值就要告警”,“本月累计值与上月同期对比偏差过大就要提示”。这些规则不一定每一条都准,但能快速帮你发现上下游口径不一致引发的诡异跳变。
我这里建议沉淀一张“数据质量稽核表”,每天把各层校验规则的执行结果写进去。这张表本身不复杂,但长期积累下来,它会成为你判断数仓健康度的重要依据。出了问题,优先看这张表最近几天有没有异常,能省掉大量无头苍蝇式的排查。
5.2 元数据治理先管好三件事
元数据管理是个大话题,小团队往往没有专门预算,但不代表什么都不做。我建议从三件成本最低的事情入手。
第一件是血缘关系。每张表的字段来自哪里、经过什么加工、被哪些下游使用,最好能在一个文档里画清楚。工具层面没有预算,就先用Excel或者wiki维护一张“表血缘清单”,每个新增任务都更新进去。血缘关系不是给别人看的,是给你自己排查问题用的。一次凌晨告警后,如果连“哪张表被谁改过、下游谁会受影响”都说不清,那个排查过程会非常痛苦。
第二件是数据字典。所有核心表、核心字段都要有注释和负责人,尤其是维度枚举值,一定要把对应的业务含义写清楚。我在实际项目里吃过太多亏,一个字段叫status,上游说1代表有效,下游理解成0代表有效,结果两个系统出来的数据就是对不上。数据字典不要求写得多系统化,关键是让后来人拿到一张表就能看懂。
第三件是owner机制。每张核心表都要有明确的负责人,当口径发生变化、字段需要调整时,由owner决策并同步影响方。没有owner的表很容易变成垃圾堆,谁都能往里加字段,最后谁都不清楚表里到底有什么。
6. 常见问题排查与心得
6.1 几个高频问题速查表
数仓上线以后,每天都会遇到各种各样的数据需求,但回过头来看,高频问题其实就那么几类。这里整理了一张速查表,适合贴在团队文档里当排查参考。
| 问题现象 | 可能原因 | 优先排查动作 |
|---|---|---|
| 同一指标两张报表对不上 | DWD层口径不一致或用了不同事实表 | 先查两张报表引用的是哪个底层模型,逐步溯源到DWD层定义 |
| 每天凌晨跑批任务延迟 | 上游任务依赖配置缺失或上游数据延迟 | 查看调度依赖关系,确认是否等到了上游数据完成 |
| 明细表主键重复 | join产生行数膨胀或未去重 | 对事实表执行group by主键,统计重复行,回溯到加工SQL |
| 重跑后数据变多 | 任务不幂等,写入前未清理目标分区 | 修改写入策略,重跑前先删分区 |
| 某个维度查询特别慢 | 维度表过大或未按常用字段分桶 | 确认是否建了辅助键,检查过滤条件是否走分区裁剪 |
| 指标数值突然异常波动 | 上游字段枚举或口径发生变化 | 检查最近是否有新版本任务上线,对比质量稽核表的波动记录 |
6.2 一个典型排查思路实录
多数数仓问题的排查思路其实是相通的。假设第二天早上业务反馈“昨天的订单报表数据一直没出”,我不会直接去看具体SQL,而是按下面这个流程走:
先确认影响范围。是只有订单报表挂了,还是所有报表都延迟了?如果只有订单报表,基本可以判断问题出在订单这条加工链路上;如果所有报表都延迟,优先怀疑公共调度平台或ODS接入层。
再从结果倒推上游。找到订单报表对应的ADS表,看它依赖哪张DWS表,DWS又依赖哪张DWD表,一直往下追到ODS。这个过程需要依赖血缘文档,如果之前维护得不好,这一步会非常耗时。
接着看关键节点的数据状态。检查ODS层当天订单表有没有数据、行数和前一天是否接近;如果没有数据,问题大概率在上游接入;如果有数据但DWS层没汇总,就去查DWD到DWS之间那个任务的运行日志,看是报错还是被依赖阻塞。
最后做验证。单独把缺少的这一天数据重新跑一遍,对比结果数据是否合理,确认后再触发下游调度。不要在根因没定位清楚时就盲目重跑全链路,那样只会把一份错误数据覆盖成另一份错误数据。
我一直认为,数仓排查能力不是靠“聪明”堆出来的,而是靠稳定方法论和文档沉淀。把每一次处理的思路写下来,下次遇到类似问题就能更快收敛。
7. 第一期收尾:几条实用建议
7.1 先定口径,再建模型
很多项目一上来就讨论表怎么建、用什么引擎,结果业务口径还没定清楚,模型建完再返工的成本极高。我强烈建议在模型设计之前,先把核心指标的定义、统计维度、更新频率整理成一页纸。哪怕只是把订单金额、支付金额、退款金额这三个基础指标的口径对齐了,后续建模都会顺畅很多。口径不明时,最忌拍脑袋先建表,因为表结构一旦被下游依赖,改起来会牵一发动全身。
7.2 能砍掉的层就砍掉
数仓设计里最常见的过度设计,就是不必要的汇总逻辑和多余的宽表。每一层汇总都会增加链路长度,链路越长,出现问题的概率越大。如果你的某个汇总表一个月只被查询两三次,那就要认真考虑是不是值得单独保留一层。做数仓不是做产品,不是功能越多越好,满足真实查询需求才是最重要的。把不需要的层砍掉,留出足够精力维护核心链路,收益往往比继续堆模型更高。
7.3 文档不是摆设,是救命的
这句话我放在最后说,因为它的重要性很容易被低估。数仓项目至少需要三份文档:核心指标口径定义、表血缘关系、常见问题排查手册。这三份文档不必追求格式华丽,但必须跟着项目迭代持续更新。我见过太多项目一开始认真写文档,后来赶进度就停了,等半年后再接手的人面对一堆无注释的表,只能靠猜。数仓是慢工出细活的事,文档就是让这个“细”能延续下去的载体。
第一期先聊到这里。我个人在实际操作中的体会是,数仓建设没有一招制胜的秘诀,真正可靠的方法就是先把口径、分层、建模、质量这几件基础事想清楚,后面才不会天天救火。后面的内容里,我打算接着拆解DWD层明细建模的详细案例、缓慢变化维的具体实现方案,以及数据质量稽核规则的落地脚本。如果你正在做数仓项目,欢迎把遇到的典型问题记下来,也许下一期就可以拿实际场景展开聊。