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方法,这是整个流程的起点。我把这条链路抽象成几个阶段,后面再逐个展开:
- 参数解析与配置加载:从命令行拿到 Master 地址、Worker 自己的 host 和 port、工作目录、内存和核数等参数,合并到
SparkConf里。 - 创建 RpcEnv:这是通信基础设施,相当于 Worker 的“电话总机”,负责收发 RPC 消息。
- 实例化 Worker 对象并注册到 RpcEnv:Worker 本身是一个
RpcEndpoint,注册之后才能接收消息。 - 向 Master 发起注册:通过
RegisterWorker消息把自己“报到”给 Master。 - 处理 Master 的响应:注册成功则记录 Master 的引用,进入心跳阶段;注册失败(比如 Master 要求 Standby 模式)则重试或退出。
这个骨架看起来简单,但每一步都有值得琢磨的地方。比如为什么参数解析要放在创建 RpcEnv 之前?因为 RpcEnv 的创建需要用到 host、port、conf 这些信息,顺序不能反。再比如为什么 Worker 要先注册到自己的 RpcEnv 再向 Master 注册?因为 Master 的响应是异步回来的,Worker 必须先有一个能接收消息的 endpoint 地址,否则 Master 回消息的时候找不到人。
2.3 关键类与职责划分
在展开细节之前,先把涉及的核心类列清楚,不然后面看方法调用容易迷路。
| 类/对象 | 职责 |
|---|---|
Worker | Worker 进程的主体,继承RpcEndpoint,处理各类消息 |
WorkerArguments | 命令行参数解析,产出 host、port、cores、memory 等 |
RpcEnv | RPC 通信环境,负责 endpoint 注册与消息收发 |
Master | 集群管理者,接收注册、维护 Worker 列表、处理心跳 |
RegisterWorker | Worker 发给 Master 的注册消息 |
RegisteredWorker | Master 回给 Worker 的注册成功消息 |
SendHeartbeat | Worker 周期性发给 Master 的心跳消息 |
Heartbeat | Master 回给 Worker 的心跳响应 |
WorkerInfo | Master 侧维护的 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.timeout | 60s | Master 判定 Worker 失联的阈值 | 网络差或 GC 长时调到 90-120s |
spark.rpc.askTimeout | 120s | RPC 请求等待超时 | 一般不用动,除非有长耗时 RPC |
| 心跳间隔 | timeout/4 | Worker 发心跳的频率 | 跟随 timeout 自动调整 |
5. 实操过程与核心环节实现
5.1 本地模拟:手动启动一个 Worker 观察日志
光看源码不够直观,我建议你实际起一个本地集群,观察 Worker 启动的完整日志。步骤大致是这样:
- 启动 Master:
sbin/start-master.sh,记下 Master 的 URL,形如spark://host:7077。 - 启动 Worker:
sbin/start-worker.sh spark://host:7077(老版本是start-slave.sh)。 - 查看 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 refused | Master 没启动或地址写错 | 检查 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,这个实验做一遍,比看十遍源码印象都深。