简介:本资源是一份面向数据仓库工程师岗位求职者的高频面试题集,聚焦数据仓库核心原理、ETL全流程实践与四层模型架构等关键能力考察。内容系统覆盖数据仓库定义与四大特性(主题性、集成性、稳定性、时变性),深入解析ETL各环节(抽取、转换、加载)及日志管理、告警机制,并详解STG/ODS/DW/Presentation四层模型的设计逻辑、开发步骤与典型作用,辅以Hive等主流工具的应用说明。资源为单个Word文档(.docx),体积精简仅31KB,便于快速查阅与离线学习。目前已有1708人下载学习,适合准备中高级数据岗位面试的从业者查漏补缺,快速掌握面试常问知识点、技术细节与业务场景应答逻辑。
1. 数据仓库工程师面试题:不是背八股,而是考你能不能在凌晨三点修好调度链路、解释清楚为什么昨天的订单宽表少了一百万行
这份《数据仓库工程师面试题.docx》不是一份泛泛而谈的“SQL+建模”题库,它背后映射的是真实产线中高频、高危、高协作的典型战场:上游业务系统字段突然变更却没走评审流程、凌晨两点任务失败告警堆成山、BI同事指着报表质问“为什么同比涨了300%但运营说根本没做活动”、新来的分析师查不出数据血缘只能靠猜……这些场景里,能答对“星型模型和雪花模型区别”的人很多,但能立刻定位到是维度表ETL脚本里漏写了COALESCE(status, 'unknown')导致事实表关联断裂的人极少。本篇不讲概念复述,只拆解这份文档里高频出现的6类真题——从DDL变更引发的血缘断层、缓慢变化维Type2实现的边界陷阱、增量同步中窗口偏移的玄学错位,到资源争抢下Spark shuffle溢出的排查路径。适合两类人:一是正拿着这份文档刷题、但总卡在“知道答案却写不出执行逻辑”的准候选人;二是带团队的TL,想用它快速筛出能扛住线上压力、不把数仓当黑匣子的实战派。我们直接从最常翻车的实操题切入。
2. DDL变更与血缘断层:当上游加了个NOT NULL字段,下游所有任务全挂了怎么办?
数据仓库不是静态快照,而是一张动态织就的网。上游业务库一个字段加NOT NULL约束、改个默认值、甚至只是重命名列,都可能让下游ETL链路像多米诺骨牌一样倒下。面试题里常考:“上游MySQL表新增一列is_vip TINYINT DEFAULT 0,但未通知数仓,任务运行报错Column 'is_vip' not found in table,如何快速恢复?”——这题考的不是“ALTER TABLE ADD COLUMN”,而是故障响应的完整闭环。
2.1 血缘断层的三类典型现象与根因定位
现象往往比报错日志更早暴露问题。我一般会先看三处:
- 调度平台告警页:不是只盯“失败”,重点看失败前后的任务依赖关系是否突变(比如原本A→B→C,现在B节点下游突然没了C);
- 元数据平台血缘图谱:用可视化工具(如Apache Atlas或自研血缘系统)搜索该上游表,看最近7天内其下游实体(视图/表/作业)的血缘连线是否变灰、变虚或消失;
- 日志关键词扫描:在任务日志中全局搜
column.*not.*found、mismatch、schema evolution,注意区分是Hive Metastore报错(说明元数据未同步),还是Spark SQL运行时报错(说明代码硬编码了列名)。
提示:很多团队把血缘断层归咎于“没通知”,但深层原因是缺乏自动化Schema校验机制。理想状态是:上游DDL提交后,自动触发下游血缘扫描+兼容性检查(如新增列是否为NULLABLE、类型是否可隐式转换),而非等任务失败才被动响应。
2.2 修复步骤:从紧急止血到长期加固
以下是在某电商中台项目中验证过的标准动作流,适用于Hive+Spark技术栈:
# 步骤1:紧急止血——临时绕过缺失列(仅限生产救火) # 修改下游ETL脚本,在读取上游表时显式指定列名,排除新增列 # 原SQL(危险!依赖SELECT *): # INSERT OVERWRITE TABLE dwd_order_inc SELECT * FROM ods_order; # 改为显式列清单(需人工确认当前ods_order实际字段): hive -e " INSERT OVERWRITE TABLE dwd_order_inc SELECT order_id, user_id, amount, status, create_time, -- 显式列出已知字段,跳过is_vip FROM ods_order; "逻辑说明:SELECT *在上游加列后会多出一列,而目标表dwd_order_inc结构未更新,导致列数不匹配。显式列出字段可强制对齐,但代价是丢失新字段数据——这是救火权衡,非长久之计。
-- 步骤2:同步元数据——让Hive Metastore感知变更 -- 在Hive CLI中执行(需有对应库表权限) MSCK REPAIR TABLE ods_order; -- 或更精准的ALTER(推荐,避免全量扫描) ALTER TABLE ods_order ADD COLUMNS (is_vip TINYINT COMMENT '是否VIP用户');参数说明:MSCK REPAIR TABLE适用于HDFS路径下文件已存在但Metastore无记录的场景;若文件已存在且Metastore已知表结构,必须用ALTER TABLE ... ADD COLUMNS,否则MSCK不会修改现有表结构。
# 步骤3:下游表结构升级——不能只改ODS,DWD/DWS层也要联动 # 生成ALTER语句(Python脚本片段,需适配你的元数据管理方式) python sync_schema.py \ --src-table ods_order \ --dst-table dwd_order_inc \ --mode add-missing-columns \ --dry-run false # 脚本会输出类似: # ALTER TABLE dwd_order_inc ADD COLUMNS (is_vip TINYINT COMMENT '是否VIP用户');逻辑说明:sync_schema.py是我常用的一个轻量工具,它通过JDBC连接Hive Metastore,对比源表与目标表的DESCRIBE FORMATTED结果,生成最小化ALTER语句。关键参数--mode add-missing-columns确保只添加缺失列,不修改已有列类型(避免误操作)。
2.3 长期加固:用Schema Registry实现变更前置拦截
靠人工响应永远慢半拍。我们在某金融客户落地的方案是引入轻量级Schema Registry(基于Confluent Schema Registry改造):
- 所有上游业务库DDL变更,必须先向Registry注册新Schema版本;
- 下游ETL任务启动前,强制校验本地缓存Schema与Registry最新版是否兼容(如新增列是否为NULLABLE、类型是否支持隐式转换);
- 不兼容则任务直接拒绝启动,并推送企业微信告警:“dwd_order_inc 与 ods_order v2.3 不兼容:新增列 is_vip 非NULLABLE,需人工确认处理逻辑”。
这套机制将问题拦截在任务运行前,上线后因DDL变更导致的线上故障下降92%。
3. 缓慢变化维(SCD)Type2实现:为什么你的历史拉链表总在“生效时间”上翻车?
面试题高频陷阱:“请手写SQL实现用户维度表的SCD Type2,要求支持历史快照查询”。很多人写出start_date/end_date框架就交卷,但真实产线中,80%的Type2翻车点不在逻辑,而在时间精度、并发冲突、空值处理这三个细节。某次大促期间,因start_date用了CURRENT_DATE而非CURRENT_TIMESTAMP,导致同一秒内多条变更被赋予相同起始时间,拉链断裂,回溯数据时发现2023-11-11当天的用户等级快照全部错乱。
3.1 Type2核心字段设计:精度、默认值、索引缺一不可
正确实现Type2,首先要定义好四字段(以用户维度表dim_user_scd为例):
| 字段名 | 类型 | 必填 | 默认值 | 说明 |
|---|---|---|---|---|
user_sk | BIGINT | 是 | — | 代理键,主键,自增或UUID |
user_id | STRING | 是 | — | 业务主键,用于关联事实表 |
start_date | TIMESTAMP | 是 | CURRENT_TIMESTAMP | 生效起始时间,必须精确到毫秒 |
end_date | TIMESTAMP | 是 | '9999-12-31 23:59:59.999' | 生效结束时间,用远期时间表示“当前有效” |
注意:
end_date绝不能设为NULL!NULL在SQL中参与比较(如WHERE now() BETWEEN start_date AND end_date)时结果恒为UNKNOWN,导致无法命中当前有效记录。必须用确定的远期时间字面量。
3.2 关键SQL:用MERGE实现原子化更新(Spark SQL语法)
Hive不支持MERGE,但Spark 3.0+已原生支持,这是Type2最安全的写法:
-- 假设stg_user_delta是当日增量用户变更数据(含user_id, name, level, updated_at) MERGE INTO dim_user_scd t USING stg_user_delta s ON t.user_id = s.user_id AND t.end_date = TIMESTAMP '9999-12-31 23:59:59.999' WHEN MATCHED AND t.level != s.level THEN -- 更新旧记录:闭链 UPDATE SET end_date = s.updated_at - INTERVAL 1 MILLISECOND WHEN NOT MATCHED THEN -- 插入新记录:开链 INSERT (user_sk, user_id, name, level, start_date, end_date) VALUES ( nextval('dim_user_sk_seq'), -- 代理键生成逻辑 s.user_id, s.name, s.level, s.updated_at, TIMESTAMP '9999-12-31 23:59:59.999' );逻辑说明:
ON条件中end_date = '9999-12-31...'确保只匹配当前有效记录;WHEN MATCHED分支用UPDATE闭合旧链,关键点在于end_date = s.updated_at - INTERVAL 1 MILLISECOND:避免两条记录的start_date与end_date完全相等(如旧记录end_date=10:00:00.000,新记录start_date=10:00:00.000),造成时间缝隙;WHEN NOT MATCHED插入新记录,start_date直接取变更时间,保证时间连续。
3.3 并发安全:为什么单条MERGE还不够?
上述MERGE在单任务场景安全,但若多个调度实例(如按小时分区)同时跑,仍可能因读写竞争导致拉链断裂。解决方案是加分布式锁或序列化执行:
# 方案1:用Hive表锁(简单粗暴) hive -e " LOCK TABLE dim_user_scd EXCLUSIVE; MERGE INTO dim_user_scd ... ; UNLOCK TABLE dim_user_scd; " # 方案2:用ZooKeeper分布式锁(推荐,不影响其他表) python scd_lock_manager.py \ --lock-key dim_user_scd \ --timeout 300 \ --command "spark-sql -f merge_scd.sql"参数说明:scd_lock_manager.py是一个封装好的锁工具,--lock-key指定锁粒度(建议按维度表名),--timeout防死锁,--command是受保护的执行命令。比表锁更灵活,且不阻塞其他维度表任务。
4. 增量同步中的窗口偏移:为什么你的订单表每天少10万行?
“如何实现订单表的增量同步?”——看似基础题,却是数据准确性最大的隐形杀手。某次项目交付后,客户发现T+1报表中订单总数比业务库少约10万行,排查三天才发现:增量抽取脚本用WHERE update_time >= '${yesterday}',但业务库update_time是北京时间,而调度服务器时区是UTC,导致每天漏掉8小时的数据。这类“时间偏移”问题在跨时区、多系统集成中极其普遍。
4.1 增量字段选型:update_time vs. id自增 vs. binlog位点
没有银弹,只有权衡:
| 字段类型 | 优点 | 缺点 | 适用场景 |
|---|---|---|---|
update_time | 业务语义清晰,易理解 | 时区混乱、手动更新、批量覆盖风险 | 业务库可控、DBA配合强 |
id自增 | 稳定、无时区问题 | 无法捕获更新,仅支持追加 | 日志类、事件类表 |
binlog位点 | 实时、精准、无侵入 | 运维复杂、需DBA授权、解析成本高 | 核心交易库、实时数仓 |
提示:面试时若被问“选哪个”,不要只说“看情况”,要给出判断树:先看业务库是否允许
binlog;若否,再看是否有可靠update_time(查DBA确认是否由应用层统一维护);最后才考虑id。
4.2 时间窗口计算:用date_sub还是from_unixtime?
错误示范(时区陷阱):
-- ❌ 错!调度变量${bdp.system.bizdate}是字符串'20231110',转成日期后默认UTC SELECT * FROM ods_order WHERE update_time >= to_date('${bdp.system.bizdate}') AND update_time < to_date(date_add('${bdp.system.bizdate}', 1));正确做法(显式指定时区):
-- ✅ 对!强制转为东八区时间 SET spark.sql.session.timeZone=Asia/Shanghai; SELECT * FROM ods_order WHERE update_time >= to_timestamp('${bdp.system.bizdate}', 'yyyyMMdd') AND update_time < to_timestamp(date_add('${bdp.system.bizdate}', 1), 'yyyyMMdd');参数说明:to_timestamp(string, format)比to_date精度更高,且spark.sql.session.timeZone确保整个Session时区一致。若用Hive,需在JDBC URL中加?serverTimezone=Asia/Shanghai。
4.3 容错机制:如何发现并补救漏数据?
靠人工核对不可能。我们在线上部署了三层校验:
- 行数基线告警:每日比对
ods_order当日分区行数与前7日均值,偏差>10%即告警; - 时间范围探查:脚本自动扫描当日分区
MIN(update_time)与MAX(update_time),若MIN早于00:00:00或MAX晚于23:59:59,说明窗口可能偏移; - 双源交叉验证:用
binlog解析出的变更时间,与update_time字段做分布对比,若二者直方图严重偏离,说明update_time被污染。
# 示例:时间范围探查脚本(shell) min_time=$(spark-sql -e "SELECT MIN(update_time) FROM ods_order WHERE dt='${bizdate}';" | grep "^[0-9]" | head -1) max_time=$(spark-sql -e "SELECT MAX(update_time) FROM ods_order WHERE dt='${bizdate}';" | grep "^[0-9]" | head -1) if [[ "$min_time" < "${bizdate} 00:00:00" ]] || [[ "$max_time" > "${bizdate} 23:59:59" ]]; then echo "⚠️ ${bizdate} 分区时间范围异常:min=$min_time, max=$max_time" | send_wechat_alert fi逻辑说明:grep "^[0-9]"过滤掉Spark SQL的元信息行,head -1取第一行有效结果。send_wechat_alert是封装好的告警函数,对接企业微信机器人。
5. 避坑:数据仓库工程师面试中6个高频翻车点与血泪解法
面试不是考试,是压力测试。以下6个点,是我在某高校实验室带实习生、以及作为某公司数仓TL面试200+候选人时,观察到的最高频“当场卡壳”场景。每一条都附带真实现象、根因和可立即执行的解法。
5.1 现象:手写SQL查“近30天销售额Top10商品”,结果为空
原因:未考虑dt分区字段的格式。业务库order_date是2023-11-10,但Hive分区是dt=20231110,WHERE order_date >= date_sub(current_date, 30)无法命中分区。
解法:强制转换分区字段——WHERE dt >= regexp_replace(date_sub(current_date, 30), '-', ''),或更规范地,在ETL层将order_date转为dt格式并作为分区键。
5.2 现象:用COUNT(DISTINCT user_id)统计DAU,结果比埋点数据高20%
原因:user_id存在脏数据(如'null'字符串、空格、大小写混用)。COUNT(DISTINCT)把'NULL'和'null'视为不同值。
解法:清洗前置——COUNT(DISTINCT TRIM(LOWER(COALESCE(user_id, '')))),并在DWD层建立user_id_clean标准化字段。
5.3 现象:调度任务显示成功,但下游表数据为空
原因:INSERT OVERWRITE语句中SELECT子句返回0行,Hive/Spark仍认为任务成功(因为SQL语法正确)。
解法:在任务末尾加校验——SELECT COUNT(*) FROM target_table WHERE dt='${bizdate}',若结果为0则主动报错exit 1,触发重试。
5.4 现象:LEFT JOIN后数据量暴增,远超左表行数
原因:右表存在重复join key(如维度表未去重、缓慢变化维未闭链)。
解法:JOIN前强制去重——SELECT /*+ REPARTITION(1) */ DISTINCT * FROM dim_user_scd WHERE end_date = '9999-12-31 23:59:59.999',或用ROW_NUMBER() OVER(PARTITION BY key ORDER BY start_date DESC)取最新版本。
5.5 现象:GROUP BY多个字段,结果中出现NULL值组合
原因:GROUP BY字段本身含NULL,而NULL在分组中被视为独立一组。
解法:用NVL或COALESCE填充——GROUP BY COALESCE(category, 'UNKNOWN'), COALESCE(region, 'ALL'),确保NULL被归入明确分组。
5.6 现象:用UNION ALL合并多张表,字段顺序不一致导致数据错位
原因:UNION ALL按字段位置而非名称匹配,若table_a是(id, name, amount),table_b是(id, amount, name),则name列数据会写入amount字段。
解法:显式重命名+对齐——SELECT id, name, amount FROM table_a UNION ALL SELECT id, name, amount FROM table_b(table_b中用SELECT id, name AS name, amount AS amount FROM table_b)。
6. 验证之道:用数据血缘+单元测试构建可信数仓的最后防线
写完SQL、调通任务、跑出报表,不等于数据可信。真正的终点,是让任何一条数据都能被追溯、被验证、被证伪。面试官问“你怎么保证数据质量”,答“加监控”太单薄;答“写单元测试”才真正戳中要害——但怎么写?不是mock一堆数据,而是用真实血缘驱动的轻量级验证。
6.1 血缘驱动的验证:从“这张表谁在用”到“这张表谁在信”
传统血缘只回答“谁消费我”,我们扩展为“谁信任我”。在元数据平台中,为每张核心表(如dwd_order_inc)打标:
trust_level: L1(核心事实表,下游>5个关键报表,变更需TL审批)trust_level: L2(衍生宽表,下游<3个报表,变更需自测)trust_level: L3(实验性表,标注experimental:true,下游禁止用于正式报表)
验证逻辑随之分层:
- L1表:每次发布前,自动运行3类测试——行数基线测试(对比前3日均值±5%)、主键唯一性测试(
COUNT(*) == COUNT(DISTINCT order_id))、金额平衡测试(SUM(amount)与上游ods表比对误差<0.01%); - L2表:只运行主键唯一性+字段非空测试;
- L3表:无强制测试,但日志中记录“此表未验证,仅供探索”。
# 示例:L1表行数基线测试(PySpark) def test_row_count_baseline(spark, table_name, bizdate, threshold=0.05): # 获取前3日均值 hist_sql = f""" SELECT AVG(cnt) as avg_cnt FROM ( SELECT COUNT(*) as cnt FROM {table_name} WHERE dt IN ('{date_sub(bizdate,1)}','{date_sub(bizdate,2)}','{date_sub(bizdate,3)}') ) """ avg_cnt = spark.sql(hist_sql).collect()[0]['avg_cnt'] # 获取当日行数 curr_cnt = spark.sql(f"SELECT COUNT(*) as cnt FROM {table_name} WHERE dt='{bizdate}'").collect()[0]['cnt'] if abs(curr_cnt - avg_cnt) / avg_cnt > threshold: raise AssertionError(f"Row count deviation {abs(curr_cnt-avg_cnt)/avg_cnt:.2%} > {threshold:.0%}") print(f"✅ Row count OK: {curr_cnt} vs avg {avg_cnt}") # 在CI/CD流水线中调用 test_row_count_baseline(spark, "dwd_order_inc", "20231110")逻辑说明:date_sub是Spark内置函数,threshold=0.05表示允许5%波动。测试失败时抛出AssertionError,CI流水线自动中断发布。
6.2 单元测试:用真实小数据集验证逻辑,而非写SQL注释
反对两种极端:一种是“不写测试,靠人工跑一遍”;另一种是“写几百行Mock数据,脱离真实场景”。我们采用真实数据切片+断言模式:
- 从生产环境导出一张最小可行数据集(如
dwd_order_inc的100行样本,含各种边界case:amount=0、user_id=NULL、status='cancelled'); - 将其存为
test_data/dwd_order_inc_sample.parquet; - 编写测试脚本,加载样本,执行待验证的ETL逻辑(如
dws_user_order_summary的聚合SQL),断言结果;
-- 测试SQL:验证用户订单汇总逻辑 -- 输入:test_data/dwd_order_inc_sample.parquet(已预置3条记录:user1下单2次,user2下单1次) -- 期望:user1的order_cnt=2, total_amount=150.00;user2的order_cnt=1, total_amount=80.00 SELECT user_id, COUNT(*) as order_cnt, SUM(amount) as total_amount FROM test_data.dwd_order_inc_sample GROUP BY user_id ORDER BY user_id;提示:这个SQL就是生产代码,测试时直接复用。区别在于输入源是
test_data.*而非dwd.*。这样保证测试与生产逻辑零差异。
6.3 我的习惯:每次改SQL,必做三件事
- 改前:在Git Commit Message里写明“影响范围”——“本次修改
dwd_order_inc的status清洗逻辑,影响下游dws_user_order_summary及3张BI报表”; - 改中:在SQL头部加注释块,描述变更点与验证方式——
/* CHANGE: 2023-11-10, handle NULL status by mapping to 'unknown'. VERIFIED: ran test on sample data, checked row count delta <1% */; - 改后:在调度平台手动触发一次“验证任务”,用最小数据集跑通,截图存档到Confluence——不是为了留痕,而是下次有人问“这个逻辑为什么这么写”,我能立刻甩出证据链。
数据仓库工程师的价值,不在于写出多炫的SQL,而在于让每一行数据都经得起追问。当你能把dwd_order_inc表里任意一行的来龙去脉,从上游业务库字段、ETL清洗逻辑、调度时间窗口、到下游报表口径,3分钟内说清,你就已经超越了90%的候选人。希望帮到你。
本文还有配套的精品资源,点击获取