简介:云计算资源分配算法是集群调度系统的核心,解决的不是单纯压榨硬件,而是让不同优先级的任务在公共算力池中有序排队与抢占。其本质是一个多目标优化问题,需要在吞吐、时延、能耗之间寻找平衡,并通过权重系数将业务损失换算为调度优先级。合理的目标函数与负载画像能够显著提升集群资源利用率,避免在线服务超时、离线任务饿死等典型问题。当前主流策略包括时间片轮转、拓扑感知分配和能耗优先集中放置,分别适配交互式短任务、数据密集型计算与夜间批处理等场景。在Kubernetes等容器化环境中,调度器还需结合打分器、优先级队列和迁移阈值等工程细节,确保调度决策稳定可信。本文从建模、策略选型到一个最小Python调度器实现,再到五个真实踩坑案例,完整呈现资源分配算法落地过程中的关键环节与参数调优经验,为云原生基础设施工程师提供一份可复现的实践参考。
1. 云计算资源分配算法不是省 CPU 的算法:先想清楚在省什么
做云计算资源分配算法,很多人第一反应是“把 CPU 用满、把内存榨干”,但我在生产环境里踩过一圈后得了个反直觉结论:资源分配算法的首要价值不是省资源,而是让不同优先级的任务在争抢有限算力时都有“盼头”。它解决的是公共资源池里的排队和抢占问题,而不是单纯压榨硬件。没有这套算法,一个高并发在线服务和一个跑批任务放在同一批节点上,往往是谁抢得凶谁说了算,最终在线服务超时、离线任务饿死,两边一起翻车。这篇笔记适合正在搭集群调度、做云原生成本治理或搞容器化改造的工程师,我会从建模、选型一直讲到可复现的调度器代码和避坑清单。
2. 资源分配问题的数学表达:目标函数与五类约束
2.1 把分配目标拆成三类可量化指标:吞吐、时延、能耗
先把资源分配当成一个最优化问题来建模。在一个既有在线任务又有离线批任务的集群里,我通常会同时看三类指标,而不是看单一指标。
- 吞吐:单位时间内完成的任务数量,或者所有任务的总完成时间,即 makespan。批处理场景用 makespan 最直观。Makespan 越小,说明整体算力被利用得越充分。
- 时延:在线服务的核心指标。一般看尾延迟(p99),因为平均值会被少数短任务拉低。资源分配不好时,p99 时延会先恶化,平均值看起来还正常,这是最坑人的地方。
- 能耗:云厂商和自建机房必须考虑的电费成本。同样一个任务,放在 3 个节点上跑分时执行和放在 1 个节点上连续执行,后者的功耗反而更低,因为空闲节点可以休眠并批量唤醒,内存的刷新功耗和风扇功耗在整机启动后是刚性开销。
更形式化一点,可以把总目标写成一个带权重的标量函数:
目标 = 吞吐项 + 时延项 + 能耗项
如果写成最小化形式,就是:
Minimize F = α × makespan / N + β × avg_p99_latency + γ × total_energy
其中 α、β、γ 分别是三个目标的权重系数,N 是任务总数,用来把 makespan 归一化到“每个任务平均花费的算力时间”,这样三项量纲才不会差太远。实际工程里,我建议先不做动态权重,而是把这组系数写进配置,用不同取值跑同一份历史负载,再选一组让全局成本最低的固定值。动态权重听起来高级,但在线上出问题时很难解释:到底是负载变了,还是权重计算出了偏差?
能耗项可以用一个简单的功率模型近似:total_energy = Σ 每个节点开机时长 × 节点基础功耗 + 每项任务执行时长 × 任务平均功率增量。这个模型不要求精确,只要求能反映“开一台空节点有多贵”这个趋势,就足够用于调度决策了。
2.2 为什么目标函数里要加权重项而不是追单一指标
真实云环境的负载是混合的,只优化吞吐会让长尾任务饿死,只优化时延又会浪费大量算力。举个例子:一个数据清洗任务需要 6 小时跑完,如果只盯着在线查询的 p99 时延,调度器可能会把所有节点都分配给在线服务,清洗任务永远排不上队,或者不断被抢占回滚。相反,如果只盯着吞吐,调度器会把所有任务尽量塞满每一个节点,导致在线服务时延剧烈抖动。
所以我在目标函数里加的权重项,本质上是给“不同任务的期望完成时间”定价。在线服务的权重高,代表它的排队等待时间要用更小的价格系数去惩罚;离线任务的权重低,代表它多等几分钟是允许的,但也不能无限等。这里有一个很实用的参数调整方式:把权重语义换算成业务容忍度,而不是抽象的数字。例如:在线查询每超过 SLA 1 秒,业务损失是 0.1 元;离线任务每延迟 1 分钟,业务损失是 0.01 元。那么按单位时间统一换算,在线服务的时延权重就应该是离线任务的 600 倍左右。这个换算方式的好处是,业务方听得懂,评审时不会吵起来。
下面是一张我在实际项目里用过的权重表,供参考和改参数起点:
| 负载类型 | 指标代表 | 权重换算依据 | 典型权重范围 |
|---|---|---|---|
| 高实时在线服务 | p99 时延 | SLA 罚款金额 / 单位超时时间 | 0.4 - 0.8 |
| 准实时计算 | 平均时延 | 上游等待成本 | 0.2 - 0.4 |
| 夜间批处理 | makespan | 人工加班成本 | 0.05 - 0.15 |
| 能耗优化类 | 千瓦时成本 | 电费单价 | 0.05 - 0.2 |
2.3 先做负载画像再选算法:三层信息缺一不可
不少团队上来就调算法,但跑不动,因为缺少负载画像。我一般把资源分配前的输入信息分成三层:任务层、节点层、拓扑层。
任务层要采集的信息包括:每个任务的 CPU 占用率分布、内存占用率分布、运行时长分布、是否存在周期性峰值。节点层要采集的是:节点当前已分配的 CPU/内存、实际使用曲线、磁盘 IO 和带宽占用、节点上的系统服务占用量。拓扑层要采集的是:任务之间的数据依赖关系、任务与数据所在机架的物理距离、跨机架通信带宽限制。
这三层信息缺了任何一层,都会造成调度误判。只统计任务平均 CPU 而不看峰值,会出现节点 CPU 看起来 70%,实际某个时刻冲到 120% 导致 CPU 限流;只统计节点已分配内存而不看系统页缓存占用,会出现内存明明还有余量,但应用一跑就 OOM。
采集手段上,Kubernetes 环境直接取 kubelet 暴露的 cadvisor 指标就能拿到容器级数据,物理机环境用sar -u、sar -r和/proc/meminfo也能凑合,但一定要按时间窗口聚合,至少保存一周的 1 分钟粒度数据。短于三天,你就看不到周维度负载规律;短于一天,你连“白天在线高、夜间批处理低”这种基本规律都覆盖不全,后面做离线仿真就是盲人摸象。
3. 三类可落地的分配策略:时间片、拓扑感知、能耗优先
3.1 时间片轮转类算法:公平但会把长尾任务拉长
时间片轮转是云计算资源分配算法里最容易理解的一类,也是很多新手第一个实现的方向。它的核心思想是:每个任务轮流使用 CPU 配额,配额用完就让给下一个任务。加权轮转(WRR)在纯粹轮转的基础上给每个任务一个权重,权重高的任务在一轮里分到更多的时间片。
这种策略的优势是公平和抗饿死,所有任务都能推进,不存在一个任务永远抢不到资源的情况。但它的短板也很明显:长任务被频繁打断,缓存和加载过的模型参数在切换时全部失效。如果一个任务一次完整执行需要 200 秒,但每次只能连续跑 2 秒,它的实际完成时间可能是理想情况的 3 到 4 倍,因为上下文切换和缓存失效的成本都被算进去了。
所以时间片轮转适合的场景是短任务、交互式任务互斥且共享 CPU 不敏感的负载。如果是大数据跑批或者模型训练,直接上时间片轮转基本会翻车,任务总时长会拉到不可接受。我在实际项目中,只在开发测试环境用 WRR 做公平调度,生产环境很少单独依赖它,通常会叠加优先级抢占。
3.2 拓扑感知分配:把流量放在同一机架内
拓扑感知分配是我在自建机房用得最多的一类策略。它的出发点非常朴素:数据从磁盘读到内存、从一台机器传输到另一台机器,代价差别很大。同一机架内通过万兆交换机互联,时延在 0.1 毫秒量级;跨机架要走核心交换机,时延可能到 0.5 毫秒到 1 毫秒,带宽还会被多租户争抢。
把两个需要频繁交换中间结果的任务放在同一个机架上,比放在跨机架的节点上要省大量网络开销。这就是数据局部性,也是 MapReduce 时代就有的经验,到了 Kubernetes 时代变成了拓扑分布约束。具体配置上,Kubernetes 的topologySpreadConstraints就是干这个事的,它会尽量把同一个工作负载的副本分散到不同拓扑域,以避免单机架故障影响整个服务。
但要注意,拓扑感知分配不能一刀切地“所有任务都在同一机架”。如果为了追求数据局部性,把所有副本都塞进一个机架,一旦这台机架断电,整个服务就全没了。正确做法是给关键服务设置 requiredDuringScheduling 的跨机架约束,给批处理任务设置 preferredDuringScheduling 的倾向约束。前者是硬性要求,后者是软偏好,这样既保证了局部性收益,又保留了容错空间。
3.3 能耗优先级:把任务压到尽可能少的节点上
能耗优先策略的目标是尽量让集群里同时开机的节点数最少。大白话就是:既然一个节点开机就要吃基础功耗,那就把所有任务都往少数节点上塞,塞不下了再开新节点。任务结束后及时把节点上的负载腾空,让节点可以被回收或休眠。
这种方式在离线批处理和夜间计算场景下效果很明显。我曾经在一个 20 台节点的集群上做过实验,把分配策略从“均匀打散”改成“能耗优先集中放置”,在相同任务负载下,电费下降了 28%,而任务平均完成时间只增加了 9%。这 9% 的代价主要来自多任务争抢同一节点的 CPU 超卖和内存带宽竞争,属于可接受的换购。
能耗优先的副作用是节点故障域变大,故障影响范围也会扩大。如果 20 台节点的负载都集中在 3 台节点上,一台节点宕机意味着集群 1/3 的算力消失,调度器要立刻做大规模迁移,这时候迁移风暴可能比任务排队更可怕。所以我会给关键在线任务打上标签,禁止它们参与能耗优先策略,或者为它们单独保留一批常驻节点。线上业务一旦受影响,省下的电费不够赔 SLA。
下面用一张表把三类策略的使用边界说清楚:
| 策略类型 | 核心优点 | 主要副作用 | 推荐负载 |
|---|---|---|---|
| 时间片轮转 / WRR | 公平、抗饿死 | 长任务被频繁打断 | 短任务、交互式请求 |
| 拓扑感知分配 | 降低跨机架通信成本 | 硬约束配置过严会降低容错 | 数据密集型的分布式计算 |
| 能耗优先集中放置 | 降低电费和基础功耗 | 故障域扩大、迁移风暴 | 离线批处理、低成本时段任务 |
选型的时候,不要指望一个策略通吃所有负载。我一般会按负载类型做分层调度:在线服务用拓扑感知加权重打分,离线任务用能耗优先加装箱算法,中间态任务用时间片保证公平。层与层之间用优先级队列连接,高优先级队列可以抢占低优先级队列的资源,但抢占前要预留迁移时间窗口。
4. 在线调度器实现:从请求队列到调度决策的最小 Python 版本
4.1 调度器的三个组成部分:队列、打分器、执行线程
在线调度器不像离线批处理,它面对的是随时到来的请求,必须在一个可接受的短时间窗口内做出分配决策,还要能处理节点出现变化的情况。我一般把它拆成三个组件:请求队列、打分器、执行线程。
请求队列负责接收新任务请求,按照优先级或到达时间排队。打分器负责计算一个任务放在某个节点上的适配度,返回一个分数。执行线程从队列里取请求,用打分器遍历候选节点,选分最高的那个落盘并启动任务。
import heapq import threading import time from dataclasses import dataclass from typing import Dict, List @dataclass class Task: task_id: str cpu_req: float # 请求的 CPU 核数 mem_req: float # 请求的内存 GB priority: int # 优先级数值越大越高 submit_time: float @dataclass class Node: node_id: str total_cpu: float total_mem: float used_cpu: float = 0.0 used_mem: float = 0.0 def available_cpu(self) -> float: return self.total_cpu - self.used_cpu def available_mem(self) -> float: return self.total_mem - self.used_mem这里的 Task 和 Node 是两个基础数据结构。Task 里 cpu_req 和 mem_req 是任务申请的额度,注意这里是申请额度而不是实际使用量,调度时按照申请额度做容量判断,否则会出现多个任务都声称低使用率,加起来超卖的情况。priority 在队列排序时用,数值越大表示优先级越高。
Node 里的 used_cpu 和 used_mem 是任务分配后累加的数值,不直接等于实时占用率。这是调度器的安全设计,实际占用率超过申请额度时会被节点层面的限流机制兜住,而不是被调度器默认允许。
4.2 用加权打分选择最合适节点
最简单可靠的打分方式是对每个节点计算一个综合分,它包括三个维度:节点剩余资源与任务需求的匹配度、任务执行后是否会加剧资源碎片化、任务与数据的物理距离。
def score_node(task: Task, node: Node, data_locality: Dict[str, float]) -> float: # 硬性条件检查,不满足直接返回 -1 if node.available_cpu() < task.cpu_req or node.available_mem() < task.mem_req: return -1.0 # 剩余 CPU 比例:越大越合适,避免资源热点 cpu_score = node.available_cpu() / node.total_cpu # 内存剩余比例:同样越大越合适 mem_score = node.available_mem() / node.total_mem # 数据本地性得分,1.0 表示数据在本节点,0.0 表示数据在远端 locality_score = data_locality.get(node.node_id, 0.0) # 加权汇总,权重按场景可配置 final_score = 0.4 * cpu_score + 0.3 * mem_score + 0.3 * locality_score return final_score这个 score_node 函数是调度器的核心。前两行代码是硬性检查,只要节点放不下任务就直接剔除,后续计算都不会执行,这是为了把资源分配失败的事故挡在早期。
cpu_score 用的是剩余比例而不是剩余绝对核数,是因为不同节点总核数可能不同。一个总核数 64 只剩 2 核的节点,和一个总核数 8 只剩 2 核的节点,剩余绝对数一样,但前者被一个 4 核任务打爆的风险远远高于后者,用比例能更好地反映这种余量弹性。
locality_score 需要外部传入,通常由数据服务模块维护一个“哪些数据块在哪些节点上”的映射。如果任务需要读取的数据分布在多个节点,locality_score 可以取其中最大占比节点的分数,也可以按数据块数量加权平均。我倾向于取加权平均,因为 MapReduce 阶段每个计算任务通常要读取多份数据,只取最大占比容易造成局部性假象。
执行线程的循环逻辑如下:
class Scheduler: def __init__(self, nodes: List[Node]): self.nodes = nodes self.queue: List[tuple] = [] def submit_task(self, task: Task): heapq.heappush(self.queue, (-task.priority, task.submit_time, task)) def schedule_once(self) -> str: if not self.queue: return "no_task" _, _, task = heapq.heappop(self.queue) best_node = None best_score = -1.0 for node in self.nodes: score = score_node(task, node, {}) if score > best_score: best_score = score best_node = node if best_node is None: return "no_capacity" best_node.used_cpu += task.cpu_req best_node.used_mem += task.mem_req return f"scheduled to {best_node.node_id}"调度循环使用最小堆实现优先级队列,负号是为了把优先级最大的任务挤出堆顶。如果所有节点都无法容纳这个任务,heapq 已经把任务弹出,如果直接丢掉任务会造成任务悄无声息地消失,所以生产版本里这里要先把任务放回一个 pending 队列,等有节点释放资源后再重新排队。否则用户提交的任务超过集群容量时,会变成黑匣子一样找不到去向。
schedule_once 每调用一次只调度一个任务。在实际运行时我会用一个后台线程循环调用它,每次调用间隔根据队列深度动态调整:队列深的时候间隔短,队列空的时候间隔拉长到 2 秒,以减少空转 CPU 开销。
4.3 三个必调参数与失败时的现场表现
第一个必调参数是 score_node 里的权重组合。0.4/0.3/0.3 只适合 CPU 密集、内存适中、数据本地性敏感的通用场景。如果任务是内存密集型的,比如缓存预热任务,要把内存权重提到 0.5 以上。判断权重是否需要调整的现象是:集群中有节点内存耗尽触发 OOM,同时另一批节点 CPU 空闲但内存不足。这说明内存维度权重低了,调度器把任务都塞进了 CPU 充裕但内存不足的节点。
第二个必调参数是任务在队列里的最大等待时间。如果没有超时机制,一个低优先级任务在大集群里可能等几个小时。我会给队列里的任务打一个 deadline,比如批处理任务 30 分钟还没被调度,就自动降级为可抢占任务,塞进最短队列的节点去执行。实现时,在 Task 里加一个 expire_time 字段,schedule_once 每次取出堆顶任务时检查是否过期,过期则放入降级队列。
第三个必调参数是迁移触发阈值。调度器决定迁移一个任务前,要先问三个问题:目标节点是否至少有任务申请额度的 1.2 倍余量?迁移后的节点与数据源是否同机架?迁移会不会造成目标节点的 CPU 突发超过 90%?三问都通过才允许迁移。很多迁移风暴就是没做这三个检查,结果任务迁过去又把目标节点打爆,接着再次迁移,集群进入抖动循环。我把这种现象叫“调度器的羊群效应”,是资源分配系统里最隐蔽的翻车方式之一。
下面给一份参数调整速查表:
| 参数 | 默认值 | 调大场景 | 调小场景 | 失效现象 |
|---|---|---|---|---|
| cpu 权重 | 0.4 | CPU 超卖严重 | 内存先耗尽 | CPU 限流/等待频繁 |
| mem 权重 | 0.3 | 内存型任务多 | CPU 饿死 | 节点 OOM |
| locality 权重 | 0.3 | 分布式计算数据量大 | 数据冷热均匀 | 跨机架带宽打满 |
| 最大等待时间 | 30 min | 低优先级任务多 | 在线 SLA 严格 | 队列积压无感知 |
| 迁移余量系数 | 1.2 | 节点规格差异大 | 节点规格整齐 | 迁完又迁 |
5. 资源分配避坑:五个真实踩坑记录
5.1 现象:按 CPU 打分,内存先耗尽
我接手过一个每天凌晨跑批量计算的集群,从监控上看 CPU 平均利用率只有 42%,但节点内存频繁飙升到 90% 以上,时不时有任务 OOM 被杀。查调度日志发现所有任务都被分配到了 CPU 分数最高的节点,但这些节点恰恰是内存已被其他任务占得差不多的节点。
原因:调度器打分时 CPU 权重占 0.6,内存权重只有 0.2,导致内存余量不足的节点也能拿到高分。内存是更刚性的资源,一旦耗尽进程直接被内核杀掉,CPU 超卖还能靠限流扛一阵,内存超卖扛不住。
解决:把打分逻辑改成两步走,先用内存余量做硬过滤,内存不满足直接淘汰,再做 CPU 打分。同时在节点状态上报中把真实内存使用率和申请使用率的差值暴露出来,用于判断是否存在大量内存页缓存可回收。
5.2 现象:容器频繁迁移,集群执行效率反而更低
有一个 16 节点集群,每个任务运行约 10 分钟,调度器每分钟触发一次重新计算,结果发现有 30% 的任务在运行中被迁移,单次迁移耗时 40 秒,整个集群花在迁移上的时间占了总执行时间的 15%。
原因:重新调度周期太短,节点资源状态稍微变化就触发迁移。迁移操作本身有镜像拉取、状态同步、网络重连等开销,对短任务来说,迁移成本往往大于留在原地排队等待的成本。
解决:把迁移分两类处理。运行时长小于 15 分钟的任务默认不迁移,只做初始放置;只有运行时长超过 1 小时的长任务才参与动态迁移,且迁移前必须满足目标节点资源余量大于 1.5 倍任务申请量。这样迁移次数下降了 80% 以上,执行效率反而提升。
5.3 现象:预留内存算错导致节点刚上线就被压垮
新扩容一批节点后,我直接把节点内存的一半设为预留内存,因为系统服务占用看着像一半。结果任务调度过去后,节点上的系统服务因为内存不足频繁触发 OOM 杀进程,整机进入不健康状态。
原因:节点内存的一半不是系统服务的固定占用,而是页面缓存、文件系统缓冲、Kubernetes 系统组件合计的动态占用。预留机制用绝对比例会导致误判。
解决:预留内存先按节点总内存的 20% 做保底,再叠加从监控曲线取最近 24 小时的内存峰值作为动态预留量。这样既有下限兜底,又能反映节点真实运行规律。
5.4 现象:网络带宽没进分配约束,造成带宽热点
在做视频转码任务时,输入源文件都在同一台存储节点上,调度器把所有转码任务都分配到了同一机架的计算节点,以为数据本地性最优。结果转码任务并发读取源文件时,这台存储节点的万兆网卡被打满,任务整体吞吐下降到原来的 30%。
原因:资源分配只考虑了 CPU 和内存,忽略了对带宽的约束。数据本地性带来的带宽收益,被多任务争抢单节点出口带宽的代价抵消了。
解决:在 Node 数据结构里增加 available_bandwidth 字段,打分时增加带宽权重。同时给存储节点单独标记为不可调度计算任务,只允许作为数据源访问,从机制上避免一不小心把计算任务也堆到存储节点上。
5.5 现象:标签语义错误,亲和性约束反向生效
生产环境里有个服务需要和它的缓存服务放在同一机架,我用 nodeSelector 配了两个标签,结果是任务全部被调度到没有缓存的节点上,服务连通性检查超时。
原因:nodeSelector 的匹配语义是必须同时满足所有标签,但我在两个服务上各打了一个不同的标签,实际没有任何节点同时满足这两个标签,调度器就只能选不满足标签约束的兜底节点。标签的取值语义是“节点物理位置”,而不是“服务所属模块”,这个混淆导致写法完全错误。
解决:改成用 topologySpreadConstraints 约束同一套服务实例间的分布,用 requiredDuringScheduling 匹配机架归属。调试时先用 kubectl get nodes --show-labels 列出所有标签,再在调度模拟器里跑一遍,确认节点筛选结果与预期一致后,再发布到生产调度器。
6. 把分配算法压测到可信:基准负载、模拟队列与回归验证
写完了调度器,最怕的是自我感觉良好,一上生产就被打脸。我现在的习惯是任何调整先过三关:基准负载、模拟队列、回归对比。
基准负载方面,我做三档:轻载用 100 个任务分布在 5 个节点上,观测调度是否公平;中载用 1000 个任务分布在 20 个节点上,观测队列排队是否合理;重载用 3000 个任务分布在 20 个节点上,观测是否会引发迁移风暴和节点雪崩。每档都记录三个指标:任务平均排队耗时、节点碎片率、资源利用率波动幅度。碎片率的计算方式是所有节点剩余可分配资源的方差,方差越大说明资源被打得越碎,后续大任务越发找不到完整节点。
模拟队列方面,我会把线上过去一周真实的任务提交时间、资源申请量、运行时长脱敏后,重放给调度器。这一步能发现很多基准负载发现不了的问题,比如在线任务集中在上午 10 点提交,那个时候调度器恰好有一批批处理任务在排队,如果没有重放真实流量模式,只看均匀到达,永远发现不了“高峰期抢资源”这个坑。
回归对比是最重要的一道工序。我把每次调整前的调度器保存为一个版本,用同一份历史负载跑出基线指标,再跑新版本,对比差值。如果调整后资源利用率提升超过 3%,但 p99 时延退化了 10%,这个调整就不该上线。我吃过一次亏,只盯着省电指标,调整了能耗权重,结果在线服务超时率翻倍,被业务方追着跑了一整天。从那以后我只做多指标同时观察的回归验证,任何单一指标的好转会直接视为可疑信号。
另一个容易被忽略的习惯是记录“分配前快照”。每次调度周期开始前,把当前节点资源余量、队列深度、任务等待时间存一份 JSON 到本地。后续排查调度异常时,不用去翻监控系统大海捞针,直接看快照就能还原出当时的资源真实状态。这个习惯帮我解决过好几次“看起来调度没错但任务就是起不来”的玄学问题,后来发现是快照里记录了某个节点的系统组件内存占用异常,把资源余量挤没了。
独创的技巧谈不上,但一个朴素的教训是:资源分配算法做得再好,也要敬畏真实负载的不确定性。你以为算清楚了,负载一变又会重新失衡。所以我保留了一整套离线仿真环境,任何调整先在仿真里跑三天,效果满意再推到生产。这个流程每次大概多花半天,但能省掉上线后半夜被叫起来的痛苦。希望这些经验和坑能帮你少走一段弯路。
本文还有配套的精品资源,点击获取