ARTICLE DETAIL

资讯详情

深耕网站建设、视觉设计与SEO优化的一线实战洞察。

数据仓库分层与维度建模:拉链表、缓慢变化维与事实表设计

数据仓库分层与维度建模:拉链表、缓慢变化维与事实表设计

简介:《数据仓库工程师面试题.docx》面向准备数据仓库岗位面试的初中级工程师和数据开发学习者,围绕数仓核心概念与工程实践梳理高频考点,可用于面试前自测、查漏补缺和日常复习。压缩包内共1个docx文件,约31KB,以问答和要点梳理的形式组织内容,涵盖数据仓库的定义及其主题性、集成性、稳定性、时变性四大特点,ETL的抽取—转换—加载流程与日志分类、告警发送机制,以及Hive、Teradata、Oracle等常见数仓工具的定位差异。文档还按STG、ODS、DW、Presentation四层模型展开,逐层说明建模方式、审计列与逻辑删除标记等设计细节,并讲解STG与ODS层的作用、CDC策略选择、mapping与物理模型设计、数据清洗与规范化、维度表与事实表提交等开发步骤。已有1712人学习下载。读者可据此建立从概念辨析到分层建模的知识框架,也能对照文档中的规范步骤检查日常开发流程,快速定位面试回答里的薄弱环节。

1. 数据仓库:为什么它宁可存十份旧快照,也不让你改一行

一份数据仓库面试题里,最容易被背成八股的反而是最核心的那句:数据仓库保存的是历史快照,不允许修改。乍看很反直觉——一个存数据的系统,凭什么不让人写?可正是这条约束,撑起了整个分析体系的可信度。业务库追求的是「此刻正确」,一条订单状态可以反复更新;数据仓库追求的是「任何时刻都能重放当时的结论」,所以它必须把变化本身记录下来,而不是把变化抹掉。搞懂这条分界线,主题性、集成性、稳定性、时变性这四个特点才不是四句口号,ETL 的分层、拉链表、缓慢变化维、事实表类型才有统一的设计动机。

2. 四层模型:STG 和 ODS 为什么看起来在重复存数据

2.1 从源系统到 DW,每一层在替谁挡刀

数据仓库的四层模型是 STG、ODS、DW、DM(展现层)。新手最容易卡住的地方是:STG 和 ODS 都是「与源系统模型一致」,为什么不能合成一层?答案藏在层的职责划分里,而不是数据结构里。

STG 是落地缓冲层。它按 CDC 策略把各源系统数据抽过来,做增量或全量抽取,不做任何数据转换,只加审计列。它的核心价值是让数据仓库和源系统解耦:抽取失败了可以重跑,源库压力大可以分批拉,抽取过程中源表结构变了,问题被限制在这一层。开发步骤也围绕这个目标展开——先确定 CDC 策略,再设计 mapping 文档,然后建物理模型。

ODS 是历史存档层。它保留源系统的全量历史数据,在 STG 的字段基础上增加了逻辑删除标记和增量时间戳。这两列是整个分层的战术核心:

-- ODS 层物理模型的典型形态(示意) CREATE TABLE ods_order ( order_id VARCHAR(32), -- 源系统业务主键 order_status VARCHAR(8), customer_id VARCHAR(32), -- 以下为审计/控制列 src_sys_name VARCHAR(16), -- 来源系统标识 src_table_name VARCHAR(64), -- 来源表名 load_time TIMESTAMP, -- 加载时间 load_mode VARCHAR(8), -- 全量 FULL / 增量 INC is_deleted TINYINT, -- 逻辑删除标记,1 表示源端已物理删除 incr_ts TIMESTAMP -- 源端增量时间戳 );

这段建表语句里没有主键约束、没有索引、没有默认值,这是有意为之。入仓阶段先把约束拿掉,让加载尽可能快;约束和索引留给 DW 层按查询模式单独设计。is_deleted处理的是源系统后台人工物理删除的场景——如果不打这个标记,ODS 会静默丢数据,下游做同比时数字对不上,排查起来就是一场血泪经验。字段长度也建议留余量,源表和目标表数据库类型不同的时候,长度不够导致的截断错误往往在跑了几小时之后才暴露。

层模型形态抽取方式关键控制列主要职责
STG与源系统一致增量/全量审计列减压、可重跑、问题定位
ODS与源系统一致全量历史审计列 + 逻辑删除 + 增量时间戳历史存档、实时查询、质量检查
DW维度模型增量审计列清洗、规范化、一致性维度
DM聚合/宽表增量或物化审计列面向分析的聚合

2.2 流式场景下为什么可以跳过 STG

原文提到一个容易被忽略的分支:如果是按日志信息实时同步,可以跳过 STG 直接进 ODS;流式同步也可以跳过 STG。这不是偷懒,而是 STG 的核心价值在流式链路上被消解了。

批处理抽取的问题在于「一次拉一大块」,需要缓冲、需要重跑、需要和源库错峰。而流式同步本身就是持续、低延迟、带位点可回溯的,重跑能力由消息中间件或日志位点保证,STG 那层「备份 + 重跑」的功能被替代了。代价是 ODS 必须自己承担数据质量检查和乱序处理。所以判断能不能跳过 STG,问的不是技术栈新旧,而是三个问题:源端有没有可靠的位点机制、下游能不能接受乱序、出问题时谁来做补偿。三个答案都成立,才敢跳。

2.3 增量抽取的时间戳陷阱

数据抽取阶段最常见的设计是拿业务发生时间做增量标志:每次抽取前先查 ODS 里记录的最大时间,再去源系统取大于该时间的记录。这个方案在业务量大的系统里几乎是必选项,但它有两个隐含前提——源系统确实记录了业务发生时间,且这个时间不会回填。

现实里常见的翻车场景是:源系统补录历史单据、或者业务时间和入库时间不是一回事。这时候按业务时间增量,会永久漏掉晚到的记录。稳妥的做法是双时间戳并存,一个是业务发生时间用于分析,一个是入库时间用于抽取水位;抽取水位只增不减,晚到数据靠拉链表在 DW 层做延迟事实处理。面试里被问到增量更新,能把这个坑说出来,比背「用时间戳做增量」有价值得多。

3. ETL 加工链:清洗、规范化、提交各自在解决什么矛盾

3.1 数据清洗的四种异常处理策略怎么选

数据清洗的定义是「发现数据质量问题并纠正数据」,原文借用了戴明质量环的思路——计划、实施、监控、行动,形成闭环。落地时第一步不是写 SQL,而是定义数据质量要求,结合业务需求和数据剖析结果排出优先级,再确定测量类型,最后产出质量测量结果表。

真正需要决策的是异常数据怎么处理,原文给了四种策略:中断处理、写入错误事件表、只做标记继续处理、直接纠正数据。这四条不是平级的选项,而是按「错误严重程度 + 可修复性」排出的梯度:

# 异常数据处理策略选择的伪代码逻辑 def handle_bad_row(row, rule): if rule.severity == "fatal": # 主键缺失、关联断裂:中断,避免脏数据扩散 raise DataQualityError(rule.name, row) elif rule.fixable_at_source: # 源头可修:写错误事件表,等业务方回补 write_error_event(row, rule) return "rejected" elif rule.needs_audit_only: # 仅需留痕:打标记继续走,保证主链路不阻塞 row.quality_flag = rule.flag return "passed_with_flag" else: # 规则明确可自动纠正,如全角转半角、去不可见字符 return rule.correct(row)

选择逻辑的关键在于「这条脏数据会不会污染下游聚合结果」。会污染且无法自动修的,中断或拒绝;只是可疑但可追溯的,打标记放行;规则确定无歧义的,直接纠正。原文还给了责任归属的判断:必须在 ETL 处理的、最好在 ETL 处理的、最好在源头处理的、必须源头处理的。这个分级很重要——把该在源头做的事搬到 ETL,等于让数据仓库替业务系统擦屁股,擦一次可以,长期擦必然拖垮整条链路。

3.2 规范化三件事:标准化、去重、共存

规范化解决的是「各业务系统各自为政」的问题。每个源系统在命名、取值上都有自己的习惯,规范化就是通过标准化、去重、合并、拆分、整合,建立企业级的标准版本数据。

第一件是数据标准化,从内容、格式、命名、计算规则统一成唯一版本。典型例子是性别字段:有的系统用 0/1,有的用 man/woman,通过映射表统一成 m/f。这类映射表本身就是一份需要版本管理的资产,映射规则改了要能追溯,否则历史报表的含义会悄悄漂移。

第二件是删除重复数据。多个源系统维护同一对象时,按保留规则只留唯一一条。这里要区分「真重复」和「业务上允许的多次记录」——同一客户在两个 CRM 里各有一条是重复,同一客户下了两笔订单不是。判断依据只能是业务主键加业务规则,不能靠相似度。

第三件是数据共存,把各源系统的数据拆分、合并、整合。同一个客户号在两个系统里都有联系方式,按业务规则选保留哪个值。这一步最容易产生争议,因为「选哪个」本质是业务决策,不是技术决策。工程上的做法是把选择规则外置成配置,让业务方签字确认,而不是硬编码在 ETL 脚本里。

3.3 提交维度表:代理键和 -1 默认记录

提交维度表有四步:确认粒度、选择代理键生成器、选择维度表类型、增量加载。

粒度就是维度表的业务主键,用它判断记录唯一性。代理键生成器可以用 ETL 工具或数据库自增。维度表类型一般选缓慢变化维的类型 1 和类型 2。

真正体现经验的是增量加载的两个细节:维表每个字段都要设默认值、不能为空;首次加载要有一条代理键为 -1 的默认记录。原因很直接——事实表加载时要用业务主键去维表查代理键,查不到就会丢记录或写空。有了 -1 这条「未知成员」,事实表永远能落库,数据完整性不被维表缺失打断。这是个很小的设计,但没做过的人大概率会在第一次跑全链路时踩到。

为了给事实表生成维度代理键,一般会建一个查找维表,存放业务主键到代理键的映射。查找过程本身是逐行或批量 join,数据量大时这一步往往是 DW 层的性能瓶颈,所以查找维表要尽量窄,只放主键和代理键两列。

3.4 提交事实表与延迟事实的处理顺序

事实表提交分六步:选类型、用代理键替换主键、增量加载、提交错误事实表、事实表合并、事实表归档。顺序不能乱——错误事实表必须在合并之前落地,否则失败记录会在合并中丢失,事后无法补偿。

维度和事实数据的修正也是同理:纠正事实、优化和更正事实表、处理延迟的事实、维度重建。延迟事实的处理顺序尤其关键,必须先重建维度再回填事实,否则回填时会再次查不到代理键。这个依赖关系在总线架构的表加载顺序里也有体现:子维度表先于维度表,维度表先于桥接表,桥接表和维度表都成功后再加载事实表。这个顺序由主外键关系决定,不能凭感觉调整。

4. 维度建模:拉链表和缓慢变化维到底在记录什么

4.1 三种缓慢变化维的取舍边界

缓慢变化维是数据仓库面试的常客,三种处理方式必须能讲清适用边界,而不是背名字。

Type 1 直接覆盖,用新值替换旧值。优点是简单、不增行、查询快;缺点是不可回溯,历史报表会随维度更新而变化,这在需要审计的场景是致命的。

Type 2 增加新行。把当前行状态置为失效并写入 end_time,同时新增一行状态有效、begin_time 为上一行 end_time 加一。它完整保留了历史,代价是维表行数膨胀,且事实表关联时必须带时间条件才能取到「当时」的维度值。

Type 3 增加新列,给表加一列存新值,同时保留原值。它只保留上一次的值,适合变化可预期且只关心「前后对比」的场景,比如组织架构调整前后归属。

选择的核心判断是:业务要不要「按当时的维度看当时的事实」。要,就 Type 2;只关心最新,就 Type 1;只做前后对比,就 Type 3。混用也常见——同一张维表里,地址字段用 Type 2,拼写纠错用 Type 1,完全合理。

4.2 拉链表的开链关链在一个 SQL 里怎么完成

拉链表解决的是「需要看历史某时间点状态,又要控制存储」的矛盾。原文的场景很具体:一张十亿级用户表,五十个字段,即使 ORC 压缩单表也超 100G,HDFS 还要多副本;每天变化的只有两百万左右,变化比例很小。全量快照每天存一份显然不可行,拉链表用 start_date 和 end_date 两个字段,把「有效区间」编码进每一行。

实现依赖三张表:DW 层的拉链历史表 dw_order_his,带 start_date 和 end_date;ODS 层的增量变化表 ods_order_update,只存当天 insert 和 update 的数据;以及源表 order。核心操作是「交集封链、增量开链」:

-- 1) 对历史表与增量表的交集做关链:旧记录的有效期在今天截止 UPDATE dw_order_his h SET end_date = CURRENT_DATE - INTERVAL '1' DAY WHERE h.end_date = DATE '9999-12-31' AND EXISTS ( SELECT 1 FROM ods_order_update u WHERE u.order_id = h.order_id ); -- 2) 增量数据开新链:有效期从今天开始,开放到无穷 INSERT INTO dw_order_his SELECT order_id, order_status, customer_id, CURRENT_DATE AS start_date, DATE '9999-12-31' AS end_date FROM ods_order_update;

第一句的end_date = CURRENT_DATE - 1和第二步的start_date = CURRENT_DATE必须严格衔接,否则会出现区间断档或重叠,查询某个历史日期时就会既查不到也可能查到两条。9999-12-31是「当前有效」的哨兵值,所有查询当前状态的条件都是end_date = '9999-12-31'。

这里有个容易忽略的细节:如果同一主键在同一天被更新多次,增量表里会有多条,直接插入会得到同一天多条开链记录,区间重叠。稳妥做法是增量表先按主键去重取最新一条,再开链。另外,比对增量时要用全字段比对(不含 start_date 和 end_date),否则字段值没变但被重复更新,会产生无意义的拉链行,时间一长表就虚胖。

4.3 拉链表查询为什么会慢,优化只能靠索引吗

拉链表跑几年之后,查询性能会明显下降。原文给了两个思路:在查询引擎里对 start_date 和 end_date 建索引;保留部分历史数据,对外只暴露近三个月的那部分。

这两个思路背后是不同层面的手段。建索引解决的是「单表按时间区间过滤」的效率,但要注意拉链表的查询条件通常是start_date <= T AND end_date >= T,这种区间条件对普通 B 树索引利用率有限,列式存储加分区裁剪往往更有效。所以更实际的做法是按 start_date 做分区,把「查某个历史时点」转化为「扫少数几个分区」。

第二个思路是数据生命周期管理:全量拉链表留在底层,对外暴露一张只含近三个月数据的视图或轻量副本。绝大多数分析需求集中在近期,历史深挖是低频长尾,用两层结构把高频和低频分开,比一味加索引更划算。这个取舍在实际项目里经常被忽略,等表大到查询超时才开始补救,成本就高了。

4.4 星型还是雪花:冗余换的是查询次数

星型模型是所有维度表直接连到事实表,图形像星星;雪花模型是部分维度表通过其他维度表间接连接,图形像雪花。两者最本质的区别只有一句:维度表是直接连事实表,还是连其他维度表。

星型是非规范化结构,维度不进一步拆分,因此存在冗余。冗余的代价是存储和一致性维护,收益是统计时不需要额外关联——大多数查询只 join 一层就能出结果。雪花减少了冗余,但查询要多层关联,性能通常更低。原文给出的判断很明确:在冗余可接受的前提下使用星型;雪花一般用在处理大的、相对静态的层次结构时。

实际项目里更常见的做法是混合:主体用星型保证查询效率,个别层级深、变化少的维度(比如地理层级、产品分类层级)拆成雪花。这不算违反原则,因为拆分的依据是「这层结构是否稳定、是否被高频下钻」。

5. 事实表与维度表:粒度和类型为什么必须先定

5.1 四类事实表的边界与 ETL 生成方式

事实表存放的是可度量的数字或用于统计的 Flag,通常行数很大。类型划分的依据是「事实字段能不能沿着时间维度相加」:

类型时间可加性典型场景ETL 生成方式
事务粒度事实表可加销售单、支付流水按原子粒度直接迁移
周期快照事实表半可加账户余额、月度财报固定时间间隔生成累计
累积快照事实表不可加合同履行、工单流程随流程步骤逐步完善
无事实事实表无度量促销覆盖、访问行为只记录事件发生

事务粒度事实表最容易理解:伴随交易事件产生,ETL 时以原子粒度直接迁移。原文特别强调,一张事实表只能有一个粒度,不能把不同粒度的事实放进同一张表——这是事实表设计的第一原则,违反它之后所有聚合口径都会含糊。

周期快照表现的是一个时间段或规律性重复,适合跟踪长期过程,最典型的是月粒度财务报表。它的数据必须符合同一个粒度,也就是测量的必须是同一时间段的活动。

累积快照表描述有明确开始和结束的过程,粒度是一个实体从创建到当前状态的完整历史,ETL 时随业务步骤逐步完善记录。它不适合长期连续处理,比如跟踪银行账户或连续生产过程。

无事实事实表记录事件发生但没有具体测量值,用来描述「发生过什么」。这类表在面试里容易被忽略,但在实际建模中很常见——它让「没发生交易但发生过互动」这类分析成为可能。

5.2 粒度为什么是建模的第一决策

粒度指数据单位中保存数据的细化或综合程度:细化程度越高粒度越小,反之粒度越大。原文把它定位为「设计数据仓库的重要前提」,这句话的分量在于——粒度一旦定错,后面所有维度设计、事实字段、聚合表都建立在错误基础上,返工成本极高。

判断粒度的方法是问「这一行代表什么」。一行代表一笔订单,就是事务粒度;一行代表一个客户一天的余额,就是日快照;一行代表一个合同从签约到履约完成的全程,就是累积快照。粒度确定后,维度自然确定(描述这一行的上下文),事实也自然确定(这一行上可度量的数值)。

层次则是描述明细数据的层级,比如时间维的年、季、月、日,地理维的国、省、市。粒度和层次是两件事:粒度决定事实表的行代表什么,层次决定维度表内部怎么组织。混淆这两者的典型表现是试图用一张事实表同时服务明细查询和汇总查询,最后两边都不好用。

维度表可以看作分析数据的窗口,包含描述性信息和汇总规则,还包含帮助汇总的层次结构。维度类型包括缓慢变化维、快速变化维、大维、迷你维、退化维。其中快速变化维按数据量分三档处理:小维度用 Type 2 就够;字段多、数据量大的用 Type 3 只留前后值;最糟的是字段多、数据量大且部分字段频繁变化,此时要把稳定字段和频繁变化字段拆开,让频繁变化的部分独立成新维表,也就是维表分离。

6. 避坑与排查:几个只有跑过全链路才会遇到的问题

6.1 事实表查不到代理键,整批数据静默丢失

现象:全链路跑完,事实表行数比预期少一截,日志里没有报错。原因:维表增量加载晚于事实表,或维表缺失该业务主键,join 时被内连接过滤掉了。解决:维表预置代理键 -1 的未知成员记录,事实表加载改为左连接并兜底到 -1,同时对兜底数量设监控阈值,超过阈值告警回查维表加载顺序。

6.2 拉链表区间重叠,同一时点查出两条记录

现象:查某历史日期的订单状态,返回两行。原因:增量表同一主键当天有多条更新,未去重直接开链,产生多个同端点的有效区间。解决:开链前按主键取最新一条;关链条件加end_date = '9999-12-31'限定,避免误关已失效的记录;上线后用「同一主键有效区间是否存在交集」的校验 SQL 做例行检查。

6.3 增量抽取漏掉回填数据,同比数字长期偏低

现象:月度报表的同比连续几个月偏低,但每月单看都正常。原因:增量水位按业务发生时间推进,源系统补录的历史单据业务时间早于水位,被永久跳过。解决:区分业务时间和入库时间,抽取水位用入库时间;对晚到数据在 DW 层做延迟事实处理,并保留可重算的窗口。

6.4 STG 层保留期设得太短,出问题无法重跑

现象:某次清洗规则上线后发现逻辑写错,想重跑历史,发现 STG 数据已被清理。原因:STG 只保留 7 天或一个月,超出窗口的数据没了。解决:STG 保留期要和最长的下游回溯需求对齐;关键源系统改为在 ODS 保留全量,STG 定位为纯缓冲;重跑能力往下沉一层,而不是指望 STG。

6.5 索引和约束堆在入仓层,加载越来越慢

现象:入仓任务耗时逐月增长,磁盘 IO 打满。原因:STG/ODS 层保留了主键、索引、默认值等约束,每批加载都要维护索引。解决:入仓层去掉非必要约束,只保留字段结构;索引和分区按 DW 层的查询模式单独建;加载数据时关闭日志,减少磁盘 IO;大数据量场景尽量避免排序操作。

7. 设计取舍:分层的代价,和怎么验证它值得

分层从来不是免费的。STG 多存一份数据,ODS 再存一份全量历史,加上 DW 和 DM,同一份业务数据在物理上可能被复制四遍。这个代价换来的是三样东西:可重跑、可追溯、可隔离变化。判断分层是否值得,不该看「业界都这么分」,而要看这三个能力在本项目里各自值多少钱。

一个实用的验证方法是做故障演练。故意让清洗规则写错一版,观察从发现到修复的路径有多长:如果有 STG 可以重跑、有 ODS 可以回溯、有审计列可以定位到具体批次,那分层的价值就成立了;如果发现某层数据早已被清理、或者审计列形同虚设,那这一层就是纯粹的存储负担,该合并就合并。同样,拉链表是否值得上,也可以用这个方法验证——问一句「业务真的会查三个月前的历史状态吗」,答案是否,那全量快照加分区可能更简单。

另一个常被忽略的取舍是规范化的位置。原文把数据质量问题的责任分成四档:必须 ETL 处理、最好 ETL 处理、最好源头处理、必须源头处理。这条分级线的意义在于提醒——数据仓库不应该无限承担上游的问题。落库前的清洗是必要的防线,但如果某类脏数据反复出现,正确动作是推动源系统加校验,而不是在 ETL 里不断加补丁。补丁加多了,ETL 脚本会变成没人敢动的黑匣子,新人接手只能靠猜。

我自己的习惯是:每次设计新一层模型前,先写三条断言——这一层的数据在什么情况下会被重跑、这一层的某一行出错时怎么定位到源、这一层的字段变化会让下游哪张表失效。三条断言写不出来,说明这一层的职责还没想清楚,先别急着建表。这个习惯来自一次惨痛教训:早期做的一个模拟项目里,为了赶进度把 STG 和 ODS 合成一层,结果清洗规则改错之后没有任何可回滚的历史数据,只能让业务方手工补录,代价远超当初省下的那点开发时间。从那以后我每次建分层模型,都强制把「可重跑窗口」和「审计列完整性」当成验收项走一遍,而不是等出事再补。

希望这份整理能帮到正在准备面试、或者正准备动手搭第一版数仓的你。

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

返回列表