资讯动态

大数据面试机制拆解:Shuffle、数据倾斜与精确一次一致性

发布时间:2026/10/27 2:13:16 来源:尧图企业网站定制
简介面向大数据开发、运维、云计算、数据治理及架构岗位求职者的面试题文档聚焦招聘中高频考察的基础原理与项目细节适合系统复习、面试前查漏补缺和日常技术对照。内容按Linux与Shell、Hadoop、Zookeeper、Flume、Kafka、Hive、HBase、Sqoop、Scala、Spark等模块编排覆盖常用命令与脚本、HDFS读写流程、MapReduce Shuffle、YARN作业提交、Flume事务与防丢失、Kafka分区副本与数据重复、Hive窗口函数与数据倾斜、HBase RowKey设计、Sqoop导入导出一致性以及Spark部署、算子、宽窄依赖、缓存与checkpoint等要点目录层次较细便于按知识块检索。压缩包内共1个docx文件大小约491KB以文档形式集中呈现题目与解析。已有882人学习下载可作为大数据方向面试准备与知识体系梳理的参考。1. 为什么大数据面试里最容易被问穿的是「你这个任务跑了多久」一道「HDFS 写流程分几步」的题背三天就能答得滴水不漏换成「你这个任务为什么跑了 6 小时卡在哪一段」绝大多数人就答不下去了。差别在于前者考的是记忆后者考的是你对 Shuffle、分区、副本同步这些机制到底有没有形成因果链——而大数据面试真正筛人的恰恰是后半段。这份题库的价值也在这里它没有停在「Hadoop 常用端口号」「Hive 内部表和外部表区别」这类记忆题上而是把 Linux Shell、Hadoop、Zookeeper、Flume、Kafka、Hive、HBase、Sqoop、Scala、Spark、Flink 一直到数据倾斜、数仓分层、拉链表、数据质量监控串成了一条从组件到架构的链路。ETL 开发、运维、云上数据开发、数据治理这些岗位问的东西看着不同底层考的都是同一批机制。下面不逐题给答案而是挑出这套题里最需要「讲透」的几组机制从现象推到实现看看那些标准答案背后的因果是什么。2. Shuffle 与数据倾斜map 端聚合到底省下了什么先给结论Shuffle 的成本大头不是磁盘读写而是「跨节点搬运的记录条数」。题库里 reduceByKey 与 groupByKey 的区别、Shuffle 的四种写盘方式、数据倾斜的七种解法全部围绕这一个量做文章——谁能让进入网络的数据变少谁就赢。理解了这一点「加一个 combiner 把 8 小时压到 40 分钟」就不是玄学而是一道可以算出来的账。2.1 Sort Shuffle 的三段式缓冲、溢写、归并不管是 MapReduce 还是 SparkShuffle 的写端都遵循同一个骨架map 端把K, V先攒进一块内存缓冲区攒到阈值后按分区编号排序并溢写到磁盘最后把所有溢写文件归并成「一个 map task 对应一份数据」。区别只在于细节。MapReduce 的经典实现是环形缓冲区默认 100MB溢写比例 0.8——也就是说写到 80MB 就开始往磁盘刷剩下的 20% 留给持续写入避免边写边刷时互相阻塞。Spark 的 SortShuffleManager 则更激进它在 map 端不按分区拆成多个文件而是把同一个 map task 的所有分区数据写进一个data 文件再配一个 index 文件记录每个分区的偏移量和长度。维度MapReduce ShuffleSpark Sort Shufflemap 端输出文件数每个分区一个文件文件数 map 数 × reduce 数每个 map task 一个 data 文件 一个 index 文件排序时机溢写前排序归并时再排序溢写前按 partitionId 排序归并时保持有序reduce 端拉取按分区拉需要处理大量小文件按 index 定位区间一次读取偏移范围典型瓶颈小文件过多导致的随机读单文件过大导致的归并内存压力这个设计取舍很值得琢磨MapReduce 的方案对 reduce 端友好——每个分区一个文件拉取时直接顺着读就行但 map 数上千、reduce 数上百时中间文件能到几十万个磁盘 inode 和随机读直接炸掉。Spark 反过来把文件数压到 map 数级别代价是 reduce 端必须依赖 index 文件做随机跳读。所谓「小文件问题」在 Shuffle 这一层就已经埋下了。还有一个容易被忽略的点归并本身是流式的。溢写文件不是一次性全读进内存排序而是用最小堆做 k 路归并堆里只放 k 个文件当前的头部记录。所以归并的内存占用和溢写文件数成正比和总数据量无关。面试里问「数据量大到内存放不下怎么办」正确答案不是「加内存」而是「让归并的 k 可控」——这也是为什么调大溢写阈值、减少溢写次数往往比调大堆内存更有效。2.2 reduceByKey 和 groupByKey差别不在 API在 combiner 的位置这两个算子经常被当成「语义相近、性能不同」的一对但它们真正的差别是聚合动作发生在网络传输之前还是之后。groupByKey的语义是「把相同 key 的所有 value 收集到一起」这个动作只有在拿到全部 value 之后才能完成所以它没得选——必须把 map 端所有的K, V原封不动地 shuffle 出去。reduceByKey的语义是「对相同 key 的 value 做归约」归约满足结合律和交换律于是 map 端可以先做一次局部聚合把(a, 1), (a, 1), (a, 1)压成(a, 3)再发出去。// 假设日志里每条记录贡献一个 (word, 1) val pairs rdd.map(line (line.split(\t)(0), 1L)) // 写法一groupByKey 之后手动求和 // map 端不做任何聚合所有 (word, 1) 全部落盘并跨网络传输 val byGroup pairs.groupByKey() .map { case (k, vs) (k, vs.sum) } // 写法二reduceByKeymap 端先做局部合并 // 同一个 map task 内相同 key 的值会被压成一条再发出去 val byReduce pairs.reduceByKey(_ _) // 写法三显式控制 map 端聚合的并行度 // numPartitions 决定下游拉取的分区数也间接决定每个 reduce task 的数据量 val byReduce2 pairs.reduceByKey(_ _, numPartitions 200)代码里三个写法的输出结果完全一致但执行计划天差地别。第一种写法下如果某个 key 在单条日志里出现了一百万次这一百万条记录会全部进入 Shuffle第二种写法在 map 端就把它们压成一条。参数numPartitions值得单独说一句它不只是决定并行度还决定了每个下游 task 要拉多少数据设得太小会直接制造倾斜设得太大又会让每个 task 的开销和调度成本盖过实际计算。注意combiner 能生效的前提是聚合函数满足结合律和交换律。求平均值这种「先求和再除以计数」的场景如果直接写成reduceByKey((a, b) (a b) / 2)map 端合并和 reduce 端合并的结果会不一致——这是踩坑高发区正确做法是带着(sum, count)一起聚合最后再除。从设计动机上看reduceByKey把「能不能提前聚合」的判断权交给了用户是因为框架没法自动推断你的聚合函数是否可结合。这也是为什么 Flink 里做keyBy之后的聚合会被自动优化成增量聚合——它把聚合函数的性质编码进了算子签名ReduceFunction、AggregateFunction框架能确定性地判断「这个函数可以增量算」。Spark 的 RDD API 没这个约束于是只能靠用户自觉。2.3 数据倾斜定位从 task 耗时分布反推热点 key倾斜的表现很好认一个 stage 里 199 个 task 三分钟跑完剩下 1 个跑了两小时。但难的是定位到具体是哪个 key。只盯着「哪个 stage 慢」是不够的你还需要知道这个 stage 里哪个 key 的数据量远超其他。常见的做法是先采样再统计而不是全量扫一遍——全量统计本身就会触发一次完整的 Shuffle代价比问题本身还大。// 对 RDD 做小比例采样统计 key 的频次分布 // 0.1 表示采样 10%false 表示不重复采样有放回采样会让热点 key 更高频不合适 val sample pairs.sample(withReplacement false, fraction 0.1) // 统计 TOP 20 的 key注意 collect 之前一定要 take避免把全量结果拉回 Driver val topKeys sample .reduceByKey(_ _) // 采样后数据量已可控这里做一次完整聚合 .map(_.swap) // 交换 KV方便按 value 排序 .sortByKey(ascending false) .take(20) // 只取前 20不要 collect 全量 topKeys.foreach { case (cnt, key) println(shot key $key, sampled count $cnt, estimated ${cnt * 10}) }这段代码的关键不在逻辑在几个参数的选择上。采样比例fraction决定了估算精度和 Driver 端压力的平衡太小了热点 key 可能一次都没被采到太大了又失去了采样的意义一般从 1% 到 10% 之间试先看分布形状对不对。take(20)而不是collect是硬性纪律——倾斜场景下最大的那个 key 可能占全量数据的 40%collect 回来就是一次 OOM。拿到热点 key 之后再谈解法才有意义。题库里列的「加随机前缀」「拆分大 key」「提高并行度」这些方案本质上是三种不同的思路随机前缀是把一个 key 打散成 N 个 key让 N 个 task 分担拆分大 key 是针对「单个 key 的 value 列表就放不下内存」的场景提高并行度则只对「key 数量足够多但分区数太少」的情况有效。先判断你的倾斜是哪一类再选方案否则很容易出现「加了并行度反而更慢」的翻车现场——因为分区数变了但热点 key 还是那一个多出来的 task 全是空转。3. 一致性语义Flume 事务、Kafka ISR 与端到端精确一次的共同底牌「Kafka 会不会丢数据」「Spark Streaming 怎么做到精确一次」这两道题经常被分开问、分开背。但它们其实是同一个问题的两个侧面在一条由多个组件串起来的链路上状态到底存在哪一层、由谁来保证不重不漏。3.1 Flume 的 Put/Take 事务Channel 是唯一的状态边界Flume 的可靠性设计非常朴素Source 到 Channel 是一次事务Channel 到 Sink 是另一次事务。Source 读到数据后先开 Put 事务把一批事件写进 Channel写成功才提交事务、通知上游「我收了」Sink 从 Channel 取数据时开 Take 事务取出来往下一跳写写成功才提交、才把 Channel 里的数据标记为已消费。# 一个典型的多路复用配置重点看 Channel 类型和事务容量 agent.sources r1 agent.channels c1 agent.sinks k1 # Source 端batchSize 决定一次 Put 事务最多写多少条 agent.sources.r1.type org.apache.flume.source.kafka.KafkaSource agent.sources.r1.batchSize 500 agent.sources.r1.channels c1 # Channel 端capacity 是总容量transactionCapacity 是单次事务最大条数 # 这两个值必须 Source 的 batchSize 和 Sink 的 batchSize否则事务永远提交不了 agent.channels.c1.type file agent.channels.c1.capacity 100000 agent.channels.c1.transactionCapacity 1000 agent.channels.c1.checkpointDir /data/flume/checkpoint agent.channels.c1.dataDirs /data/flume/data # Sink 端hdfs.batchSize 决定一次 Take 事务写多少条 agent.sinks.k1.type hdfs agent.sinks.k1.hdfs.path /warehouse/ods/log/dt%Y-%m-%d agent.sinks.k1.hdfs.batchSize 1000 agent.sinks.k1.hdfs.rollSize 134217728 agent.sinks.k1.hdfs.rollCount 0 agent.sinks.k1.channel c1配置里最容易配错的是容量关系。capacity是 Channel 能容纳的事件总数transactionCapacity是单次事务的上限如果transactionCapacity小于 Source 的batchSizeSource 想一次写 500 条但 Channel 一次只让写 1000 条以下的某个更小值就会出现「事务反复回滚、数据一直积压」的现象——日志里全是事务异常但看起来每个参数都「配了」。真正的关键结论是Flume 只在 Memory Channel 下会丢数据。Memory Channel 没有落盘进程一挂缓冲区里的数据直接消失而且 Put 事务提交后 Source 就认为数据「已交付」了。File Channel 用 WAL 加 checkpoint 做持久化进程重启后能从 checkpoint 恢复。所以「Flume 采集数据会不会丢」这个问题标准答案不是「不会」而是「取决于 Channel 类型和你有没有开 Sink 的重试」——两者都是配置层的选择不是框架的默认保证。3.2 Kafka 的 LEO、HW 与 ISRleader 写完不等于消费可见Kafka 的副本机制里有两个位置指针经常被混为一谈LEOLog End Offset日志末端偏移量和 HWHigh Watermark高水位。LEO 表示这个副本自己写到了哪HW 表示所有 ISR 副本都确认写到了哪。消费者只能读到 HW 之前的消息。这个设计解释了一个反直觉现象生产者明明收到了 ack消费者却读不到最新数据。原因就在于 leader 写完了自己的 LEO但 follower 还没同步过来HW 没推进消费者就看不到。这是 Kafka 为了「消费者不会读到可能丢失的数据」付出的代价——如果允许读 HW 之后的数据一旦 leader 挂掉、follower 还没同步消费者读到的消息就凭空消失了。ISRIn-Sync Replicas是另一层机制它维护的是「和 leader 保持同步的副本集合」。副本落后超过replica.lag.time.max.ms就会被踢出 ISR避免一个慢副本拖住整个分区的 HW 推进。这也解释了为什么acksall不等于「绝对不丢」——如果 ISR 里只剩 leader 自己min.insync.replicas1那acksall退化成acks1跟只写 leader 没区别。# 查看某个 topic 各分区的 ISR 情况 # 如果 ISR 里只剩 leader 一个 broker说明副本同步已经跟不上此时 acksall 形同虚设 kafka-topics.sh --bootstrap-server broker:9092 \ --describe --topic ods_user_action # 输出形如 # Topic: ods_user_action Partition: 0 Leader: 1 Replicas: 1,2,3 Isr: 1,2 # 这表示 3 号副本已被踢出 ISR只剩 1 和 2 在同步min.insync.replicas这个参数的意义就在这它给acksall加了一个下限。设成 2则 ISR 少于 2 个副本时生产者直接报错宁可写失败也不接受「只有 leader 写了」的确认。这是典型的可用性换一致性——在数据治理和金融类场景里这个交换通常是划算的。3.3 精确一次的三块拼图幂等、事务、offset 提交时机「端到端精确一次」这个词被用得太多容易让人以为是个开关。实际上它是三块拼图叠出来的生产端幂等、生产端事务、消费端 offset 与处理结果同批提交。少任何一块退化成「至少一次」。生产端幂等靠的是 PIDProducer ID加分区内的序列号broker 对同一个PID, 分区, 序列号只接受一次写入重复的直接丢弃。这解决了「生产者重试导致的重复」但解决不了「跨分区的原子写」和「消费端处理完但 offset 没提交」这两类问题——后者必须靠事务。// 生产者开启幂等 事务的前提配置 Properties props new Properties(); props.put(bootstrap.servers, broker:9092); // 幂等性开关开启后 broker 会按 PID 序列号去重 // 注意它要求 acksall、retries0、max.in.flight5否则会直接抛配置异常 props.put(enable.idempotence, true); props.put(acks, all); // 事务 ID 必须是全局唯一且稳定的同一个 transactional.id 重启后会 // 中止上一个实例未提交的事务这是实现「跨会话精确一次」的关键 props.put(transactional.id, ods-user-action-etl-01); KafkaProducerString, String producer new KafkaProducer(props); producer.initTransactions(); // 向 broker 注册 PID必须在 send 之前调用 try { producer.beginTransaction(); for (Record r : batch) { producer.send(new ProducerRecord(dwd_user_action, r.key(), r.value())); } // 把消费端的 offset 作为事务的一部分提交 // 这一步是端到端精确一次的核心写结果和记进度在同一个事务里 producer.sendOffsetsToTransaction(currentOffsets, consumerGroupId); producer.commitTransaction(); } catch (Exception e) { producer.abortTransaction(); // 任何一条失败整批回滚 throw e; }这段代码里最值得说的是sendOffsetsToTransaction。它把「下游写成功」和「上游读到哪里」绑进了同一个事务于是这两个动作要么都生效、要么都不生效。如果只是简单地在处理完后调用consumer.commitSync()那么「数据处理完了但 commit 前进程挂了」就会导致重复消费——这就是为什么很多团队明明开了幂等还是会出现重复数据。参数transactional.id也有讲究它必须对同一个逻辑生产者稳定不能每次启动随机生成否则重启后旧事务不会被清理会出现「事务超时后数据一直挂起」的问题。而max.in.flight.requests.per.connection在开启幂等后会被框架限制在 5 以内因为请求乱序重排会破坏序列号的单调性。提示精确一次的成本是延迟。事务提交要等所有分区确认commitTransaction是一次同步的网络往返吞吐会明显低于「至少一次」。如果业务能接受少量重复比如日志类数据不要为了「听起来更正确」硬上事务。4. 常见排查与避坑五条线上翻车记录分别卡在哪个机制上标准答案能背下来不代表线上不会翻车。下面这五条都是「配置看起来都对、报错信息看起来都无关」的典型每一条都对应前面讲过的某个机制。4.1 小文件把 NameNode 撑爆HDFS Sink 的滚动策略没配现象Flume 采集跑了一周NameNode 告警堆内存不足hdfs dfs -count一看目录下有上千万个几十 KB 的小文件每个文件对应一分钟的数据。原因HDFS Sink 的rollInterval、rollSize、rollCount三个滚动条件默认是按时间触发的rollSize默认值很小。采集频率高、单批数据量小时每个文件都够不到大小阈值就被时间条件滚走了。HDFS 的每个文件、每个块在 NameNode 里都是一条元数据记录占一百多字节上千万文件就是 GB 级内存。解决三个滚动条件里只保留rollSize比如 128MB把rollInterval设为 0 关闭时间滚动rollCount也设为 0。同时在下游加一步定时的小文件合并任务用concatenate或者重写分区的方式把文件数压下来。根子上的思路是HDFS 适合大文件顺序读任何按时间切分的策略都会制造小文件滚动策略要按数据量而不是按时间来定。4.2 字段里混进 \t 导致整行错列分隔符选型没做转义现象Hive 表查出来的数据整体右移一列某些行的最后一个字段变成了 NULL而且错位的行数每天不一样。原因源数据里某个文本字段比如用户评论、商品标题本身就含有制表符。用\t做列分隔符时这一行会被多切一刀后续所有字段的位置全部偏移。这类问题在数据量小的时候几乎发现不了量一大必然出现。解决短期用regexp_replace在入仓前把这一个字段里的\t替换掉长期换分隔符用\001ASCII 的 SOH这类业务数据里几乎不可能出现的控制字符。Hive 默认的分隔符就是\001很多人为了「看得清楚」改成\t反而给自己埋了雷。字段里含分隔符这件事没有一劳永逸的方案只能在采集端做清洗或者在建表时选一个业务字段里不会出现的字符。4.3 动态分区一次写出几万个分区没有限制单次分区数现象一个 Hive 任务平时跑 20 分钟某天突然跑了 5 小时还没结束最后报Number of dynamic partitions exceeded。原因动态分区会根据数据里出现的分区字段值自动创建分区。当天上游数据里混进了一批测试数据分区字段的取值从几十个暴涨到几万个每个分区都要在 HDFS 上建目录、写文件元数据操作直接把 Metastore 拖死。解决给动态分区加上限同时开启非严格模式避免一个分区一个文件地写。-- 单次任务最多允许创建 500 个动态分区超过直接失败而不是把集群拖垮 SET hive.exec.max.dynamic.partitions 500; -- 每个节点上最多创建 100 个分区防止单节点元数据操作过载 SET hive.exec.max.dynamic.partitions.pernode 100; -- 非严格模式分区字段值不做额外校验但要求至少有一个静态分区列 SET hive.exec.dynamic.partition.mode nonstrict; INSERT OVERWRITE TABLE dwd_user_action PARTITION (dt) SELECT ..., dt FROM ods_user_action WHERE dt ${bizdate}; -- 加上静态分区过滤把扫描范围钉死最后那个WHERE dt ${bizdate}是关键。很多动态分区任务没有分区过滤扫的是整张 ODS 表数据里一旦混入历史分区值就会触发全量重写。动态分区的数量应该由业务维度决定而不是由数据里碰巧出现的值决定。4.4 消费积压后重启重复消费一大片offset 提交时机不对现象Kafka 某个 topic 积压了几千万条运维把消费端重启扩容结果下游报表出现了大面积重复计数去重之后对不上账。原因消费端是「先处理、后提交 offset」且处理逻辑里有批量写库的操作。重启时最后一批「已处理但未提交」的数据被重新拉取了一遍下游没做幂等重复数据直接入库。解决分两层。第一层是消费端把 offset 提交和处理结果绑进同一个事务或者至少做到「写库成功后再提交 offset」第二层是下游表设计上带唯一键用INSERT ... ON DUPLICATE KEY UPDATE或者MERGE做幂等写入。只靠消费端保证精确一次是不可靠的因为「处理成功」和「offset 提交成功」之间永远有一个时间窗口除非你把它们放进同一个原子操作里。4.5 Checkpoint 一直失败但任务照跑状态后端和超时没对上现象Flink 任务的 UI 上 checkpoint 一直显示失败但任务本身跑得好好的延迟也没明显变化于是没人管直到某天作业重启从很早的状态恢复丢了一大段数据。原因checkpoint 失败通常有两个来源状态太大导致超时或者反压导致 barrier 迟迟对齐不了。任务不挂是因为 Flink 的默认策略是「checkpoint 失败不影响运行」它会一直重试。但重试期间状态一直在涨直到超过checkpoint.timeout之后被丢弃。作业重启时发现没有可用的最新 checkpoint只能回退到很久以前的那一个。解决先把 checkpoint 的超时和间隔调到一个合理的关系间隔的 10 倍左右是常见的经验值再确认状态后端是 RocksDB 而不是堆内存大状态下堆内 backend 做增量 checkpoint 几乎不可能。更重要的是把 checkpoint 失败当作故障处理配监控告警而不是让它默默重试。注意checkpoint 失败不报错、任务继续跑是很多人误以为「没问题」的原因。它和 Flink 的容错语义强绑定checkpoint 断了Exactly-once 就成了纸面上的承诺。5. Flink 的运行时三层图、Task Slot 与 barrier 对齐怎么串起来Flink 那部分题三层图、Slot、并行度、反压、分布式快照在题库里被拆成了十几个独立问题但它们其实是一条链上的不同环节用户代码先被翻译成图图被切成可以并行执行的子任务子任务被塞进 Slot 里跑跑的过程中靠 barrier 做快照、靠背压信号控制速度。理解这条链那十几个问题会自动连成一个整体。5.1 StreamGraph → JobGraph → ExecutionGraph每一步在合并什么用户写的DataStreamAPI 调用在提交时会先被翻译成 StreamGraph——这是一张逻辑图节点是算子边是数据流向。接着做一步关键优化算子链合并。如果两个算子之间是 One-to-One 的转发关系不需要 shuffle且并行度一致它们会被合并成一个 JobVertex跑在同一个线程里中间的数据传递退化成方法调用不走序列化和网络。阶段生成者关键动作合并了什么StreamGraph客户端把算子调用翻译成逻辑节点和边不做合并纯粹的逻辑表达JobGraph客户端算子链优化、确定 Slot 共享组把 One-to-One 的算子合并成 OperatorChainExecutionGraphJobManager按并行度展开成并行子任务生成调度拓扑把 JobVertex 展开成 N 个 ExecutionVertex算子链合并的收益很直接一条source - map - filter - sink的链路如果不合并每条记录要走三次序列化、三次网络传输合并之后全部在同一个 task 线程里完成序列化只剩下 source 读入和 sink 写出两处。代价是合并后的 task 只能整体调度某个算子的反压会直接卡住整条链——这也是为什么有时候需要手动disableChaining()或者startNewChain()来打断链条把 CPU 密集的算子和 IO 密集的算子分开。一个常见的误解是「并行度是在图上设置的」。实际上并行度在 StreamGraph 阶段就已经确定在算子上JobGraph 阶段只负责合并ExecutionGraph 阶段才按并行度展开成具体的执行单元。所以「某个算子并行度设错了」这件事在 ExecutionGraph 里会表现为「这一个 JobVertex 的 ExecutionVertex 数量和其他不一致」进而导致上游必须做 rescale 或者 rebalance 才能对接。5.2 Slot 与并行度资源槽不是线程数也不是 CPU 核数Task Slot 是最容易被理解错的概念。它不是线程、不是 CPU 核而是TaskManager 内存资源的切分单位。一个 TaskManager 启动时声明自己有 N 个 slot就把可用内存均分成 N 份每个 slot 跑一条完整的算子链一个 pipeline。同一个 slot 里的并行子任务共享这个 slot 的内存配额。# flink-conf.yaml 里和 slot 相关的三项配置 taskmanager.numberOfTaskSlots: 4 # 每个 TM 切出 4 个 slot taskmanager.memory.process.size: 8192m # TM 总内存 8G均分后每 slot 约 2G # 作业默认并行度它会和 slot 数一起决定需要几个 TM parallelism.default: 8这三项放在一起才能算清楚账总并行度 8、每个 TM 4 个 slot那么至少需要 2 个 TaskManager。如果实际只起了 1 个 TM作业会一直处于「等待资源」状态UI 上显示 slot 不够但 CPU 和内存看起来都很空闲——因为卡住的是 slot 配额不是物理资源。Slot 共享slot sharing group是另一层机制不同算子的子任务可以跑在同一个 slot 里只要它们属于同一个共享组。默认所有算子都在default组于是source(并行度 8) - map(并行度 8) - sink(并行度 2)这条链只需要 8 个 slot而不是 18 个。这个设计的动机是让一个 slot 里同时跑输入密集和输出密集的算子把资源利用率拉满。代价是隔离性下降一个算子 OOM 会拖垮整个 slot 里的所有任务。提示如果某个算子明显比同链路的其他算子重比如带状态的聚合把它单独拆到一个 slot 共享组里可以让它的资源需求和别的算子解耦出问题时影响面也小。5.3 Checkpoint barrier 对齐与反压同一套背压信号的两个方向分布式快照的核心是 barrierJobManager 周期性往 source 注入一个带编号的 barrier它像一道栅栏一样随着数据流往下游走。每个算子收到 barrier 后先把当前状态做一次快照写到状态后端再把 barrier 转发给下游。当某个算子有多个输入通道时它必须等所有通道的 barrier 都到齐才能做快照——这就是 barrier 对齐。对齐的意义在于保证快照的一致性如果不等齐就直接快照那么快的那个通道在 barrier 之后的数据已经被处理了而慢的通道还没有快照里就会出现「一部分输入已消费、一部分未消费」的中间态恢复时会重复或丢失。// 开启 checkpoint 并配置对齐行为 StreamExecutionEnvironment env StreamExecutionEnvironment.getExecutionEnvironment(); env.enableCheckpointing(60000); // 每 60 秒做一次 checkpoint env.getCheckpointConfig().setCheckpointingMode(CheckpointingMode.EXACTLY_ONCE); env.getCheckpointConfig().setCheckpointTimeout(600000); // 单次 checkpoint 超时 10 分钟 env.getCheckpointConfig().setMinPauseBetweenCheckpoints(30000); // 两次之间至少间隔 30 秒 env.getCheckpointConfig().setMaxConcurrentCheckpoints(1); // 不允许并发避免互相抢资源 env.getCheckpointConfig().setTolerableCheckpointFailureNumber(3); // 连续失败 3 次才判定作业异常 // 开启非对齐 checkpoint跳过 barrier 对齐把对齐期间的数据也写进快照 // 适合反压严重、对齐耗时长的场景代价是快照体积变大 env.getCheckpointConfig().enableUnalignedCheckpoints();非对齐 checkpoint 是一个很有意思的设计取舍。对齐的代价是反压时慢通道迟迟不推进快通道的数据要在缓冲区里等着等的时间越长checkpoint 越容易超时同时缓冲区积压又加剧反压——一个自我强化的恶性循环。非对齐的做法是把「对齐期间到达的数据」也一并写进快照这样不用等但快照里会包含大量在途数据状态体积明显变大。所以它是拿存储换延迟不是免费的。反压本身是另一套机制但方向和 barrier 相反下游处理不过来缓冲区水位上涨上游通过 Netty 的 credit 机制感知到「对端可读的缓冲区满了」于是降低发送速率这个信号一路往上游传到 sourcesource 就降速读。整个链路上没有中心化的限流器全靠每个 TCP 连接的信用额度逐级传递。这也解释了为什么「反压时看哪个算子最慢」往往不准——反压会沿着链路往回传你看到的慢算子可能只是被下游拖住的受害者。真正定位瓶颈要看每个算子的busy和backpressured时间占比backpressured高说明是被下游卡住busy高说明是自己算不过来。6. 设计取舍同一份数据在离线与实时链路上的两套账把上面这些机制串起来看会发现整个大数据栈其实在做同一件事的两种极端版本离线链路用「重跑」换简单实时链路用「状态」换延迟。离线数仓的分层ODS/DWD/DWS/ADS本质上是在给「重跑」留退路。ODS 层不做清洗、原样保留是因为上游数据一旦出问题你只有这一份原始证据可以回溯DWD 层做清洗和维度关联把口径固定下来DWS 层按主题做轻度汇总让 ADS 层的报表查询不必每次扫全量。每一层都存一份数据代价是存储和计算翻倍换来的是「某张表算错了可以只重跑那一层」。这也是为什么不少团队会选择把 ODS 层用压缩率更高、但读取稍慢的格式存着而 DWD 之后换成列式存储——层次不同读写模式不同格式选择自然不同。实时链路没有这个退路。Flink 的状态一旦丢了你只能从上一个 checkpoint 恢复中间那段时间的数据要靠离线补。所以实时链路的设计重心全在「状态怎么持久化、checkpoint 怎么不失败、端到端怎么不重不漏」上也就是第 3 章和第 5 章讲的那些机制。两条链路的口径要对齐靠的是同一份 DWD 明细——离线算一遍、实时算一遍然后拿两边对账对不上就说明某一边的语义出了问题。取舍点离线链路的选择实时链路的选择判断依据数据保留全量保留多层可回溯只保留状态和最近的原始数据能否接受重跑一致性靠分区覆盖写天然幂等靠 checkpoint 和两阶段提交失败后的恢复成本延迟分钟到小时级毫秒到秒级业务对时效的真实需求成本存储便宜、计算贵常驻资源贵、延迟低峰值资源能否复用验证一个答案站不站得住我一般用两个动作一是从指标倒推机制。比如有人告诉你「用了广播变量所以变快了」你就问一句「小表有多大广播阈值是多少广播之后每个 executor 多占了多少内存」——能答上来的是真做过答不上来的是背的。二是从故障倒推设计。问「Kafka 的 ISR 有什么用」不如问「如果一个 follower 落后了 10 分钟会发生什么你怎么发现」后一个问题会逼着对方把 LEO、HW、replica.lag.time.max.ms和监控指标全串起来。这套题库真正值得看的地方不是它列了多少道题而是它把从 Linux 命令一直到 Flink 三层图的整条链路摊开了每一段都留了可以往下钻的口子。顺着目录往下挖每一节背后都是一套可以拿线上故障去验证的机制。我自己的习惯是每次准备面试或者 review 一个方案之前先不看答案而是把「这个组件挂了会怎样、这个参数改大了会怎样、这条链路哪一步可能重复」这三个问题写在纸上然后逐条去代码和文档里找证据。找了几年之后发现能答得漂亮的问题越来越少能问出好问题的时候越来越多。从那以后我每次判断一个技术方案都强制自己走一遍「如果这一步失败数据会去哪」的推演而不是先看它跑得多快。希望帮到你。本文还有配套的精品资源点击获取

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑