Spark Shuffle 服务:外置 Shuffle 为什么比内置的更稳
Spark Shuffle 服务:外置 Shuffle 为什么比内置的更稳
一、一个凌晨三点的故障
先讲故事。上个月凌晨三点,我们的大盘看板突然挂了。查了半天发现是 Spark 任务跑到一个 Join 算子时,4 个 Executor 先后 OOM 退出。没等 YARN 重新分配好资源,整个 Stage 就失败了。日志里全是 FetchFailedException: Failed to connect to xxx。
根因就一句话:那台节点挂了,上面 Executor 存的 Shuffle 中间数据全丢了。
这引出一个灵魂拷问:Shuffle 数据到底该放哪儿? Spark 社区给出的答案就是 External Shuffle Service(外部 Shuffle 服务,简称 ESS)。
flowchart TB
subgraph 内置Shuffle["内置 Shuffle(Executor管理)"]
A1[Task 写完 Shuffle 文件] --> A2[文件存在 Executor 本地]
A3[Executor 因 OOM/GC/NodeLost 挂掉] -.-> A4["Shuffle 数据全丢<br/>Stage 重算!"]
end
subgraph 外置Shuffle["外置 Shuffle Service(ESS)"]
B1[Task 写完 Shuffle 文件] --> B2[文件注册到 ESS 守护进程]
B3[Executor 挂掉] -.-> B4["ESS 继续提供数据<br/>仅重算丢失的 Task 分区"]
end
style A4 fill:#E74C3C,color:#fff
style B4 fill:#27AE60,color:#fff
二、Shuffle 到底慢在哪儿
理解 ESS 之前,得先搞清楚 Shuffle 的本质。
Shuffle 是一个让分布式计算头疼了二十年的操作。它的过程比你想象的要复杂得多:
-
Map 端:每个 Task 把数据按 key 分区,写入本地磁盘。注意,这里每个 Task 要产生 N 个文件(N = 下游 Reduce Task 数量)。所以如果有 100 个 Map Task 和 50 个 Reduce Task,就会产生 5000 个小文件。
-
Reduce 端:每个 Task 从所有 Map Task 的对应分区拉取数据。这就涉及大量的网络连接。
-
合并排序:拉过来的数据要在内存中排序或聚合。
可以看出 Shuffle 的痛点非常集中:磁盘 I/O、网络带宽、小文件数量。这三个随便哪个拉胯,整个 Stage 就卡住了。
内置 Shuffle 模式下,数据生命周期和 Executor 绑定。Executor 一挂,所有本地 Shuffle 数据都消失,只能重算整个 Stage。这就是开头那个故障的根因。
三、ESS 的架构与工作原理
External Shuffle Service 的核心设计理念就一句话:让计算和管理解耦。
看看 ESS 的架构怎么运转的:
# 伪代码:ESS 的注册与读取流程
# 这不是可运行的代码,而是帮你理解 ESS 做了什么
class ExternalShuffleService:
"""外部 Shuffle 服务的核心逻辑示意"""
def __init__(self):
# 维护 Executor 到 Shuffle 文件的映射关系
# 即使 Executor 挂了,映射还在
self.registered_executors = {} # executor_id -> {block_map}
def register_executor(self, executor_id: str, app_id: str):
"""Executor 启动时向 ESS 注册
这一步是 ESS 和内置模式的最大区别:
内置模式:Shuffle数据 = Executor的私有财产
外置模式:Shuffle数据 = 托管给ESS的公共资产
"""
self.registered_executors[executor_id] = {
"app_id": app_id,
"blocks": {}, # block_id -> 本地文件路径
"status": "alive"
}
def report_blocks(self, executor_id: str, block_locations: dict):
"""Task 写完 Shuffle 数据后,把文件路径告诉 ESS
比如 Task 1 写完了 partition_0 的数据,路径是 /tmp/spark/shuffle_1_0.data
把这个信息注册到 ESS,其他节点才能找到
"""
self.registered_executors[executor_id]["blocks"].update(block_locations)
def fetch_shuffle_block(self, shuffle_id: int, partition_id: int) -> bytes:
"""下游 Task 通过 ESS 读取 Shuffle 数据
即使源 Executor 挂了,ESS 作为独立进程仍然可以读取本地文件
"""
# ESS 遍历所有注册的 Executor,查找目标 partition 的数据
for executor_id, info in self.registered_executors.items():
block_key = f"shuffle_{shuffle_id}_{partition_id}"
if block_key in info["blocks"]:
file_path = info["blocks"][block_key]
# ESS 直接读取本地磁盘文件,不经过已挂的 Executor
with open(file_path, 'rb') as f:
return f.read()
raise Exception(f"Shuffle block {shuffle_id}_{partition_id} not found")
ESS 带来的效果非常直观:
| 对比维度 | 内置 Shuffle | ESS 外置 Shuffle |
|---|---|---|
| Executor 退出 | 数据丢失,重算整个 Stage | 数据保留,仅重算退出的 Task |
| 动态资源分配 | 不支持(释放Executor会丢数据) | 支持(Executor可以安全释放) |
| 进程稳定性 | Executor和Shuffle同进程,互相影响 | ESS独立守护进程,隔离故障 |
| 磁盘管理 | Executor 自己管 | ESS 统一管理,可清理 |
动态资源分配是 ESS 带来的一个意外收获。没有 ESS 时,Spark 不敢随便释放 Executor,因为一旦释放,上面的 Shuffle 数据就没了。有了 ESS,Executor 用完就可以释放,云原生环境下能省不少钱。
为什么 ESS 作为一个"独立守护进程"就能解决所有问题? 答案在于进程生命周期的独立性。内置模式下,Shuffle 数据和 Executor 共享同一个 JVM 进程——OOM 时杀的不是 Executor 的 Java 线程,而是整个 JVM 进程,磁盘上所有本地文件跟着变得不可达。ESS 把 Shuffle 数据的读写路径从 Executor 进程剥离到独立的 NodeManager 守护进程中(端口 7337),这个独立进程不跑 Task、不受用户 Spark 代码的 GC 和 OOM 影响,即使 4 个 Executor 全挂了,ESS 仍然可以读取本地磁盘上的 Shuffle 文件、通过网络发给下游 Task。本质上,ESS 做的事情跟 Redis 做缓存、HDFS 做存储一样——把"不稳定计算"和"稳态数据"的耦合解开,让故障的爆炸半径限定在计算层,不蔓延到数据层。
四、配置与调优实战
ESS 的配置不算复杂,但有几个参数值得认真调:
# spark-defaults.conf — ESS 关键配置项
# 1. 开启 External Shuffle Service(默认是关闭的)
# 为什么默认关闭?因为ESS需要单独部署守护进程,
# 对简单的批处理任务来说,额外运维成本不划算
spark.shuffle.service.enabled true
# 2. ESS 的端口(所有 NodeManager 需要统一)
spark.shuffle.service.port 7337
# 3. ESS 的磁盘目录 — 这是最容易踩的坑
# 默认在 /tmp 下,系统重启就没了!
# 生产环境必须指向大容量数据盘
spark.local.dir /data1/spark,/data2/spark,/data3/spark
# 4. Shuffle 文件保留超时时间(毫秒)
# 为什么要有超时?防止僵尸Executor的Shuffle文件永远不清理
# 默认120s,如果ETL任务量大可以适当增加
spark.shuffle.service.removeShuffle.timeout 600
# 5. ESS 的 IO 线程数
# 如果你的节点上有大量Executor同时读写,增加这个值
spark.shuffle.io.serverThreads 16
再补充两个实践中的细节:
关于磁盘监控。 ESS 会在 spark.local.dir 下不断写入 Shuffle 文件,如果不监控磁盘使用率,迟早会把数据盘写满。我们的做法是配置一个 cron 脚本,每 30 分钟检查一次,超过 80% 的节点自动发告警。
关于 ESS 的优雅停止。 如果你要重启 ESS 守护进程,直接用 kill -9 会导致正在运行的 Spark 任务失败。正确的做法是:
# 优雅重启 ESS 的正确姿势
# 1. 先把 ESS 标记为 draining(不接受新注册)
# 2. 等待当前所有 Shuffle 连接自然结束
# 3. 再执行重启
yarn nodemanager stop # ESS 随 NodeManager 一起管理
为什么
spark.local.dir必须指向大容量数据盘而非系统盘? 这个问题如果不认真对待,某一天你会发现整个集群的根分区被写满了。Shuffle 文件的总量 = Map Task 数量 × Reduce 分区数 × 平均分区大小。假设一个 Stage 有 200 个 Map Task、100 个 Reduce 分区,每个分区 10MB,总 Shuffle 数据量 = 200 × 100 × 10MB = 200GB / 节点。这些文件全部写入spark.local.dir,如果指向/tmp(操作系统分区,通常只有 50-100GB),磁盘很快被塞满——后果不只是 Shuffle 失败,而是整个节点的操作系统因为磁盘满而失联(sshd 写不了日志、sudo 执行不了命令)。所以spark.local.dir必须配置多块数据盘(/data1/spark,/data2/spark,/data3/spark),让 ESS 做磁盘级别的负载均衡。
🚨 踩坑提醒
- ESS 端口(7337)多集群共享时的冲突 — 如果一个物理节点上跑了两个 Hadoop 集群(比如生产+测试),两个 ESS 抢同一个端口,后启动的会绑定失败。必须给不同集群分配不同端口:
spark.shuffle.service.port 7337(生产)、7338(测试),并在防火墙规则里区分。 spark.shuffle.service.removeShuffle.timeout设太短导致任务失败 — 这个超时是 ESS 清理 Shuffle 文件的 TTL。如果你的 ETL 任务有 10 分钟的 stage 间隔(上一个 stage 结束到下一个 stage 开始拉 Shuffle 数据之间空等 10 分钟),而 timeout 只有 120 秒,ESS 会在你还没拉数据之前就把文件删了——下一个 stage 会收到FetchFailedException。正确做法:设成任务最长 stage 间隔的 2 倍。- K8s 环境不要用 ESS——那是给 YARN 设计的 — 如果你在 Kubernetes 上跑 Spark,ESS 无法部署(K8s 的 Node 上没有 NodeManager 守护进程)。Spark 3.2+ 提供了 push-based shuffle,效果跟 ESS 类似但架构不同:Map Task 写完后主动把数据 push 给远程 Shuffle 服务,而不是等 Reduce Task 来拉。
五、总结
ESS 解决的问题其实很简单——把 Shuffle 数据的生命周期从 Executor 的铁链上解下来。但就是这一个变化,让你的 Spark 集群稳定性提升了一个数量级。
划几个重点:
- ESS 是生产环境标配,除非你的 Spark 任务从不因 Executor 挂掉而重算——但这不现实。
- 最大的收益是动态资源分配,云原生环境能省不少钱。
- 磁盘管理是关键,别把 Shuffle 目录放在系统盘,记得加监控和清理策略。
- 如果你在用 Kubernetes 运行 Spark,ESS 不适用——K8s 环境用 Spark 3.2+ 的 push-based shuffle 更合适。
一句话总结:ESS 就是给 Shuffle 数据买了个独立保险,Executor 可以挂,数据不能丢。
我是朱大喜,Spark 调优这条路上踩过的坑比工资条还长。你的 Shuffle 也翻过车吗?评论区聊聊?
更多推荐

所有评论(0)