从零设计分布式 AI 调度平台

深入解析 go-ai-scheduler 的四服务架构设计,探讨如何在 Go 中构建一个支持 AI 辅助操作的高可用分布式任务调度系统。

问题背景

在构建一个任务调度系统时,最常见的起点是 cron 表达式加数据库轮询。这种方式在任务量小、单机部署时工作良好,但随着规模增长,会面临三个核心问题:

  1. 调度精度与性能的矛盾——高频轮询数据库会迅速成为瓶颈
  2. Worker 动态扩缩容——静态配置无法应对负载波动
  3. AI 能力的融入——如何让大模型辅助运维而不破坏调度链路的可靠性

go-ai-scheduler 是我针对这些场景设计的一个分布式调度平台。它不追求极简,而是在工程复杂度与可维护性之间做 trade-off,最终形成了一个四服务架构。这篇文章会拆解它的整体架构、各服务的职责边界,以及关键的设计决策。

四服务架构概览

整个系统拆分为四个独立进程:

服务端口核心职责
api:8082外部管理 API、Web 控制台、AI 代理
scheduler:8081 / :9090控制平面:触发、重试、分发、Leader 选举
worker:8080任务执行:Shell / HTTP / 容器,心跳上报
ai-service:8083LLM Agent、日志分析、调度建议、趋势预测

这种拆分不是为了微服务而微服务。schedulerworker 分离是因为它们的生命周期和扩缩容模式完全不同:调度器需要强一致性(Leader 选举),而 Worker 是无状态的、可以水平扩展的执行节点。

调度器的核心链路

scheduler 是整个系统最复杂的部分。它的 main.go 初始化了一条完整的调度链路:

// cmd/scheduler/main.go
bp := ratelimit.NewBackpressureController(bpCfg)
cacheMgr := schedulercache.NewManager(resources.Redis, ...)
router := route.NewRouter(resources.Repositories.Worker)
dispatcher := dispatch.NewClientWithRateLimiter(3000)

elector := leader.New(resources.DB, cfg.EtcdAddrs, l)
elector.Acquire(leaderCtx)

schedEngine := engine.New(resources.Repositories.Task, resources.Repositories.TaskInstance, l)
schedEngine.OnTrigger = func(taskID int64) {
    if !bp.AllowDispatch() { return }
    // 创建实例 → 选 Worker → 分发 → 更新下次触发时间
}

这条链路上有几个关键设计值得展开。

1. 混合调度引擎

调度器不依赖数据库轮询来发现到期任务。它使用了一个**时间轮(Timing Wheel)+ 最小堆(Min-Heap)**的混合引擎:

  • 时间轮:600 个槽位,100ms 一个 tick,覆盖 60 秒。用于处理周期性的 cron 任务触发。
  • 最小堆:50ms 扫描一次,用于精确的短期任务和重试任务。

引擎每 10 秒从 MySQL 预热一次数据,将未来 60 秒内的 cron 任务写入时间轮,将重试任务写入最小堆。

// internal/scheduler/engine/engine.go
wheelTicker := time.NewTicker(e.wheel.TickDuration())  // 100ms
heapTicker := time.NewTicker(50 * time.Millisecond)

for {
    select {
    case <-wheelTicker.C:
        e.processWheelTick()   // 粗粒度触发
    case <-heapTicker.C:
        e.processHeapTick()    // 精确触发
    }
}

这个设计把时间相关的查询负载从数据库转移到了内存,只保留周期性的刷新。后续文章会详细拆解时间轮的实现。

2. 负载感知路由

Worker 注册到调度器时会声明自己的 MaxConcurrency 和当前负载。路由模块支持两种策略:

// internal/scheduler/route/router.go
switch opts.Strategy {
case "round_robin":
    best = r.pickRoundRobin(filtered)
default:
    best = r.pickLeastLoaded(filtered)
}

best.CurrentLoad++
r.workers.UpsertWorker(ctx, best)

这里的关键是乐观容量预留:在分发瞬间就递增 CurrentLoad,而不是等 Worker 确认。如果分发失败(网络超时、Worker 宕机),通过 router.Release() 回滚。这种设计牺牲了严格的一致性,换取了更高的吞吐量。

3. 背压控制

当系统负载过高时,调度器需要保护自己。背压控制器监控两个维度:

  • Pending 实例数:超过 1000 时进入 Red 状态,拒绝新的分发
  • Worker 平均负载:超过 85% 时触发限流
// internal/scheduler/ratelimit/backpressure.go
switch {
case ratio >= b.redRatio:      // 0.9
    b.state = PressureRed
case ratio >= b.yellowRatio:   // 0.7
    b.state = PressureYellow
}

背压状态会直接影响分发决策:

  • Green:正常分发
  • Yellow:延迟 500ms 后重试
  • Red:直接拒绝,任务留在队列中等待下一周期

Worker 的高可用设计

Worker 的设计哲学是尽可能自治。它需要在网络抖动、调度器短暂不可用时继续工作。

本地缓冲(Local Store)

当 Worker 完成任务后,如果上报状态到调度器失败,会将报告写入本地磁盘:

// internal/worker/local_store.go
func (s *Store) Buffer(schedulerURL string, report apiservice.TaskStatusReportRequest) {
    sr := StoredReport{SchedulerURL: schedulerURL, Report: report, StoredAt: time.Now().Unix()}
    data, _ := json.Marshal(sr)
    filename := filepath.Join(s.dir, report.ScheduleInstanceID+".json")
    os.WriteFile(filename, data, 0600)
}

后台有一个 30 秒周期的 flush loop,不断尝试将缓冲的报告重新投递。只有当投递成功后,本地文件才会被删除。这实现了断网续传的能力。

去重与幂等

分布式系统中,同一个任务实例可能被重复分发。Worker 使用 sync.Map 维护了一个带 TTL 的去重缓存:

// internal/worker/handler.go
func (h *Handler) isDuplicate(scheduleID string) bool {
    if _, loaded := h.dedupMap.LoadOrStore(scheduleID, time.Now()); loaded {
        return true
    }
    return false
}

配合调度器生成的唯一 ScheduleInstanceID(格式为 task-{id}-{nanosecond}),可以保证同一个实例不会被重复执行。

AI 服务的边界

ai-service 是这个项目最有特色的部分。它是一个基于 LLM 的 Agent,拥有 12 个工具函数,可以通过自然语言查询任务状态、分析失败日志、创建新的任务定义等。

// internal/ai/adapter/adapter.go
func (a *LLMAdapter) CompleteWithTools(ctx context.Context, messages []Message, tools []Tool) (string, []ToolCall, error) {
    // 调用 OpenAI-compatible API,支持 function calling
}

Agent 的核心是一个最多 10 轮的工具调用循环:LLM 生成请求 → 执行工具 → 将结果返回给 LLM → 继续生成,直到获得最终答案或达到轮数上限。

可观测性

系统在每个关键路径上都埋了 Prometheus 指标:

  • scheduler_dispatch_total:分发成功/失败计数
  • leader_election_total:Leader 选举结果(backend 标签区分 etcd/mysql/local)
  • worker_executions_total:Worker 执行状态分布

日志使用 Go 1.21 的 log/slog,统一输出 JSON 格式,方便接入 Grafana Loki 或 ELK。

总结

go-ai-scheduler 的架构可以概括为:控制平面强一致、执行平面高自治、AI 平面弱耦合

  • scheduler 通过 Leader 选举保证调度决策的唯一性
  • worker 通过本地缓冲和去重保证执行的可靠性
  • ai-service 被隔离在建议层,不触碰核心调度链路

这套架构在设计上刻意保留了一些"不完美":比如路由的乐观预留、Redis 的 optional 设计。这些 trade-off 让系统在小规模部署时足够轻量,在规模扩大时也有清晰的扩展路径。

下一篇会深入调度引擎的内部,拆解时间轮与最小堆的协同工作原理。