1. 为什么要动Shuffle:PB级场景下的几个硬伤
做离线数仓和大数据跑批的同学,对Shuffle应该都不陌生。Map阶段处理完数据,要按照Key重新分区交给Reduce端,这个过程中间数据的搬运和落盘,就是Shuffle。很多Spark作业跑得慢、动不动OOM、任务失败后恢复要半天,根子往往不在计算逻辑本身,而是Shuffle环节先撑不住了。
我们这次要聊的,是vivo在PB级Shuffle场景下做的一项典型优化实践——引入Celeborn作为Remote Shuffle Service,把Shuffle从“本地磁盘反复读写”的模式,改造成“远端服务统一聚合”的模式。对于单次作业Shuffle数据量达到PB级的平台来说,这不仅仅是提速,更是让任务能稳定跑完的关键手段。
先说清楚原生Shuffle在PB级场景下到底有哪些问题,否则你很难理解为什么非要引入一套独立服务。
1.1 Map端落盘、Reduce端拉取的老链路
原生Spark的Shuffle链路大致是这样:每个Map Task处理完数据后,按照分区器把数据写到本地磁盘,生成Shuffle中间文件。Reduce Task启动后,从各个Map Task所在的节点拉取属于自己的那部分数据,边拉边聚合、边做Reduce计算。
这套设计本身没毛病,小规模任务完全够用。但当数据量大到PB级,问题就来了。
Map端每个Task都会产生一份Shuffle文件,Task数量一上来,文件数量呈爆炸式增长。一个几千个Map Task的作业,光Shuffle小文件就是几万、几十万个。这些文件在节点之间分散存储,Reduce端要跨节点随机拉取,随机IO的比例非常高,整个集群的磁盘和网络压力都很大。
1.2 PB级场景下原生方案的四道坎
第一道坎是磁盘空间浪费。很多平台为了保障Shuffle数据不丢,底层存储做了三副本。Shuffle中间数据本身是临时数据,用完就删,却要和正式数据一样占三倍空间,这个成本对大集群来说非常肉疼。
第二道坎是任务失败恢复慢。Spark的Shuffle文件是跟随Executor生命周期的。如果一个节点宕机,或者一个Executor被Kill,上面所有的Shuffle数据就没了。Spark只能把上游所有Map Task重新跑一遍,才能重新生成这些数据。在PB级作业里,重算上游意味着几十分钟甚至几个小时的额外开销,稳定性很难保证。
第三道坎是IO毛刺和列队阻塞。Task并发一高,本地磁盘频繁读写,再加上其他计算任务的干扰,磁盘IO很容易出现毛刺。某个节点磁盘慢了,拉取Shuffle的对端任务就会跟着等,形成连锁阻塞,最终表现为整个作业变慢。
第四道坎是资源利用率不均。原生Shuffle下,数据落在哪个节点,Reduce任务就得到哪个节点去读。节点之间数据量天然不均衡,加上集群里同时跑很多作业,热点节点的磁盘和网络会被打满,而其他节点可能很闲。
1.3 Shuffle外部化的思路为什么能成立
既然Shuffle中间数据放在本地有这么一堆问题,业界很自然的想法就是:把Shuffle数据统一收口到一个独立服务里,由这个服务负责存储和供给。Map端把数据推上去,Reduce端从服务里拉,本地磁盘不再承担Shuffle中间数据的压力。这就是Remote Shuffle Service,简称RSS。
Celeborn就是这种思路的一个开源实现,它前身是腾讯的Apache Incubator项目,现在已经在很多公司的大数据平台落地。我们的实践场景和它非常契合:PB级Shuffle、大集群、对稳定性要求高、存储成本敏感。所以当初选型,思路很快就确定下来。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. Celeborn整体设计与工作流程拆解
Celeborn这个项目,名字叫"喜树",寓意"聚木成林"。它的核心设计可以概括为:用一个 Master 集群管理元数据,用一组 Worker 节点承担Shuffle数据的存储和读取,再用一个内嵌在Spark应用里的Client完成数据的上推和拉取。
2.1 Master、Worker、Client三方角色
Master的角色类似Spark的Driver,也可以理解成整个Shuffle服务的调度中心。它负责管理Worker的注册、心跳、状态维护,以及处理Shuffle申请、Release等元数据请求。Master一般部署奇数个节点,用Raft协议做选主和状态同步,保证高可用。我们实际部署了三个Master,挂掉一个不影响服务。
Worker是存储和计算的数据平面。每个Worker管理自己的多块磁盘和内存,Map端推上来的Shuffle数据会以分区文件的形式写在Worker上,Reduce端需要数据时也来Worker上读取。Worker之间还会做数据副本复制,默认情况下一份数据在两个Worker上各存一份,避免单个Worker宕机导致数据丢失。
Client是嵌入在Spark Executor里的组件,它负责和Master通信,申请Shuffle相关的元数据,然后把Map输出的数据按分区推到对应Worker上。Reduce端则通过Client从Worker拉取数据。整个过程中,Spark应用感知到的还是标准ShuffleManager接口,只是底层实现替换成了RssShuffleManager。
2.2 从Map端推送到Reduce端拉取的完整流程
一次完整的Celeborn Shuffle流程大致是:
- Spark作业启动时,通过
spark.shuffle.manager指定Celeborn的ShuffleManager实现。Executor启动后,Celeborn Client会向Master注册并获取可用的Worker列表。 - Map Task处理完数据后,不再是写本地磁盘,而是按分区将数据写入内存缓冲,通过异步线程批量推送到对应的Worker。
- Worker收到Push数据后,写入本地的Partition文件。如果开启了多副本复制,还会同步推给另一个副本Worker。
- Reduce Task启动后,向Celeborn Client申请读取某个Shuffle的某个分区数据,Client根据元数据找到对应的Worker地址,发起读取请求。
- Worker从Partition文件中读取数据块,返回给Reduce端,Reduce端做正常的聚合和计算。
这个链路里有一个很关键的设计:数据推送到Worker后,Map端就可以认为自己的Shuffle完成了,不用等Reduce端拉完。这就把Shuffle生命周期和Executor的本地状态解耦了。
2.3 Celeborn相比其他优化方案的核心优势
先说说和ESS(External Shuffle Service)的对比。ESS只是把Shuffle文件的保存从Executor挪到了一个更稳定的常驻进程里,本质上还是本地磁盘存储、Reduce端跨节点随机拉取。它解决的只是Executor被Kill后Shuffle文件丢失的问题,磁盘压力、存储浪费、IO毛刺这些问题都没有本质改善。
Celeborn则是把Shuffle数据集中到专门的Worker上,用更优的写入模式来优化IO。Map端是顺序写,Reduce端是顺序读,读写过程中还能做聚合和去重,对随机IO的压力小很多。
再对比一下过去流行的“Shuffle时先合并小文件”方案。那个思路是在Map端做合并,减少文件数,但Reduce端还是要跨节点拉取,网络纷争和热点问题依然存在。Celeborn则是从整个链路重新设计了数据流,Push模型和Fetch模型都改变了,这对超大Shuffle场景的优势非常明显。
3. PB级规模下的部署规划与参数调优
选型和架构确认之后,真正的硬骨头是落地。PB级的数据规模,不是装一套开源组件就能跑起来的,部署方式、内存模型、参数配置、灰度节奏,每一步都要仔细设计。
3.1 集群规模估算与Worker部署方式
部署Celeborn之前,先要回答一个问题:需要多少个Worker?
我当时的估算方式很简单。先看一个典型高峰时段的Shuffle总量,比如某个大作业单次Shuffle达到500TB,压缩后按40%算,就是200TB的存储量。考虑到Worker要保留两份副本,实际需要400TB的空间。单块磁盘按8TB算,裸容量大概需要50块这样的盘。再把写放大、临时空间、其他作业的占用考虑进去,一个Worker节点挂4-6块盘,整体规模大概就是12-15个Worker。
当然这只是一个静态估算,真正决定Worker数量的还有网络和CPU。Push和Fetch的流量加起来,是Shuffle数据量的好几倍,所以要重点看网卡是否扛得住。在万兆网卡下,一个Worker同时服务几百个并发流量属于正常状态,如果观察到网卡持续打满,就需要增加Worker或者拆分作业。
部署方式上,我们采用了和DataNode混部的方案。大数据集群里DataNode往往有大量磁盘,这些磁盘平时读写压力并不均衡,把Celeborn Worker部署在DataNode节点上,可以复用已有资源,也能更好地贴近数据。混部时要注意给Celeborn预留足够的IO带宽和内存,避免和DataNode抢资源太凶。
3.2 内存与磁盘的分配模型
Celeborn的Worker内存使用需要认真规划。它内部有两块核心内存:一块是用于Caching的堆外内存,存放正在写入和等待读取的数据块;另一块是Netty网络通信使用的Direct Memory,管理请求的收发缓冲。
我们的配置经验是,给每个Worker分配64GB内存,其中约30GB作为数据缓存,20GB留给Direct Memory,剩余部分留给系统和其他进程。这个比例不是拍脑袋定的,而是根据“一个Worker上同时活跃的请求并发数”反推出来的。并发请求多、数据块大,Direct Memory消耗就快,这20GB在很多大作业下都能用到一半以上。
磁盘方面,建议每个Worker至少挂4块盘,并配置celeborn.worker.dirs把多块盘都纳入存储池。Worker的磁盘管理按照目录粒度做数据分布,盘多的时候,单块盘的写入压力能明显分散。实测下来,4块盘比2块盘的任务稳定性能提升一个档次。
3.3 关键Spark侧参数配置清单
参数配置这块,是踩坑最多的环节。按下表列一份可以直接参考的关键配置:
| 配置项 | 推荐值 | 说明 |
|---|---|---|
| spark.shuffle.manager | org.apache.spark.shuffle.celeborn.RssShuffleManager | 替换原生ShuffleManager |
| spark.celeborn.master.endpoints | host1:9097,host2:9097,host3:9097 | Master地址列表 |
| spark.celeborn.push.timeout | 120s | 单次Push请求超时,网络抖动时可以调大 |
| spark.celeborn.push.max.reqs.in.flight | 64 | 单个Executor上同时在途的Push请求数 |
| spark.celeborn.push.data.timeout | 120s | Push数据块发送超时 |
| spark.celeborn.client.spark.fetch.timeout | 120s | Fetch阶段读取数据超时 |
| spark.celeborn.shuffle.chunk.size | 8MB | Fetch时单块读取大小,块大减少请求次数 |
| spark.celeborn.push.replicate.enabled | true | 是否开启多副本,生产环境建议开启 |
这些参数里面,最需要讲清楚的是push.max.reqs.in.flight。这个值控制单个Executor上同时在途的Push请求数量,太小会导致Map端Push速度跟不上,太大则Worker端压力过大。64是我们反复压测后得到的一个平衡点。大作业、并发高的时候,可以适当降到32,优先保证稳定性。
团队里如果用的是Spark 3.x,还需要确认Slot配置和Dynamic Allocation之间的配合。Celeborn模式下,Executor的Shuffle文件不再占用本地磁盘,所以Driver的调度逻辑里关于Executor磁盘空间的一些判断需要调整,建议在Spark配置里把spark.driver.extraJavaOptions中相关磁盘空间检查参数放宽,否则会误报Executor磁盘不足。
3.4 压测和灰度上线的节奏
PB级场景,谁都不敢直接全量上线。我们的节奏是三步走。
第一步是单作业验证。挑几个Shuffle量在10TB到50TB之间的离线任务,切到Celeborn模式,观察作业运行时间、GC频率、Worker的IO和内存曲线。这一步主要验证功能正确性。
第二步是小范围灰度。把同一个数据域下的几十个任务切过来,和原生Shuffle模式的任务做对比。对比维度包括:作业完成时间、Task失败率、Executor重启次数、本地磁盘IO使用率。
第三步是接入全量任务。之前积累的参数和配置,在全量阶段还会遇到一些意料之外的问题,比如多个大作业同时跑导致Worker热点。这就引出了下一节的踩坑内容。
4. 踩坑实录与排查思路
再完美的方案,到了生产环境都会遇到各种幺蛾子。这一节我把实际落地过程中遇到的典型问题整理出来,按“现象-排查-解决方案”的格式来写,方便大家直接对照。
4.1 Worker热点导致单个节点被打满
现象是某个Worker节点的CPU和网络使用率远高于其他Worker,Map端推送和Reduce端读取都聚到这个节点,整个作业被拖慢。
排查后发现,热点和数据分布策略有关。Celeborn的Worker会根据分区Hash来分配数据,如果上游Map输出的分区大小本身就严重倾斜,某个Worker自然会被分配到大块数据。再加上当时作业里存在数据倾斜,个别分区的数据量是其他分区的几十倍,就形成了热点。
解决方案分了两层。第一层是从作业层面解决数据倾斜,开启Spark AQE(Adaptive Query Execution)的自动倾斜处理,尽量让每个分区数据量均匀。第二层是在Celeborn侧调整Worker的数据分布策略,把单个Worker的Partition文件数上限调低,强制数据打散到更多Worker。
4.2 连接数过多打爆Netty线程
某个大任务上线后,Worker的Direct Memory飙升,Jedis客户端反复报OOM,最后Netty线程池直接拒绝新连接。
这个问题的根子在于Push并发太高。push.max.reqs.in.flight设置得太激进,加上作业的并行度极高,短时间内Worker收到了海量并发Push请求,每个请求都要占用一定的连接和内存资源。
排查手段是看Worker的监控指标,重点观察Netty内存使用量和Push请求吞吐量。当时看到Netty内存占用了24GB,且持续不释放,基本可以确定是连接数打满。
解决方案是双管齐下:把push.max.reqs.in.flight从128降到64,同时把spark.celeborn.push.timeout从60s调到120s,给Worker留出足够的处理窗口。另外对于超大作业,我们还把spark.celeborn.shuffle.partition.type做了细分,避免一个作业把所有分区都推给同一批Worker。
4.3 Worker端GC频繁影响整体吞吐
Worker作为常驻服务,JVM堆内对象的分配和回收同样会影响性能。压测阶段观察到一个规律:每过十几分钟,Worker的Young GC频率就会明显上升,作业的整体吞吐同步下降。
排查时先怀疑是数据块元数据对象过多。每个分区文件的元数据都要在堆内创建对象,分区数量一多,Young GC自然频繁。后来开了对象复用和堆外缓存之后,GC频率明显降低。
具体配置方面,建议给Worker的JVM加上-XX:+UseG1GC并配合合理的年轻代大小,同时把Celeborn的Partition元数据写入到堆外内存,减少堆内对象的晋升压力。这个优化对长稳运行特别重要。
4.4 Fetch阶段偶发超时导致Task重试
Shuffle量上去之后,偶尔会出现某几个Task的Fetch超时,Task重试后又能成功,但重试次数多了会影响整体作业时间。
定位后发现,超时常发生在Worker磁盘IO高峰和网络抖动叠加的时候。Celeborn的Worker在Push和Fetch并发很高的时候,如果磁盘调度偏慢,Fetch请求就会排队。这个问题的直接推手是Worker上同时运行的Push和Fetch请求数量太多,互相争抢IO。
解决方案是给Worker的Push和Fetch请求分别设置并发上限,让两类请求之间有个互相退避的机制。另外在Spark侧调大了spark.celeborn.client.spark.fetch.timeout,从60s调到120s,给Fetch阶段更充裕的等待时间。调整之后,Fetch超时率从千分之五降到了十万分之一以下。
4.5 常见问题速查表
| 问题现象 | 根因 | 调整方案 |
|---|---|---|
| Worker节点CPU/网络明显高于其他节点 | 分区倾斜或数据分布策略不合理 | 开启AQE处理倾斜;调低单Worker分区上限 |
| Worker Direct Memory飙升 | Push并发请求过载 | 调低push.max.reqs.in.flight;调大push timeout |
| Worker GC频繁、吞吐下降 | 堆内元数据对象过多 | 使用G1GC;Partition元数据放堆外内存 |
| 偶发Fetch超时Task重试 | Worker磁盘/网络抖动叠加 | 控制Push和Fetch并发上限;调大fetch timeout |
| 大作业Executor频繁被Kill | 本地磁盘空间误报 | 放宽Driver对Executor磁盘空间的判断参数 |
除此之外,还有两个小坑值得提醒。一个是Worker和DataNode混部时,一定不要把所有磁盘都配给Celeborn,要给系统和其他服务留足IO带宽;另一个是如果集群里同时存在多个版本Spark,Celeborn的Client和Worker端需要版本匹配,否则会出现协议不兼容问题。
5. 收益对比与落地建议
经过一个多月的压测、灰度、调优,Celeborn最终在PB级Shuffle场景下稳定上线。对这个项目的收益,我个人的体会是很直观的。
5.1 优化之后的变化
首先是存储成本。Shuffle数据不再依赖本地三副本,而是由Celeborn按照多副本策略统一管理,整体Shuffle存储开销下降了将近一半。对于需要长期保留Shuffle中间数据做诊断的场景,这个节省非常可观。
其次是任务稳定性。引入Celeborn后,Executor被Kill不再导致上游Task重算,因为Shuffle数据已经不在本地。我们统计过一个典型大作业,恢复时间从之前的将近1小时缩短到10分钟以内,这还是在有Worker故障的情况下。
再次是作业性能。大作业的完成时间平均提升了15%到25%。这个提升主要来自两个部分:一个是Map端不再有大量磁盘随机写,Map阶段耗时缩短;另一个是Reduce端读取数据从随机拉取变成了相对连续的块读取,Fetch效率提升。
5.2 给准备落地的团队一些建议
如果你们正在考虑引入Celeborn,我的建议是不要一上来就追求全量接入,也别只在小任务上打转。挑一两个有代表性的中等规模生产任务先跑通,把压测做扎实,把参数摸透,再逐步扩大范围。
另外一定要把监控做细。Celeborn的Master和Worker都有对应的Metrics接口,建议接入你们现有的监控体系,重点看Netty内存、Push请求吞吐、Fetch延迟、Worker磁盘IO这几个指标。出了问题时,这些指标能帮你省下大量排查时间。
最后一点是关于团队认知的拉齐。RSS不是灵丹妙药,它对超大Shuffle场景的收益最大,对只有几GB Shuffle的小作业,收益其实不太明显,甚至可能因为增加了网络传输反而变慢。选场景比调参数更重要,把这个认知传递到位,能少走很多弯路。
最后再分享一个小技巧,我们后期把所有跑批作业的Shuffle参数都统一沉淀成了一套默认模板,新作业接入时直接套模板,再根据作业特征微调几个关键参数就行。这样既保证了稳定性,也降低了使用门槛。这个方案真正跑顺之后,你会发现Shuffle这个原来最让人头疼的环节,反而变成了整个链路里最省心的部分。
