
接手过TB级离线任务的人基本都经历过这样一个场景集群CPU看起忙得不行作业却越跑越慢监控面板上一堆执行器在反复GC内存加了又加效果却非常有限。后来把目光转到大数据的“内存计算”上事情才真正有了突破口——不是数据量太大算不动而是数据搬运成本太高中间结果频繁落盘把效率全吃掉了。今天这篇就聊聊内存计算在优化数据处理时的核心逻辑重点放在资源分配怎么做上。这篇内容适合谁读一类是刚把自己的作业搬上分布式计算平台的开发同学另一类是负责集群资源规划和监控调优的工程师。我会按“为什么内存计算能提速→资源分配的几个关键决策→一份完整调优实操记录→高频问题排查”这条线展开尽量把背后算账的逻辑也讲清楚而不是只给一套配置模板。看完之后你至少能回答两个问题自己的作业到底该吃多少内存以及当内存不够时第一步该调什么。1. 内存计算为什么能把处理速度提一个量级1.1 慢的根源不在运算而在“搬运数据”大数据量上去之后计算瓶颈早就不是CPU了而是IO。硬盘的顺序读写也就每秒几百MB随机读写更惨内存的带宽动辄几十GB每秒延迟差出几个数量级。分布式集群还多一层网络开销任何需要跨节点搬数据的操作都会把整体速度拖到网络的水平线上。传统的批处理模型有个很重的习惯每个计算阶段之间中间结果都要落到磁盘下一阶段再读回来。一个复杂作业如果有几十个阶段中间结果就会反复写盘读盘。算一次可能只要几秒但读写磁盘花掉了几分钟这就是“算得快、搬得慢”的典型症状。内存计算调整的核心思路很直接尽量让中间结果留在内存里用内存当数据的主要流通通道。现在主流的内存计算框架都采用类似RDD或DataFrame这样的抽象把数据切成若干分区分布在各节点上每个分区只保留在内存中操作的逻辑配合血缘关系实现容错——某个分区丢了只重算这一个分区而不是整批落盘。这套设计的本质是把“我用磁盘换可靠性”改成“我用重算换可靠性”省下的就是大把磁盘IO时间。1.2 计算模型上做了哪些取舍内存计算框架另一个关键设计是懒计算。写代码时的一系列转换操作并不会立刻执行框架先把它们记录成一张DAG图等到真正需要结果时才根据整张图统一调度执行。这样做的好处很像数据库的查询优化器把一堆零散算子合并成更优的执行计划能合并的步骤合并掉能减少的数据搬运减少掉。DAG本身还要看依赖关系。窄依赖的算子可以在一个任务内部直接管道式处理数据不用等宽依赖涉及跨节点重新分区框架会在这里划一道分界线也就是Stage边界shuffle总是发生在这个位置。整体调度策略就是窄依赖尽量流水线化宽依赖才落盘或走网络把shuffle的次数压到最低。这套设计天然适合迭代计算、多轮join、交互式查询这类“同一份数据反复用”的场景。如果是那种一次性读数据、处理后写完就结束的简单ETL提升反而有限因为你的数据本身没有多少复用价值。理解这一点很重要不然你会误判调优方向把精力花在根本不需要内存计算的地方。2. 资源分配优化的关键维度与决策逻辑2.1 内存不是堆得越大越好见过不少同学上来就把Executor内存调到几十GB以为内存大就一定跑得快。实际结果往往是GC越来越频繁任务反而更慢严重时直接OOM。原因很简单JVM里的对象很多是活不过几轮的堆越大Full GC扫描的范围就越大暂停时间越长分摊到每个任务上的停顿就越致命。Executor的内存大体可以分为几块执行内存用于shuffle、join、aggregation时的中间数据、存储内存用于缓存RDD或DataFrame、用户代码内存以及堆外开销。框架默认会留一小部分给系统再把可用的堆内存按比例划分。真正需要调的是“执行内存”和“存储内存”之间的比例而不是一味加堆。经验值上单Executor的堆内存控制在16-24GB比较舒服。超过32GB后JVM的对象指针压缩会失效内存占用直接涨GC停顿也更难优化。就算物理机内存很富余也不如拆成多个中等规格的Executor反而能提高并行度让GC影响范围变小。举个例子一台16核64GB的物理节点操作系统和系统进程先吃掉约10GB剩下50GB左右给计算。如果选择每个Executor分配5核就能在同一节点上放3个Executor用掉15核每个Executor堆内存约15GB再留2GB左右堆外开销。这样一台节点总占用不到51GB核和内存都有余量GC压力也远比一个32GB大堆好控制。2.2 并行度与分区设计资源被“吃”掉的隐形原因资源分配不光是内存和CPU的事分区数与并行度往往才是决定资源能不能被用起来的关键。同一个作业分区数太少任务调度不起来核在空转分区数太多每个任务太碎调度和网络报文开销又反超收益。理想并行度大致可以按“总CPU核数×2到3”来估。分区数量至少要覆盖这个并行度但也不是越多越好。更实用的判断标准是看单个任务处理的数据量对于压缩后的数据单个任务处理量在200MB到1GB区间比较合适。如果一个stage读进来800GB理论上该stage设置800到4000个分区而不是拍脑袋定个200完事。还有个隐藏很深的坑是shuffle分区数默认值是200。很多作业前面跑得很快一到join或groupBy就突然慢下来点开Stage页面一看整个阶段就200个任务每台机器只分到几个CPU根本忙不过来。这时候把spark.sql.shuffle.partitions提高到600甚至更高整体时间可能立刻降一半还多。数据倾斜则是另一个极端数据本身分布不均某个key的数据量特别大导致大量数据都涌进同一个task。这个task跑不完整个Stage就得等它内存也容易被它压爆。倾斜的解决办法不是单纯加内存而是要去拆key、加盐、做局部聚合再全局聚合或者干脆用广播变量把小表广播到每个Executor把shuffle整个消掉。2.3 数据形态与序列化同样一份数据内存占用差出几倍同一个Dataset在内存里的形态会直接影响资源占用。用Java对象存每个对象有对象头、引用、对齐填充空间浪费很大GC还要处理这些对象引用用二进制编码方式存很多框架的列式存储就是这种思路数据变成紧凑的字节数组同样一份数据可能只有Java对象的五分之一到十分之一大小GC基本不感知这些字节数组内部的字段。配合序列化器也很有讲究。默认的Java序列化方便但体积大、速度慢Kryo序列化能把对象体积缩小很多CPU开销也更低。代价是需要重新注册一些类但一次配置长期受益。我的习惯是项目里统一注册好凡是会被缓存或跨节点传输的数据类全部用Kryo。还要提一嘴存储级别。RDD缓存有多种级别从纯内存、内存加磁盘、序列化存储到堆外存储。很多人一听内存计算就什么数据都往内存里塞结果内存被缓存占满执行内存反而不够shuffle又大量写盘。正确做法是评估数据的复用频率——复用次数多、并且体积不大才放到内存只顺手用一两次的数据用MEMORY_AND_DISK_SER这类带磁盘备份的级别更稳妥内存不足时自动落盘不至于直接把作业压垮。3. 一次完整的资源分配优化实操记录3.1 场景描述与基线数据前段时间帮某业务团队看了一个日志分析离线作业。上游每天产出约800GB压缩日志作业要完成过滤、关联一张维度表、多维度聚合最后把结果写入数仓。当时的配置是10台worker节点每台16核64GB内存每个Executor分配32GB堆内存、3核节点上起了5个Executorshuffle分区保持默认的200。初看配置挺“大方”但任务每天稳定跑4个小时。监控面板显示GC时间占总CPU时间的30%以上Executor经常被系统判定为超时或异常而重启。问题几乎一眼就能看出来32GB的堆太大了GC频繁一个节点塞5个Executor每个只有3核核数太少任务经常排着队等CPUshuffle分区数200对于这种体量完全不够。这种场景很典型资源给得越多不代表效率越高。堆太大GC失控并行度又配不平整个集群处于“内存占满、CPU吃不满、磁盘猛写”的状态。3.2 分步调整与收益变化我调整的时候每次都只动一个变量跑完一轮对比一轮避免多个改动混在一起搞不清是谁的功劳。第一步先改Executor规格。保留每个节点3个Executor每个分配5核、堆内存16GB堆外开销2GB。这样一台节点的计算资源规划如下15核用于计算留1核给系统内存占用约54GB3×(162)还有约10GB给操作系统做页缓存。改完后GC时间占比从30%降到12%左右作业总时间缩短到2.5小时。这一步几乎没有改任何业务逻辑纯粹是把资源形态摆正。第二步调shuffle分区数。日志数据经过过滤后仍然有接近700GB参与聚合200个分区太粗。我把spark.sql.shuffle.partitions提高到600单任务处理数据量从约3.5GB降到约1.2GB。同时把spark.default.parallelism同步调到60总核数150乘2再降一点保证初始阶段也有足够的并发。这一轮下来总时间降到1.8小时。第三步处理维度表关联。维度表只有几十MB却参与了一次全量shuffle join这属于典型的“可以消掉但没消掉”的浪费。我把维度表改成广播变量并在代码里用broadcast提示让每个Executor持有一份副本省掉一轮跨节点shuffle。这一步直接把作业从1.8小时压到1.3小时。第四步缓存和序列化收尾。中间有一个会被后续多次复用的过滤结果集之前用默认存储级别缓存发现经常被清出内存基本没用上。我改成MEMORY_AND_DISK_SER同时使用Kryo序列化后内存占用降了一半缓存终于稳定留在内存里。最终作业稳定在1.2小时左右GC时间占比降到5%以下。下表是完整调整路径的汇总调整项调整前调整后主要收益Executor规格32GB堆 / 3核16GB堆 / 5核GC减少、单任务并发更充分shuffle分区数200600单任务数据量更合理、并行度提升维度表关联shuffle joinbroadcast join省掉一轮跨节点shuffle缓存与序列化默认Java序列化 / 纯内存缓存Kryo / MEMORY_AND_DISK_SER内存占用减半、缓存命中率提高3.3 动态分配与“预算思维”静态配置调好之后还可以根据集群的负载情况考虑动态资源分配。把这个机制打开后框架会在作业执行初期按需申请Executor空闲时自动把多余的Executor回收掉。对于凌晨和白天波峰波谷特别明显的集群这种做法能省下不少资源不会出现夜间没任务跑资源却都被长期占住的尴尬。我自己更推荐一种“预算思维”来看资源分配每次调整之前先记住当前作业的耗时和资源占用调整后对比单位成本。所谓单位成本大致可以简化成“占用内存GB × 运行小时数”。优化前那个作业是32GB×5×4小时优化后是18GB×3×1.2小时单位成本降了差不多八成这就是把资源用在刀刃上的量化结果。4. 常见问题与排查技巧实录4.1 三类最高频内存问题速查表内存计算环境下遇到的无非就是那几类老问题内存溢出、频繁GC、shuffle数据量异常大。我把实际工作中最高频的问题和排查动作整理成一张速查表方便你处理时对号入座现象常见原因排查动作Executor反复抛OOM堆内存配置过大导致GC失控或单个task数据量过大缩小Executor堆增加分区数检查是否有数据倾斜GC时间占比超过20%堆内对象太多、Java序列化膨胀启用Kryo使用二进制列式存储降低缓存级别shuffle fetch failed上游执行器丢失、或shuffle数据量超过预期看Web UI中shuffle读写量检查是否发生了超大shuffle单个task处理时间远大于其他task数据倾斜查看stage任务耗时分布加盐或拆分热点key堆外内存不足overhead设置偏小或代码里大量使用堆外缓存调大executor.memoryOverhead减少堆外缓存依赖如果只是偶尔一次OOM优先怀疑单个task数据量过大措施是提高分区数如果频繁OOM反而要先怀疑Executor堆太大措施是拆小规格。这个判断方向很多人反着来结果内存越调越大问题越调越严重。4.2 正确读取Web UI指标的姿势很多人查问题习惯一头扎进日志翻Stack Trace其实内存计算平台的Web UI信息密度很高先把指标看明白了能省一半排查时间。看Stage页面先关注Input Size和Shuffle Read/Write。Input很小但Shuffle Write很大说明代码里有不必要的重分区Shuffle Read远大于上游Output说明下游发生了数据膨胀或广播策略没生效。再看任务耗时分布如果绝大多数任务十几秒就跑完有几个任务挂了十几分钟那基本就是倾斜不必纠结资源总量问题。Executor页面重点看两列GC Time和Storage Memory。GC Time占总运行时间比例如果超过10%-15%就要检查堆配置和序列化方式Storage Memory长期占用很高但作业里实际使用的缓存很少说明有人把不常用的数据也塞进了缓存该清理了。4.3 几个用实践换来的小技巧最后分享几个不算主流、但实测很管用的经验。第一出问题先查并行度再查内存。内存加的边际收益是递减的堆从16GB加到24GB可能有明显收益从32GB加到64GB可能一点变化都没有反而GC变长。并行度不足时每个核都闲着加内存当然没用。第二大结果集不要在Driver端用collect方法。很多人为了调试方便直接把大RDD拉到本地结果Driver内存爆掉看着像Executor的问题实际上是Driver侧。正确做法是在Executor端用foreachPartition或save直接写存储。第三缓存不是越多越好。一次作业里缓存几十个RDD存储内存被占满之后执行内存不足又会把缓存数据踢出去形成反复重算。我的原则是缓存只给至少被复用两次、且重算成本高的数据。其它数据让它们自然流转内存压力反而小。第四调优全程保存基线记录。我习惯在每个作业上线前截一张Web UI基线图记录耗时、GC、shuffle量三个数字之后每次调参都对比这组数字。这样不会出现“今天作业慢了但到底是谁改了什么说不清”的情况。我自己的习惯是任何时候都先在Web UI里存一张基线图再动手调。每次只动一个参数跑完记录时间、GC、shuffle量三个核心数字。调优不是玄学本质是给数据搬动算运输费内存计算最大的价值就是把钱花在最值得复用的那份数据上。资源分配搞到最后不是参数调得多漂亮而是每一块内存都清楚自己在为什么服务。