2. 调度系统架构设计

调度系统,说白了就是整个推理系统的「大脑」。它决定了哪个任务先跑、哪个任务后跑、用多少资源跑。我见过不少团队,模型本身写得挺好,结果调度层一塌糊涂,吞吐量上不去,延迟还高得离谱。今天咱们就把这块掰开揉碎了讲。

调度器核心组件

一个调度器,核心就三块:任务管理器、资源管理器、调度引擎。这三者配合得好,系统才能转得起来。

核心组件一览

  • 任务管理器:负责任务的接收、状态跟踪、生命周期管理。说白了,任务从生到死都归它管。
  • 资源管理器:维护GPU、CPU、内存等资源的分配情况。谁占了多少,还剩多少,它心里有本账。
  • 调度引擎:根据策略,决定下一个该执行哪个任务。这是调度器的「决策中心」。

我在项目中遇到过一个问题:任务管理器把任务状态搞丢了,结果调度引擎以为任务还没执行,又派发了一次。嗯,那场面,两个相同的任务同时跑,资源直接打满。后来我加了个状态持久化机制,才彻底解决。

任务队列设计

任务队列,是调度器的「缓冲区」。设计得好,系统能扛住突发流量;设计得不好,稍微来点压力就崩了。

我个人习惯用多级队列。为什么?因为推理任务天然有优先级差异。比如在线服务请求必须秒级响应,而离线批量任务可以等几分钟。你想想看,如果把它们混在一个队列里,在线请求被离线任务堵住,用户体验直接崩。

多级队列的设计要点:

  • 优先级队列:高优先级任务插队,低优先级任务排队。但要注意,不能无限插队,否则低优先级任务永远得不到执行——这叫「饥饿问题」。
  • 公平队列:每个用户或每个租户一个队列,轮转调度。我曾在某云厂商的推理平台上用过这个方案,效果不错,能保证每个租户都有资源用。
  • 延迟队列:有些任务需要延迟执行,比如定时推理。用延迟队列可以避免轮询浪费资源。

避坑指南

我曾经在队列长度上吃过亏。队列设得太短,任务频繁被拒绝;设得太长,内存撑爆。后来我用了有界队列 + 背压机制——队列满了就通知上游降速,而不是直接丢弃任务。这个方案在多个项目中验证过,很稳。

工作线程池模型

线程池,是调度器的「执行单元」。模型选对了,资源利用率能翻倍。

常见的模型有三种:

模型 特点 适用场景
固定线程池 线程数固定,避免频繁创建销毁 负载稳定的场景
动态线程池 根据负载自动扩缩 流量波动大的场景
分片线程池 按任务类型分片,互不干扰 多租户、多模型场景

我个人更倾向于分片线程池。为什么?因为推理任务对资源的需求差异很大。比如大模型推理需要整张GPU,小模型推理只需要CPU。如果混在一起,大模型任务会把小模型任务的资源抢光。

来看一个简单的分片线程池实现:

public class ShardedThreadPool {
    private final Map<String, ExecutorService> pools;
    
    public ShardedThreadPool(Map<String, Integer> config) {
        this.pools = new HashMap<>();
        for (Map.Entry<String, Integer> entry : config.entrySet()) {
            pools.put(entry.getKey(), 
                      Executors.newFixedThreadPool(entry.getValue()));
        }
    }
    
    public void submit(String shardKey, Runnable task) {
        ExecutorService pool = pools.get(shardKey);
        if (pool != null) {
            pool.submit(task);
        } else {
            throw new IllegalArgumentException("Unknown shard: " + shardKey);
        }
    }
}

这段代码看着简单,但实际项目中要考虑的东西不少。比如线程池满了怎么办?要不要加个等待队列?我建议加一个有界等待队列,队列满了就拒绝任务,而不是无限等待。

注意

动态线程池虽然灵活,但扩缩容有延迟。流量突然暴涨时,线程数还没扩上去,任务已经堆积了。我建议配合预扩容机制——根据历史流量预测,提前把线程数拉上去。

调度策略接口设计

调度策略,是调度器的「灵魂」。接口设计得好,策略可以灵活切换,甚至热加载。

我习惯用策略模式来设计调度策略接口。核心就一个方法:

public interface SchedulingStrategy {
    Task next(List<Task> readyTasks, ResourceSnapshot resources);
}

这个方法接收两个参数:

  • readyTasks:所有就绪的任务列表
  • resources:当前资源快照

返回下一个要执行的任务。就这么简单。

基于这个接口,可以实现各种策略:

  • FIFO策略:谁先来谁先跑。简单,但不公平。
  • 优先级策略:按优先级排序。注意防止饥饿。
  • 最短任务优先:先跑耗时短的任务。能降低平均延迟。
  • 资源感知策略:根据当前资源情况,选择最合适的任务。比如GPU有空闲,就优先跑GPU任务。

我在项目中用过资源感知策略。当时有个场景:GPU资源紧张,CPU资源空闲。如果按FIFO调度,GPU任务会堵住CPU任务,导致CPU资源浪费。资源感知策略能动态调整,把CPU任务调度到空闲的CPU上,整体吞吐量提升了30%。

设计技巧

接口设计时,记得把策略元信息也暴露出来。比如策略名称、参数配置、当前状态。这样方便监控和调试。我曾经因为策略参数配错了,排查了半天才发现。后来加了个getConfig()方法,问题一目了然。

知识体系总览

下面这张图,把调度系统的核心逻辑串起来了。你可以对照着看,每个组件负责什么,数据怎么流动。

调度系统核心架构 任务管理器 接收 · 跟踪 · 生命周期 状态持久化 任务队列 多级队列 · 优先级 公平队列 · 延迟队列 调度引擎 策略选择 · 决策 资源匹配 工作线程池 固定/动态/分片 执行任务 资源管理器 GPU/CPU/内存 分配 · 回收 调度策略 FIFO · 优先级 最短任务 · 资源感知 数据流向:任务 → 队列 → 调度引擎 → 线程池 虚线表示调度引擎依赖资源管理器和调度策略做决策

这张图里,数据从左到右流动:任务先到任务管理器,然后进入队列,调度引擎从队列里取任务,结合资源管理器和调度策略做决策,最后交给工作线程池执行。整个流程环环相扣,任何一个环节出问题,都会影响整体性能。

好了,调度系统架构的核心内容就这些。记住:组件要解耦,队列要分级,线程池要分片,策略要灵活。把这四点做好,你的调度系统基本就稳了。