
做数仓这几年被问得最多的往往不是某个SQL怎么写而是“你们这数仓到底怎么分层的ODS、DWD、DWS看着都明白真到自己建表就不知道怎么切了”。很多人聊起分层架构头头是道架构图画得比谁都漂亮等真正落库建层的时候连第一张ODS表该存什么都不确定。这篇文章我把实际落地数仓项目的过程捋一遍把每一层为什么存在、怎么设计、边界怎么划、实时数仓有什么不同一条一条讲明白。适合正在搭建数仓或者刚接手数仓项目还没找到上手感觉的同学参考。1. 数仓为什么要分层1.1 分层的本质跟做饭一个道理我们经常说数仓分层到底分的是什么东西我先给个最直白的定义分层就是把“从业务库到报表”这段漫长的数据加工过程拆成多个职责单一的阶段每个阶段只对上一层负责不做越级的事情。我习惯拿做饭来类比。业务系统数据库就是菜市场买回来的原始食材——带泥的土豆、没处理过的鱼东西是真的但没法直接上桌。报表和算法要用的数据相当于成品菜要的是清洗切好的净菜、摆好盘的凉菜。如果从菜市场买回来直接倒进锅里煮最后出来的东西大概率是没法吃的泥还在、内脏没去、切得大小不一。数仓分层就是那间中央厨房洗菜、切配、配菜、热炒各有各的工位每道工序的人只管自己那一摊最终才出一桌稳定的菜。这个类比能解释一个关键问题为什么不能业务库直接出报表因为业务系统关注的是交易能不能跑通它不关心数据能不能直接做统计。订单表里状态字段可能存了1、2、3、4不同业务系统的含义还不一样用户表里性别可能存了0、1、2还可能存了null、空字符串。这些数据不经处理直接统计出来的数字谁敢信。1.2 不分层的代价我全都交过学费早期我刚接触数仓的时候接过一个很“野生”的项目。当时的做法就是业务库抽一张表数仓就建一张同样的表报表要什么就直接查底层表中间不经过任何加工。表面上看起来敏捷实际上是一地鸡毛。第一个崩掉的是口径。同一个订单金额运营那边统计的时候把退款订单也算进去了财务觉得应该剔除退款销售觉得只算支付成功且未取消的。三个部门拿到的数字都不一样天天扯皮最后都来问数仓“到底哪个准”。问题不在他们在于我们根本没有一个统一加工的地方各查各的口径当然乱。第二个崩掉的是效率。底层明细表动辄几亿行报表查询直接扫全表凌晨跑批业务高峰的时候Hive的任务排队排到天亮。那时候最怕听到的一句话就是“昨天的报表怎么还没出”第二天一查跑批超时了因为每个报表都去扫了一遍原始表。第三件事更头疼人员流动以后旧任务没人敢动。因为任务之间没有任何规范路径全都是点到点的“人情链路”只有写任务的那个人知道中间做了什么转换。他走了那堆ETL就成了黑匣子坏了只能重刷。1.3 分层带来的直接收益后来按标准分层重新搭了一遍最直观的变化是三个词解耦、复用、可控。解耦很好理解每一层只跟相邻层打交道。业务库加了个字段只影响ODS同步下游基本不用动报表要加一个新指标只需要在DWS层往上加不用去碰底层逻辑。复用就更实惠了DWD层把订单、用户、商品这些公共的明细加工好下游十个应用都查它一次加工多次使用跑批的资源消耗直接降了一个量级。可控则体现在权限和数据质量上业务库的账号权限收到最小ODS只能被DWD读DWS只能被应用和报表读如果有人查数查得不对顺着链路一层一层看很快就知道是哪一步出了问题。2. ODS、DWD、DWS每一层到底做什么2.1 ODS层先把原始数据原封不动地接进来ODS层全称是Operational Data Store操作数据存储层。它是数仓和业务系统之间的第一道桥。核心职责就四个字原样落地。从业务库同步过来的数据是什么样就存什么样字段名不改、类型不转、状态码不翻译、不做任何业务逻辑加工。我说“原样”但不代表“什么都不做”。有两件事ODS必须做。第一是分区管理。绝大多数数仓用的是Hive或类似的大数据组件ODS表必须按照日期做分区一般用dt作为分区字段每天一个分区。这样既方便管理也方便后续回溯——某一天数据出了问题直接重刷那一个分区就够了不用全量推倒重来。第二是数据同步策略。数据量小的表可以每天全量覆盖反正快大表一般用增量同步每天只有变化的数据进来。这里有个容易踩的坑只用增量的话如果业务库发生了数据更新比如订单状态从待支付变成已支付单纯按创建时间增量同步会漏掉变化。所以要综合考虑业务库有没有更新字段、能否开启binlog、有没有自增主键这些条件。常用做法是增量更新标记字段或者干脆基于binlog做拉流同步确保ODS里的数据跟业务库保持一致。ODS层不建议做深度清洗。有人喜欢在ODS就过滤掉脏数据我的建议是过滤可以做但只做物理过滤比如去掉完全为空的记录、去掉分区值异常的记录凡是涉及业务规则的转换比如把状态码翻译成中文、把单位统一都放到DWD层。原因很简单ODS要尽量保留原始语境万一后续发现之前清洗的规则错了还能从ODS重新来一遍。2.2 DWD层脏活累活都在这层干DWD层Detail Warehouse Detail明细数据层。如果说ODS是仓库的收货区DWD就是加工车间。这一层要做的事情很明显把ODS的原始数据变成干净、标准、好用的明细数据。具体拆开说DWD的核心工作有三块。第一块是数据清洗。空值填充、格式统一、状态码翻译、非法数据过滤。比如订单金额可能有负数业务上不该出现维度表没有对应的用户——这些都在DWD处理掉。我常用的做法是建立一套清洗规则表每个字段的清洗逻辑都写在配置里代码和规则分离改规则不用改代码。第二块是维度退化。这是DWD的精髓也是新手最容易困惑的地方。传统关系型建模喜欢规范化用户单独一张表、商品单独一张表然后靠外键关联。但在大数据场景下几亿行明细去关联维度表非常耗资源。维度退化就是把常用的维度字段直接冗余到明细表里用户性别、商品分类、店铺名称都直接“退化”进订单明细表查询时就省掉了一次join。统计一下就知道绝大多数报表查询需要的维度字段就那么十几个冗余进去性能和易用性双双提升。第三块是构建拉链表。业务库的数据很多是变化的用户等级从普通升成VIP、订单状态从待付款变成已付款。如果只保存当前状态历史就丢了如果每次都全量覆盖历史倒是有了但空间翻倍。拉链表通过记录每条数据“生效开始日期”和“生效结束日期”既能查到当前状态也能回溯历史任意时间点的状态。我在订单维度表上就这么干过效果立竿见影下游做留存分析、状态流转分析直接查拉链表就行。2.3 DWS层给指标做“半成品”DWS层Data Warehouse Summary汇总数据层。这一层干的事情一句话就能说清楚把DWD的明细数据按照常用维度预先聚合成宽表让它变成“开袋即食”的半成品。为什么需要这么一层拿电商数仓举例。运营每天要看各品类的销售额、订单量、支付用户数。如果每次都从几亿行DWD明细里group by凌晨跑批的时间会非常感人。DWS做的事情就是提前把“天 品类”维度的汇总算好。品类有哪些、订单量多少、支付金额几何统统放到一张宽表里。报表层直接查这张汇总表秒出结果跑批时间从小时级缩短到分钟级。DWS设计的核心是“公共指标下沉”。什么意思呢做数仓最怕的就是同一个指标在不同报表里算出来的数不一样原因就是各算各的。DWS把这类公共指标统一定义好、计算好比如“交易金额过滤退款过滤未支付有效订单金额”后续所有应用层指标都从DWS这张表取数口径天然统一。DWS的粒度通常有三档天粒度、周粒度、月粒度。天粒度是最常用的我通常先做这一档周和月的指标可以用天粒度再聚合不必单独建表。也要注意控制DWS表的数量只沉淀高频使用、多维组合的指标别把DWS做成“明细表的复制品”。2.4 ADS层容易被忽略的最后一块拼图热搜词里没提到ADS但完整的分层体系里它必须存在。ADS是应用数据层直接对接报表、大屏、邮件推送、算法特征服务。这一层的特点是结构简单、查询极快、面向特定应用。ADS层的数据来源不一定是DWS也可能是DWD甚至ODS看具体需求。比如BI报表要展示某个促销活动每分钟的实时销售曲线走的可能就是实时DWD到ADS的链路月报里要统计跨三层业务的漏斗转化可能需要同时读DWS和ODS做二次加工。我见过很多团队把ADS省掉报表直接查DWS表面看少了一层很轻松但时间久了问题就来了报表需求五花八门有的要聚合再聚合有的要行列转换有的要关联外部数据全堆在DWS上DWS的语义就会变得混乱。ADS的价值在于“需求隔离”——把五花八门的应用需求消化在这一层核心层次不受干扰。3. 分层设计规范与边界把握3.1 各层之间的数据流向分层架构最核心的规则只有一个数据必须自下而上流动不能跳层更不能反向流动。ODS → DWD → DWS → ADS这条链路是数仓的“默认路线”。为什么要定这么死因为一旦允许跳层分层就形同虚设。报表缺一个指标开发偷懒直接从ODS查刚开始觉得没什么时间一长同一张报表在不同团队手里数据来源完全不一样我们又回到“口径失控”的起点。还有一个很现实的理由数据血缘。有了严格的层级关系任何一张表往上追溯依赖关系清清楚楚哪天业务方问“这个数是不是包含了退款”顺着血缘往下捋五分钟就能回答。当然规矩实践久了会发现有些场景确实需要例外。比如一些特殊审计需求必须直接查ODS原始数据这种走独立通道就行但不建造成常规路径。表结构上我还会约定每层表名让人一眼看出它属于哪层层级表名前缀示例ODS层ods_ods_order_info_diDWD层dwd_dwd_order_info_diDWS层dws_dws_user_order_1dADS层ads_ads_app_repurchase_1d命名里的后缀也要有约定。我用的是“分区粒度同步策略”比如di表示日增量、df表示日全量、1d表示一天汇总、1h表示小时汇总。这套命名规范一开始就要定好等整个数仓建起来以后再改前缀代价非常大。3.2 字段命名与开发规范表名前缀只是骨架字段命名才是血肉。字段规范这块我踩过比较大的坑是“英文字段没有中文注释”。建表的时候觉得简单order_status谁看不懂三个月后回来看谁都看不懂它里面存的1、2、3到底对应什么。现在我的标准是每个字段必须有COMMENT枚举含义必须在字段注释里写清楚核心业务指标注释里要写明计算口径和来源层比如“退款金额(排除未支付订单,取自DWD层)”。开发规范方面每个跑批任务脚本开头必须写清“来源表、目标表、更新策略、责任人”。这不是形式主义是出问题以后救命的稻草。遇到过凌晨跑批失败任务挂了没人知道是哪一天的因为脚本里没写日期参数后来在规范里强制要求日期参数必须显式传递失败重跑时才知道是哪个分区。3.3 分层不是越细越好说句得罪人的话很多数仓不是设计得太粗而是分得太细。我在一些团队见过七层八层的架构每一层之间含义模糊DDS、ADS、DIM、TMP全混一起新增一张表要纠结半天该放哪层。我的建议是中小规模团队四层就够。ODS、DWD、DWS、ADS再额外加一个公共维度层DIM放维度表。不要为了“看起来专业”去加多余层次。分层本质是拆解复杂度层次多了调度依赖反而越来越复杂链路越长数据延迟越高出了问题越难排查。等业务量真的增长到需要细分时再拆也比一开始就铺开大摊子来得稳妥。4. 实时数仓的分层变式与开发重点4.1 实时和离线分层思想一样技术栈大有不同最近“实时数仓开发工作内容”这个词挺火的很多同学问实时数仓是不是就不用分层了。答案是分层思想一样要保留但每层的落地介质和加工方式变化非常大。离线数仓的核心介质是Hive/Spark数据以批处理为主一天一跑延迟小时级胜在数据量大、稳定可靠。实时数仓的核心诉求是秒级或分钟级出数比如实时大屏、实时风控、实时推荐特征它关注的是“刚刚发生了什么”。所以介质上实时链路一般是消息队列Kafka接入Flink做流式加工最终结果落在OLAP引擎里比如Doris、ClickHouse供查询端快速读取。做个简单对比对比维度离线数仓实时数仓数据时效小时/天级秒/分钟级计算引擎Hive、SparkFlink、Spark Streaming存储介质HDFS、Hive表Kafka、Doris、ClickHouse开发方式SQL跑批为主Flink SQL 流式状态计算回溯能力强重刷分区即可相对弱依赖日志重放或落盘数据适用场景报表、T1分析、算法训练大屏、监控、实时营销、风控4.2 实时数仓每一层怎么落地实时链路的分层逻辑跟离线完全对应。实时ODS一般就是Kafka里的原始业务消息。业务库的binlog变更消息、埋点日志原样接进来保留所有字段不加工。这里要注意消息格式规范化——统一JSON或Avro统一消息key的策略否则后续Flink解析会非常痛苦。实时DWD在Flink里做清洗、去重、维度补全。比如订单流和支付流要做双流join才能拼出一条完整订单用户维度数据要维护在状态里或维表里实时补充用户城市、会员等级同一订单的重复消息要在窗口内去重。这是实时数仓开发工作内容里最核心的部分也是最容易出问题的地方。Flink SQL写起来快但join的语义、状态TTL、checkpoint策略都得仔细调不然会出现数据延迟越来越大甚至状态爆炸的情况。实时DWS在Flink里做秒级、分钟级的窗口聚合结果写入Doris或ClickHouse。比如每5分钟统计一次各品类的订单金额写进Doris明细表OLAP引擎里再建好聚合模型查询端做任意维度下钻。一个常见的误区是以为实时链路不需要“清洗”Flink处理就行了。实际上实时数据的脏数据问题比离线更头疼——字段解析失败、事件时间乱序、维度表数据缺失这些在实时链路上都需要专门的策略处理。好一点的团队会把“实时脏数据流”单独隔离到一个Kafka topic方便排查和回放。4.3 Lambda架构两条腿走路是常态实操中大多数公司不会把全部报表迁到实时而是“实时每天都有离线照样跑”。这就是Lambda架构实时链路出快数离线链路出准数。同样一个指标实时大屏看趋势、离线报表看最终数两边各自独立。Lambda架构最大的痛点是对不上数。明明同是“今日销售额”实时链路说3.5万离线说3.52万差在哪儿其实就差在实时链路的数据截止时间和离线不一样。我的处理办法是实时和离线共用同一套口径定义并且实时结果只用于展示和告警不进入财务、不进入对账对账的事全交给离线。清晰的业务边界比技术统一更重要。5. 实战演练电商订单从ODS到DWS的完整链路5.1 场景设定拿一个最常见的场景电商订单。业务库有一张order_info表每天新增几十万订单有状态变更我们希望最终在DWS层得到“每个用户、每个品类、每天的下单金额和下单量”。5.2 ODS落地脚本示例ODS层第一步就是建表保留业务库原字段create table if not exists ods.ods_order_info_di ( id string comment 订单ID, user_id string comment 用户ID, shop_id string comment 店铺ID, product_id string comment 商品ID, category_id string comment 品类ID, order_amount decimal(10,2) comment 订单金额(原始), pay_amount decimal(10,2) comment 实付金额(原始), order_status string comment 订单状态: 1待支付 2已支付 3已发货 4已完成 5已取消, create_time string comment 下单时间, update_time string comment 更新时间 ) comment 订单原始表ODS partitioned by (dt string comment 日期分区) stored as parquet;ODS的同步脚本我用DataX或Sqoop从业务库抽取每天按dt分区写入Hive。关键点是同步时不要做任何表结构变更。业务库字段是order_status我这边就叫order_status哪怕知道这个命名不友好也等到了DWD再改。5.3 DWD轻清洗实现DWD层承接ODS做清洗和维度退化。我把用户信息、商品信息直接用left join退化进来顺便把状态码翻译成可读文本insert overwrite table dwd.dwd_order_info_di partition (dt2024-01-01) select t1.id, t1.user_id, t1.product_id, t3.category_id, t2.user_name, t2.user_level, t1.order_amount, t1.pay_amount, case when t1.order_status 1 then 待支付 when t1.order_status 2 then 已支付 when t1.order_status 3 then 已发货 when t1.order_status 4 then 已完成 when t1.order_status 5 then 已取消 else 未知 end as order_status_desc, t1.create_time, t1.update_time from ods.ods_order_info_di t1 left join dim.dim_user_info t2 on t1.user_id t2.user_id left join dim.dim_product_info t3 on t1.product_id t3.product_id where t1.dt 2024-01-01 and t1.user_id is not null and regexp(t1.order_amount, ^[0-9](\\.[0-9]{1,2})?$);这里有个实操注意点过滤非法金额我用的正则有人喜欢用cast然后判断是否null效果一样但正则写在SQL里更直观。更重要的是清洗规则写在注释里不然后期没人知道这一步挡掉了什么数据。5.4 DWS汇总实现DWS层从DWD层取数按用户和品类两个维度做聚合insert overwrite table dws.dws_user_category_order_1d partition (dt2024-01-01) select user_id, category_id, count(1) as order_cnt, sum(pay_amount) as pay_amount_sum, sum(if(order_status_desc 已支付, 1, 0)) as paid_order_cnt from dwd.dwd_order_info_di where dt 2024-01-01 group by user_id, category_id;不要小看这张DWS表它承接了后续所有“用户维度”“品类维度”的分析需求。运营要看品类销售排行直接查它算法要算用户品类偏好也查它。一张表养活一堆下游任务这才是DWS该有的状态。这条链路的隐藏价值在于每一步都有明确的依赖边界。ODS挂了重跑ODSDWD脏数据多了只调DWD逻辑DWS要加指标不碰其他层。各层独立重刷互不影响这在实际生产中太重要了。6. 构建数仓时我踩过的坑与排查经验6.1 常见问题速查表下面这些问题是我和身边同事在实战中真实遇到过的整理成了一张速查表代码里藏了很多年不一定在教科书里找得到问题现象可能原因排查与解法ODS表某天数据突然少一截业务库同步任务失败但没有重跑或分库分表新增了分库没加到同步任务里先对比ODS和业务库总行数检查同步任务日志确认同步配置是否覆盖所有分表DWD字段大量为null关联的维度表数据没同步或维度表主键冲突先查维度表数据量用left join的语义检查关联条件是否正确警惕维度表主键不唯一导致的数据发散DWS汇总数值和报表对不上上游DWD存在重复数据或DWS过滤条件跟口径不一致检查DWD是否有重复记录可用count对比明细和汇总逐个指标核对口径定义实时链路数据延迟越来越大Kafka lag持续增加Flink状态后端压力大反压查看Kafka消费组lag检查Flink的反压指标调大并行度或优化Flink SQL join的key分布回刷历史分区后下游报表数据变了回刷时关联的维度表用的当前维度历史维度不对使用拉链表——回刷历史分区时必须带上当时对应的维度快照数据不能用当前维度反推历史6.2 几条拿得出手的实操经验第一个经验是“先跑通再优化”。我第一次搭数仓的时候上来就画了一堆“未来要做”的功能天天想着要不要用最先进的建模方案。后来发现对一个业务量还没起来的项目来说跑通数据链路比什么都重要。第一版哪怕只做ODS到DWD的全量同步先把报表做出来业务方看见了价值才有后续迭代的钱和资源。架构设计慢慢演进比一开始就追求完美靠谱得多。第二个经验是“拉链表一定要早做”。很多团队一开始图省事维度表直接用全量快照觉得反正数据量不大每天全量拉一遍多简单。等数据量涨到几千万行了回头看增量更新没做、历史变化也没留存所有的分析都只能看当前状态做留存、做状态流转全是瞎猜。拉链表一开始就要建后面再补很痛苦。第三个经验是“监控一定要挂在最底层”。我们经常关注最终表对不对却忘了监控ODS的同步量。其实很多问题早期在ODS就有苗头今天的订单量只有平时的一半、某张表新增分区为空、某个接口的延迟突然拉高。在ODS同步任务上加上行数波动监控和延迟监控比在报表层发现问题提前好几个小时。数仓出问题不可怕可怕的是业务方比你先发现。第四个经验是“实时数仓的质量意识不能降”。很多人做实时Flink任务比离线Hive粗心很多觉得实时流跑起来不出错就行。实际上实时任务的状态管理、数据回溯机制、脏数据隔离每一项都需要和离线一样严谨。我在实时DWD里漏配过一次状态TTL结果用户维度的状态一直不更新推荐算法连续三天推的都是历史偏好上线效果一塌糊涂。从那以后实时任务的review严格度比离线还高。踩过这些坑以后我现在设计数仓的第一原则是每一张表都要能回答“我是谁、我从哪来、我要到哪去”——属于哪一层、来源是哪里、下游是谁。链路清晰了绝大多数问题都能在半小时内定位到根因。数仓分层这件事看上去是技术设计本质上其实是工程管理。把产出物的边界划清楚把人力和责任也划清楚才谈得上稳定和高效。