
调度器是分布式集群里最容易被低估的组件。节点少的时候用一个进程管所有资源分配就够了节点和任务量一旦涨上去最先出问题的往往是它。前阵子我把一套云规模协同调度的设计方案从头到尾拆了一遍也就是标题里这个 Apollo 的思路发现它解决的问题和采取的手段很多都可以直接迁移到今天的集群架构里。这篇文章不堆术语把我认为最有价值的几个设计点、它们各自的取舍、以及工程落地时容易被忽略的细节一次性讲清楚。先说明讨论边界这里说的调度指的是大批量计算任务的调度——一个作业拆成几万到几百万个任务任务之间有数据依赖调度器要同时负责数据本地性、防饥饿、高吞吐和故障恢复。如果你刚好在维护自研调度器或者打算改造现有平台的调度链路这篇可以当作一份设计参考来读。1. 所有调度器的共同瓶颈为什么单点集中式方案撑不住上万节点1.1 集中式调度器被压垮的数学过程很多团队最初都会选择一个“最正统”的方案一个调度器进程一个全局队列所有节点定时上报资源调度器收到任务后做一个全局最优匹配。节点少的时候一切正常。可一旦集群涨到几千台、任务到达率涨到每秒几百上千个问题就出现了。问题不在调度器“变笨了”而在单点决策的成本随集群规模线性上涨。每次调度一个任务原则上都要回答三个问题当前集群里哪些节点满足数据约束这些节点的资源余量如何跟等待队列里的其他任务比这个任务的优先级如何这三个问题都要访问全局状态。状态可以缓存在内存里但缓存自身的更新和校验是有代价的。更麻烦的是全局队列本身就是一把大锁高并发下锁竞争会把调度吞吐打到很低。可以做个简单估算假设一次调度决策需要扫描候选节点并做排序单台机器负载高时一次决策要花 50 到 200 毫秒。那么单调度器每秒最多只能做出几千个有效分配。而一个云规模批处理作业动辄几十万任务任务到达率远超这个数。结果是调度器变成流水线上的瓶颈工位后面堆满物料前面却在慢慢磨。1.2 云规模调度要同时扛住的三类硬指标要判断一套调度方案是不是真的为云规模设计的不能只看“能不能跑”要看它有没有在四个维度同时做优化指标单点集中式的问题云规模下的目标调度吞吐锁竞争加全局扫描每秒几千就是极限每秒至少数万到十万次有效分配数据本地性本地性计算依赖完整状态状态越新代价越大本地性优先的同时不牺牲吞吐作业完成时间队头阻塞严重长任务可能拖垮整批短任务避免某个大作业堵住整个集群故障恢复单点宕机等于集群调度停摆协调者故障后能快速恢复不丢状态这四个指标互相牵扯。比如你要保数据本地性调度器就得在决策时查询每个候选节点上的数据分布要保高吞吐这个查询就不能太重。Apollo 的思路是放弃“单点做全局最优”的想法改成多个调度器并行、靠共享状态和事后对账来保证一致性。这也是“可扩展协同调度”这个词的真正含义——扩展是靠加调度器协同是靠一套异步共享的状态。2. 多个调度器怎么不来抢同一份资源共享状态加上冲突兜底2.1 核心架构一个集群状态服务加一组分组调度器整套架构可以拆成三块节点代理、集群状态服务和分组调度器。节点代理运行在每台机器上负责采集本机的 CPU、内存、磁盘、网络余量以及当前正在运行的任务列表周期性上报到集群状态服务。集群状态服务汇总所有节点的资源视图并把它异步广播给所有调度器。分组调度器则各自负责一部分作业每个调度器独立维护自己的任务队列按自己的节奏做调度决策。作业进来时由一个分发作业的服务把它分配给某个分组调度器分配依据是该调度器当前的负载。这个架构的精髓在于调度器之间没有直接的握手协议它们通过“大家都能读到同一份状态”来实现协同。每个调度器的本地视图都是集群状态的快照可能是几百毫秒甚至几秒前的。它们各自做决策决策结果再写回集群状态。因为视图有延迟两个调度器完全可能同时看中了同一台机器的同一块资源这就是冲突。协同的核心不是消灭冲突而是把冲突控制在可接受比例内并且保证冲突发生后任务还能被正确调度。2.2 状态陈旧性分布式协同里绕不开的那本账既然状态是异步广播的就必然存在陈旧性。分析这套方案时一定要区分两种陈旧水平陈旧度节点代理上次上报资源到现在过去了多久。这个值越大说明调度器看到的节点资源越不可信。垂直陈旧度调度器自己最近做的一批分配是否已经传播到集群状态并被其他调度器看到。简单说就是自己给自己挖了多少“尚未兑现”的坑。两种陈旧叠加会让调度器对节点真实负载的估计产生偏差。你看到某台机器还剩 8 核实际上另一路调度器已经把这 8 核分配走了只是消息还没到。处理办法是在决策时加一个“安全边际”陈旧度越高边际越大。这个边际可以基于历史到达率来推算已知集群平均每秒钟会新增多少任务也知道状态传播延迟就能估算出这段时间里大概有多少看不见的分配。我在自己的测试环境里复原过这个逻辑发现这里最值得抄的是那个心态不要追求精确只求“不会错得太离谱”。用陈旧度乘到达率做一个可调的补偿系数比疯狂缩短广播间隔划算得多。广播间隔缩短到一定程度后网络开销和状态服务的处理压力会陡增而调度质量提升非常有限。2.3 冲突发生之后调度器的重试机制冲突没有办法完全避免所以必须设计好冲突后的处理链路。一个调度器选中某台节点并写入分配后节点代理在下次上报时会发现实际资源对不上或者集群状态服务在做变更验证时发现冲突这条分配就会被拒绝。调度器收到失败反馈后需要重新选择节点进入下一轮。这里有一个工程细节极容易踩坑冲突重试如果做得太整齐会造成“重试风暴”。假设状态广播因为网络抖动延迟了一秒期间所有调度器做了大量基于旧视图的分配恢复后这些冲突集中爆发所有调度器同时重试瞬间把状态服务和节点代理的负载再拉高一截。正确做法是给重试加入随机抖动和退避同时按任务的重要程度排序让高优先级的任务先拿到新的决策机会。这个经验不只在调度器里有凡是做分布式乐观并发控制的系统都会遇到。3. 先调度再说、不行就杀无锁调度与抢占是有代价的3.1 乐观调度默认资源可用分配完再对账传统调度器最害怕的事情是资源被重复分配所以设计上会加各种锁和保留机制。Apollo 的做法反过来——它假设决策时看到的资源是基本可用的直接分配分配动作是“先写状态、后验真伪”。如果验证发现资源其实已经被占了那就回滚重试。这种无锁调度的好处非常明显调度器不需要在分配前去问一堆节点“你还有没有资源”省掉了一次分布式往返。节点上的资源视图本来就是异步同步的你去现问也未必是最新的不如统一走乐观路径把“确认”这件事放到事后。用生活的话说就像饭店里先按桌上的空位安排客人万一两拨客人看中同一张桌子再协调而不是门口放一个专门管座位的大爷每来一个客人都要全局查一遍。乐观调度要成立前提是冲突率必须低。如果冲突率超过一定阈值重试开销会倒过来超过锁的开销。这也是为什么调度决策不能盲目乐观必须结合前文说的陈旧度补偿来压低冲突概率。3.2 抢占与杀任务拖延症任务的终结者乐观调度的另一面是任务实际运行时间可能超过预期导致节点上的排队任务迟迟得不到资源。Apollo 处理这个问题的方式很直接——抢占必要时直接杀任务。具体来说调度器手里有一个“预测空闲时间”表示某节点在什么时候能腾出足够资源给当前任务。如果这个时间晚于当前任务的预期启动时间调度器可以选择把节点上正在运行的低优先级任务杀掉腾出位置给高优先级任务。杀谁是有讲究的优先杀剩余运行时间短的任务这样浪费的计算量最小再杀优先级低的任务保证高优作业不被阻塞。这里我想强调一个容易误解的点抢占不是用来发脾气的是用来保证集群里所有作业都有进展的。如果没有抢占一个占着资源不放的长任务能让一堆短任务活活饿死。Apollo 的杀任务机制本质上是给“资源所有权”加了一个时效限制——你可以占着但当更高优先级的任务必须运行而你又不让位时系统就会把你挪走。杀任务当然有代价被杀的作业要重新调度之前算的部分结果可能白算。所以实际工程里要设一个保护阈值只有当抢占的收益明显大于损失时才动手。这个阈值怎么设没有标准答案只能根据集群里任务的平均时长和重启开销来调。3.3 短任务的“机会主义调度”减少对全局状态的依赖云规模集群有个明显特征大量任务运行时间非常短可能只有几百毫秒到几秒。如果每个短任务都走标准的资源分配流程都要更新集群状态、广播分配消息状态服务的压力会大得离谱。Apollo 对这类短任务采用机会主义调度不提前在集群状态里做正式分配直接在有剩余资源的节点上跑。跑着跑着如果节点上来了一个需要正式资源的长任务短任务就会被杀掉让位。因为短任务本身计算量小被杀了重跑的成本也低这个交易很划算。这个设计的工程启示是并不是所有任务都需要同样重的调度保证。给任务按运行时长和重要性分级让“需要确定性”的任务走重路径让“跑了就行”的任务走轻路径整体吞吐才能上去。很多自研调度器性能上不去就是因为对每个任务一视同仁把最贵的机制浪费在了最不值得的任务上。4. 数据本地性、队列与公平调度策略不是拍脑袋定优先级4.1 数据本地性约束的三级分类大数据作业里绝大多数任务需要读取输入数据。数据放在哪台机器上直接决定任务的运行效率。跨机架传输数据的代价比本地读大得多所以调度器必须把任务尽可能分配到数据所在的节点。Apollo 把数据约束分成三档约束级别数据的实际位置候选节点范围调度失败时可采取的动作紧约束数据只存在于某台具体节点单台节点等这台节点腾出资源或放宽数据要求中约束数据在一个机架或一小批节点内若干节点在候选集合内找最早空闲的节点松约束数据全局可读或有多个副本全部节点任意选择负载最低的节点为什么要分级因为不同任务的调度灵活性完全不同。如果所有任务都按“数据必须在本机”来调度很多任务会卡在等待某台特定节点上集群明明有空闲资源却用不上。反过来如果完全不管数据位置调度是快了但任务运行时的网络开销会把收益吃掉。实际做法是先按紧约束尝试如果一段时间内调度不成功就逐步放宽成中约束、松约束。这个“放松”动作很关键它相当于给调度器一个逃生通道宁可牺牲一点本地性也不能让任务永远排队。4.2 任务队列怎么排优先级、等待时长与重试每个分组调度器内部维护着若干任务队列队列的排序规则直接决定公平性。最简单的做法是纯按优先级排高优先级任务永远先走。问题很明显低优先级任务可能永远得不到资源尤其在高优先级任务源源不断的时候。Apollo 的队列设计考虑了等待时长用“作业等待时间 优先级”共同决定出队顺序。运行得越久、越没机会的任务权重会被逐步放大。这个机制跟操作系统里的“老化”类似目的是保证任何一个作业最终都能获得资源而不是被新来的高优任务插队插到天荒地老。重试任务的处理也需要留意。任务因抢占被杀后重新排队如果直接插到队头高优先级任务会被反复插队如果排到队尾又要等很久。比较好的折中是给重试任务一个独立的等待区间保证它比新任务优先但低于正在正常排队的高优任务。这个细节在真实集群里很容易被忽略但往往就是从这里开始出现“某些作业永远跑不完”的诡异现象。4.3 防止饥饿让每个作业都有“可见进展”好的调度策略必须回答一个问题怎么知道系统没有被卡死光看集群利用率是不够的因为利用率高可能说明少数大作业霸占了全部资源。Apollo 的做法是关注每个作业的进展——已经完成的任务数、正在跑的任务数、排队时间综合计算出一个“饥饿指数”。一旦某个作业的饥饿指数超过阈值调度器就会人工提高它的优先级甚至主动杀一批低优先级任务来给它腾资源。我特别想提醒的一点防饥饿不能指望优先级配置自动生效必须配上监控和服务治理机制。你需要在调度器的输出指标里持续观察每个作业的排队时间分布而不仅仅是平均排队时间。平均数会骗人——大部分作业等 5 秒一个作业等 2 小时平均值还是很好看。要看 P99、P999以及“最大等待作业等待了多久”。5. 延迟负载反馈调度器如何感知自己是快还是慢5.1 调度器之间互相报负载作业分发才有依据分布式协同调度的难点之一是每个调度器都不知道自己是不是“太忙”。如果一个分组调度器已经积压了十万任务而作业分发服务还在继续往它这里塞新作业这个调度器就会越来越慢最终拖垮整个集群的响应速度。Apollo 的解法是让每个调度器周期性广播自己的负载水平。所谓负载不只是队列里有多少任务还包含这些任务预计需要的总资源量和预估完成时间。作业分发服务在分配新作业时会参考这些负载报告把作业交给当前最空闲的调度器。这个设计很像现实中的客服系统每个客服手里有几张工单系统根据工单量把新来电派给最闲的那个人。如果客服不汇报工单量派单员就只能瞎猜。放到调度系统里也一样负载反馈的粒度、频率和准确性决定了协同的有效性。5.2 用完成时间预估替代瞬时负载选节点眼光要放远调度器选定节点时很多人下意识会选“当前剩余资源最多”的节点。但这是短视的。不同任务运行时间差异很大一台当前空闲的节点可能马上要迎来一大批任务而一台当前忙碌的节点可能几分钟内就全部跑完。更合理的指标是“这个节点什么时候能有空位”也就是预测空闲时间。计算预测空闲时间时调度器需要知道节点上正在运行的任务还能跑多久。这依赖任务的心跳和进度上报。Apollo 的做法是结合每个任务的剩余时间和新任务预期运行时长估算出一个时间点然后选择最早能空出资源的节点。这个思路跟选登机口很像不是看哪个口现在人少而是看哪个口的航班最早能腾出来。配合陈旧度补偿预测公式要做一点修正节点上报的资源越旧预测空闲时间就越要往悲观方向调整。我在实践里习惯把“陈旧时间 × 任务平均到达率”作为隐藏负载加进去相当于给所有节点的资源视图打一个与年龄相关的折扣。这个操作简单但效果立竿见影冲突率能明显下降。5.3 调参经验广播间隔、安全边际与冲突率的三角关系这套方案里最需要手工调的就是三个参数状态广播间隔、陈旧度补偿系数和抢占阈值。三个参数互相牵制其中广播间隔决定了陈旧度陈旧度决定补偿系数补偿系数又影响冲突率和资源利用率。实测下来的手感是广播间隔太短状态服务的 CPU 和网络被打满调度吞吐反而下降。广播间隔太长冲突率直线上升重试风暴随时可能发生。补偿系数调太大调度器会过度悲观明明有资源也不敢分配集群利用率被压低。抢占阈值太宽松任务频繁被杀计算浪费严重太严格低优先级作业又会饿死。没有一劳永逸的参数只能做闭环监控。我在测试环境里的做法是每五分钟统计一次调度吞吐、冲突率、本地性命中率和作业等待时间然后把参数调整跟这些指标绑成一条反馈链路。方案再先进落到具体环境里都免不了这一轮“务实的拧螺丝”。6. 故障、重试与真实运营工程落地时最容易被忽略的细节6.1 集群状态服务的高可用少了它整个协同就散架这个架构里集群状态服务是唯一需要重点保护的状态中心。它挂了所有调度器都会失去同步基础系统会退化成各干各的“野生调度”冲突率飙升。高可用方案一般做成主备或多数派。主备模式简单但切换时需要把最近的广播状态重新补发期间调度器会基于陈旧度极高的状态做决策。多数派模式恢复性更好但写放大更明显状态服务本身可能更容易成为瓶颈。我的建议是优先保证状态服务的写性能和广播的可靠性不要在切换速度上追求极致。调度器已经在容忍秒级陈旧了切换多花一两秒并不会造成灾难真正怕的是丢状态、重启后回放不完整。6.2 对账发现冲突后的重试也要做流量控制前面提到过重试风暴这里是完整操作链路状态服务发现冲突后会给相应调度器返回失败调度器把这个任务放回队列等待下一轮调度。如果同一时间大量冲突返回调度器的处理线程会被失败消息淹没正常的调度逻辑反而被挤掉。应对方法包括失败消息按优先级分队列处理重试加随机延迟把尖峰摊平对同样的失败不要重复尝试太多次连续失败后转入一个更保守的“慢速重试”状态。把冲突当成正常的请求体来治理而不是当异常来对待这个心态非常重要。6.3 这套协同调度思路对今天设计系统的启发把这套方案放到今天的视角看它的价值不在于“照搬架构”而在于几个可以被复用的设计判断全局最优的执念要放下。用多个决策点加异步状态换吞吐和扩展性代价是冲突和重试这笔账在大规模场景下是划算的。状态的陈旧性是客观存在的与其追求实时同步不如在设计之初就接受它并用补偿模型把影响控制住。对任务分级分类是提升吞吐最有效的手段。不是所有任务都需要确定性保证给短任务开一条机会主义的轻量通道能省掉大量不必要的状态写入。一切策略都要有可观测的量化指标。调度吞吐、冲突率、等待时间分布这三组数比任何复杂的公平算法更能说明系统是否健康。如果你所在团队的集群规模还不到几千台节点这套方案确实显得重。但只要你准备往云规模走早晚会遇到单点调度器扛不住的那一天。提前理解协同调度的设计逻辑把你现在的调度器从“单点决策”逐步改造成“多点协同”比到时候推倒重来要稳妥得多。最后再分享一个我在实际操作中的体会改造调度器最忌讳一步到位。可以先在集群里划出一部分节点跑新的协同调度器另一部分节点继续用老调度器中间通过状态服务做数据互通观察一段时间调度质量和冲突率。等新方案跑稳了再逐步扩大范围。这样既验证了设计也不会把整个集群的稳定运营赌在一次大版本切换上。