我们这边离线集群跑Spark任务,单stage shuffle量过PB是常有的事。以前用Spark原生shuffle,一到这种超大规模任务就各种翻车:Fetch failed重试、小文件打爆NameNode、临时目录撑满磁盘、GC把作业拖到超时。后来我们决定把Shuffle从计算节点挪出去,调研了一圈,最终落地了Apache Celeborn做远程Shuffle服务。从切完到现在,稳定性和作业耗时都有质的改善。这篇就我个人的实操和踩坑经验,聊聊Celeborn在PB级Shuffle场景下的优化思路、部署参数和故障排查,给同样被Shuffle折磨的Spark运维、开发同学一个参考。
1. 为什么PB级任务最先扛不住的是Shuffle
1.1 Spark本地Shuffle的四个老大难问题
先说说我们原来为什么不得不换方案。Spark原生Shuffle在数据量到了PB这个量级后,问题会被放大得特别明显。
第一个是Fetch Failed的指数级放大。本地Shuffle模式下,每个Map Task都会给每个Reduce Partition物化一份data和index文件。PB级作业的Map Task数量能到几万甚至几十万,Reduce也有几千,你算一下这个文件对的规模:几万乘几千,几千万个文件。任何一个data或index文件因为磁盘坏道、节点重启、临时文件清理被误删而缺失,对应的Fetch请求就会失败。Reduce端一拉不到数据就重试,重试还失败就抛FetchFailedException,然后整个Map Stage被调度器推倒重算一部分。数据量越大,文件越多,出故障的概率几乎是线性往上升的。
第二个是小文件风暴。几千万个shuffle小文件意味着NameNode要处理海量getBlockLocations请求,RPC队列直接积压,HDFS全局受影响。那时候我们的NameNode经常因为某个超大作业出现毛刺,其他所有任务的HDFS读写都被拖慢。事后看监控,瓶颈全在shuffle小文件的元数据查询上。
第三个是数据倾斜。PB级作业里倾斜几乎无法避免,比如按某个商家或用户维度做聚合,头部key能占到全量的百分之二三十。单个Reducer要拉走几百GB的数据,内存缓冲根本兜不住,GC时间飙升,Fetch反复重试,最后只能靠人工调spark.sql.shuffle.partitions碰运气。
第四个是计算和存储互相踩踏。shuffle数据占的是计算节点的本地磁盘,大任务一跑,临时目录能打满一两块盘,直接影响同节点其他任务的shuffle读写。我们碰到过几次因为一个任务把磁盘写满,导致同节点其他任务的JVM进程莫名其妙卡死的状况。
这些问题不是调一两个Spark参数能解决的。本地Shuffle的设计假设是“数据放在计算节点附近”,但到了PB级,文件数量、故障概率、IO争抢已经完全超过了这个假设的cover范围。
1.2 远程Shuffle服务的取舍与选型
既然本地Shuffle扛不住,思路就是把中间数据拿到一个独立的Shuffle集群去存。Map端的中间结果不落本机磁盘,而是推送到独立的远程Shuffle Service,Reduce端再从这个服务拉取。这样计算节点只负责算,Shuffle节点只负责中转和存储。
主流的开源方案那时候主要对比了Celeborn(当时还在Apache孵化器,叫RSS)和Unshuffle,也聊了自研方案。Unshuffle的思路是做线程模型优化,减少shuffle读写时的线程切换开销,但生态相对窄,社区迭代速度一般。自研RSS可控性最强,但人力投入太大,要维护的模块从Master、Worker到客户端SDK全套,不是我们短期能接住的。
最后选了Celeborn。核心原因是它把“文件合并”和“流式读写”这些关键设计做得很扎实,同时兼容Spark和Flink,一套集群两套引擎都能用。而且社区活跃度很高,遇到问题能在社区找到不少案例,我们当时判断这个方向的长期可维护性最好。
1.3 为什么是Celeborn而不是其他方案
简单说下选择Celeborn的几个具体原因。
一是它解决了Fetch失败和小文件问题的本质:数据在Worker端按Partition合并成大的Partition文件,而不是保留Map Task维度的小文件。Fetch的时候按大文件的chunk去读,文件数量从千万级降到万级甚至千级,NameNode的压力骤减。
二是PushMerger机制。Celeborn在Executor端会先做一层数据合并,把同一个Map Task里多个sub-shuffle分片聚合之后再批量发给Worker,减少了网络请求数量。这点在Map Task数量很大的时候非常关键,否则Push的请求数量也会变成瓶颈。
三是多副本容错。Celeborn支持在Worker之间做副本,默认建议PB级任务开2副本。一个Worker挂了,另一个副本还能独立响应Fetch,Map Stage不需要重算,这个容错能力原生Shuffle是完全不具备的。
四是读写路径分离。控制面的Master只负责注册、调度和心跳,数据面完全由Worker承载,不会出现单一控制节点成为数据流瓶颈的问题。
当然Celeborn也不是没有成本:需要独立部署Master和Worker集群,占一定的机器资源。但从我们后续压测和生产的反馈来看,这个成本换来的稳定性和性能收益是划算的。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. Celeborn的核心机制:先搞懂数据是怎么流转的
2.1 Master、Worker、Client三件套
在调参数之前,建议先把整个Celeborn的架构链路看懂。它其实就三块:Master、Worker、Client(LifecycleManager)。
Master负责集群资源管理。它接收作业注册,给每个Shuffle分配Worker节点,维护Worker的心跳和状态,做HA切换。Master本身不传数据。
Worker是真正存Shuffle数据的地方。它接收Map端推上来的数据,在内存缓冲后刷到磁盘,按Partition维度聚合为大的Partition文件。Reduce端要数据时,Worker从这些文件里按chunk读取返回。
Client(LifecycleManager)其实是跑在Spark Executor里的一个组件,负责向Master申请资源、建立到Worker的连接、Push数据、Fetch数据,以及最后的commit确认。
这个过程可以类比成:Map端是个快递公司,产生的包裹(shuffle数据)要先运到中转仓(Worker),Reduce端是收件人,拿着运单号(Partition信息)去中转仓取货。Master是调度中心,负责告诉快递员去哪个仓发货、收件人去哪个仓取货。所以务必要记住一个原则:数据流只走Worker,Master只走控制流。
2.2 数据生命周期:从PushData到ChunkRead
一个完整的Shuffle数据流大概是这样:
Map端Task启动后,客户端根据Reduce Partition数量把数据拆成多个sub-shuffle分片。如果没有合并机制,一个Map Task就会和很多Worker建立一堆小连接,数据包碎片化严重。Celeborn在Executor端做了PushMerger,把同一个Map Task的多个分片按目标Worker分组,合并成更大的Buffer再批量Push。这样网络连接数量从“Map数量×分区数”降到了“Map数量×Worker数量”,小包变成了大流。
Worker收到数据后,先把数据放进内存中的MemoryStore,攒到阈值或达到间隔时间后,统一刷到磁盘的Partition文件。这个文件不是按Map维度切片的,而是所有Map推给同一个Partition的数据都追加到同一个文件区段里,索引文件记录这个Partition在文件中的offset和length。
Reduce端要拉某一个Partition的数据时,向Worker发起ChunkRead请求。Worker根据索引文件定位到文件区段,然后把数据按chunk发回去。chunk是读写的基本单位,默认我们配的是8MB,这样一次请求能拿到一大块连续数据,网络开销和IO次数都大幅降低。
整个链路里,写入和读取都是顺序追加和顺序读取,几乎没有随机IO。相比原生Shuffle那种“成百上千个小文件来回定位”,IO模型完全不同。
2.3 文件与索引设计为什么能抗住PB级
Celeborn能抗住PB级数据,核心在于把文件矩阵做了一次降维打击。
原生Spark的文件数量是Map Task数乘Reduce Partition数。假设一个stage有2万个Map、5千个Reduce,那就有1亿个data/index文件对。这在任何分布式存储上都是灾难。Celeborn则把文件聚合到“Partition×副本”,一个Worker上每个Partition对应一个物理文件区段,文件数量和Reduce Partition数、Worker数、副本数相关,跟Map Task数解耦了。
索引方面,它不是按Map粒度建索引,而是按Partition粒度记录offset。Fetch一个Reduce分区的数据,只需要在索引文件里定位一次,然后按chunk顺序读就行。这个设计让Fetch请求从“几万个小请求”合并成“几十个大请求”,整体IO量少了一个数量级。
这里也顺带提一句:我们当时在压测里专门数过一个小文件指标,同一个跑批作业,Spark原生shuffle阶段产生了大约几百万个小文件,切到Celeborn后,Worker上的Partition文件数只有原来的百分之几,NameNode的RPC P99下降非常明显。
3. 落地PB级场景前必须关注的参数与资源规划
3.1 服务端配置:Worker内存、磁盘、Flush策略
Celeborn部署看起来简单,但PB级场景下Worker端参数配错了,后面全是大坑。我把我实际在用的几个关键参数和理由列出来。
因为我们用2副本,刷盘策略上选了async。如果选了force模式,每批数据都会强制刷盘,可靠性和丢数据风险会小,但吞吐下降非常明显,PB级场景下性价比不高。我们的姿态是:用副本数换可靠性,用异步刷盘换吞吐。
磁盘是另一个关键。Worker的磁盘目录一定要多块盘分散配置,比如每台Worker至少8块盘,通过celeborn.worker.disk.dir配置多个目录,逗号分隔。我见过有人图省事把整个数据目录放一块大盘上,结果一个热点Partition就把整块盘的IO占满,所有Worker上的其他任务都跟着慢。多目录之后,不同Partition的文件在多个盘间散列,单盘压力会平滑很多。
内存上有个容易踩的坑:Celeborn Worker的JVM堆外内存(direct memory)要单独配大一点,不要只调堆内。数据传输和压缩缓冲大量走堆外,堆外给太小会报Direct buffer memory。我们线上Worker堆内给到8GB,堆外给到16GB,高峰期没有出现内存问题。
有一类问题是用默认配置直接上生产导致的:MemoryStore缓存设置太大,Worker的JVM堆内塞不下,触发频繁Full GC。建议把MemoryStore的容量开到堆内的40%左右,剩下的空间留给元数据、Netty和临时对象。
3.2 客户端参数与Spark作业的衔接
客户端参数主要在Spark的Configuration里配,我贴一份我们常用的基础配置:
bash复制spark.shuffle.manager org.apache.spark.shuffle.celeborn.RssShuffleManager
spark.celeborn.master.endpoints c-master1:9097,c-master2:9097
spark.shuffle.service.enabled false
spark.celeborn.shuffle.chunk.size 8m
spark.celeborn.client.push.buffer.size 64k
spark.celeborn.client.compression.codec zstd
spark.celeborn.client.compression.zstd.level 3
spark.celeborn.replicate.enabled true
其中spark.shuffle.service.enabled必须设成false,否则会和Yarn的external shuffle service冲突。chunk.size用8m是我们在压测里的折中,太小IO频繁,太大单个请求会撑爆接收端的DirectBuffer。压缩建议开zstd,虽然CPU开销比LZ4高一点,但PB级数据量下,省下的网络带宽比CPU划算得多。如果你们集群CPU资源比较紧张,可以换成LZ4。
Executor内存也要跟着调:开启RSS后,Executor不再为每个shuffle文件创建句柄和磁盘缓冲,但网络发送缓冲和接收缓冲会多一些。Java堆可以维持原来的大小,堆外内存反而要适当调大,否则大任务高并发时容易报堆外内存不足。这个属于非常典型的“代码逻辑没问题,但分配的内存不在预期位置”的问题。
3.3 网络与容错:几个容易被忽略的基础设施
Celeborn对网络的要求比Spark原生Shuffle高很多,因为所有Shuffle数据都要走网络,一个PB级任务跑下来,几十TB甚至上百TB的数据流过Worker网卡。
先说网卡:最高频的推荐是万兆起步,有条件直接上25GbE或100GbE。我们用万兆网卡时观察到一个现象,高峰期Worker的网卡带宽能拉到八九成,如果Shuffle量再上去,Push和Fetch互相挤,超时重试就冒出来了。所以网卡是决定Celeborn集群天花板的第一硬件要素。
再说容错。PB级任务建议把replicate.enabled开成true。副本数从1调到2,磁盘故障、Worker宕机带来的数据丢失风险基本清零,Shuffle阶段不需要重算,整个作业的稳定性上了一大截。代价是写放大一倍,Worker磁盘容量和带宽占用也会翻倍,这个在容量规划时要预留。
还有一点是Master和Worker的心跳参数。PB级作业并发高,Worker在高峰期GC时间变长是正常的,如果心跳超时设得太短,Master会把一堆活着的Worker误判成dead,触发不必要的replicate和数据搬迁。建议把心跳超时和GC停顿时间对齐,给慢GC留足余量。
4. 生产环境里最典型的四个疑难问题与排查链路
4.1 Worker反复OOM,GC拖垮写入
我们上Celeborn后遇到的第一个严重的生产故障是Worker频繁Full GC,然后Map端Push超时重试。
排查链路是这样的:先看Worker的GC日志,发现Full GC间隔从原来的十几分钟一次缩短到一两分钟一次,说明堆内内存压力大。再往上追,MemoryStore缓存的数据量已经顶到了堆内上限,而单partition数据量又特别大,刷盘来不及,缓存满导致新Push进不来。
具体根因有两个。一是MemoryStore容量配得太大,和JVM堆的其他部分互相挤占。二是单个Partition的数据量超过了Celeborn默认的单文件上限,触发文件膨胀逻辑,导致刷盘时临时数据暴增。
解决动作分两步:把MemoryStore容量从堆内50%调低到40%,给Netty和元数据留出空间;然后把Worker实例按磁盘路径拆开,原来是16块盘挂在一个Worker上,拆成两个8盘的Worker实例,相当于把OOM的影响面缩小了一半。改完后Full GC频率恢复到正常水平。
4.2 PushData超时导致Map端任务重试
PushData超时是另一个高频问题。现象是任务前半段一切正常,跑到中途开始出现大量“PushData timeout”,Map Task重试,shuffle数据被重复推送,Fetch量跟着翻倍。
我们当时拿着告警去排查,Worker的CPU和内存看着都正常,但iostat一看,磁盘utilization长期在90%以上,await时间到几百毫秒。进一步分析,磁盘写慢的根本原因是部分目录落到了同一块机械盘上,因为当时部署时磁盘目录配置没做散列,数据全冲到一个盘了。
解决方法是把Worker的磁盘目录彻底打散到多块物理盘,同时把压缩算法从zstd换成LZ4,减少CPU对磁盘IO的影响。另外,客户端重试参数做了限制,maxPushDataTimes从默认的很多次降到3次,配合指数退避,避免重试风暴把Worker彻底打挂。换完之后,PushData超时基本消失。
排查这种问题时有一个很实用的技巧:优先看Worker日志里的flush timeout和disk full关键字。如果这两个词出现,基本就是刷盘速度的问题,不要再纠结客户端参数。
4.3 Fetch失败与数据倾斜叠加
Fetch失败的排查比Push超时复杂一些,因为原因可能来自两个方向。
第一个方向是副本丢失。如果工作节点维护时Worker重启,而副本数只有1,那么该Worker上的Partition数据就永久丢了,Reduce端Fetch时拿到的是“数据不存在”的响应。这种情况在关闭replicate的时候非常常见。我们最终把PB级任务的replicate强制设成2,Worker重启带来的Fetch失败基本清零。
第二个方向是数据倾斜。某个Partition的数据量远超其他Partition,Reduce端单个Task拉取时间过长,fetch请求反复超时,表现出来也是Fetch失败。这个时候先从Spark UI上看Shuffle Read大小是否集中在一两个Task上,确认倾斜后,再结合SQL层面的加盐方式打散key,同时把该stage的reduce分区数适当调大,不要让单个Partition承载过多的数据量。
这两个问题经常同时出现,所以排查时一定要先区分现象:如果是偶发的Fetch失败,先查副本和Worker状态;如果是某个Task反复Fetch失败,先查Partition大小分布。
4.4 Master切换后的收敛慢问题
Master做HA切换时,我们观察到一个比较隐蔽的问题:切换完成后,新任务的Master上报资源要等很久,作业排队时间变长。
原因是客户端和Worker侧对Master新地址的感知有延迟,依赖心跳超时来触发切换,这个时间窗口内所有新注册请求都会卡住。
优化动作是:Master的endpoints配成两个地址,客户端自动failover;适当缩短Worker到Master的heartbeat间隔和超时阈值,保证切换在秒级被感知;同时给Master的JVMHeap调大,因为PB级任务并发高时,Master接收的大量心跳和commit请求其实也是不小的压力源。这个问题解决之后,Master切换对上层作业的影响基本无感。
5. 实测效果与优化前后对比
5.1 稳定性指标变化
切到Celeborn并稳定运行一段时间后,我们最直观的感受是告警少了。以典型的PB级shuffle作业为例,几个核心稳定性指标对比如下。
| 指标 | 优化前(Spark本地Shuffle) | 优化后(Celeborn) |
|---|---|---|
| Fetch失败次数/日 | 日均几十次 | 个位数以下 |
| 大作业重试率 | 约20%以上的任务至少重试一次 | 基本无重试 |
| NameNode RPC P99 | 受shuffle小文件影响毛刺明显 | 明显下降 |
| 磁盘临时目录报警 | 频繁 | 基本消失 |
这类变化带来的直接好处是无需频繁人工介入重跑作业,整个夜批的稳定性上了一个台阶。
5.2 性能与成本的变化
性能方面,最典型的一个大Join作业,原来要跑接近一个小时,切到Celeborn后稳定在40分钟左右,提速30%以上。提升主要来自两块:Fetch小文件合并后,Reduce端拉取数据量少了,网络交互次数少了;重试减少后,重复计算和重复拉取消失了。
成本方面,计算节点的本地磁盘不再被shuffle文件占满,我们就敢把计算节点的磁盘水位调高,原本预留的临时空间可以释放给其他数据落地。Celeborn集群的硬件投入对比节省下来的重试算力和磁盘压力,整体是划算的。
5.3 还能继续优化的方向
目前我们还在持续推进几个方向:一是把Celeborn的底层存储从HDD逐步换成NVMe,让刷盘速度进一步提升;二是把Flink的Batch任务也接到同一套Celeborn集群上,复用Shuffle集群能力;三是完善Worker的Push吞吐、Fetch成功率、磁盘IO、网络重传率等指标的可观测性,提前预防高峰期故障。
这块后续有新的实测数据,我再单独写一篇展开聊。
最后再分享一个我个人的体会。Celeborn不是装上就能跑好的组件,它真正考验人的地方不在部署,而在对资源规划和监控体系的把握。我在踩过几次坑之后,养成了几个习惯:Worker的GC日志、iostat、网卡重传是每次上线前必看的三个指标;磁盘盘符一定要多目录散列,不要贪省事;PB级场景下副本数直接拉满,不要为了省磁盘去赌Worker不出故障。给所有准备上Celeborn的朋友一个建议:先挑一两个shuffle量大、但结果不敏感的任务做灰度,把监控慢慢完善,再逐步铺开。排查Celeborn问题的时候,优先看worker日志里有没有“Disk is full”和“flush timeout”,这两个词一出现,八九不离十是刷盘速度跟不上了,比反复折腾客户端参数管用得多。
