ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

Spark Worker启动流程与心跳通信机制源码深度解析

Spark Worker启动流程与心跳通信机制源码深度解析

1. 从一个“看不见的守护进程”说起

很多人第一次接触分布式计算框架时,注意力几乎都被SparkContext、RDD、DAGScheduler这些“明星组件”吸走了,反而忽略了集群里那个最沉默、却最不能出问题的角色——Worker。我刚开始啃源码那会儿也是这样,觉得 Worker 不就是个干活的嘛,Master 让它干啥它干啥,能有多复杂?直到有一次在测试环境里,一个 Worker 进程莫名其妙“假死”:进程还在,端口还占着,但 Master 那边已经把它标记成 Lost,任务全部重新调度,整个集群抖了好几分钟。那次排查让我彻底改变了对 Worker 的看法——它不是一个被动的执行器,而是一个有独立生命周期、有主动心跳、有状态上报机制的自治节点。

这篇内容就是围绕Worker 的启动流程以及Worker 与 Master 之间的心跳通信这两条主线展开的。我会把源码里那些关键类、关键方法、关键参数一个个拆开讲清楚:Worker 进程从main方法进去之后到底做了哪几件事,它怎么向 Master 注册自己,注册成功之后心跳是怎么发出去的,Master 收到心跳之后又做了什么,心跳超时是怎么判定的,以及为什么这套机制要设计成现在这个样子。适合已经用过 Spark、想往底层走一步的读者,也适合正在准备分布式系统相关面试、需要把“心跳机制”讲明白的朋友。读完之后,你应该能自己画出 Worker 的启动时序,也能在遇到 Worker 掉线类问题时知道从哪几个点下手排查。

需要先说明一点:不同大版本的 Spark 在 Worker 启动细节上有差异,比如早期版本 Worker 继承自Actor,后来随着 Akka 的移除改成了基于RpcEndpoint的模型。我下面讲的是基于 RpcEndpoint 这套相对现代的模型,这也是目前主流版本采用的方案。如果你手上是更老的代码,思路是一致的,只是通信层的实现换了皮。

2. Worker 启动流程的整体设计与思路拆解

2.1 为什么 Worker 要设计成一个独立进程

要理解启动流程,先得理解一个设计前提:Worker 是一个独立 JVM 进程,不是 Master 内部的一个线程。这个选择背后有很实在的工程考量。

分布式集群里,Master 负责资源调度和状态管理,Worker 负责实际拉起 Executor、管理本机资源。如果把 Worker 做成 Master 内部的线程,那么 Master 一旦崩溃,所有节点上的执行能力就全没了;而且资源隔离也无从谈起,一个节点的负载会直接影响 Master 本身。做成独立进程之后,Master 和 Worker 之间只通过 RPC 通信,职责边界清晰:Master 挂了可以重新选主,Worker 挂了只影响它自己那台机器上的任务,其他节点照常工作。这就是典型的控制面与数据面分离思路。

所以你在源码里看到的 Worker 启动,本质上就是“一个独立 JVM 进程如何把自己接入集群”的过程。它要解决三个问题:第一,我得知道 Master 在哪;第二,我得让 Master 知道我是谁、我有什么资源;第三,接入之后我得持续证明“我还活着”。

2.2 启动流程的骨架:从 main 到注册

Worker 的入口是Worker.main方法,这是整个流程的起点。我把这条链路抽象成几个阶段,后面再逐个展开:

  1. 参数解析与配置加载:从命令行拿到 Master 地址、Worker 自己的 host 和 port、工作目录、内存和核数等参数,合并到SparkConf里。
  2. 创建 RpcEnv:这是通信基础设施,相当于 Worker 的“电话总机”,负责收发 RPC 消息。
  3. 实例化 Worker 对象并注册到 RpcEnv:Worker 本身是一个RpcEndpoint,注册之后才能接收消息。
  4. 向 Master 发起注册:通过RegisterWorker消息把自己“报到”给 Master。
  5. 处理 Master 的响应:注册成功则记录 Master 的引用,进入心跳阶段;注册失败(比如 Master 要求 Standby 模式)则重试或退出。

这个骨架看起来简单,但每一步都有值得琢磨的地方。比如为什么参数解析要放在创建 RpcEnv 之前?因为 RpcEnv 的创建需要用到 host、port、conf 这些信息,顺序不能反。再比如为什么 Worker 要先注册到自己的 RpcEnv 再向 Master 注册?因为 Master 的响应是异步回来的,Worker 必须先有一个能接收消息的 endpoint 地址,否则 Master 回消息的时候找不到人。

2.3 关键类与职责划分

在展开细节之前,先把涉及的核心类列清楚,不然后面看方法调用容易迷路。

类/对象职责
WorkerWorker 进程的主体,继承RpcEndpoint,处理各类消息
WorkerArguments命令行参数解析,产出 host、port、cores、memory 等
RpcEnvRPC 通信环境,负责 endpoint 注册与消息收发
Master集群管理者,接收注册、维护 Worker 列表、处理心跳
RegisterWorkerWorker 发给 Master 的注册消息
RegisteredWorkerMaster 回给 Worker 的注册成功消息
SendHeartbeatWorker 周期性发给 Master 的心跳消息
HeartbeatMaster 回给 Worker 的心跳响应
WorkerInfoMaster 侧维护的 Worker 元数据(地址、资源、最后心跳时间)

这张表建议先记住,后面所有流程都是围绕这几个对象转的。尤其是WorkerInfo,它是 Master 判断 Worker 是否存活的核心数据结构,里面有个lastHeartbeat字段,心跳超时判定全靠它。

3. 核心细节解析与实操要点

3.1 参数解析:WorkerArguments 到底解析了什么

Worker 启动的第一步是解析命令行参数。源码里对应的是WorkerArguments这个类,它做的事情比很多人想的要细。除了显式的--host、--port、--webui-port、--cores、--memory、--work-dir之外,它还会做几件容易被忽略的事。

第一,host 的自动推断。如果你没传--host,它会尝试通过Utils.localHostName()拿到本机主机名。这里有个坑:在某些多网卡机器上,拿到的可能是回环地址或者一个 Master 根本连不通的网段地址。我踩过一次,Worker 起来了,日志显示注册成功,但 Master 那边一直显示 Worker 是 Unreachable,最后发现是 host 推断错了。所以生产环境强烈建议显式指定--host,别偷懒。

第二,cores 和 memory 的默认值。如果不传--cores,默认是使用机器上所有可用的核;--memory默认是 1024MB。这里要注意,Worker 上报给 Master 的资源是“这台机器愿意贡献给 Spark 的资源”,不是机器物理资源。你完全可以让一台 64 核的机器只贡献 16 核,剩下的留给别的服务。这个参数直接决定了 Master 能在这台 Worker 上调度多少 Executor。

第三,work-dir 的处理。Worker 会在工作目录下为每个 Executor 创建子目录,用来存放日志、临时文件等。如果--work-dir指向的磁盘空间不足,Executor 启动会失败。我一般会把它指到一块独立的数据盘上,避免和系统盘抢空间。

提示:参数解析阶段如果发现必填项缺失或者格式不对,Worker 会直接抛异常退出,不会进入后续流程。所以启动失败时,第一件事就是看日志里参数解析有没有报错。

3.2 RpcEnv 的创建:通信基础设施怎么搭起来

参数解析完之后,Worker 会调用RpcEnv.create创建通信环境。这一步是整个启动流程里最“重”的一步,因为它要初始化网络传输层、启动消息分发线程池、绑定监听端口。

RpcEnv.create内部会根据配置选择具体的 RpcEnv 实现。在移除 Akka 之后,默认走的是基于 Netty 的实现。它会做这么几件事:创建TransportContext,配置编解码器,启动 server 绑定到指定端口,初始化Dispatcher用于消息分发。你可以把 RpcEnv 理解成一个“邮局”:每个 RpcEndpoint 是邮局里的一个信箱,消息投递到信箱之后由 Dispatcher 负责叫醒对应的人来处理。

这里有个实操要点:Worker 的 RPC 端口和 WebUI 端口是两个不同的端口。RPC 端口用于 Master 和 Worker 之间的内部通信,WebUI 端口用于浏览器访问 Worker 页面。默认情况下 RPC 端口是随机分配的(传 0 的话),但生产环境建议固定,方便防火墙配置和问题排查。我见过有人只开了 WebUI 端口没开 RPC 端口,结果 Worker 死活注册不上,排查半天才发现是网络策略的问题。

3.3 Worker 注册到 RpcEnv:先给自己安个家

创建完 RpcEnv 之后,Worker 会实例化自己并调用rpcEnv.setupEndpoint把自身注册进去。这一步的意义是:Worker 必须先有一个可被寻址的 endpoint 引用,才能接收 Master 的异步响应。

注册的时候会指定一个 endpoint 名称,通常是"Worker"。RpcEnv 会为这个 endpoint 分配一个RpcEndpointRef,这个 ref 包含了地址信息(host、port、endpoint 名称)。后续 Worker 给 Master 发消息、Master 给 Worker 回消息,用的都是这种 ref。

这里有个细节值得说:setupEndpoint返回的 ref 和 Worker 内部的self是同一个东西。Worker 在onStart方法里会用到self来给自己发送消息(比如触发首次注册)。这种“自己给自己发消息”的模式在 RpcEndpoint 模型里很常见,好处是所有逻辑都走统一的消息处理入口,避免并发问题。你可以理解为:Worker 不直接调用注册方法,而是给自己发一条“该去注册了”的消息,然后由消息处理线程去执行。这样注册逻辑和心跳逻辑就在同一个线程模型下,不会出现状态竞争。

3.4 向 Master 发起注册:RegisterWorker 消息里有什么

Worker 的onStart方法被调用后,会触发向 Master 注册的逻辑。核心动作是构造一个RegisterWorker消息并发送给 Master 的 ref。这个RegisterWorker消息里携带的信息相当关键,我列一下主要字段:

  • id:Worker 的唯一标识,通常是worker-<时间戳>-<host>-<port>这种格式。
  • host、port:Worker 的 RPC 地址,Master 后续要靠这个地址回消息和发指令。
  • workerRef:Worker 自己的 RpcEndpointRef,Master 保存下来用于主动联系 Worker。
  • cores、memory:Worker 上报的可用资源。
  • webUiUrl:Worker WebUI 的地址,方便从 Master 页面跳转。
  • endpoint:Worker 的 endpoint 名称。

Master 收到RegisterWorker之后,会做一系列校验:这个 Worker 是不是已经注册过了?资源参数是否合法?当前 Master 是不是 Active 状态?如果 Master 处于 Standby 状态,它会回一个MasterInStandby消息,Worker 收到后需要重新解析 Master 地址(可能发生了主备切换)再重试注册。

注册成功的话,Master 会回一个RegisteredWorker消息,里面包含masterRef(Master 的引用)和masterWebUiUrl。Worker 收到这个响应后,会把master字段设置好,然后启动心跳定时器。到这里,注册阶段才算真正完成。

注意:注册不是一次性的。如果 Worker 和 Master 之间的连接断了,Worker 会重新发起注册。所以注册逻辑必须设计成幂等的,Master 侧也要能处理重复注册的情况。

4. 心跳通信机制:Worker 怎么证明“我还活着”

4.1 心跳的发起:定时器与 SendHeartbeat

注册成功之后,Worker 会调用Utils.timer或者类似的调度机制,启动一个周期性任务,每隔spark.worker.timeout相关配置的一段时间(默认是spark.worker.timeout的四分之一,通常是 15 秒左右)向 Master 发送一条SendHeartbeat消息。

为什么是超时时间的四分之一?这是个很经典的心跳设计经验:心跳间隔要显著小于超时阈值,这样才能容忍偶发的网络抖动或消息丢失。假设超时是 60 秒,心跳间隔 15 秒,那么即使连续丢 3 次心跳,Master 才会判定 Worker 失联。如果心跳间隔设成 30 秒,丢一次就接近超时了,容错空间太小。这个比例不是随便定的,是工程上反复验证过的经验值。

SendHeartbeat消息本身携带的信息不多,主要是workerId和workerRef。Master 收到之后,会根据workerId找到对应的WorkerInfo,更新它的lastHeartbeat时间戳。这个时间戳就是超时判定的依据。

4.2 Master 侧的处理:Heartbeat 响应与超时检测

Master 收到心跳后,除了更新lastHeartbeat,还会回一个Heartbeat消息给 Worker。这个响应看起来像是“多此一举”——Worker 发心跳是为了告诉 Master 自己活着,Master 为什么还要回?

原因在于双向确认。Worker 也需要知道 Master 是否还正常。如果 Worker 发了好几次心跳都收不到响应,它就知道 Master 可能出问题了,需要触发重新注册或者主备切换逻辑。所以心跳不是单向的“报到”,而是双向的“握手”。

超时检测是 Master 侧另一个独立的任务。Master 会定期(通常是超时时间的一半)扫描所有WorkerInfo,检查now - lastHeartbeat是否超过了spark.worker.timeout。如果超了,就把这个 Worker 标记为 Lost,从可用资源里移除,并通知相关的 Application 重新调度任务。

这里有个容易混淆的点:Worker 被标记为 Lost,不代表 Worker 进程真的死了。可能只是网络分区,Worker 还在跑,只是心跳到不了 Master。这种情况下,如果网络恢复,Worker 重新注册,Master 会把它当成一个新 Worker 加进来。但之前在这个 Worker 上跑的 Executor 可能已经被 Master 通知杀掉了,所以会出现“Worker 还在但 Executor 没了”的现象。这是分布式系统里典型的脑裂问题,理解这一点对排查线上故障非常重要。

4.3 心跳超时参数怎么调

和心跳相关的参数主要有两个:spark.worker.timeout和spark.rpc.askTimeout。前者决定 Master 多久没收到心跳就判定 Worker 失联,后者决定 RPC 请求的等待超时。

spark.worker.timeout默认是 60 秒。这个值在稳定网络环境下够用,但在网络质量差或者机器负载高的场景下可能偏小。我遇到过一台机器因为 GC 停顿导致心跳延迟,被误判为 Lost 的情况。后来把超时调到 120 秒,问题就消失了。但也不能调太大,否则 Worker 真挂了之后,Master 要很久才能发现,任务恢复会变慢。

我的经验是:先看网络 RTT 和机器 GC 情况,再决定超时值。如果 RTT 稳定在毫秒级,GC 停顿也在百毫秒级,60 秒绰绰有余;如果网络抖动大或者有长 GC,可以适当放大到 90 到 120 秒。调整的时候要同步考虑心跳间隔,保持四分之一的比例关系。

参数默认值作用调整建议
spark.worker.timeout60sMaster 判定 Worker 失联的阈值网络差或 GC 长时调到 90-120s
spark.rpc.askTimeout120sRPC 请求等待超时一般不用动,除非有长耗时 RPC
心跳间隔timeout/4Worker 发心跳的频率跟随 timeout 自动调整

5. 实操过程与核心环节实现

5.1 本地模拟:手动启动一个 Worker 观察日志

光看源码不够直观,我建议你实际起一个本地集群,观察 Worker 启动的完整日志。步骤大致是这样:

  1. 启动 Master:sbin/start-master.sh,记下 Master 的 URL,形如spark://host:7077。
  2. 启动 Worker:sbin/start-worker.sh spark://host:7077(老版本是start-slave.sh)。
  3. 查看 Worker 日志:logs/spark-*-org.apache.spark.deploy.worker.Worker-*.out。

日志里你会看到类似这样的关键行:

Registering worker with master... Successfully registered with master spark://host:7077

如果注册失败,日志里会有明确的错误信息,比如Connection refused(Master 地址不对或没启动)、Master is in standby mode(主备切换中)等。这一步是排查 Worker 问题的第一现场,养成看日志的习惯比什么都重要。

5.2 关键代码路径走读

我把 Worker 启动和心跳的核心代码路径梳理一下,方便你对照源码阅读。注意不同版本方法名可能有细微差异,但逻辑是一致的。

// Worker.main 入口 def main(argStrings: Array[String]): Unit = { val args = new WorkerArguments(argStrings, conf) val rpcEnv = RpcEnv.create("Worker", args.host, args.port, conf, securityMgr) val worker = new Worker(rpcEnv, args.webUiPort, args.cores, args.memory, args.master, args.workDir, conf) rpcEnv.setupEndpoint("Worker", worker) rpcEnv.awaitTermination() }

Worker的onStart方法里会触发注册:

override def onStart(): Unit = { // 启动 WebUI webUi = new WorkerWebUI(...) webUi.bind() // 向 Master 注册 registerWithMaster(1) }

registerWithMaster会构造RegisterWorker消息并发送。收到RegisteredWorker之后,启动心跳:

case RegisteredWorker(masterRef, masterWebUiUrl, masterAddress) => this.master = Some(masterRef) // 启动心跳定时器 for (worker <- workers) { worker.send(SendHeartbeat) }

心跳的发送逻辑通常封装在一个Utils.timer调度里,每隔固定时间触发一次。Master 侧收到SendHeartbeat后更新WorkerInfo.lastHeartbeat,并回Heartbeat。

5.3 参数计算:Worker 上报的资源怎么算

Worker 上报的 cores 和 memory 直接决定了集群的调度能力,这里有个计算过程值得说清楚。

假设一台机器有 32 核、128GB 内存,你启动 Worker 时传了--cores 16 --memory 64g。那么 Master 侧记录的WorkerInfo里,cores = 16,memory = 64 * 1024 = 65536 MB。当 Application 提交时,Master 会从这些资源里分配 Executor。比如每个 Executor 要 4 核 8GB,那么这台 Worker 最多能跑 4 个 Executor(16/4=4,64/8=8,取小的那个)。

如果你不传--cores,Worker 会用Runtime.getRuntime.availableProcessors()拿到所有核。但要注意,这个值可能包含超线程的核,实际计算能力和物理核有差异。生产环境我一般会显式指定 cores,留一部分给系统和其他服务,不要让 Spark 吃满整台机器。

内存同理,--memory指的是 Worker 愿意贡献给 Executor 的总内存,不包括 Worker 自身 JVM 的开销。所以实际机器内存要比--memory大一些,留出 Worker 进程本身和系统缓存的空间。

6. 常见问题与排查技巧实录

6.1 Worker 注册失败类问题速查

注册失败是 Worker 启动阶段最常见的问题,我把典型现象和排查方向整理成表:

现象可能原因排查方向
日志报 Connection refusedMaster 没启动或地址写错检查 Master 进程和 URL
日志报 Master is in standby主备切换中等待切换完成或检查主备配置
注册成功但 Master 页面看不到host 推断错误显式指定--host
反复注册又断开网络不稳定或端口被拦检查防火墙和 RPC 端口
注册超时RPC 端口不通确认 RPC 端口已开放

6.2 心跳超时类问题排查

心跳超时表现为 Master 日志里出现Removing worker ... because it has been idle for ...之类的记录。排查思路分三步:

第一步,确认 Worker 进程是否真的还在。如果进程没了,那就是 Worker 崩溃,去看 Worker 日志找崩溃原因,常见的是 OOM 或者磁盘写满。

第二步,如果进程还在,检查网络。用telnet或nc测试 Worker 到 Master 的 RPC 端口是否通。如果通,再看是不是偶发丢包。

第三步,检查 GC 情况。如果 Worker 的 JVM 有长时间 Full GC,心跳线程可能被暂停,导致心跳发不出去。这种情况可以通过调大堆内存或者换 GC 策略缓解。

提示:我踩过最坑的一次是 Worker 所在机器的系统时间被 NTP 同步跳变了,导致 Master 计算lastHeartbeat差值时出现负数,误判超时。所以集群机器的时间同步一定要做好,这是很多分布式问题的隐藏根源。

6.3 独家避坑经验

分享几个文档里不会写、但实际运维中很有用的经验。

第一,Worker 的 work-dir 要定期清理。Executor 退出后,工作目录里的日志和临时文件不一定被自动删除。时间长了磁盘会被撑满,然后 Worker 就起不来了。我一般会配一个定时任务,清理超过 7 天的旧目录。

第二,不要频繁重启 Worker。每次重启,Master 都要重新分配资源、重新调度任务。如果 Worker 上有正在运行的 Executor,重启会导致任务失败重跑。滚动重启的时候要一个一个来,等前一个完全注册成功再动下一个。

第三,关注 Worker 的 WebUI。Worker WebUI 上能看到当前运行的 Executor、可用资源、日志链接。很多问题在 WebUI 上一眼就能看出来,比翻日志快得多。养成定期扫一眼 WebUI 的习惯。

第四,心跳间隔不要随意改。有人觉得心跳太频繁浪费资源,想调大间隔。但间隔调大之后,超时判定也要跟着调,否则容错空间被压缩。这两个参数是联动的,改一个必须改另一个。

7. 从源码设计看心跳机制的价值

把 Worker 启动和心跳这套机制拆完,回头再看它的设计,会发现几个很值得借鉴的点。

注册与心跳分离。注册是一次性的、携带完整信息的动作;心跳是周期性的、只携带最小信息的动作。这种分离让注册逻辑可以做得重一些(校验、资源记录),心跳逻辑做得轻一些(只更新时间戳),各司其职。如果每次心跳都带全量信息,网络开销和 Master 处理开销都会上去。

双向心跳。Worker 发心跳、Master 回响应,双方都能感知对方状态。这比单向心跳更健壮,因为 Worker 也能主动发现 Master 异常,触发主备切换或重新注册。

超时阈值与心跳间隔的比例关系。四分之一这个比例是容错设计的经典手法,给消息丢失和网络抖动留出了足够的缓冲。这个思路在很多分布式系统里都能看到,比如各种一致性协议里的 lease 机制。

状态集中在 Master 侧。Worker 本身不维护“我是否被 Master 认可”这种状态,它只管发心跳。是否存活由 Master 根据lastHeartbeat判定。这种设计让 Worker 的逻辑更简单,也避免了双方状态不一致的问题。

我个人在实际操作中的体会是,理解这套机制最大的价值不在于能背出源码,而在于遇到 Worker 掉线、任务重调度这类问题时,脑子里能快速定位到是注册阶段的问题、心跳阶段的问题,还是超时判定阶段的问题。有了这个框架,排查效率会高很多。最后再分享一个小技巧:如果你在测试环境想快速复现心跳超时,可以把spark.worker.timeout调到 10 秒,然后手动kill -STOPWorker 进程模拟假死,观察 Master 多久之后把它标记为 Lost,这个实验做一遍,比看十遍源码印象都深。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进