1. 大数据高级核心技术点全景拆解
1.1 从“能跑通”到“跑得稳”的分水岭在哪里
刚入行那几年,我对大数据的理解长期停留在“能跑通就行”的阶段——任务提交上去,结果能出来,就认为万事大吉。直到有一次,一个每天凌晨调度的小时级任务突然从40分钟膨胀到3个小时,把下游一整条链路全部拖垮,我才真正意识到:大数据这门手艺,入门靠的是会写SQL、会调API,而真正拉开差距的,是那些藏在“能跑通”背后的高级核心技术点。
所谓“高级核心技术点”,不是指某个冷门函数或者某个偏门参数,而是那些决定系统在数据量翻十倍、并发翻百倍时还能不能稳住的底层能力。它大致可以归为四类:存储与文件格式的取舍、计算引擎的执行模型、资源调度与并发控制、数据倾斜与性能调优。这四类问题几乎覆盖了日常工作中90%的“疑难杂症”,也是面试里区分初中级和高级工程师的核心分水岭。
这篇文章适合两类人看:一类是已经能独立完成ETL开发、但一遇到性能瓶颈就无从下手的工程师;另一类是准备往数据架构方向走、需要把零散知识点串成体系的技术人。我会尽量用大白话把每个技术点讲透,配上我实际踩过的坑和可复现的参数配置,让你看完就能拿去对照自己的项目排查。
1.2 四个绕不开的核心领域
在展开细节之前,先把这四个领域的关系理清楚,不然后面容易越看越乱。
存储与文件格式是地基。数据以什么格式落在磁盘上,直接决定了后续读取时要付出多少I/O代价。列式存储和行式存储的区别、压缩算法的选择、小文件问题的治理,都属于这一层。
计算引擎的执行模型是发动机。同样一个聚合逻辑,为什么有的写法能走向量化执行、有的却退化成逐行处理?为什么同样是Join,Broadcast Join和Sort Merge Join的性能能差出一个数量级?理解执行计划,是调优的前提。
资源调度与并发控制是交通规则。YARN、Kubernetes这些调度器怎么分配CPU和内存,任务之间怎么排队,并发度设多少才合理,这些参数设错了,再好的代码也跑不快。
数据倾斜与性能调优则是综合实战。前面三层的基础打牢了,这一层就是把这些知识组合起来,去解决真实场景里最恶心的那个问题——某个Key的数据量是其他Key的几百倍,导致个别Task拖垮整个作业。
下面我按这个顺序,逐个拆开讲。
2. 存储与文件格式:被低估的性能地基
2.1 列式存储为什么快,快在哪里
很多人知道Parquet、ORC是列式存储,比CSV、JSON快,但说不清楚快在哪。我用一个生活化的类比来解释:假设你要统计一个班级所有学生的数学平均分。行式存储就像把每个学生的所有科目成绩写在一张卡片上,你得把每张卡片都翻一遍,才能找到数学那一栏;列式存储则是把所有学生的数学成绩单独抄在一列纸上,你只需要看这一列,其他科目完全不用碰。
这个差异在宽表场景下会被急剧放大。一张有200个字段的表,如果查询只用到其中5个字段,列式存储的I/O量理论上只有行式存储的2.5%。实际测试中,因为列式存储还有更好的压缩率(同一列的数据类型一致、重复度高),综合下来读取速度能快5到10倍。
但列式存储不是没有代价。它的写入成本更高,因为要按列组织数据,还要维护元数据;它也不适合频繁的单行更新,每次更新都可能触发整个Row Group的重写。所以选型时要看场景:分析型查询(OLAP)用列式,事务型写入(OLTP)用行式,这是基本原则。
2.2 Parquet与ORC的选型对比
Parquet和ORC是当前最主流的两种列式格式,很多人纠结选哪个。我把它们的核心差异整理成一张表:
| 对比维度 | Parquet | ORC |
|---|---|---|
| 起源生态 | 面向通用分析,跨引擎支持好 | 起源于Hive生态,与Hive深度绑定 |
| 嵌套结构支持 | 原生支持嵌套(Dremel模型) | 支持但相对弱一些 |
| 压缩效率 | 优秀,尤其配合Snappy | 优秀,配合Zlib通常略优 |
| 谓词下推 | 支持,但统计信息粒度较粗 | 支持,索引信息更丰富 |
| 向量化读取 | 支持 | 支持更成熟 |
| 社区活跃度 | 极高,多引擎默认 | 高,但偏Hive系 |
我的实际经验是:如果你的技术栈是Spark为主、需要跨多种引擎读取,选Parquet更省心;如果整个链路都在Hive/Tez体系内、追求极致的压缩和谓词下推,ORC会更合适。两者没有绝对优劣,关键是整个团队统一,不要一个项目里两种格式混用,否则维护成本会很高。
2.3 小文件问题:最容易被忽视的性能杀手
小文件问题是大数据领域最经典也最容易被忽视的坑。它的本质是:每个文件在读取时都要经历一次打开、寻址、读取元数据的开销,如果文件数量太多、单个文件又很小,那么真正用于读取数据的I/O时间占比会非常低,大部分时间都耗在了元数据操作上。
我遇到过一个极端案例:某张表有800万个文件,平均每个文件只有几KB,一个简单的count查询跑了40分钟还没出结果。后来用合并任务把文件数降到2000个,同样的查询20秒就返回了。
治理小文件的常见手段有这么几种:
- 写入时控制:在Spark里通过
spark.sql.files.maxRecordsPerFile限制单文件记录数,或者用repartition/coalesce控制输出分区数。 - 定期合并:写一个定时任务,用
INSERT OVERWRITE把多个小文件读出来重新写成大文件。 - 使用支持自动合并的格式:比如Delta Lake、Hudi这类表格式,它们有自动的compaction机制。
注意:合并小文件本身也要消耗资源,不要频繁执行。一般按天或按周做一次即可,具体频率看数据增量速度。
2.4 压缩算法的取舍逻辑
压缩算法的选择,本质是在CPU开销和I/O开销之间做权衡。压缩率越高,磁盘占用越小、读取时I/O越少,但解压时CPU消耗越大。
| 算法 | 压缩率 | 压缩/解压速度 | 是否可切分 | 适用场景 |
|---|---|---|---|---|
| Snappy | 中等 | 很快 | 否 | 热数据、频繁读取 |
| LZ4 | 中等偏低 | 极快 | 否 | 对延迟极敏感 |
| Zlib/Gzip | 高 | 慢 | 否 | 冷数据归档 |
| Zstd | 高 | 较快 | 否 | 综合最优,新项目首选 |
这里有个关键点:Snappy、LZ4、Zstd这些压缩格式本身不可切分,也就是说一个压缩块必须整体解压。但因为Parquet/ORC是分块存储的,每个块内部独立压缩,所以整体文件仍然可以并行读取。这也是为什么列式格式能兼顾压缩率和并行度。
我的建议是:热数据用Zstd或Snappy,冷数据归档用Zlib。Zstd这几年成熟度已经很高,压缩率接近Zlib而速度快很多,新项目可以直接上。
3. 计算引擎执行模型:看懂执行计划才算入门
3.1 宽依赖与窄依赖:Shuffle的根源
Spark里最核心的概念之一就是依赖关系。窄依赖是指父RDD的每个分区只被一个子RDD分区使用,比如map、filter,这类操作不需要跨节点传输数据;宽依赖是指父RDD的每个分区可能被多个子RDD分区使用,比如groupByKey、join,这类操作需要把数据按Key重新分发到不同节点,也就是Shuffle。
Shuffle是Spark里最昂贵的操作,因为它涉及磁盘I/O、网络传输和序列化。一次Shuffle的数据量可能是原始数据的几倍(因为要写临时文件、要排序、要合并)。所以调优的第一原则就是:能避免Shuffle就避免,避免不了就尽量减少Shuffle的数据量。
举个具体例子:reduceByKey和groupByKey都能实现按Key聚合,但reduceByKey会在Map端先做一次本地聚合(combine),把数据量压下来再Shuffle;而groupByKey会把所有原始数据都Shuffle过去。同样一个逻辑,用reduceByKey可能只需要传输1GB,用groupByKey可能要传100GB。这个差异在数据量大时是致命的。
3.2 Join策略的选择与触发条件
Join是Shuffle的重灾区,也是调优的重点。Spark支持三种主要的Join策略,触发条件各不相同:
Broadcast Hash Join:当一张表足够小(默认阈值10MB,可通过spark.sql.autoBroadcastJoinThreshold调整)时,把小表广播到所有Executor,每个Executor在本地完成Join,完全避免Shuffle。这是最快的Join方式,能用就用。
Sort Merge Join:两张表都很大时的默认策略。先按Join Key对两张表分别排序,然后归并。它需要两次Shuffle(如果数据已经按Key分桶则可以避免),但内存占用可控,适合大数据量。
Shuffled Hash Join:把一张表按Key分区后加载到内存,另一张表流式探测。它比Sort Merge Join快,但要求分区后的数据能放进内存,否则会OOM。可以通过spark.sql.join.preferSortMergeJoin=false来启用。
选择逻辑可以用一句话概括:小表能广播就广播,不能广播且内存够就用Shuffled Hash,否则用Sort Merge。实际调优时,先看执行计划里用的是哪种Join,再判断是否合理。
3.3 向量化执行与代码生成
这两个是Spark 2.x之后引入的重要优化,很多人用了但不知道。
向量化执行是指一次处理一批数据(通常1024行)而不是一行一行处理。它利用了CPU的SIMD指令,能大幅减少函数调用开销和虚函数分派开销。在Parquet/ORC读取、聚合、过滤等场景下,向量化能带来2到5倍的性能提升。
**代码生成(Whole-Stage Code Generation)**是指Spark在运行时把多个算子动态编译成一个Java函数,避免中间结果的物化和虚函数调用。它通过spark.sql.codegen.wholeStage控制,默认开启。
这两个优化默认都是开的,但有些情况会失效:比如用了自定义UDF(尤其是Python UDF),向量化和代码生成都会退化。所以能用内置函数就别用UDF,这是性能调优的一条铁律。
3.4 内存管理与GC调优
Spark的内存模型分为三块:Execution Memory(用于Shuffle、Join、Sort)、Storage Memory(用于缓存RDD/DataFrame)、User Memory(用户自定义数据结构)。Execution和Storage之间可以动态借用,但User Memory是固定的。
GC问题通常出现在数据倾斜或者缓存过多导致内存压力大时。表现是Task执行时间忽长忽短,日志里频繁出现Full GC。调优手段包括:
- 增大Executor内存,给Execution留足空间。
- 使用Kryo序列化(
spark.serializer=org.apache.spark.serializer.KryoSerializer),它比Java序列化快且体积小。 - 减少
spark.memory.storageFraction,把更多内存留给Execution。 - 如果用了大量缓存,考虑用
MEMORY_AND_DISK级别,避免内存被撑爆。
实操心得:GC问题很难通过调参彻底解决,根本办法还是减少单Task处理的数据量。把大Task拆成小Task,让每个Task的内存占用降下来,GC自然就轻了。
4. 资源调度与并发控制:参数设错全盘皆输
4.1 Executor数量与核数的黄金比例
这是被问得最多的问题之一:一个作业该配多少Executor、每个Executor多少核、多少内存?
先说结论:没有万能公式,但有一个合理的起点。我的经验起点是:每个Executor配4到5个核,内存按每核4GB左右估算。比如一个作业需要20个核,那就配4个Executor、每个5核、每个20GB内存。
为什么是4到5核?因为核数太少,Executor的I/O和计算无法充分并行;核数太多(比如超过8),单个Executor内的GC压力和内存竞争会加剧,而且HDFS客户端的并发连接数也会成为瓶颈。
内存的估算逻辑是:总内存 = Execution Memory + Storage Memory + User Memory + 预留开销。其中预留开销一般占10%到15%。如果作业有大量Shuffle,Execution Memory要留足;如果有大量缓存,Storage Memory要留足。
| 参数 | 建议值 | 说明 |
|---|---|---|
| spark.executor.cores | 4-5 | 单Executor核数 |
| spark.executor.memory | 16-24GB | 单Executor堆内存 |
| spark.executor.memoryOverhead | 堆内存的10%-15% | 堆外内存 |
| spark.driver.memory | 4-8GB | Driver内存,收集结果多时调大 |
| spark.sql.shuffle.partitions | 200-2000 | Shuffle分区数,按数据量调 |
4.2 动态资源分配的是与非
动态资源分配(Dynamic Allocation)允许Spark根据负载自动增减Executor,理论上能提高集群利用率。但在生产环境里,它是一把双刃剑。
好处很明显:作业空闲时释放资源,忙时申请资源,多作业共享集群时能提升整体吞吐。但问题也很突出:资源申请和释放有延迟,在数据量波动大的场景下,可能出现“刚要扩容任务就结束了”或者“扩容速度跟不上数据增长”的情况。更麻烦的是,如果集群资源紧张,动态分配可能导致作业长时间卡在申请资源的状态。
我的建议是:离线批处理作业、运行时间长且数据量稳定的,用固定资源更可控;交互式查询、数据量波动大的,用动态分配更划算。如果开启动态分配,一定要设置spark.dynamicAllocation.minExecutors和maxExecutors,避免资源被无限占用或释放。
4.3 并发度与分区数的匹配
并发度不是越高越好。分区数太少,Task数量不足,集群资源用不满;分区数太多,每个Task处理的数据量太小,调度开销和任务启动开销占比过高。
一个经验公式是:分区数 ≈ 总核数 × 2到3。比如集群有100个核,那分区数设在200到300比较合适。这样既能保证所有核都有活干,又不会因为Task太多导致调度开销过大。
但要注意,这个公式只适用于没有数据倾斜的情况。如果存在倾斜,单纯增加分区数解决不了问题,反而可能让倾斜更严重(因为倾斜的Key还是集中在一个分区里)。倾斜的处理方法后面单独讲。
4.4 队列与优先级:多作业共存的生存法则
在生产集群里,你的作业往往不是一个人在跑。这时候队列配置和优先级就很重要。
YARN的Capacity Scheduler和Fair Scheduler是两种主流调度器。Capacity Scheduler适合多租户场景,每个队列有固定的资源上限,保证互不干扰;Fair Scheduler适合资源共享场景,空闲资源会被动态分配。
我的经验是:核心报表作业放高优先级队列,探索性分析放低优先级队列。这样即使探索性作业把资源占满,核心报表也能抢占资源按时产出。另外,给每个队列设置合理的maximum-am-resource-percent,避免一个队列的ApplicationMaster占满所有资源。
5. 数据倾斜:最恶心的性能问题
5.1 怎么判断是不是数据倾斜
数据倾斜的典型表现是:绝大多数Task很快跑完,但个别Task跑得极慢,甚至卡死。打开Spark UI,看Stage的Task时间分布,如果最长Task和平均Task时间差了几倍甚至几十倍,基本可以确定是倾斜。
倾斜的根源通常是某个或某几个Key的数据量远大于其他Key。比如按用户ID聚合,但有一个用户ID是“未知用户”,占了总数据量的30%;或者按地区聚合,但某个地区的记录数特别多。
5.2 常见倾斜场景与对应解法
不同场景的倾斜,解法完全不同。我把常见的几种整理如下:
| 倾斜场景 | 表现 | 解法 |
|---|---|---|
| Group By倾斜 | 某个Key数据量特别大 | 两阶段聚合:先加随机前缀打散,再聚合 |
| Join倾斜(大表Join小表) | 小表不够小无法广播 | 调大广播阈值,或强制广播 |
| Join倾斜(大表Join大表) | 某个Key在两张表里都很多 | 拆分倾斜Key单独处理,其余正常Join |
| 空值倾斜 | 大量Null值集中到一个分区 | 过滤Null或给Null加随机后缀 |
两阶段聚合是解决Group By倾斜最通用的方法。思路是:先给Key加上随机前缀(比如0到9),这样原来一个Key的数据被分散到10个分区里做局部聚合;然后再去掉前缀,做全局聚合。代价是多了一次Shuffle,但能有效打散倾斜。
拆分倾斜Key是解决大表Join大表的常用手段。先把倾斜的Key单独抽出来,用广播或者加随机前缀的方式处理;剩下的非倾斜Key走正常的Join。最后把两部分结果Union起来。
5.3 加盐打散的正确姿势
“加盐”就是给Key加随机前缀,是打散倾斜最直接的手段。但加盐有几个坑要注意:
第一,盐的粒度要合适。加10个前缀,就是把数据分成10份;加100个前缀,就是分成100份。前缀太多,小文件问题会加剧;前缀太少,打散效果不够。一般从10开始试,根据效果调整。
第二,加盐后要记得去盐。局部聚合完的结果带着前缀,全局聚合前必须把前缀去掉,否则同一个Key会被当成多个Key处理,结果就错了。
第三,加盐只适用于聚合类操作。如果是Join,加盐后两张表都要加同样的盐,否则对不上。而且如果只有一张表倾斜,另一张表不加盐,那加盐的那张表的数据会被打散到多个分区,但另一张表的数据还是集中的,Join时仍然会倾斜。
5.4 倾斜治理的长期策略
临时打补丁只能解决一时的问题,长期来看,还是要从数据源头治理:
- 在数据接入层做预处理:对已知的倾斜Key(比如空值、默认值)提前过滤或转换。
- 合理设计分区键:避免用分布极不均匀的字段做分区键。
- 建立监控告警:对Task时间分布做监控,一旦发现倾斜苗头及时处理。
- 定期做数据质量检查:统计各Key的数据量分布,提前发现潜在的倾斜风险。
踩过的坑:有一次为了快速解决倾斜,直接给所有Key加了100个随机前缀,结果Shuffle数据量暴增,作业反而更慢了。后来才明白,加盐要针对倾斜的Key加,不能无差别地加。
6. 高频问题排查与实战避坑
6.1 OOM问题:从现象到根因的排查路径
OOM(Out Of Memory)是大数据作业最常见的报错之一,但“OOM”这三个字本身信息量很少,关键是要定位是哪里OOM。
Driver OOM:通常是因为collect()把大量数据拉到Driver,或者广播变量太大。解法是避免collect()大结果集,改用分布式写入;广播变量超过阈值就改用普通Join。
Executor OOM:可能是Shuffle数据太大、缓存太多、或者单个Task处理的数据量太大。排查时先看是哪个Stage OOM,再看那个Stage的Task处理了多少数据。如果是Shuffle OOM,增大分区数;如果是缓存OOM,降低缓存级别;如果是单Task数据量大,先解决倾斜。
堆外内存OOM:报错信息里会有“Direct buffer memory”或“Container killed by YARN for exceeding memory limits”。这是spark.executor.memoryOverhead设小了,调大即可,一般设为堆内存的10%到15%。
6.2 任务卡死与长尾Task
任务卡死通常有两种情况:一种是资源申请不到,作业一直卡在等待状态;另一种是某个Task执行极慢,拖垮整个Stage。
资源申请不到,先看集群资源是否真的紧张,再看队列配置是否合理。有时候是spark.dynamicAllocation在反复申请释放,导致作业一直在等资源。
长尾Task就是前面说的数据倾斜。除了加盐和拆分Key,还有一个技巧是开启推测执行(spark.speculation=true)。它会让Spark在发现某个Task明显慢于其他Task时,启动一个备份Task同时跑,谁先完成用谁的结果。但推测执行会消耗额外资源,而且如果倾斜是必然的(比如某个Key就是数据多),推测执行也救不了,只是把慢Task复制了一份而已。
6.3 Shuffle失败的常见原因
Shuffle失败的原因很多,我按出现频率排个序:
- 磁盘空间不足:Shuffle会写大量临时文件,磁盘满了就失败。解法是清理磁盘或调整Shuffle文件目录。
- 网络超时:Shuffle数据量大时,网络传输可能超时。调大
spark.network.timeout和spark.shuffle.io.connectionTimeout。 - Executor挂掉:Executor OOM或GC时间过长被YARN杀掉,导致Shuffle数据丢失。解法是调大内存、优化GC。
- Shuffle Service未配置:在YARN模式下,如果没有配置External Shuffle Service,Executor挂掉后Shuffle数据就无法被其他Executor读取。生产环境一定要配。
6.4 常见问题速查表
| 问题现象 | 可能原因 | 排查方向 | 解决手段 |
|---|---|---|---|
| 作业跑得越来越慢 | 小文件太多 | 看文件数量和大小 | 合并小文件 |
| 个别Task极慢 | 数据倾斜 | 看Task时间分布 | 加盐、拆分Key |
| Executor频繁OOM | 内存不足或倾斜 | 看OOM的Stage和Task | 调内存、治倾斜 |
| Shuffle失败 | 磁盘/网络/Executor问题 | 看具体报错 | 对应调参 |
| 作业卡在申请资源 | 集群资源紧张 | 看队列资源 | 调队列或错峰 |
| 结果不对 | 加盐后未去盐 | 检查逻辑 | 去盐后再聚合 |
6.5 我踩过的三个典型坑
第一个坑:盲目调大分区数。有一次作业慢,我直接把spark.sql.shuffle.partitions从200调到2000,结果更慢了。原因是分区数太多,每个Task处理的数据量太小,调度开销和文件打开开销占了主导。后来调回500,配合倾斜治理,才真正解决问题。
第二个坑:忽略数据本地性。有段时间作业的Shuffle Read特别慢,排查后发现是数据本地性太差,大量数据需要跨节点传输。后来调整了Executor的分布策略,让计算尽量靠近数据,Shuffle Read时间降了一半。
第三个坑:UDF滥用。早期为了图方便,很多逻辑用Python UDF实现,结果向量化和代码生成全部失效,性能比内置函数慢了一个数量级。后来把能改的都改成内置函数,整体作业时间缩短了60%。
7. 从单点优化到体系化治理
7.1 建立性能基线
优化不是拍脑袋,得有基线。我的做法是:每个核心作业都记录三个指标——运行时长、Shuffle数据量、峰值内存。这三个指标能覆盖大部分性能问题。运行时长突然变长,可能是数据量增长或倾斜;Shuffle数据量变大,可能是Join策略变了或分区不合理;峰值内存变高,可能是缓存或倾斜。
有了基线,才能判断一次优化到底有没有效果,而不是凭感觉说“好像快了点”。
7.2 参数模板化
调参最怕每次从头来。我的做法是:按作业类型沉淀几套参数模板。比如“小数据量ETL”“大数据量聚合”“大表Join大表”各一套,新作业直接套模板,再根据实际情况微调。这样既保证了起点合理,又避免了重复劳动。
模板不是一成不变的,每次遇到新问题、找到更好的配置,就更新模板。时间长了,这套模板就是团队最宝贵的经验资产。
7.3 监控与告警的闭环
优化做完不是终点,还得有监控保证它不退化。我会对核心作业配置告警:运行时长超过基线的1.5倍就告警,Shuffle数据量突增就告警,OOM就告警。告警触发后,按前面讲的排查路径走一遍,找到根因再优化。
这个闭环跑起来之后,大部分问题都能在影响下游之前被发现和处理,而不是等业务方来投诉。
7.4 团队协作中的经验沉淀
最后说点软性的。大数据调优这件事,个人经验再丰富也有限,关键是团队能沉淀。我们的做法是:每次解决一个典型问题,就写一篇内部复盘,记录现象、排查过程、根因、解法。时间长了,这些复盘就成了团队的“错题本”,新人遇到类似问题直接查,不用从头踩坑。
另外,代码Review时重点关注几个点:有没有不必要的Shuffle、有没有滥用UDF、分区数是否合理、有没有处理倾斜。这几个点把住了,大部分性能问题在开发阶段就能避免。
这套体系跑了一年多,我们团队的核心作业平均运行时长降了40%,OOM告警减少了80%。这些数字背后,其实就是把前面讲的这些技术点,一个一个落到实处的结果。