大数据与数仓 · 86 道高频面试题详解
> 配套《高频面试题.md》使用:先自己想 30 秒,再对照【速答】,面试前重点背【速答】、理解【展开】。
> 标注【代码】的题目有可运行案例(均已在实验环境中验证通过),标注配图的题目在 `assets/` 下有对应示意图。
> 版本口径:Hadoop 2.x/3.x、Spark 3.x、Flink 1.18+/2.0、Kafka 3.x/4.0。
---
一、体系与 HDFS 题(1–12)
1. Hadoop 三件套是什么?各干什么?
**速答**:HDFS 负责分布式存储(分块 + 多副本),YARN 负责资源管理与作业调度(ResourceManager 申请资源、NodeManager 在各节点执行),MapReduce 是分布式计算框架(Map/Shuffle/Reduce 三阶段)。
**展开**:三件套体现"存算分离"的雏形——存储层(HDFS)与计算层(MR)独立部署,同一个 HDFS 上可以跑 MR、Spark、Flink 多种引擎。广义的 Hadoop 生态还包括 Hive(SQL 化)、HBase(KV 存储)、ZooKeeper(协调)、Sqoop/DataX(同步)等。
2. HDFS 为什么块要设大(128MB/256MB)?
**速答**:为了把"寻址开销"摊薄。磁盘一次寻址约 10ms,顺序传输约 100MB/s;块设到 128MB 后,寻址时间占比降到千分之一量级,读写接近纯顺序吞吐。同时块越大,NameNode 需要管理的元数据条目越少(1GB 文件只需 8 条块记录)。
**展开**:块从 1.x 的 64MB 升到 2.x 的 128MB(`dfs.blocksize`),就是这个逻辑的延续。但块不能无限大:块太大 → 单任务处理时间长、失败重跑代价高、split 难以切出足够并行度。**块是存储/容错单位,不是读取最小单位**——读取仍按字节流,客户端可以只读块内一段。
3. 3 副本策略?副本放置规则(机架感知)?
**速答**:默认 3 副本,放置规则:第 1 副本在客户端所在节点(集群外则随机选一个),第 2 副本放另一个机架的某节点,第 3 副本与第 2 副本同机架的不同节点。
**展开**:这样兼顾两件事——**容错**:任一节点坏有同机架副本,任一机架断电仍有 1 个跨机架副本;**带宽**:写路径只在第 2 副本时跨一次机架交换机,第 3 副本走同机架内网。读的时候客户端优先取最近的副本(同节点 → 同机架 → 跨机架)。Hadoop 3.x 对冷数据可改用**纠删码 RS(6,3)**,存储开销从 3 倍降到 1.5 倍,代价是读放大与修复成本。
配图:`assets/fig_hdfs_blocks.png`
4. NameNode 与 DataNode 分工?NameNode 挂了怎么办(HA)?
**速答**:NameNode 管元数据(目录树、文件→块映射,常驻内存,靠 fsimage + editlog 持久化);DataNode 存实际块数据并周期心跳、汇报块位置。NameNode 单点用 HA 解决:Active/Standby 两台 NameNode,editlog 写入共享的 JournalNode 集群(Quorum),ZKFC 基于 ZooKeeper 做选主与故障切换。
**展开**:两个易错点:①块到 DataNode 的位置信息**不持久化**,重启后由 DataNode 汇报重建(避免元数据与实际数据不一致);②内存量级——每块元数据约 150 字节,10 亿块就要数百 GB 内存,所以超大集群用 Federation 拆多个命名空间。
5. 读写流程?为什么数据流不过 NameNode?
**速答**:写:Client 向 NameNode 申请 → NameNode 返回 DataNode 列表 → Client 把块写入第一个 DataNode,DataNode 间以流水线复制副本 → 全部确认后提交。读:Client 向 NameNode 取"块→节点列表" → 就近并行从 DataNode 拉块。数据流不过 NameNode,因为 NameNode 是单点元数据服务,若再承载全部数据带宽会立刻成为瓶颈——它只回答"去哪拿",不搬运"拿什么"。
配图:`assets/fig_hdfs_rw.png`
6. 小文件问题是什么?怎么解决?
**速答**:每个文件、每个块都要占 NameNode 一条内存元数据(约 150 字节),1 亿个 1KB 小文件仅元数据就要几十 GB 内存,且读写时寻址与 RPC 次数暴涨、Map 任务数=文件数导致大量碎片任务。解决:①源头合并(采集端攒批再落 HDFS);②定期 Compaction(按分区合并成大 Parquet/ORC);③HAR/SequenceFile 归档;④改用湖格式(Iceberg/Hudi 自带小文件合并);⑤Map 端用 CombineFileInputFormat 把多小文件合并成一个 split。
7. 存算分离是什么?好处与代价?
**速答**:存储(对象存储 S3/OSS 或独立 HDFS)与计算(Spark/Flink/Presto 集群)物理分开、独立扩缩容。好处:弹性(白天扩计算跑报表、晚上缩掉)、成本(冷数据放廉价对象存储)、多引擎共享同一份湖数据。代价:数据本地性消失,计算要跨网络拉数据 → 依赖高速网络与缓存(Alluxio/本地盘缓存)弥补。
**展开**:传统 Hadoop 是存算耦合(DataNode 同时是计算节点,本地性优先);云原生架构几乎都是存算分离,这是两种架构最本质的差别。
8. HDFS 适合什么场景?不适合什么?
**速答**:适合:大文件、高吞吐、顺序读、一次写多次读(日志、数仓底座、模型数据)。不适合:①海量小文件(元数据爆炸);②低延迟随机点查(毫秒级 KV 查询请用 HBase/Redis);③频繁修改(HDFS 只支持追加,不支持随机改);④超低延迟交互式扫描(走 Doris/ClickHouse)。
9. 数据湖 vs 数仓的区别?Lakehouse(湖仓一体)是什么?
**速答**:数仓:先建模后入库(schema-on-write),数据治理好、BI 查询快,但上数据流程重、存结构化数据;数据湖:先存原始数据后建模(schema-on-read),存日志/文本/图像等任意格式、成本低、灵活,但缺事务与治理,容易变"数据沼泽"。Lakehouse:在湖存储(S3/HDFS + Parquet/ORC 开放格式)之上加一层表格式(Iceberg/Hudi/Delta),补上 ACID、元数据、快照、upsert,做到"湖的成本 + 仓的管理"。
10. Iceberg / Hudi / Delta Lake 解决什么问题?
**速答**:三者都是**开放表格式(table format)**,解决数据湖上的四大问题:①ACID 事务(并发读写隔离);②快照与时间旅行(按历史版本查询/回滚);③行级更新(upsert/delete,流式入库);④schema 演化与小文件治理。
**展开**:Iceberg 用独立的元数据快照树,引擎中立(Spark/Flink/Trino/StarRocks 都能读),隐藏分区让"按天分区"对用户透明;Hudi 主打 upsert(COW 写时复制 / MOR 读时合并两种表),CDC 入库场景常见;Delta 与 Spark/Databricks 生态结合紧密。选型口径:多引擎共存选 Iceberg,强 upsert/CDC 选 Hudi,All-in Spark 选 Delta。
11. 冷热数据分层存储怎么做?
**速答**:按访问频率把数据放到不同介质:热数据(近 7–30 天,高频查询)放 SSD/内存缓存;温数据放 HDD;冷数据(超过半年)转对象存储归档或纠删码。HDFS 原生支持 storage policy(HOT/WARM/COLD/ARCHIVE),Iceberg 可按分区做冷热迁移,Kafka 4.0 起内置分层存储(热数据本地盘、冷数据自动转对象存储)。
12. 为什么 HDFS 不适合直接支撑高频点查?
**速答**:三个原因:①随机 IO——点查需要随机寻道,HDFS 设计目标是顺序吞吐;②元数据无索引——NameNode 只有目录树,没有二级索引,定位小对象要完整路径解析 + RPC;③延迟叠加——每次读都要问 NameNode 再连 DataNode,毫秒级延迟对点查场景太慢。正确做法:KV 化(HBase 用 LSM 树 + rowkey 索引、Redis 内存查询),或数仓列存 + 谓词下推。
---
二、MapReduce 题(13–22)
13. MR 三个阶段?Map 和 Reduce 各做什么?
**速答**:Map:对每个输入分片的每条记录调用 map(),输出 (k2, v2) 中间结果,任务间完全并行;Shuffle:按 k2 分区(hash%R)、排序、分组,把相同 key 从各 Map 节点传给同一个 Reduce;Reduce:对每个 key 的 value 列表调用 reduce() 聚合,输出结果到 HDFS。
**展开**:词频统计:`map(行号, 行文本) → [(词,1)...]`;`reduce(词, [1,1,...]) → sum`。Map 任务数 = 分片数(默认一片=一个 HDFS 块,保证本地读取),Reduce 任务数由 `job.setNumReduceTasks(R)` 指定。
配图:`assets/fig_mr_flow.png`
14. Shuffle 是什么?为什么慢?
**速答**:Shuffle 是"Map 输出到 Reduce 输入之间"的全过程:**分区、排序、溢写落盘、归并、网络传输、Reduce 端归并**。慢在三次代价叠加:①磁盘 IO——Map 输出写入环形缓冲区(默认 100MB),到 80% 溢写临时文件,最后归并成一个已排序文件;②排序——溢写前排序、归并时再排序,比较开销大;③网络——Reduce 通过 HTTP 从每个 Map 节点拉取自己的分区。
**展开**:优化三板斧:Combiner(map 端预聚合减少传输量)、压缩中间数据(Snappy/LZ4,CPU 换带宽)、调参(环形缓冲 `mapreduce.task.io.sort.mb`、溢写阈值 0.8、reduce 拉取线程数)。
15. Combiner 的作用?
**速答**:Combiner 是可选的"本地 mini-reduce",在 Map 节点内先聚一次再传输。对求和/计数/最大最小值这类满足结合律的运算,能成倍减少 Shuffle 传输量。注意:Combiner 可能被调用 0 次或多次,逻辑必须与调用次数无关;**求平均不能直接 Combiner**——先平均再平均不等于总平均,要传 (sum, count) 二元组。
【代码】(实验 01 已验证):
```python
records = [('一班', 85), ('一班', 78), ('一班', 90), ('二班', 92), ('二班', 95)]
acc = {}
for cls, sc in records:
s, c = acc.get(cls, (0, 0))
acc[cls] = (s + sc, c + 1) # Combiner 输出 (sum, count)
avg = {cls: round(s / c, 2) for cls, (s, c) in acc.items()}
avg['一班'] = 84.33 正确;若 Combiner 输出局部均值 81.5,再与 90 平均得 85.75,错误
```
16. 分区器(Partitioner)干什么?
**速答**:决定每条 Map 输出去哪个 Reduce 任务,默认 HashPartitioner:`partition = hash(key) % numReduceTasks`。相同 key 必进同一分区——这是"按 key 分组"跨机器成立的根本保证。可自定义:如按 key 范围分区实现全局有序输出(配合 TotalOrderPartitioner + 采样),或把倾斜的大 key 单独定向。
17. MR 为什么被 Spark 替代?
**速答**:①中间结果必须落盘,而 Spark 窄依赖流水线在内存中直通,迭代类任务快 10–100 倍;②MR 只支持 Map→Reduce 两层模型,Spark 有完整 DAG;③MR 每个作业 JVM 冷启动,Spark Executor 常驻复用;④生态:Spark SQL/Structured Streaming/MLlib 一栈解决。
**展开**:MR 没有消失——一些超大规模"单遍聚合"和历史 Hive 集群仍在用,且 Hive 3/4 默认引擎已转向 Tez。
18. 数据量超大时怎么避免 OOM?
**速答**:分三处看。Map 端:逐条流式处理天然不 OOM,注意别在 map 里攒全量容器。Reduce 端:①reduce() 的 values 是**迭代器**(边拉边用),不要 `for` 一次存进大 List;②大 key(一个 key 上亿 value)要拆分或改写逻辑。Join 大表:分桶 join(两表按 join key 分桶排序)或改写为 map-side join(小表广播)。框架本身靠**外排**(溢写+归并)避免排序 OOM——这也是它慢的原因之一。
19. MR 处理 TopN 的思路?
**速答**:分治:每个 Map 任务只维护**局部 TopN**(TreeMap,内存 O(N)),输出 N 条到 Reduce;Reduce 归并所有局部 TopN 得全局 TopN。数据量从"全部记录"降到"分区数 × N"。
【代码】(实验 07 已验证,配图 `assets/fig_topn.png`):
```python
def topn(nums, n):
return sorted(nums, reverse=True)[:n]
partitions = [[5, 9, 3], [10, 2, 7], [8, 1, 6]]
local = [topn(p, 3) for p in partitions] # 每分区局部 Top3
global_top = topn([x for p in local for x in p], 3) # 归并
assert global_top == [10, 9, 8]
```
20. 什么是数据本地性?
**速答**:把计算任务调度到**数据所在节点**执行,让磁盘读走本地盘而非网络。Hadoop/YARN 调度优先级:同节点 > 同机架 > 跨机架,等待本地槽位有超时(默认几秒),超时降级到机架/远端(避免干等)。这就是"移动计算比移动数据便宜"的落地。存算分离架构下数据本地性减弱,改靠缓存弥补。
21. 一个 Reduce 和多个 Reduce 的取舍?
**速答**:单 Reduce:无并行、无归并、天然全局有序,但吞吐受限于单节点——适合数据量小或必须全局序(如全局 TopN 收尾、全局排名);多 Reduce:并行吞吐高,但只有分区内有序、输出 R 个文件,倾斜时最慢的 task 决定整体时长。工程经验:先估算各 key 分布,热点场景必须配合倾斜治理。
22. MR 能处理实时数据吗?为什么不行?
**速答**:不能。MR 是批模型:①输入必须是有界静态数据集;②中间落盘 + 作业冷启动,延迟秒到分钟级;③没有状态管理与事件时间语义。实时链路选 Flink(逐条、毫秒级、有状态)或 Spark Streaming(微批、秒级)。
---
三、Spark 题(23–34)
23. RDD 是什么?为什么"弹性"?
**速答**:RDD(Resilient Distributed Dataset)是不可变的、分区的、可并行操作的分布式数据集。"弹性"体现在四点:①分区可自动并行;②故障时沿**血缘**重算恢复(不需要数据副本);③数据可在内存与磁盘间自动降级(内存不够落盘);④分区数与存储位置可动态调整。
**展开**:RDD 内部记录五元信息:分区列表、依赖(血缘)列表、计算函数(对每个分区怎么算)、分区器(可选,Key 类型 RDD)、优先位置列表(本地性提示)。
24. Transformation 与 Action 区别?惰性求值的好处?
**速答**:Transformation(map/filter/reduceByKey 等)只记录转换、不立即执行;Action(collect/count/reduce/save 等)触发真正的作业。惰性的好处:①框架看到完整 DAG 后做全局优化(合并相邻窄转换、谓词下推、裁剪未使用分支);②不为中间结果付落盘/网络成本;③血缘认证清晰,代码"声明式",出错可重放。
【代码】(实验 02 已验证):
```python
class MiniSpark:
def __init__(self, partitions):
self._parts = partitions
self._plan = [] # 只记录转换,不执行
self._executed = 0
def map(self, f):
self._plan.append(('map', f)); return self
def filter(self, f):
self._plan.append(('filter', f)); return self
def run(self): # Action:此刻才逐分区流水线执行
parts = [list(p) for p in self._parts]
for name, fn in self._plan:
if name == 'map':
parts = [[fn(x) for x in p] for p in parts]
elif name == 'filter':
parts = [[x for x in p if fn(x)] for p in parts]
self._executed += 1
return parts
ms = MiniSpark([[1, 2, 3], [4, 5, 6], [7, 8, 9]])
ms.map(lambda x: x * 2).filter(lambda x: x > 5)
assert ms._executed == 0 # 构建阶段:0 次遍历
res = ms.run()
assert ms._executed == 2 and sum(len(p) for p in res) == 7
```
25. DAG 是什么?Action 时怎么执行?
**速答**:DAG 是"从源 RDD 到目标 RDD"的转换依赖图。Action 触发 Job 后:DAGScheduler **从后往前**遍历 DAG,遇到**宽依赖就切一个 Stage**(Shuffle 边界),窄依赖留在同一 Stage 内流水线执行;每个 Stage 生成"分区数 = Task 数"的 TaskSet 交给 TaskScheduler 派发。
配图:`assets/fig_spark_lazy.png`
26. 血缘(Lineage)有什么用?cache 和 checkpoint 区别?
**速答**:血缘记录"每个 RDD 由哪些父 RDD 经什么转换得到",用途是**容错**:某个分区丢失,只重算丢失分区对应的窄依赖链,不必全量重跑。cache:把 RDD 快照放内存/本地盘,**血缘保留**,快照丢了还能重算,且是 lazy 的(首次 Action 才真正缓存);checkpoint:把 RDD 写到**可靠存储**(HDFS),并**切断血缘**,适合血缘超长、迭代算法(如 PageRank 上百轮)的场景。用前记得 `sc.setCheckpointDir()`。
27. 宽依赖 vs 窄依赖?为什么宽依赖要 Shuffle?
**速答**:窄依赖:每个父分区最多被一个子分区消费(map、filter、union、mapPartitions)——同 Stage 流水线、无 Shuffle、容错可精确重算;宽依赖:父分区被多个子分区消费(reduceByKey、groupByKey、join 非同分区、repartition)——数据必须**按 key 重新分布**到目标分区,跨节点网络传输即 Shuffle。
配图:`assets/fig_spark_deps.png`
28. reduceByKey 为什么优于 groupByKey?
**速答**:reduceByKey 在每个分区内部**先做 map 端预聚合(combine)**,跨网络传输的是"每个分区内每个 key 的局部和";groupByKey 把每个 key 的**全部原始值**都传到 Reduce 端再聚合——Shuffle 量可能大一个数量级,且 Reduce 端要为 value 列表在内存中缓冲,大 key 直接 OOM 风险。
【代码】(实验 02 已验证):
```python
partitions = [[('a', 1), ('b', 1)], [('a', 1), ('a', 1)], [('b', 1), ('c', 1)]]
for i, p in enumerate(partitions):
d = {}
for k, v in p:
d[k] = d.get(k, 0) + v # 分区内预聚合
print(f'分区{i}: {p} -> {list(d.items())}')
传输量对比:groupByKey 传 6 条原始记录;reduceByKey 只传 4 条 (局部和)
```
29. DataFrame vs RDD?Catalyst 优化器做什么?
**速答**:RDD 是"无结构 + 函数"的黑盒;DataFrame 是带 schema 的分布式表,SQL/DSL 表达。DataFrame 的优势在 **Catalyst 优化器**:解析出逻辑计划 → 规则优化(谓词下推、列裁剪、常量折叠、join 重排)→ 生成物理计划(多实现择优)→ **Tungsten** 做全阶段代码生成与堆外二进制内存管理。RDD 只在需要精细控制分区与自定义数据分布时使用。
**展开**:生产代码原则:能 SQL/DataFrame 就不用 RDD;UDF 性能差(序列化开销),优先用内置函数。
30. 什么是数据倾斜?怎么发现?怎么解决?
**速答**:key 分布不均导致个别 task 的数据量/耗时远超其他 task,整个 Stage 被长尾拖住。**发现**:Spark UI 看 task 耗时与 shuffle read bytes 的长尾(如 199 个 task 各 1s、1 个 task 10min);SQL 层面先看 key 的频次分布。**解决**:①两阶段聚合——key 加随机前缀打散局部聚合,再去前缀全局聚合(仅限聚合类);②热点 key 拆分——把特大 key 单独拎出来单独处理或广播;③join 倾斜——小表广播(broadcast hash join)绕过 shuffle;④过滤/修正异常 key(null、测试账号);⑤提高 shuffle 分区数(缓解但不根治)。
【代码】(实验 07 已验证,配图 `assets/fig_skew.png`):
```python
import random
records = [('hot', 1)] * 100 + [('a', 1)] * 10 + [('b', 1)] * 5
def two_phase_agg(records, n_rand=3):
phase1 = {}
for k, v in records:
p = f'{k}#{random.randint(0, n_rand - 1)}' # 第一阶段:加随机前缀打散
phase1[p] = phase1.get(p, 0) + v
phase2 = {}
for pk, v in phase1.items():
k = pk.split('#')[0] # 第二阶段:去前缀合并
phase2[k] = phase2.get(k, 0) + v
return phase2
res = two_phase_agg(records)
assert res['hot'] == 100 and res['a'] == 10 and res['b'] == 5
```
31. 分区数怎么设置?
**速答**:下限不低于总核心数(吃满并行度),经验值取**总核心数的 2–3 倍**——任务耗时不均时,忙核可以接长尾任务。读 HDFS 时分区数≈块数较自然;Shuffle 分区由 `spark.sql.shuffle.partitions`(默认 200)控制。分区过多:调度开销大、每个 task 有效工作太少、写出的文件数≈分区数(小文件);分区过少:并行度不足、单 task 内存压力大、倾斜放大。
32. Spark 执行流程?
**速答**:`spark-submit` → 启动 Driver(解析代码建 DAG,不执行)→ 首个 Action 触发 Job → DAGScheduler 按宽依赖切 Stage → 各 Stage 生成 TaskSet → TaskScheduler 把 Task 派发给 Executor(考虑数据本地性)→ Executor 用线程池执行 Task → ShuffleMapTask 写中间数据、ResultTask 产最终结果 → 结果回传 Driver。容错:task 失败重试(默认 4 次),stage 失败重试,长尾可用**推测执行**(对慢 task 冗余起备份)。
33. 广播变量与累加器?什么时候用广播?
**速答**:广播变量:把 Driver 端的只读变量(如维表字典、规则映射)以高效方式发到每个 Executor **一份**(否则每个 task 序列化传输一份,大对象开销巨大)。典型场景:**大表 join 小表**——把小表广播,走 broadcast hash join,完全避免 Shuffle。累加器:只能"加"的分布式变量(如统计坏行数),Executor 端更新、Driver 端读取;注意 transformation 里更新累加器在重算时会重复累加,只在 action 或幂等场景用。
34. Spark SQL 与 Hive 的关系?
**速答**:Spark SQL 可以直接读取 Hive Metastore 管理的表(`enableHiveSupport()`),兼容大部分 HiveQL 语法与 UDF,做到"表不动、换引擎"——同一份数据,执行引擎从 MR/Tez 换成 Spark(Catalyst + Tungsten),通常快数倍。差异点:个别函数与类型转换行为不同、事务表/ACID 支持有限(Hive ACID 表在 Spark 里只读受限)。数仓演进口径:**Metastore 统一管元数据,计算引擎按需混用**。
---
四、Flink 与实时计算题(35–46)
35. 批处理 vs 流处理区别?
**速答**:批:数据有界、一次性拉起作业、延迟高(分钟/小时)、结果确定性天然保证;流:数据无界、作业常驻、延迟低(毫秒/秒)、必须处理乱序与状态恢复。本质差别是"数据是否有终点"——Flink 用同一套引擎跑批(有界流)与流(无界流)实现流批一体。
36. Lambda vs Kappa 架构?
**速答**:Lambda:批链路(准)+ 流链路(快)并行计算,查询层合并结果。问题:同一逻辑两套代码(Java+SQL/Hive+Spark+Flink),口径漂移、双倍维护成本、对账繁琐。Kappa:一切皆流,需要历史结果时**重放消息**从头算。前提:Kafka 保留期够长(或能重置到任意 offset)、重放吞吐扛得住。现状:多数公司是"Lambda 简化版"——实时链路为主,离线链路做对账与重刷兜底。
37. 事件时间 vs 处理时间 vs 摄入时间?
**速答**:事件时间:数据**发生**的时刻(业务时间戳字段),正确性标准;处理时间:算子机器时钟,低延迟但不可复现(重启/反压都会扭曲);摄入时间:进入 Flink 的时刻,介于两者之间,少用。严肃统计(计费、报表)必须事件时间 + Watermark;监控大盘秒级刷新可接受处理时间。
38. 乱序数据怎么处理?Watermark 是什么?
**速答**:Watermark 是随流流动的"时间进度条",定义为 `wm = max(已见事件时间) - 允许乱序容忍 d`,单调递增。含义:**事件时间 ≤ wm 的数据基本到齐**。窗口关闭条件:wm 推进到窗口终点。d 是准确性与延迟的折中:d 太小关窗早→漏数据;太大关窗晚→结果延迟高。多并行度下,下游取各输入通道 watermark 的**最小值**(木桶效应)。
【代码】(实验 03 已验证,配图 `assets/fig_watermark.png`):
```python
arrive = [1, 4, 2, 7, 3] # 按到达顺序的事件时间
allow = 2
wm, late = 0, []
for t in arrive:
late.append(t < wm) # 事件时间已落后水位线 -> 迟到
wm = max(wm, t - allow) # 水位线单调推进
第5条 t=3 到达时 wm=5,3 < 5 -> 迟到,走侧输出/丢弃
```
39. Watermark 怎么生成?
**速答**:两种触发:**周期性**(默认每 200ms,`autoWatermarkInterval`)按当前最大事件时间减容忍度生成——生产默认;**逐条/打点**(每条记录后立即生成)。常用策略 `BoundedOutOfOrderness(maxDelay)`(固定乱序容忍)与单调递增(maxDelay=0)。注意**空闲源问题**:某分区长时间无数据会把整体 watermark 卡住,需 `withIdleness` 把空闲源剔除。
40. 滚动/滑动/会话窗口区别?
**速答**:滚动窗口:固定长度、不重叠,每条数据只属一个窗口——固定周期报表(每 5 分钟 PV);滑动窗口:长 > 步长、有重叠,一条数据可属多个窗口——"最近 1 小时、每 5 分钟更新"的趋势监控;会话窗口:按**活动间隔 gap** 动态切分,无固定长度,活跃数据顺延窗口——用户行为会话分析。
【代码】(实验 03 已验证,配图 `assets/fig_windows.png`):
```python
def tumbling_window(events, win_len):
buckets = {}
for eid, ts in events:
w = (ts // win_len) * win_len # 窗口起点 = ts - ts % win_len
buckets.setdefault(w, []).append((eid, ts))
return buckets
def sliding_window(events, win_len, slide):
buckets = {}
for eid, ts in events:
start = 0
while start <= ts:
if start <= ts < start + win_len: # 一条数据可命中多个窗口
buckets.setdefault(start, []).append((eid, ts))
start += slide
return buckets
```
41. 迟到数据怎么办?
**速答**:三层策略:①`allowedLateness`:窗口触发后**保留状态**再等一段时间,迟到数据到了就**更新窗口结果**(要求下游支持更新/ retract,如 Kafka 事务、可更新的 OLAP);②侧输出流(sideOutputLateData):太迟的数据导出到独立流,人工核对或离线补偿;③丢弃(默认,最简单但最不准)。口径记忆:**Watermark 决定窗口何时关,allowedLateness 决定关了还能不能补**。
42. Flink 状态是什么?Checkpoint 怎么做?
**速答**:状态是算子要"记住"的中间结果(窗口累加值、去重集合、最近事件时间)。两类:**Keyed State**(按 key 隔离:ValueState/ListState/MapState/ReducingState)与 **Operator State**(按算子并行子任务隔离,如 Kafka offset)。Checkpoint 基于 **Chandy-Lamport 异步屏障快照**:JobManager 的 CheckpointCoordinator 周期向 source 注入**屏障**;屏障随数据流向下游,算子收齐所有输入通道的屏障后,把自身状态**异步快照**到状态后端(堆内存/RocksDB,大状态用 RocksDB + 增量快照),然后向下游转发屏障;全部算子确认后该 Checkpoint 完成。故障恢复:全图重启,从最近快照恢复状态,source 重放。
43. 精确一次(Exactly-once)怎么实现?
**速答**:分两层。**内部精确一次**:Checkpoint 恢复 + 重放,算子状态不多不少。**端到端精确一次**:sink 也要参与——**两阶段提交(2PC)**:屏障到达 sink 时把已算出的结果**预提交**(写入但对外不可见,如 Kafka 事务挂起);Checkpoint 全部完成后**正式提交**(对外可见)。故障时预提交数据随事务中止丢弃,不会多写。要求 sink 支持事务(Kafka 事务)或幂等写(按主键 upsert)。
44. Flink vs Spark Streaming?
**速答**:模型:Flink 真流(逐条处理,毫秒级);Spark Structured Streaming 微批(默认几百毫秒~秒级攒批,Continuous Mode 仍实验性)。语义:Flink 原生事件时间/Watermark/状态更完善;Spark 起家批处理,流是"增量查询"。生态:Flink 在实时数仓/CEP/风控占优;Spark 在批流混合、与 Hive/湖格式配合时省心。选型口径:强实时低延迟选 Flink,"准实时+重批"一体选 Spark。
45. 背压(Backpressure)是什么?怎么处理?
**速答**:下游算子处理不过来,反压逐级向上游传播,最终 source 限速——系统自我保护不丢数据,但延迟飙升。发现:Flink WebUI 的 backpressure 标签(HIGH)、算子 busyTime 高、outputQueueLength 打满、checkpoint 超时(屏障传不动)。处理:①加并行度(受 key 分区数限制的场景先看倾斜);②优化算子逻辑(避免每条记录重对象创建、减少序列化);③同步 IO 改异步 IO(维表查询);④攒批(上游预聚合减少流量);⑤扩大网络缓冲、调 checkpoint 间隔。
46. 实时指标和离线指标对不上怎么办?
**速答**:先按四类原因排查:①**口径**——去重范围(分端去重 vs 全局)、窗口边界(左闭右开)、指标定义不一致;②**时间**——事件时间 vs 处理时间、时区(UTC vs UTC+8)、T-1 分区边界;③**数据**——乱序迟到导致实时少算、上游重放导致实时多算、实时读 Kafka 流离线读全量表数据源不同;④**修复机制**——实时链路是否有 retract/补数能力。治理:建立以离线为准的对账监控(小时级 diff 告警),超阈值自动用离线结果校准/重刷实时。
---
五、数仓建模题(47–58)
47. 数仓为什么分层?各层职责?
**速答**:四层:ODS 原始层(贴源存储、原样保留、可回溯重放);DWD 明细层(清洗、标准化、维度退化,最细粒度事实);DWS 汇总层(按主题/维度组合轻度汇总,复用中间结果);ADS 应用层(面向报表、大屏、算法特征的结果级指标)。价值:**解耦**(上游变化只影响下一层)、**复用**(DWS 算一次多处用)、**质量**(逐层校验,问题早暴露)、**成本**(中间结果只算一次)。
配图:`assets/fig_wh_layers.png`
48. ODS/DWD/DWS/ADS 分别做什么?粒度如何变化?
**速答**:粒度自上而下从"与源一致"到"结果级":ODS 每行=一条源记录(含脏数据);DWD 每行=一次业务事实(一笔订单/一次点击);DWS 每行=一个维度组合的汇总(某天某城市的订单量);ADS 每行=一个应用指标(某日 GMV Top3)。**跨层去重陷阱**:DWS 按 (天, 城市) `COUNT(DISTINCT user_id)` 后,ADS 再 SUM 会重复计数——跨组去重必须回 DWD 明细层单独算。
49. 事实表 vs 维度表?
**速答**:事实表存"业务过程的可加度量"(订单金额、点击次数),行数巨大、粒度明确(一次交易一行);维度表存"描述上下文的实体属性"(用户、商品、日期),行数少、字段多。事实表通过外键关联维度表。特殊事实表:周期快照(每日库存余额)、累积快照(订单从下单到签收全流程)、无事实事实表(仅记录"某学生某天到课")。
50. 星型模型 vs 雪花模型?怎么选?
**速答**:星型:维度表直连事实表、维度冗余——join 少、查询快、建模简单,BI 报表首选;雪花:维度表再规范化拆分(如商品维拆出品类维)——省存储、一致性更好,但 join 层数多、查询慢。**选型口诀:查询性能优先选星型,存储/一致性优先选雪花**;实践中数仓 DWD/DWS 层普遍星型 + 宽表,规范化留给源业务库。
配图:`assets/fig_star_snow.png`
51. 什么是宽表(维度退化)?什么时候用?
**速答**:把常用维度字段直接冗余进事实/汇总表(如 dws_daily_orders 里直接带 city 名、品类名),查询时不再 join 维度表——空间换时间,是 DWS/ADS 层的常规操作,OLAP 引擎(Doris/ClickHouse)的大宽表就是这一思路的极致。代价:维度变更要回刷历史、存储冗余。适合:维度变化少、查询频繁、对延迟敏感的场景。
52. 指标口径不统一怎么治理?
**速答**:四步:①**统一命名**——指标字典(原子指标 + 修饰词 + 时间周期,如"日 GMV_有效订单"),同义指标合并;②**统一计算**——口径只在 DWS 层落地一次,下游只能引用不能重算;③**统一来源**——明确每个指标的源表与过滤条件(如"有效订单"排除哪些 status);④**对账监控**——关键指标设校验任务,数出多门自动告警。组织上要有数据 BP/指标委员会拍板争议口径。
53. 数据质量四维度?
**速答**:①**完整性**——行数对不对、字段缺失率(主键唯一、非空);②**准确性**——值域合理(年龄 0–150)、波动检测(今日 GMV 环比 ±50% 告警)、与源系统对账;③**一致性**——同名指标多表一致(跨表主外键、口径一致);④**及时性**——任务按时完成(SLA 告警)、数据新鲜度。落地:调度里加质量稽核节点(质量不过→阻断下游 + 告警)。
54. 元数据管理包含什么?血缘有什么用?
**速答**:元数据分三类:技术元数据(表结构、分区、存储位置、行数)、业务元数据(指标口径、字段含义、责任人)、操作元数据(任务依赖、运行历史、资源消耗)。**血缘**记录"表/字段的上下游关系",三大用途:①影响分析(改表前看下游波及面);②故障溯源(报表异常沿血缘向上定位);③治理(识别无下游孤儿表下线、识别高频高频链路做优化)。
配图:`assets/fig_lineage.png`
55. 缓慢变化维(SCD)Type1/Type2?
**速答**:维度属性变化怎么存。Type 1:直接覆盖(UPDATE)——省事但丢历史,适合"改错字";Type 2:加版本(旧记录封版 end_date=变更日前一天,新记录开链到 9999-12-31)——历史可溯,适合用户地址、商品品类。变体:Type 3 只存"上一版值"(加一列 previous_value),处理能力有限。拉链表是 Type 2 的工程化落地。
【代码】(实验 04 已验证):
```python
import sqlite3
conn = sqlite3.connect(':memory:')
cur = conn.cursor()
cur.execute('CREATE TABLE d_user_zip (user_id TEXT, city TEXT, start_date TEXT, end_date TEXT)')
cur.execute("INSERT INTO d_user_zip VALUES ('u1', '北京', '2024-01-01', '9999-12-31')")
def zipper_update(user, new_city, eff_date):
cur.execute("UPDATE d_user_zip SET end_date = ? WHERE user_id=? AND end_date='9999-12-31'",
(eff_date, user)) # 1) 闭链
cur.execute('INSERT INTO d_user_zip VALUES (?,?,?,?)',
(user, new_city, eff_date, '9999-12-31')) # 2) 开链
conn.commit()
zipper_update('u1', '上海', '2024-04-01')
q = lambda d: cur.execute("SELECT city FROM d_user_zip WHERE user_id='u1' "
"AND ? >= start_date AND ? <= end_date", (d, d)).fetchone()[0]
assert q('2024-03-15') == '北京' and q('2024-05-01') == '上海'
print('拉链表:3月15日在北京,5月1日在上海 —— 历史可溯')
```
56. 数据倾斜在 SQL/MapReduce 里怎么处理?
**速答**:聚合类:①两阶段聚合——`GROUP BY key, floor(rand()*N)` 局部聚合后再按 key 全局聚合;②热点 key 拆出单独处理。Join 类:③小表广播(map-side join)绕过 shuffle;④大 key 单独拎出广播 join,其余正常 join;⑤倾斜 key 加随机前缀扩容匹配(空间换时间)。前提动作:先 `GROUP BY key` 看 key 分布确认倾斜点,能过滤的异常 key(null/测试数据)先过滤。
57. 为什么数仓不建议大量 join?怎么优化?
**速答**:分布式 join 是最贵的算子(Shuffle 网络 + 排序 + 大表扫描),且 join 层数多时优化器难以选择执行序、口径易错。优化:①**维度退化/宽表**——DWD 生成时就把常用维度冗余进去;②**预聚合**——DWS 层把高频分组先算好;③必须 join 时按代价选算法:小表广播 > 分桶 join(两表按 key 预分桶排序,merge join 免 shuffle)> shuffle hash join > sort merge join。
58. 拉链表是什么?怎么更新?
**速答**:拉链表 = SCD Type 2 的落地表结构:`(业务主键, 属性, start_date, end_date)`,end_date=9999-12-31 表示当前有效。每日更新两条 SQL:**闭链**(把有变化的当前记录 end_date 改为生效日前一天)+ **开链**(插入新版本)。优点:全量快照表存 N 天 = N 份数据,拉链表只存"变化量",适合亿级用户/商品维表;查询任意历史时间点用 `start_date <= d <= end_date` 过滤。注意:需要每日比对"今日全量 vs 昨日拉链"找变化(哈希比对全字段)。
---
六、Kafka 与管道题(59–70)
59. Kafka 在数据链路中起什么作用?
**速答**:四个作用:①**解耦**——上游只管往 Topic 写,下游各自消费,互不影响、可随时增减;②**削峰**——突发流量先入队列,下游按消费能力匀速处理(如大促日志洪峰);③**缓冲与持久化**——消息落盘可回放,下游故障重启后从 offset 续传不丢;④**分发的总线**——一份日志同时供实时计算、数仓入库、搜索索引多条链路使用。
配图:`assets/fig_kafka.png`
60. Topic / Partition / Offset / Broker 概念?
**速答**:Topic:逻辑消息类别(如 order_events);Partition:Topic 的物理分片——并行与有序性的单位,每分区是一条**只追加、不可改**的日志,消息在分区内有唯一递增编号 offset(从 0 开始);Broker:Kafka 服务节点,存分区副本,每分区 1 个 Leader(负责读写)+ N 个 Follower(同步)。分区数决定最大并行度:消费者数超过分区数则多出的闲置。
61. 分区内有序还是全局有序?要全局有序怎么办?
**速答**:Kafka 只保证**分区内有序**(追加日志天然有序)。全局有序只能:单分区 Topic(牺牲并行与吞吐,需要全局序的小流量场景可用);或业务层带序列号/时间戳由消费端重排(如按事件时间窗口内重排)。多数业务其实只需要"同一实体有序"(同一订单/用户),按 key 路由即可满足。
62. 为什么同 key 路由同分区?
**速答**:生产者默认分区策略 `hash(key) % 分区数`(Kafka 客户端用 murmur2)。同 key 同分区保证两件事:①**分区内有序**——同一实体的操作序列不乱(先支付后退款不能反);②**状态局部性**——下游按 key 聚合/Join 时同一实体在同一分区处理,无需跨分区合并。注意:分区数扩容会改变 hash(key)%N 的映射(老 key 换分区、临时乱序),所以**分区数尽量一次规划到位**。
63. 消费者组是什么?分区与消费者数量关系?
**速答**:Consumer Group 是协作消费单位:**组内互斥**(一个分区同一时刻只分配给组内一个消费者)、**组间广播**(每个组都拿到全量消息——实时链路一个组、数仓入库另一个组)。数量关系:消费者数 ≤ 分区数才有意义,超出的消费者闲置;理想配比是消费者数=分区数(或整除关系)。消费者增减触发 Rebalance 重新分配分区。
配图:`assets/fig_kafka_offset.png`
64. Rebalance 是什么?为什么有代价?
**速答**:消费者组成员变化(上线/下线/心跳超时/订阅变更)时,全组重新分配分区的过程。代价:①Rebalance 期间**整组停止消费**(旧协议 stop-the-world,可能分钟级);②分区重新分配后,消费者从新分区的**已提交 offset** 续传——若 offset 提交不及时会重复消费。优化:调 `max.poll.interval.ms` 防误判离组、用**静态成员**(group.instance.id,重启不触发)、升级 Kafka 4.0 的 KIP-848 增量协作式 Rebalance(Broker 协调、只迁移受影响分区、不停全组)。
65. 消息积压怎么排查和处理?
**速答**:先量化:`最新 offset − 已提交 offset`(Lag)看哪个分区/组积压。排查链路:①消费端慢在哪(处理逻辑慢?外部调用慢?反序列化慢?GC?)——看消费 TPS 与单条耗时;②是不是倾斜(某分区流量大——热点 key 单分区独大);③消费者挂了或 Rebalance 循环。处理:短期——扩分区+扩消费者(注意扩分区改变 key 路由)、批量拉取批量写、跳过非关键消息事后补偿;中期——优化消费逻辑(异步 IO、攒批写库)、热点 key 拆分;长期——容量规划 + Lag 告警阈值。
66. acks=0/1/all 区别?怎么选?
**速答**:acks=0:发出去就算成功,不等确认——最快但 leader 挂/网络抖动会丢;acks=1:Leader 写入本地日志即确认——Leader 应答后未同步给 Follower 就挂,新 Leader 上位后这条丢失;acks=all(-1):**ISR 中所有副本**确认才算成功——最稳,配合 `min.insync.replicas=2`(ISR 至少 2 个)与 3 副本使用,兼顾可用性与不丢。选型:埋点日志可用 1(可容忍少量丢),订单/支付必须 all。**标准组合拳:3 副本 + acks=all + min.insync.replicas=2 + retries>0 + 消费端幂等**。
67. 消费语义:at-most-once / at-least-once / exactly-once?
**速答**:at-most-once:先提交 offset 再处理——崩溃时丢消息(适合埋点可容忍);at-least-once:先处理再提交 offset——崩溃时重复消费(主流选择,配幂等去重);exactly-once:处理与提交原子化——Kafka 事务(生产端幂等 + 事务跨分区原子写)或消费-计算-生产闭环(Flink 两阶段提交 + Kafka 事务)。去重手段:唯一业务键幂等写(INSERT ON DUPLICATE KEY / 主键覆盖)、Redis 记录已处理 ID、窗口内去重表。
68. Kafka 为什么吞吐高?
**速答**:四件事:①**顺序写**——分区是 append-only 日志,磁盘顺序写数百 MB/s(随机写的百倍);②**零拷贝**——消费者拉数据走 `sendfile`,从页缓存直达网卡缓冲区,不进用户态、省两次拷贝;③**批量+压缩**——producer 攒批(`linger.ms`、`batch.size`)+ lz4/zstd 压缩,网络与磁盘都省;④**依赖 OS 页缓存**——热数据在页缓存命中,读写基本不碰磁盘。另外 Broker 间与消费者端都是稀疏索引定位(offset→position),查找 O(log n)。
69. 重复消费怎么去重?
**速答**:重复来源:Rebalance 后从旧 offset 续传、生产端 retry 重发、offset 提交失败。去重三招:①**写端幂等**——业务唯一键(订单号)做主键,`INSERT ... ON DUPLICATE KEY UPDATE` 或 upsert,天然免疫重复;②**处理日志**——Redis/HBase 记录"已处理的 message ID",处理前查一下(注意过期时间);③**事务**——Kafka 事务把"消费-处理-生产-提交 offset"绑成原子操作(配合流处理引擎)。工程首选幂等键设计,把去重下沉到存储层。
70. Kafka vs RocketMQ?
**速答**:定位与侧重不同:Kafka 吞吐极高、生态最全(流处理、连接器),但消息可靠性语义偏流式场景,延迟队列/定时消息需自己实现;RocketMQ 面向业务消息:天然支持定时/延迟消息、事务消息(半消息)、消息轨迹、按吞吐调优更省心,金融业务常用。顺序性两者都靠分区/队列内有序。选型:大数据管道(日志、数仓、流计算)选 Kafka;业务交易消息(订单、支付)与需要延迟/事务消息的场景选 RocketMQ(或 RabbitMQ 低流量场景)。
---
七、平台与调度题(71–78)
71. 调度系统做什么?
**速答**:四件事:①**定时触发**——按 crontab/数据时间周期拉起任务;②**依赖编排**——按 DAG 依赖决定执行顺序(拓扑排序),上游没好下游不跑;③**失败处理**——重试(指数退避)、超限告警、阻断下游;④**重跑与补数**——按分区批量回刷历史(backfill),支持优先级与并发控制。再加运行监控(时长/资源/SLA)与权限审计。
72. 数据时间 vs 运行时间?
**速答**:运行时间是任务真实执行的墙钟时间;数据时间(业务日期)是这批数据对应的分区(如 ds=20261007)。天级任务普遍 **T-1**:今天凌晨跑昨天的数据——因为昨天的数据到今天零点才完整。排查问题三连问:任务跑了吗?跑到几点?处理的是哪天的分区?举例:"10 月 8 日 01:30 跑的任务,运行时间 10-08,数据时间 10-07。"
73. 任务失败怎么处理?
**速答**:四步:①**自动重试**——指数退避(1/5/15min),网络抖动类瞬时故障大多自愈;②**告警分级**——超限后按严重度升级(IM 消息 → 短信 → 电话),带上任务名、失败原因、影响范围(血缘下游);③**依赖阻断**——上游失败,下游一律不调度,防止用残缺数据算出错误指标污染下游;④**恢复与补数**——修好数据后按分区重跑,验证后通知下游。事后复盘要沉淀监控规则(同样问题第二次自动发现)。
74. 任务积压怎么追?
**速答**:先看积压原因:资源不够(队列排队)、单任务变慢(倾斜/数据量涨)、依赖链变长。手段按序:①**提并行度**——扩 YARN 队列资源、调大并发 slot;②**拆任务**——大任务按分区/业务拆成并行子任务;③**优先级调度**——核心链路插队,非关键任务让路;④**跳重跑/合并**——历史补数任务只跑关键分区,非关键基线合并;⑤**增量替代全量**——改增量计算减少单次数据量。预防:大促前容量评估 + SLA 演练。
75. 血缘怎么采集与使用?
**速答**:采集三种方式:①**解析 SQL/任务脚本**——静态解析 INSERT/CREATE 的表引用(主流,覆盖度高);②**运行时拦截**——Hook 执行计划/审计日志拿真实依赖(更准);③**手工登记**——兜底补漏。使用:①影响分析(改表前查下游);②故障溯源(异常沿血缘上溯定位源头任务);③治理(孤儿表下线、高成本链路优化、字段级溯源支撑合规审计)。
配图:`assets/fig_lineage.png`
76. 权限与安全?
**速答**:四个层次:①**权限模型**——表级/列级授权(行级通过视图或策略引擎实现),默认最小权限、按角色申请审批;②**脱敏**——手机号/身份证在查询时动态脱敏(掩码、哈希),导出需走审批;③**审计**——所有查询记录"谁、何时、查了什么表、多少行",异常行为(批量拉取敏感列)告警;④**加密与隔离**——传输 TLS、存储加密,生产/测试环境数据隔离。面试口径:说"我们按最小权限 + 列级脱敏 + 全量审计"即可展开。
77. Airflow vs DolphinScheduler?
**速答**:Airflow:Python 代码定义 DAG(DAG as code),插件生态最大,适合工程化团队,但权限与多租户较弱;DolphinScheduler:国产、可视化拖拽 + 代码双模式,原生多租户、分布式去中心化架构(无单点),对大数据组件(Hive/Spark/Flink/DataX)支持友好。另有 Dagster/Prefect(数据感知资产编排)、Argo(K8s 原生)。答题思路:先讲自己实际用过的(怎么定义依赖、怎么重跑、怎么告警),再对比选型理由。
78. 一条数据从产生到应用的全链路怎么讲?
**速答**(30 秒版):业务库/日志产生数据 → **采集**:CDC(Binlog)或 Flume/Filebeat 推 Kafka → **实时支线**:Flink 消费 Kafka 做窗口聚合,产出实时指标/风控特征;**离线支线**:Kafka→HDFS/湖,Hive/Spark 完成 ODS→DWD→DWS→ADS 分层加工 → **应用**:BI 报表、数据 API、算法特征回灌业务。全程由调度编排、血缘追踪、质量稽核、权限管控支撑。追问会沿着任意一环深挖(见前 77 题)。
配图:`assets/fig_pipeline.png`
---
八、手撕与 SQL 题(79–86)
> 以下代码均为**完整可运行**版本(单机 Python 3.x 验证通过),面试时先讲思路再写代码。
79. 手写 MR 词频统计(Map→Shuffle→Reduce)
```python
def mapper(text):
"""Map:每行文本 -> [(词, 1), ...]"""
return [(w, 1) for w in text.lower().split()]
def shuffle(map_outputs):
"""Shuffle:按键分组(框架还会排序+按 hash 分发到不同 Reduce)"""
grouped = {}
for kv_list in map_outputs:
for k, v in kv_list:
grouped.setdefault(k, []).append(v)
return grouped
def reducer(grouped):
"""Reduce:对每个 key 聚合"""
return {k: sum(vs) for k, vs in grouped.items()}
lines = ['hello world hello big data', 'big data big data hello']
result = reducer(shuffle([mapper(line) for line in lines]))
assert result['hello'] == 3 and result['big'] == 3 and result['data'] == 3
print(result) # {'hello': 3, 'world': 1, 'big': 3, 'data': 3}
```
配图:`assets/fig_mr_flow.png`、`assets/fig_wc_bar.png`
80. 实现拓扑排序(判断 DAG)
```python
from collections import deque
def topo_sort(graph):
"""graph: {task: [依赖的上游...]},返回拓扑序;有环返回 None"""
indeg = {t: len(d) for t, d in graph.items()}
adj = {t: [] for t in graph}
for t, ds in graph.items():
for d in ds:
adj[d].append(t)
q = deque(t for t, i in indeg.items() if i == 0)
order = []
while q:
cur = q.popleft()
order.append(cur)
for nxt in adj[cur]:
indeg[nxt] -= 1
if indeg[nxt] == 0:
q.append(nxt)
return order if len(order) == len(graph) else None
tasks = {'ods': [], 'dwd': ['ods'], 'dws': ['dwd'], 'ads': ['dws'], 'feature': ['dws']}
assert topo_sort(tasks) == ['ods', 'dwd', 'dws', 'ads', 'feature']
assert topo_sort({'a': ['b'], 'b': ['a']}) is None # 环 -> 非法依赖
```
配图:`assets/fig_scheduler_dag.png`
81. 手写滚动/滑动窗口
```python
def tumbling_window(events, win_len):
"""滚动窗口:固定长、不重叠。events: [(id, ts)]"""
buckets = {}
for eid, ts in events:
buckets.setdefault(ts - ts % win_len, []).append((eid, ts))
return buckets
def sliding_window(events, win_len, slide):
"""滑动窗口:长 win_len、步长 slide,一条数据可属多个窗口"""
buckets = {}
for eid, ts in events:
start = (ts - (win_len - 1) - (ts - (win_len - 1)) % slide) if ts >= win_len - 1 else 0
while start <= ts:
if start <= ts < start + win_len:
buckets.setdefault(start, []).append((eid, ts))
start += slide
return buckets
stream = [('a', 3), ('b', 5), ('c', 8), ('d', 12), ('e', 17)]
assert sum(len(v) for v in tumbling_window(stream, 10).values()) == 5 # 不重叠:总数=数据数
assert sum(len(v) for v in sliding_window(stream, 6, 3).values()) >= 5 # 重叠:总数>=数据数
```
配图:`assets/fig_windows.png`
82. 手写两阶段聚合(抗倾斜)
```python
import random
def two_phase_agg(records, n_rand=3):
"""records: [(key, value)]。热点 key 加随机前缀打散 -> 局部聚合 -> 去前缀全局合并"""
phase1 = {}
for k, v in records:
salted = f'{k}#{random.randint(0, n_rand - 1)}'
phase1[salted] = phase1.get(salted, 0) + v
phase2 = {}
for salted, v in phase1.items():
k = salted.split('#')[0]
phase2[k] = phase2.get(k, 0) + v
return phase2
records = [('hot', 1)] * 100 + [('a', 1)] * 10 + [('b', 1)] * 5
res = two_phase_agg(records)
assert res == {'hot': 100, 'a': 10, 'b': 5}
```
配图:`assets/fig_skew.png`
83. 手写全局 TopN 分治
```python
def topn_partition(nums, n):
"""每分区局部 TopN:等价于维护一个大小为 N 的最小堆,这里用排序表达"""
return sorted(nums, reverse=True)[:n]
def global_topn(partitions, n):
local = [topn_partition(p, n) for p in partitions] # 各分区只上传 N 条
return topn_partition([x for p in local for x in p], n) # 归并
partitions = [[5, 9, 3], [10, 2, 7], [8, 1, 6]]
assert global_topn(partitions, 3) == [10, 9, 8]
正确性:全局 TopK 必在"各分区局部 TopK 的并集"内(反证:若 x 不在其分区 TopK 内,
则该分区有 K 个元素比 x 大,全局也有 >= K 个比 x 大,x 不可能进全局 TopK)
```
配图:`assets/fig_topn.png`
84. SQL:分组 top1(row_number)
```sql
-- 每个城市最近一笔有效订单
SELECT city, order_id, ts
FROM (
SELECT city, order_id, ts,
ROW_NUMBER() OVER (PARTITION BY city ORDER BY ts DESC) AS rn
FROM dwd_orders
WHERE is_valid = 1
) t
WHERE rn = 1;
```
【可运行验证】(sqlite3,窗口函数需 SQLite 3.25+):
```python
import sqlite3
conn = sqlite3.connect(':memory:')
c = conn.cursor()
c.executescript('''
CREATE TABLE dwd_orders (city TEXT, order_id TEXT, ts TEXT, is_valid INT);
INSERT INTO dwd_orders VALUES
('北京','o1','2024-05-01 10:00',1), ('上海','o2','2024-05-01 11:00',1),
('北京','o3','2024-05-02 09:00',0), ('北京','o4','2024-05-03 12:00',1);
''')
rows = c.execute('''
SELECT city, order_id, ts FROM (
SELECT city, order_id, ts,
ROW_NUMBER() OVER (PARTITION BY city ORDER BY ts DESC) AS rn
FROM dwd_orders WHERE is_valid = 1) t
WHERE rn = 1 ORDER BY city''').fetchall()
assert rows == [('上海', 'o2', '2024-05-01 11:00'), ('北京', 'o4', '2024-05-03 12:00')]
print(rows)
```
要点:`ROW_NUMBER`(强制取一条)vs `RANK`(并列同名次,top1 可能多条);先 WHERE 过滤再开窗,减少扫描量。
85. SQL:日活/月活去重、留存
```sql
-- 日活(DAU)
SELECT dt, COUNT(DISTINCT user_id) AS dau FROM user_active GROUP BY dt;
-- 次日留存
SELECT a.dt AS d1,
COUNT(DISTINCT a.user_id) AS users,
COUNT(DISTINCT b.user_id) AS retained,
ROUND(COUNT(DISTINCT b.user_id) / COUNT(DISTINCT a.user_id), 3) AS retention_1d
FROM user_active a
LEFT JOIN user_active b
ON a.user_id = b.user_id AND b.dt = DATE_ADD(a.dt, 1) -- Hive 语法
GROUP BY a.dt;
```
【可运行验证】(sqlite3,DATE_ADD 换成日期整数演示):
```python
import sqlite3
conn = sqlite3.connect(':memory:')
c = conn.cursor()
c.executescript('''
CREATE TABLE user_active (dt INT, user_id TEXT);
INSERT INTO user_active VALUES
(1,'u1'),(1,'u2'),(1,'u3'), -- 第1天 3 人
(2,'u1'),(2,'u2'), -- 第2天 u1/u2 回访
(3,'u1');
''')
留存:自连接(b.dt = a.dt + 1)
rows = c.execute('''
SELECT a.dt AS d1, COUNT(DISTINCT a.user_id) AS users,
COUNT(DISTINCT b.user_id) AS retained
FROM user_active a
LEFT JOIN user_active b ON a.user_id = b.user_id AND b.dt = a.dt + 1
GROUP BY a.dt ORDER BY a.dt''').fetchall()
assert rows == [(1, 3, 2), (2, 2, 1), (3, 1, 0)] # 第1天留存 2/3,第2天 1/2
print(rows)
```
要点:①活跃口径先说清("当日至少一次有效行为");②大表 JOIN 大表先按天分区裁剪,`COUNT(DISTINCT)` 大时改两阶段(先 `GROUP BY dt, user_id` 再 count);③留存也可用**自关联窗口**或按天拉平的宽表预计算。
86. 手写模拟 HDFS 分块与 3 副本分配
```python
import math, random
def split_file(size_mb, block_mb=128):
"""按块大小切分文件:n = ceil(S/B)"""
n = math.ceil(size_mb / block_mb)
sizes = [block_mb] * (n - 1) + [size_mb - block_mb * (n - 1)]
return sizes
def assign_replicas(n_blocks, n_nodes, racks, replication=3):
"""机架感知简化版:副本必须分布在不同机架;返回 {block: [node...]}"""
assign = {}
for b in range(n_blocks):
chosen, used_racks = [], set()
while len(chosen) < replication:
node = random.randrange(n_nodes)
if node in chosen or racks[node] in used_racks:
continue # 同节点/同机架跳过
chosen.append(node); used_racks.add(racks[node])
assign[b] = sorted(chosen)
return assign
sizes = split_file(500) # [128, 128, 128, 116]
assert sum(sizes) == 500 and len(sizes) == 4
racks = ['A', 'A', 'B', 'B', 'C'] # 5 节点 3 机架
assign = assign_replicas(len(sizes), 5, racks)
assert all(len(nodes) == 3 and len({racks[n] for n in nodes}) == 3
for nodes in assign.values()) # 每块 3 副本且跨 3 机架
print(assign)
```
配图:`assets/fig_hdfs_blocks.png`
---
附:90 秒背诵骨架(对照原清单版)
**全链路**:日志/库 → 采集(Kafka) → 存储(HDFS/湖) → 离线(Hive/Spark) → 数仓(ODS→DWD→DWS→ADS) → 报表/特征;实时:→ Flink → 实时指标;调度+血缘+质量贯穿。
**HDFS**:块 128MB、3 副本(机架感知:本节点→另一机架→同机架异节点)、NameNode 元数据/DataNode 数据、小文件是坑、随机点查找 HBase。
**MR**:Map(局部) → Shuffle(分区+排序+落盘+网络) → Reduce(聚合);Combiner 传 (sum,count) 而非均值;Shuffle 慢、被 Spark 替代。
**Spark**:RDD+惰性求值+血缘;窄依赖流水线、宽依赖 Shuffle 切 Stage;reduceByKey 预聚合;倾斜用两阶段聚合/广播 join;DataFrame+Catalyst 为主。
**Flink**:事件时间+Watermark(max−d) 处理乱序;滚动/滑动/会话窗口;迟到用 allowedLateness/侧输出;精确一次=Checkpoint+两阶段提交;背压查 WebUI、加并行度/异步 IO。
**数仓**:四层解耦复用;事实表可加、维度表描述;星型快/雪花省;宽表最快;口径统一靠指标字典;质量四维(完整/准确/一致/及时);拉链表=SCD Type2;倾斜打散。
**Kafka**:分区并行、同 key 同分区有序;消费者组组内互斥组间广播;acks=all+min.insync.replicas=2 最稳;高吞吐=顺序写+零拷贝+批量压缩;积压=扩分区扩消费者+优化消费逻辑。
**平台**:DAG+拓扑排序+重试阻断;数据时间 T-1;血缘=影响分析/故障溯源/治理;权限=最小权限+列级脱敏+审计。