做大数据的人对shuffle这个词都不会陌生。我在vivo数据平台这边主要负责计算引擎优化,这几年业务规模一路往PB级走,Shuffle的性能问题就成了最让人头疼的事。经常是一张跑几个小时的大表任务,其中一半甚至更多时间耗在Shuffle上;一旦集群抖动,磁盘或网络先扛不住,整个任务直接失败,重跑又要再交一次时间成本。去年我们花了大半年时间,把Spark的Shuffle机制从原生模式替换成Apache Celeborn这套远程Shuffle服务,陆续接入了上百个核心批处理任务,取得了比较明显的收益。这篇内容就是这次PB级Shuffle优化处理的完整复盘,覆盖机制分析、部署接入、参数调优和线上排障,希望能给正为Shuffle发愁的同行一些参考。
1. 为什么PB级数据下原生Shuffle撑不住了
1.1 先看一组典型的线上表现
以一条核心的用户标签计算链路为例,每天早晨批处理阶段,单条作业的Shuffle数据量能到几十TB,整条链路一天下来Shuffle总量经常突破PB级。任务运行的时间分布大致是:Source表读取和解析占两成,计算转换占两成,Shuffle相关占近五成,剩下是结果写出。这个比例在大数据量场景下并不罕见,但真正的问题是它不稳定。大促或者业务上量的时候,Shuffle耗时波动特别大,一个原本跑40分钟的任务突然变成2小时,平台侧就只能干着急。
你可以把Shuffle理解成一次大规模的“数据搬家”:上游Map任务把中间结果整理好,下游Reduce任务再来取。数据量小的时候,这个搬家过程没什么感觉,但数据量到了PB级,搬家的方式、路线和存储就非常关键。原生Spark把中间结果直接写在每个Executor的本地磁盘上,Reduce端再来远程“取件”,这个机制在数据量上来之后会暴露几个致命弱点。
1.2 小文件风暴
第一个问题是Shuffle中间文件的数量和规模完全失控。Spark Map端每个Task会为下游每个Partition生成一份数据文件,虽然执行时会做一定程度的合并,但遇到Reducer数量多、Map Task多的情况,中间文件数量轻松突破百万级。我们有一个两百多个Reduce的作业,一次运行之后统计到的Shuffle临时文件超过一百二十万份,把节点的磁盘Inode直接占满,其他进程开始报“No space left on device”,而实际上磁盘容量还有很多。
这是一个非常反直觉的问题:容量没满,但文件太多,文件系统先扛不住了。这个问题的根源是原生Shuffle的设计目标偏向中小规模批处理场景,它没有把“超大规模文件管理”当成一等公民。当文件数到了百万级别,光是文件系统元数据的维护开销就已经很大,更别说后续清理阶段还需要遍历删除这些文件,又会抢走一批CPU和磁盘I/O。
1.3 网络连接数爆炸
第二个问题出现在Reduce端的Pull阶段。下游每个Task需要到上游所有Executor上去拉取数据,假设上游有500个Executor、下游有500个Reduce Task,一次Shuffle就要建立几十万次网络连接。这些连接大多还是短连接,频繁创建和销毁,把NodeManager和Executor的线程池占满,反过来把任务压垮。
我在排查一次线上事故时看过监控,任务运行到Shuffle拉取阶段时,单台机器同时打开的TCP连接超过三万个,网络层已经出现SYN重传,应用层的拉取速度掉到几百KB每秒,整个任务卡在一个“不失败也跑不动”的状态。这种问题单靠调大连接数参数很难根治,因为瓶颈往往不在某一个参数上,而在整个网络栈的承载能力。
1.4 稳定性与GC问题
第三个问题来自内存和GC。原生Shuffle在Reduce端拉数据时,需要把大量数据块放到内存缓冲里做聚合,当单个Partition的数据特别大时,很容易触发Full GC,甚至直接OOM。特别是遇到数据倾斜,一个Task拉到的数据量是其他Task的几十倍,节点内存直接被拉满,紧接着就是一连串的任务失败和Executor失联。
这些问题单看都不是特别难解决,但它们交织在一起,就变成了一套结构性瓶颈。沿着“加机器、加参数”的思路去补,今天修好磁盘,明天网络又爆;今天调大内存,明天GC又把CPU吃满。一个很自然的想法是:能不能把Shuffle从计算节点中拆出来,做成一个独立的、可弹性扩展的服务?这就是我们后来选择Apache Celeborn的初衷。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. Celeborn核心机制拆解:它凭什么能扛住PB级
2.1 三个核心角色
先把架构说清楚。Celeborn把Shuffle做成了一个独立的服务集群,整体上分成三个角色:Master、Worker和Client端的LifecycleManager。
- Master是整个集群的“大脑”,维护所有Worker的资源状态,负责给App分配Shuffle数据写入的Worker节点,同时处理Worker的心跳和故障转移。Master自身可以配置HA,数据量大的生产环境一般配三个节点,通过Raft协议做选主。
- Worker是真正存储Shuffle数据的地方,负责接收Map端推过来的数据、将数据落盘,并且为Reduce端的读取提供数据读取接口。每个Worker会挂多块磁盘,数据按照一定的策略分配到不同磁盘上。
- LifecycleManager运行在Driver端,是客户端的协调者。Map Task执行前,LifecycleManager会向Master申请一批“槽位”,拿到槽位之后才知道当前要写到哪些Worker的哪些Shuffle Partition上。任务结束时也要通过它和Master完成最终确认。
这三个角色合起来,其实就是把原来散落在Spark Executor里的“中间数据存储与交换”抽出来,变成了一个专业分工的存储服务。好处是计算侧可以更专注,存储侧可以单独优化。
2.2 Push模型如何解决小文件和连接数
Celeborn最核心的设计是数据流向从“Reduce端拉取”变成了“Map端推送”。在原生Shuffle里,Map端写完数据就什么都不管了,由Reduce端一个个去拉;而在Celeborn里,Map Task在写数据的同时,会通过异步推送框架把数据直接推给Worker。
Push模型有三个天然优势。第一,数据提前到达Worker节点,Reduce端开始读取时数据已经就位,减少了整个任务的关键路径耗时。第二,Map端推送时是按Partition维度聚合成块的,每份数据会被写入到固定的文件区域中,而不是产生无数碎片小文件。第三,Reduce端只需要连接Worker节点就能拉到全部数据,连接数从“上游Executor数乘以Reduce Task数”降成了“Worker节点数乘以Reduce Task数”,网络压力直接小了一个数量级。
我们在一个1000并发Map Task、200个Reduce Task的作业上统计过,原生Shuffle创建的网络连接数是两百多万次,换成Celeborn后只有几万次,差距非常明显。数据在Worker端也会做类似“段”的管理,读取时按连续块返回,整体I/O模式更接近于顺序读,对磁盘更友好。
2.3 和ESS及其他方案的本质差别
很多同学会问,Spark本身不是也有External Shuffle Service(ESS)吗?ESS确实把Shuffle文件的读取服务化了,但它的数据仍然写在Executor本地,也没有解决中间文件数量问题,只是把Fetch的通道从Executor挪到了一个常驻进程中。所以在PB级场景下,ESS能缓解连接数,但解决不了小文件和GC问题。
Celeborn这类Remote Shuffle Service还有一个隐藏优势是“数据与计算分离”的灵活性。因为Shuffle数据不在Executor本机,任务失败或者Executor重启之后,Shuffle数据不会丢失,Stage不需要整个重算。这个特性在下游Reduce任务特别多、失败概率高的情况下,能省掉非常多的重试成本。
| 对比项 | 原生Shuffle | Spark ESS | Celeborn |
|---|---|---|---|
| 数据存储位置 | Executor本地磁盘 | Executor本地磁盘 | 独立Worker集群 |
| 中间文件数量 | 极多 | 极多 | 少 |
| Reduce连接数 | M×R | 相对较少 | Worker×R |
| 支持Executor重启 | 否 | 否 | 是 |
| 数据倾斜应对 | 差 | 一般 | 较好 |
| 部署维护成本 | 无 | 中 | 较高 |
表格里也能看出来,Celeborn并不是银弹。它用一定的部署和运维成本,换来了更可控的Shuffle路径。到底值不值,要结合集群规模和作业特性来判断。
2.4 容错与降级机制
再补充一个很关键的机制:降级。Celeborn虽然是独立服务,但并不意味着它一定比原生Shuffle更稳定。为了避免Worker故障导致任务直接失败,Celeborn客户端内置了降级逻辑:当某个Partition的数据连续多次推送失败,它会把这个Partition标记为“本地模式”,接下来的数据直接写到Executor本地,Reduce端读取时由Client统一合并远程和本地两部分数据。
这个设计很实用。我们线上遇到过一次Worker批量滚动重启,由于降级机制的存在,任务只是变慢了一些,没有出现大面积失败。如果你去观察Spark UI,会看到同一个App里既有远程Shuffle的读取,也有本地数据的读取,这是正常现象,说明降级已经生效。理解了这个机制之后,遇到类似情况就不会慌。
3. vivo落地Celeborn的部署接入与参数调优
3.1 集群部署规划
接下来谈一谈我们是怎么落地的。如果你要在生产环境上跑Celeborn,有几个基本维度需要先想清楚。
第一,Worker节点数量。我们在初期用20台物理机组成Worker集群,每台机器配置了64核CPU、256G内存和8块4T的SATA盘。为什么用SATA盘而不是SSD?因为Shuffle数据是以顺序写为主、顺序读为主,SATA的吞吐量在多数场景下够用;SSD延迟更好但成本高,容量也容易成为瓶颈。当然,如果你的作业对延迟特别敏感,可以换SSD,但容量和成本需要重新评估。
第二,磁盘规划。Celeborn的Worker会把数据写到本地磁盘目录,所以挂载目录要尽量多、尽量分散。我们每台挂了8块盘,每块盘一个挂载目录。早期试过把8块盘做成RAID后再挂载,后来发现多目录并行写更稳,因为Celeborn自身会做磁盘负载均衡,多个独立目录能更好地利用I/O能力。配置项在Worker端通过存储目录列表指定,多个目录之间用逗号分隔。
第三,Master高可用。Master我们部署了3个节点,用Raft模式做选主。节点数必须是奇数,这是Raft协议的要求,不能省。网络方面,Master和Worker之间建议走独立的内网网段,避免和业务流量互相干扰。如果计算集群和Celeborn集群跨机房,需要重点评估专线带宽和延迟,否则Push阶段的性能会很难看。
3.2 Spark侧的关键配置
接入Spark时,主要在spark-defaults.conf里修改:
properties复制spark.shuffle.manager=org.apache.spark.shuffle.celeborn.SparkShuffleManager
spark.serializer=org.apache.spark.serializer.KryoSerializer
spark.celeborn.master.endpoints=master01:9097,master02:9097,master03:9097
spark.celeborn.client.push.buffer.size=256k
spark.celeborn.client.fetch.maxReqsInFlight=3
spark.sql.adaptive.enabled=true
spark.sql.adaptive.shuffle.targetPostShuffleInputSize=67108864
spark.sql.adaptive.maxNumPostShufflePartitions=200
重点是spark.shuffle.manager必须替换成Celeborn的ShuffleManager实现,序列化器建议使用Kryo,因为Celeborn传输过程中数据是序列化后的字节,Kryo相比Java序列化在时间和空间上都有明显优势。自适应查询执行(AQE)建议保持开启,它会在Reduce端动态调整分区数,避免因为分区数不合理导致大量小任务。
我自己比较推荐在接入初期先拿两三个中大规模的作业做灰度,而不是全部切换。每个作业的并行度、分区数、数据量不同,直接全量接入容易在出问题时没办法快速回滚。我们当时的做法是通过Spark配置中心按作业名打标签,先让10%的任务走Celeborn,观察两三天,确认稳定后再逐步扩大到50%、100%。
3.3 调参踩坑后的几点结论
这一段我直接上干货,每个参数背后都是线上踩过坑后的结论:
-
spark.celeborn.client.push.buffer.size:这是Map端推送缓冲大小,默认通常是128K或256K,可以根据网络带宽调整。我们把它从128K调到256K之后,大数据的推送吞吐有明显提升;但继续往上调到512K,反而因为缓冲区占内存太高,导致Executor GC变多,收益为负。合理区间要结合Executor的内存余量来定。 -
spark.celeborn.client.fetch.maxReqsInFlight:控制Reduce端同时发起的Fetch请求数量,默认一般是3。如果你在Worker端发现网络接收队列经常打满,可以把这个参数降下来;如果Worker很空闲而Reduce端等待时间偏高,再尝试调高。这个参数不能拍脑袋调,要配合Worker端的网络监控一起看。 -
Worker端的内存参数
celeborn.worker.memory:建议不要超过机器总内存的一半。我们一开始给Worker分配了128G堆内存,结果Full GC非常频繁,后来降到64G,并把剩余内存交给操作系统做页缓存,Shuffle读性能反而上来了。原因是Worker内部的数据写入链路大量使用堆外和PageCache,堆内存不是越大越好。 -
Worker刷盘阈值
celeborn.worker.flush.buffer.size:也就是Worker每攒多少数据刷一次磁盘。太小会导致频繁刷盘,随机写居多;太大又容易造成内存压力。我们最终固定在256K,磁盘写顺序性最好。调试这个参数时,可以观察Worker磁盘的await时间,通常保持顺序写的情况下,await会非常平稳。
调参的过程本质上就是在“内存-网络-磁盘”三者之间找平衡。建议每调一个参数就跑一轮基准测试,不要同时调多个,否则出了问题很难定位是哪一个参数引起的。
4. 运维与排障:线上遇到的问题实录
4.1 Worker频繁Full GC,任务偶发失败
刚上线那会,我们遇到最头疼的问题是Worker的Full GC。现象是任务运行一段时间后,Worker的CPU飙高,随后一些Partition推送超时,触发客户端降级。
排查过程分了三步。第一步先看GC日志,发现Old区持续增长,回收不掉。第二步看堆内存快照,发现大量对象来自PartitionWriter的内部缓存。第三步确认是celeborn.worker.memory给得太大,导致JVM堆内缓存了过多的数据块,GC压力巨大。
解决方案是把Worker内存降到64G,同时把堆外内存调大,让数据尽量在堆外流转,避免堆内对象堆积。事实上,Celeborn的PartitionWriter在设计上就主要依赖堆外存储,如果你把堆内存给得很大,系统会倾向于缓存更多,反而不利。这个问题在社区里也讨论过多次,属于比较经典的误配置案例。
4.2 数据倾斜造成的Worker热点
PB级数据十有八九存在倾斜,我们一个用户行为日志的作业里,某个热门用户ID的关联数据占了全量的20%。在原生Shuffle下,倾斜主要体现在Reduce任务执行时间拉长;换成Celeborn后,倾斜问题变成了某个Worker盘的I/O飙高,热度集中在那块盘上。
应对方式有三层。第一层,在Spark侧开启动态分区裁剪和倾斜Join优化,从源头减少倾斜数据量。第二层,给Celeborn的Worker配置更多磁盘,让热点盘的压力分散到多块盘上。第三层,实在处理不了的极端倾斜任务,在ETL阶段对Key加盐打散,再二次聚合。
需要说明的是,Celeborn并不负责解决数据倾斜问题,它只是把倾斜的压力从“节点内存”转移到“磁盘I/O”,所以上游的倾斜优化该做还是要做。如果以为换了一套Shuffle服务就能自动处理倾斜,那大概率会失望。
4.3 连接数与Backpressure
还有一个常见问题是Reduce端拉取数据时吞吐量上不去。我们最开始Fetch请求并发设置得比较高,结果Worker网卡被打满,出现大量TCP重传,数据一直没有真正到达Reduce端。
后来在Worker端加了限流,同时在客户端把spark.celeborn.client.fetch.maxReqsInFlight从5调整为3,整个集群的网络重传率立刻降了下来,吞吐反而稳定了。这个案例说明,在大规模场景下,过高的并发不一定带来性能,往往先触发网络拥塞,再触发应用层超时,最后得不偿失。观察指标除了吞吐量之外,一定要看TCP重传率、Worker接收队列长度这类底层指标。
4.4 滚动升级与故障演练
这里分享一个运维上的经验。我们每隔一段时间会对Celeborn集群做一次滚动重启,并且刻意在低峰期跑在真实作业上演练“杀掉一个Worker”,验证降级逻辑。第一次演练时,确实发现有些任务在Worker故障后卡了很久才恢复,后来排查是因为Master的失败检测时间配置太长,把它从120秒调到了30秒,故障恢复速度明显加快。
如果你也准备在生产环境上引入Celeborn,我强烈建议把故障演练纳入例行运维。否则第一次真实故障来的时候,你根本不知道系统会怎么表现。线上系统最怕的不是故障本身,而是故障路径完全不可预期。
5. 上线效果与经验沉淀
5.1 线上收益数据
经过半年多的磨合,我们在vivo内部成功接入了上百个核心Spark批处理任务,覆盖了用户增长、画像分析、推荐特征等多个场景。从监控平台统计来看,优化效果主要体现在这几个维度:
| 指标 | 优化前表现 | 优化后表现 |
|---|---|---|
| Shuffle阶段耗时 | 全任务耗时占比约50% | 平均下降35%左右 |
| 大促期间耗时波动 | 波动接近100% | 控制在20%以内 |
| Shuffle临时文件 | 单任务超百万文件 | 无本地临时文件堆积 |
| 任务失败重试 | 大Reduce任务常失败重跑 | 多数场景自动恢复 |
需要说明的是,这些效果不是白来的。引入Celeborn意味着多维护一套分布式系统,这个成本必须计算在内。如果是只有几十个任务的小集群,我反而建议先不要上这套方案,先把原生Shuffle的参数调一调可能更划算。
5.2 如何判断你的场景是否适合
我后来总结了一套判断条件,如果你同时命中两条以上,就值得尝试:
- 集群中有大量数据量在几十GB到几TB的Shuffle作业。
- Shuffle阶段经常成为任务瓶颈,并且耗时波动大。
- 计算集群经常因为Shuffle中间文件导致磁盘或Inode爆满。
- 作业失败重试的成本很高,需要更快的故障恢复能力。
- 计算节点和存储节点可以分离部署,团队有专职的大数据平台运维能力。
如果只是临时跑一些小查询、小任务,或者整个集群Shuffle量很小,原生Shuffle已经够用,引入这套服务反而会增加不必要的复杂度和运维负担。技术选型最忌讳“为了上新技术而上新技术”。
5.3 后续的演进方向
现在Celeborn已经进入Apache孵化器,社区迭代速度很快,后面我们计划进一步做几件事:把Flink的Shuffle也逐步接入Celeborn,统一批流Shuffle底座;探索在Worker端使用更高吞吐的存储介质,进一步降低读延迟;同时把任务级别的Shuffle监控做得更细,从“平均耗时”细化到“每个Partition的读写耗时分布”,这样排障时能更快定位到是节点问题还是数据倾斜。
最后说一点我个人的体会。很多人问我,PB级Shuffle优化到底难在哪里?我觉得技术选型反而是最简单的部分,真正的难点在于对现有系统瓶颈的判断,以及上线后持续的调优和运维。Celeborn并不是灵丹妙药,它只是帮我们把Shuffle这件事从“计算引擎内部的黑盒”变成了“一个可以自己控制的服务”。当你把中间数据的存储、网络、容错全部掌握在自己手里时,很多原来看起来无解的性能问题,其实就变成了工程问题,一点一点去优化就好。希望这篇复盘能给你一些启发。
