1. 从一次集群扩容说起:为什么Worker启动流程值得单独拎出来讲
很多人啃Spark源码,习惯从SparkContext或者DAGScheduler入手,觉得那才是"核心逻辑"。但我在实际运维和调优过程中发现,集群层面最容易被忽视、出问题后最难排查的,恰恰是Worker这个"干活的"角色。你想想,一个几十上百节点的集群,某天突然有几台机器的Executor莫名其妙起不来,Master日志里只看到"Worker removed"或者"heartbeat timeout",你从哪里查?如果对Worker的启动链路和心跳机制没有清晰的认知,基本就是盲人摸象。
这篇内容就是围绕Spark源码解析中Worker的启动流程以及它与Master之间的心跳通信展开的。我会把Worker从main方法进入开始,到注册成功、启动心跳线程、周期性发送心跳、处理Master响应这条完整链路拆开来讲。适合已经用过Spark、想深入理解集群内部运转机制的开发者,也适合正在做资源调度层二次开发或者排查集群稳定性问题的运维同学。
需要提前说明的是,这里分析的代码基于Spark 3.x版本的Standalone模式。虽然不同小版本之间有些细节差异,但核心骨架是稳定的。我尽量用"人话"把每一步的意图讲清楚,而不是对着代码逐行翻译——那样你自己看IDE就行了,没必要看我写的东西。
2. Worker进程的入口:main方法里到底做了哪几件事
2.1 从命令行参数到RpcEnv的创建
Worker的入口在Worker.main方法。跟Master类似,它首先会解析命令行传入的参数,比如--webui-port、--cores、--memory、--work-dir这些。这些参数决定了这个Worker能提供多少资源、工作目录在哪、WebUI端口是多少。
解析完参数之后,第一件重要的事情是创建RpcEnv。这是Spark自己封装的一套RPC通信框架,底层基于Netty。你可以把它理解成Worker的"通信底座"——没有它,Worker就没法跟Master和其他组件说话。
val rpcEnv = RpcEnv.create(SYSTEM_NAME, hostname, port, conf, securityMgr)这里有几个细节值得注意。SYSTEM_NAME在Worker端是"worker",这个名称会影响到RPC地址的格式。hostname的获取逻辑是先看SPARK_LOCAL_HOSTNAME环境变量,再看spark.local.hostname配置,最后才走系统默认。我在实际部署中遇到过因为多网卡导致hostname绑定错误的情况,Worker注册到了Master,但Master回连的时候走错了网卡,心跳一直超时。这种问题排查起来非常隐蔽,后面讲心跳的时候我会再展开。
2.2 Worker对象的构造与RpcEndpoint注册
拿到rpcEnv之后,会new一个Worker对象。这个Worker类继承自ThreadSafeRpcEndpoint,也就是说它本身就是一个RPC端点,能够接收和处理消息。
val worker = new Worker(rpcEnv, webUiPort, cores, memory, masterAddresses, ...) rpcEnv.setupEndpoint("Worker", worker)setupEndpoint这一步很关键。它做了两件事:一是把Worker这个端点注册到RpcEnv的调度器里,二是触发onStart方法。注意,onStart是在setupEndpoint内部被调用的,不是在构造函数里。这个时序很重要,因为onStart里才会去连接Master。
2.3 onStart:注册与启动心跳的分水岭
onStart方法里主要做两件事:调用registerWithMaster()向Master注册自己,以及启动一个定时线程池forMasterHeartbeat用于后续的心跳发送。
override def onStart() { assert(!registered) createWorkDir() registerWithMaster() ... }这里有个设计上的巧思:Worker在onStart阶段并不会阻塞等待注册成功,而是发起注册请求后继续往下走。注册的结果通过异步消息回调来处理。这样做的好处是Worker启动不会被Master的响应速度拖慢,但代价是后续逻辑必须处理好"注册中"和"已注册"两种状态。
注意:
createWorkDir()这一步如果失败(比如磁盘权限不对、路径被占用),Worker会直接抛异常退出。我见过好几次因为work-dir配置到了没有写权限的目录,Worker进程反复重启的情况。部署前一定要确认这个目录存在且可写。
3. 注册链路拆解:Worker是如何让Master"认识"自己的
3.1 registerWithMaster的重试逻辑
registerWithMaster()这个方法名字看起来简单,但里面的重试机制是保证集群健壮性的关键。它会遍历所有配置的Master地址(Standalone模式下可以配多个Master做HA),对每个Master发起RegisterWorker请求。
private def registerWithMaster() { masterRefs = ... val registerMasterFutures = masterRefs.map { masterRef => val registerMasterRpc = new RegisterMasterRpc(masterRef) registerMasterThreadPool.submit(registerMasterRpc) } ... }注意这里用了一个独立的线程池registerMasterThreadPool来并发发送注册请求。为什么要并发?因为如果串行发送,第一个Master如果网络不通,超时时间会拖累后续Master的注册。并发发送能让Worker尽快找到可用的Master。
注册请求发出去之后,Worker会等待所有Future完成,然后检查是否有成功的响应。如果全部失败,会延迟一段时间后重试,重试间隔由spark.worker.timeout控制,默认60秒。
3.2 RegisterWorker消息里带了什么
RegisterWorker这个消息体里包含的信息相当丰富,我列一下关键字段:
| 字段 | 含义 | 备注 |
|---|---|---|
| id | Worker的唯一标识 | 格式为worker-时间戳-主机名-端口 |
| host | 主机名 | 用于Master回连 |
| port | RPC端口 | 通信端口 |
| webUiPort | WebUI端口 | 展示用 |
| cores | 可用CPU核数 | 资源调度依据 |
| memory | 可用内存 | 资源调度依据 |
| endpoint | RpcEndpointRef | Master回调Worker用的引用 |
这里endpoint字段特别重要。Master收到注册请求后,会保存这个引用,后续给Worker发消息(比如LaunchExecutor、KillExecutor)就靠它。如果这个引用有问题,Worker虽然注册上了,但Master指挥不动它。
3.3 Master端的处理与RegisteredWorker响应
Master收到RegisterWorker后,会做一系列校验:Worker ID是否重复、资源是否合法、是否在黑名单里等等。校验通过后,Master会把Worker信息记录到内存结构里,然后回复一个RegisteredWorker消息。
Worker收到RegisteredWorker后,才算真正注册成功。这时候它会做几件事:
- 把
registered标志置为true - 记录Master的RpcEndpointRef
- 启动心跳定时器
- 如果之前有注册失败的重试任务,取消掉
case RegisteredWorker(masterRef, masterWebUiUrl, masterAddress) => registered = true this.master = Some(masterRef) this.masterAddress = Some(masterAddress) ... forMasterHeartbeat.scheduleAtFixedRate(...)提示:如果你在日志里看到Worker反复打印"Registering with master",但迟迟没有"Successfully registered",大概率是Master端校验没通过或者网络不通。这时候要去看Master的日志,而不是只盯着Worker。
4. 心跳机制:Worker与Master之间的"生命线"
4.1 心跳线程的启动与调度参数
注册成功后,Worker会启动一个名为forMasterHeartbeat的ScheduledExecutorService,以固定频率向Master发送Heartbeat消息。发送间隔由spark.worker.heartbeat.interval控制,默认是spark.worker.timeout / 4,而spark.worker.timeout默认60秒,所以心跳间隔默认是15秒。
forMasterHeartbeat.scheduleAtFixedRate( new Runnable { override def run(): Unit = Utils.tryLogNonFatalError { self.send(Heartbeat) } }, 0, HEARTBEAT_MILLIS, TimeUnit.MILLISECONDS)这里用self.send(Heartbeat)而不是直接调用RPC发送,是一个很聪明的设计。self.send是把消息发到Worker自己的消息队列里,由RPC调度线程统一处理。这样做的好处是心跳发送和消息处理在同一个线程模型下,避免了并发问题。
4.2 心跳消息的内容与Master的响应
Heartbeat消息本身很简单,就是一个空消息。但Master收到心跳后,会回复一个HeartbeatResponse,里面可能包含:
MasterWorkerInfo的更新信息- 需要Worker执行的动作,比如
KillExecutor、LaunchExecutor - Master的WebUI地址(用于Worker展示)
Worker收到HeartbeatResponse后,会遍历里面的动作并执行。这就是Master向Worker下发指令的主要通道之一。
case HeartbeatResponse(masterInfo, masterWebUiUrl, responseId) => ... masterInfo.foreach { info => if (info.cores < cores) { ... } }4.3 心跳超时与Worker摘除
Master端有一个后台线程会定期检查所有Worker的最后心跳时间。如果某个Worker超过spark.worker.timeout没有发心跳,Master会认为它失联,将其从集群中移除,并通知相关Application。
这个机制是集群自愈的基础,但也带来一个问题:如果网络抖动导致心跳丢失,Worker可能被误摘除。我在实际环境中遇到过因为交换机瞬时拥塞,导致一批Worker同时被摘除,然后它们又重新注册,引发Executor大规模重启的情况。
注意:
spark.worker.timeout不要设置得太小。有些同学为了"快速发现故障",把它调到10秒甚至更短,结果网络稍微抖一下集群就炸了。默认60秒是有道理的,除非你的网络环境极其稳定,否则不建议大幅调低。
5. 那些文档里不会写的坑:心跳与注册的实战经验
5.1 多网卡环境下的心跳黑洞
前面提到过hostname绑定的问题,这里展开说。假设一台机器有两块网卡,IP分别是192.168.1.10和10.0.0.10。Worker启动时如果绑定的是10.0.0.10,注册到Master时上报的host也是这个。但Master回连的时候,如果路由表指向的是192.168.1.x网段,就会连不上。
表现是什么?Worker日志显示注册成功,但Master端很快就把Worker标记为失联。因为Master发HeartbeatResponse发不出去,Worker收不到响应,但Worker自己还在傻傻地发心跳。
解决办法是在spark-env.sh里显式设置SPARK_LOCAL_HOSTNAME,或者在spark-defaults.conf里配spark.local.hostname,强制绑定正确的网卡。
5.2 Worker重启后的ID冲突
Worker的ID格式是worker-时间戳-主机名-端口。如果Worker进程重启,时间戳会变,所以ID通常不会冲突。但如果你是用容器化部署,容器重启后主机名和端口可能不变,而时间戳精度只到毫秒,理论上存在极小概率的ID碰撞。
更常见的问题是:Worker重启后,Master端还保留着旧Worker的记录(因为还没到超时时间),新Worker注册时Master发现"同一个地址来了两个Worker",可能会拒绝或者覆盖。这种情况下,要么等旧记录超时,要么手动清理Master状态。
5.3 心跳间隔与GC的相互影响
Worker进程如果发生长时间Full GC,心跳线程可能被暂停,导致Master收不到心跳。如果GC时间超过spark.worker.timeout,Worker就会被摘除。
我建议在Worker节点上配置GC日志,监控Full GC的频率和时长。如果发现频繁Full GC,要么调大堆内存,要么排查是什么在吃内存。Worker本身不应该占用太多内存,如果它内存吃紧,通常是有什么东西泄漏了。
5.4 注册重试的隐藏成本
registerWithMaster的重试逻辑虽然健壮,但有个隐藏成本:每次重试都会创建新的RegisterMasterRpc任务,如果Master长时间不可用,这些任务会堆积。虽然线程池有队列限制,但日志会被大量重试信息淹没,给排查带来干扰。
我的做法是在Master不可用期间,临时调大重试间隔,减少日志噪音。等Master恢复后再调回来。
6. 从源码回到运维:如何用这些知识定位真实故障
6.1 一张排查路径图
当你遇到Worker相关问题时,可以按这个顺序排查:
- Worker进程是否存活:
jps看有没有Worker进程 - 注册是否成功:Worker日志搜"Successfully registered"
- 心跳是否正常:Master日志搜该Worker的ID,看最后心跳时间
- 网络是否通:从Worker节点telnet Master的RPC端口
- 资源是否足够:Master WebUI看Worker的cores和memory
这个顺序是从最底层往上层走,能快速缩小问题范围。
6.2 日志里的关键线索
Worker日志里几个关键字符串:
"Registering with master":开始注册"Successfully registered":注册成功"Master has changed":Master切换了(HA场景)"Heartbeat":心跳相关(通常不打印,除非开了DEBUG)
Master日志里:
"Registering worker":收到注册请求"Worker removed":Worker被摘除"Heartbeat timeout":心跳超时
把这些字符串加到你的日志监控规则里,能提前发现很多问题。
6.3 一个真实的排查案例
某次集群扩容后,新加的5台Worker只有3台注册成功。检查发现失败的两台机器上,spark-env.sh里的SPARK_LOCAL_HOSTNAME写的是另一台机器的hostname(复制粘贴惹的祸)。Worker启动时绑定了一个不存在的主机名,注册请求发出去后Master回连失败。
这个问题从Worker日志看是"注册成功"的,因为注册请求确实发出去了,Master也回复了。但Master的回连失败,导致后续心跳收不到。所以排查时不能只看Worker日志,一定要对照Master日志。
7. 写在最后:一些个人体会
啃Spark源码这件事,我的建议是不要一上来就追求"全看懂"。像Worker启动和心跳这种链路,你先把主干流程跑通,知道每一步在干什么、为什么这么干,细节可以在遇到具体问题时再深入。
另外,源码分析一定要结合实战。我见过不少人能把源码讲得头头是道,但集群一出问题就懵了。原因就是没有把代码和日志、监控、故障现象对应起来。你在读registerWithMaster的时候,就应该想到"如果这里失败了,日志会打什么";你在看心跳定时器的时候,就应该想到"如果GC卡住了,会发生什么"。
最后分享一个小技巧:在测试环境里故意kill掉Master或者Worker,观察日志和集群状态的变化。这种"破坏性测试"比看十遍源码都管用。你会亲眼看到心跳超时后Master怎么摘除Worker,Worker重连后怎么重新注册,Executor怎么被重新调度。这些直观的经验,才是真正属于你的东西。