LAN worker租约边界
在局域网里调度任务,比在云端复杂得多。云端的机器永远在线,网络永远通畅,API永远可用——至少你可以这样假设。局域网不是这样。机器会休眠、网络会断开、IP会变、进程会崩溃。如果你用云端的思维设计LAN任务调度,迟早会遇到worker"拿了任务就消失"的情况。租约(lease)是解决这个问题的核心机制,但租约的边界在哪里,是一个值得深入思考的问题。
LAN环境的不确定性
LAN环境和云端环境有三个本质区别:可用性不确定、身份不确定、时钟不确定。
可用性不确定是最直观的。局域网里的机器可能因为休眠、关机、网络断开而随时不可用。一台Mac Mini设置了定时任务,但晚上自动休眠了,任务不会执行。一台NAS上跑着worker,但固件升级重启了,正在处理的任务中断。云端的虚拟机也有偶尔不可用的时候,但概率低几个数量级,而且通常有SLA保障。
身份不确定是更隐蔽的问题。局域网里的机器可能通过DHCP获取IP,每次连接可能拿到不同的地址。如果worker的注册信息依赖IP,那每次IP变更后调度器就找不到这个worker了。即使使用主机名,也依赖mDNS或本地DNS的正确配置。在跨子网的情况下,mDNS可能不可用。
时钟不确定是容易被忽略的。局域网里的机器时钟可能不同步,尤其是没有配置NTP的情况下。租约机制依赖时间戳来判断worker是否存活。如果worker的时钟比调度器快5分钟,调度器可能误判租约已过期;如果慢5分钟,可能误判租约仍然有效。毫秒级的时钟偏差在大多数场景下可以忽略,但在分钟级的租约场景下可能造成问题。
租约机制的设计
租约的本质是一份有时间限制的契约。worker向调度器申请租约,调度器授予后,worker在租约期内拥有执行任务的权限。租约到期后,调度器可以安全地将任务重新分配给其他worker。
租约获取。worker启动时向调度器注册,申请初始租约。注册信息包括worker标识(建议使用UUID而非IP)、能力描述(能执行什么类型的任务)、当前负载(CPU、内存、磁盘)。调度器根据注册信息决定是否授予租约。
租约续期。worker在租约期内需要定期向调度器发送心跳,续期租约。心跳间隔应远小于租约时长——如果租约时长是5分钟,心跳间隔应该在30秒到1分钟之间。这样即使一两次心跳丢失,租约也不会过期。
租约释放。worker主动完成任务或决定退出时,应主动释放租约。主动释放让调度器能立即重新分配任务,而不是等租约过期。
租约过期。如果调度器在租约期内没有收到心跳,认为worker已不可用,租约自动过期。过期后,调度器将该worker正在处理的任务标记为"待重分配"。
租约的时长需要权衡:太短(如30秒)会导致频繁续期,增加网络开销,也容易因短暂网络波动导致误判;太长(如30分钟)意味着worker崩溃后,任务需要等很久才能被重新分配。5分钟是一个常见的折中选择。
租约边界的三个问题
租约机制看似简单,但边界情况非常棘手。以下三个问题在实践中频繁出现。
问题一:半完成的任务怎么处理
worker拿到任务后处理了一半,租约过期了(比如机器休眠)。调度器将任务重新分配给另一个worker。但前一个worker醒来后可能继续处理这个任务,导致两个worker同时操作同一个任务。
解决方案是幂等性设计。任务的处理逻辑必须是幂等的——同一个任务执行一次和执行多次的结果相同。这要求任务在每个步骤完成后写入检查点,恢复执行时从检查点继续而非从头开始。同时,共享资源(如候选池状态)的更新必须使用条件写入,确保只有持有当前租约的worker才能更新。
更根本的方案是任务可中断性。在任务执行的关键节点检查租约是否仍然有效。如果租约已过期,立即停止处理。这比幂等性设计更简单,但要求任务能被安全地中断。
问题二:网络分区时的仲裁
局域网中的网络分区比云端更常见。一台交换机故障可能导致部分worker与调度器失去连接。此时,这些worker的租约会过期,调度器将任务重新分配。但分区恢复后,这些worker可能仍然认为自己持有有效租约。
解决方案是租约验证。worker在执行任何写入操作前,先验证租约是否仍然有效。验证可以是向调度器发送确认请求,也可以是检查本地租约的过期时间。如果租约已过期,worker应立即停止所有操作并尝试重新注册。
另一个方案是fencing token。调度器在授予租约时同时发放一个递增的token。共享资源在更新时检查token,只接受比当前token更大的请求。这样即使旧worker尝试更新,也会因为token过旧而被拒绝。
问题三:调度器本身的可用性
如果调度器不可用,所有worker都无法续期租约,所有任务都会停止。调度器是单点故障。
解决方案有两种。第一种是调度器高可用:部署多个调度器实例,使用Raft或Paxos协议选举leader。只有leader接受注册和分配任务,follower同步状态。leader故障时自动切换。这种方案复杂但可靠。
第二种是去中心化调度:不使用中心调度器,而是worker之间通过gossip协议协调。每个worker维护一份任务列表的副本,通过共识协议决定任务分配。这种方案没有单点故障,但实现复杂度更高,且在worker数量较多时性能下降。
对于局域网场景(通常3-10台机器),第一种方案更实际。使用etcd或Consul作为调度器的后端存储,天然具备高可用和一致性保障。
LAN worker的生命周期
一个LAN worker从启动到退出,经历以下阶段:
- 注册。worker启动后向调度器注册,获取唯一ID和初始租约。注册信息包括主机标识、能力描述、网络地址。
- 就绪。注册成功后进入就绪状态,等待任务分配。调度器根据worker的能力和负载决定分配什么任务。
- 执行。worker收到任务后开始执行,同时启动心跳续期。执行过程中定期检查租约有效性。
- 汇报。任务执行完成后向调度器汇报结果。如果执行失败,汇报失败原因,调度器决定是否重试。
- 释放。worker决定退出时,释放租约并通知调度器。调度器将该worker标记为离线。
- 过期。如果worker异常退出(崩溃、断网、休眠),调度器在租约过期后自动将其标记为离线。
休眠是LAN worker特有的问题。Mac的节能设置可能导致机器在非活跃时段自动休眠。如果worker正在执行任务,休眠会导致任务中断且租约无法续期。解决方案有两个:在执行任务时临时禁止休眠(使用caffeinate命令),或者在系统唤醒后自动重新注册并检查未完成的任务。
租约与任务粒度
租约的粒度应该与任务的粒度匹配。如果一个任务需要执行30分钟,5分钟的租约显然不够——worker需要每30秒续期一次,整个执行期间需要续期60次。任何一次续期失败都可能导致租约过期。
两种解决方案:延长租约时长或分阶段执行。
延长租约时长是最直接的方案。将租约时长设置为任务预期执行时间的2-3倍,确保即使有波动也有足够的缓冲。但这意味着worker崩溃后需要更长时间才能被检测到。
分阶段执行是更优雅的方案。将长任务拆分为多个短阶段,每个阶段的执行时间不超过租约时长。每完成一个阶段,写入检查点并续期租约。如果worker崩溃,新的worker可以从最近的检查点继续执行。这种方式既保持了较短的租约时长,又不会因为长任务导致频繁续期。
实践中的经验
- 租约时长5分钟,心跳间隔30秒是LAN场景下的合理默认值。
- worker标识用UUID不用IP,避免DHCP导致的注册失效。
- 任务必须是幂等的或可中断的,否则租约过期后会出现并发问题。
- 休眠是LAN worker的杀手,要么禁止休眠,要么在唤醒后重新注册。
- 调度器需要高可用,至少做一个热备。局域网场景下用etcd或Consul即可。
- 所有状态变更都要有日志,包括租约获取、续期、过期、释放。出了问题才能追溯。
- 心跳不要太轻,顺便带上worker的负载信息,调度器据此做更合理的任务分配。