在局域网里调度任务,比在云端复杂得多。云端的机器永远在线,网络永远通畅,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可以从最近的检查点继续执行。这种方式既保持了较短的租约时长,又不会因为长任务导致频繁续期。
分阶段执行的具体实现
以一个视频转码任务为例,原设计是将整个视频作为一个任务,执行时间可能长达数小时。分阶段设计将其拆分为:
- 初始化阶段:解析视频元数据、创建输出目录、验证编解码器可用性(执行时间<30秒)
- 分片转码阶段:将视频按时间切分为多个片段,逐片转码(每个片段执行时间<5分钟)
- 合并阶段:将转码后的片段合并为完整视频(执行时间<2分钟)
- 清理阶段:删除临时文件、更新状态记录(执行时间<30秒)
每个阶段完成后,worker写入检查点到共享存储(如数据库或文件系统),并续期租约。如果worker在第二阶段崩溃,新的worker从最近的检查点(某个片段的转码结果)继续,而不是从头开始。
检查点的数据结构需要包含:任务ID、当前阶段、已完成的工作量(如已转码的片段索引)、中间结果的位置。调度器在重新分配任务时,将这些信息传递给新的worker,避免重复工作。
租约续期的容错策略
租约续期本身也可能失败。网络波动、调度器短暂不可用、worker负载过高导致心跳线程饿死,都可能造成续期失败。简单的重试策略是:
- 第一次续期失败后,等待5秒重试
- 第二次失败后,等待10秒重试
- 第三次失败后,等待20秒重试
- 连续三次失败后,立即停止任务执行并尝试重新注册
这种指数退避策略避免了在调度器不可用时雪崩式的重试请求,同时给了调度器恢复的时间窗口。
更健壮的做法是在worker本地维护一个租约过期时间的副本。每次续期前,先检查本地副本判断租约是否即将过期。如果距离过期还有充足时间(比如还有2分钟),可以等待重试;如果只剩几秒,则应该立即停止任务执行,避免在租约过期后继续操作共享资源。
实践中的经验
- 租约时长5分钟,心跳间隔30秒是LAN场景下的合理默认值。
- worker标识用UUID不用IP,避免DHCP导致的注册失效。
- 任务必须是幂等的或可中断的,否则租约过期后会出现并发问题。
- 休眠是LAN worker的杀手,要么禁止休眠,要么在唤醒后重新注册。
- 调度器需要高可用,至少做一个热备。局域网场景下用etcd或Consul即可。
- 所有状态变更都要有日志,包括租约获取、续期、过期、释放。出了问题才能追溯。
- 心跳不要太轻,顺便带上worker的负载信息,调度器据此做更合理的任务分配。
租约边界之外:LAN调度的隐性成本
实现租约机制只是LAN任务调度的起点。在实际运行中,还有几个容易被低估的隐性成本。
调试成本。局域网环境下的调试比云端困难得多。云端可以集中查看日志,LAN里需要分别登录多台机器排查。当任务执行失败时,可能需要检查调度器日志、worker日志、网络连通性、机器休眠记录。一个有用的做法是让所有worker将日志统一发送到一个中心位置,但这本身又引入了对日志服务的依赖。
部署成本。每台机器都需要安装和配置worker程序。云端可以用容器镜像一键部署,LAN里往往需要手动处理每台机器的环境差异——不同的操作系统、不同的Python版本、不同的依赖库。一个实用的策略是让worker程序尽量自包含,打包成单二进制或单脚本,减少对外部环境的依赖。
信任成本。局域网内的机器通常是个人设备或小团队服务器,安全性不如云端隔离环境。worker程序需要访问共享资源(文件系统、数据库、API),这意味着任何一台机器被入侵都可能影响整个调度系统。租约机制本身提供了一层保护——入侵者只能操作当前租约允许的资源——但信任边界仍需谨慎划定。
这些隐性成本不会出现在架构图上,却会持续消耗维护者的时间和精力。在评估LAN调度方案时,把这些成本纳入考量,才能做出更现实的决策。
租约监控与告警
租约机制本身需要监控。当租约频繁过期、续期失败、worker反复注册又消失时,说明系统存在潜在问题。建立监控和告警机制,可以在问题恶化前及时发现。
关键监控指标
租约过期率是最核心的指标。统计单位时间内租约过期的次数,与总租约数的比例。正常情况下,过期率应该接近零(大部分worker都是主动释放租约)。如果过期率突然上升,说明网络不稳定、worker负载过高、或机器频繁休眠。
续期延迟是另一个关键指标。从续期请求发出到收到确认的时间间隔。正常情况下应该在毫秒级。如果延迟持续增长,说明调度器负载过高或网络拥塞。设置一个阈值(如500毫秒),超过阈值即触发告警。
worker存活时间可以反映系统稳定性。统计每个worker从注册到最终过期或主动释放的平均时间。如果平均值很短(如几分钟),说明worker进程频繁崩溃或机器不稳定。长时间存活的worker是系统稳定性的标志。
任务重分配率是下游指标。统计因为租约过期而需要重新分配的任务数量,占总任务数的比例。高重分配率意味着系统在空转,大量计算资源被浪费。这比直接监控租约过期更能反映业务影响。
告警策略
告警需要区分严重程度。单次租约过期可能是偶发事件,不需要告警;但如果5分钟内有3个worker租约过期,就需要通知管理员。告警规则的设计原则:
- 聚合告警:相同类型的告警在短时间内聚合,避免告警风暴
- 分级告警:轻微问题用低优先级通知(如Slack消息),严重问题用高优先级(如电话)
- 上下文信息:告警消息中包含worker ID、机器名、租约时长等关键信息,便于快速定位
日志与审计
所有租约操作都应该记录日志,包括:租约授予(worker ID、租约ID、过期时间)、租约续期(租约ID、续期时间、worker负载)、租约过期(租约ID、原因)、租约释放(租约ID、原因)。这些日志不仅用于调试,也是审计的基础。
当出现问题时,日志可以回答:哪个worker在什么时间拿到了哪个任务?租约什么时候过期?过期前最后一次心跳是什么时候?这些信息对于排查任务执行失败、数据不一致等问题至关重要。
日志的存储需要权衡成本和查询效率。对于小规模LAN(10台机器以内),记录到本地文件并用grep查询足够了;对于更大规模,需要集中式日志系统(如ELK、Loki)来支持快速检索和聚合分析。
自愈机制
监控的目的是发现问题,但有些问题可以自动修复。常见的自愈策略:
- worker自动重启:当检测到worker进程崩溃但机器仍然在线时,自动重启worker进程
- 任务自动重分配:当租约过期后,自动将任务重新分配给其他可用worker
- 调度器故障切换:当主调度器不可用时,自动切换到备用调度器
自愈机制的关键是幂等性。自动重启的worker应该能从上次停止的地方继续,而不是重新开始;重新分配的任务应该能安全地重复执行。如果任务不是幂等的,自愈反而会造成更大的问题。
在LAN场景下,一个实用的自愈配置是:允许每个worker最多连续崩溃3次后自动重启,超过3次则标记该机器为"不稳定"并暂停向其分配新任务,等待人工介入检查机器状态。