从零设计分布式 AI 调度平台
深入解析 go-ai-scheduler 的四服务架构设计,探讨如何在 Go 中构建一个支持 AI 辅助操作的高可用分布式任务调度系统。
问题背景
在构建一个任务调度系统时,最常见的起点是 cron 表达式加数据库轮询。这种方式在任务量小、单机部署时工作良好,但随着规模增长,会面临三个核心问题:
- 调度精度与性能的矛盾——高频轮询数据库会迅速成为瓶颈
- Worker 动态扩缩容——静态配置无法应对负载波动
- AI 能力的融入——如何让大模型辅助运维而不破坏调度链路的可靠性
go-ai-scheduler 是我针对这些场景设计的一个分布式调度平台。它不追求极简,而是在工程复杂度与可维护性之间做 trade-off,最终形成了一个四服务架构。这篇文章会拆解它的整体架构、各服务的职责边界,以及关键的设计决策。
四服务架构概览
整个系统拆分为四个独立进程:
| 服务 | 端口 | 核心职责 |
|---|---|---|
api | :8082 | 外部管理 API、Web 控制台、AI 代理 |
scheduler | :8081 / :9090 | 控制平面:触发、重试、分发、Leader 选举 |
worker | :8080 | 任务执行:Shell / HTTP / 容器,心跳上报 |
ai-service | :8083 | LLM Agent、日志分析、调度建议、趋势预测 |
这种拆分不是为了微服务而微服务。scheduler 与 worker 分离是因为它们的生命周期和扩缩容模式完全不同:调度器需要强一致性(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 让系统在小规模部署时足够轻量,在规模扩大时也有清晰的扩展路径。
下一篇会深入调度引擎的内部,拆解时间轮与最小堆的协同工作原理。