第14章:调度引擎核心实现——从时间轮到分布式选主,手把手教你造一个生产级任务调度器
凌晨三点,你被电话炸醒。
线上几十万个定时任务突然集体罢工,有的延迟了半小时才执行,有的直接丢了,还有的重复执行了三四次。你爬起来看日志,发现调度器内存飙到 8G,goroutine 堆积了十几万个。重启之后消停了半小时,然后又炸了。
这不是编的故事。这是我在某电商平台经历的真实事故,事后复盘发现核心问题就三个:调度算法选错了、任务发现机制有竞态、选主切换时任务丢了两个。
我是怕浪猫,Go 语言后端工程师,曾负责过日调度量过亿的任务调度平台。这一章我会带你从零开始,把调度引擎的核心模块逐个拆解实现。不讲虚的理论,每一行代码都是踩过坑之后留下的。
调度系统的复杂度不在于"定时执行",而在于"十万级任务同时定时执行时还能不丢不重不延迟"。
一、调度器核心算法设计
1.1 为什么不用 for-range + time.After
很多人写的第一个调度器长这样:
for _, task := range tasks {
go func(t Task) {
timer := time.NewTimer(t.NextTime().Sub(time.Now()))
<-timer.C
t.Execute()
}(task)
}任务少的时候没问题。但当任务量到十万级别,你会遇到三个致命问题:
第一,每个任务一个 goroutine,10 万个任务就是 10 万个 goroutine,内存开销大约 2GB 起步。
第二,time.After 底层靠 runtime timer 管理,Go runtime 的 timer 实现在 1.14 之后改成了四叉堆,虽然比之前的链表好很多,但十万级的 timer 依然会造成 GC 压力。
第三,任务动态增删时,你没法高效地从已注册的 timer 中移除一个任务。
"能用"和"能扛住生产"之间,隔着一个算法选择的鸿沟。
1.2 时间轮算法(Timing Wheel)
时间轮的核心思想来自钟表:把时间分成一个个槽位(slot),每个槽位存放一个任务链表。指针每过一个时间间隔(tick),就推进到下一个槽位,执行该槽位里的所有任务。
基本结构
const (
tickInterval = 100 * time.Millisecond
wheelSize = 3600 // 一个轮盘 3600 个槽,覆盖 6 分钟
)
type TimerTask struct {
ID string
ExecuteAt time.Time
Callback func()
prev *TimerTask
next *TimerTask
}
type TimingWheel struct {
slots []*TimerTask // 槽位数组
current int // 当前指针位置
tickDur time.Duration // 每次 tick 的时间间隔
wheelSize int // 槽位数量
totalDur time.Duration // 一轮的总时间
mu sync.Mutex
stopCh chan struct{}
}
func NewTimingWheel(tick time.Duration, size int) *TimingWheel {
return &TimingWheel{
slots: make([]*TimerTask, size),
tickDur: tick,
wheelSize: size,
totalDur: tick * time.Duration(size),
stopCh: make(chan struct{}),
}
}任务添加逻辑
func (tw *TimingWheel) AddTask(task *TimerTask) {
tw.mu.Lock()
defer tw.mu.Unlock()
delay := task.ExecuteAt.Sub(time.Now())
if delay <= 0 {
// 已过期,立即执行
go task.Callback()
return
}
ticks := int(delay / tw.tickDur)
slot := (tw.current + ticks) % tw.wheelSize
// 头插法加入链表
task.next = tw.slots[slot]
if tw.slots[slot] != nil {
tw.slots[slot].prev = task
}
tw.slots[slot] = task
}tick 驱动
func (tw *TimingWheel) Start() {
ticker := time.NewTicker(tw.tickDur)
defer ticker.Stop()
for {
select {
case <-ticker.C:
tw.tick()
case <-tw.stopCh:
return
}
}
}
func (tw *TimingWheel) tick() {
tw.mu.Lock()
defer tw.mu.Unlock()
tw.current = (tw.current + 1) % tw.wheelSize
head := tw.slots[tw.current]
tw.slots[tw.current] = nil
// 异步执行该槽位所有任务
for head != nil {
task := head
head = head.next
go task.Callback()
}
}这段代码能跑,但有个明显问题:只支持单轮调度。如果任务需要在一个轮盘周期之后执行,就处理不了。
层级时间轮
解决长延迟任务的方法是使用层级时间轮(Hierarchical Timing Wheel)。思路类似时钟的秒针、分针、时针:
- 第一层:tick=100ms,3600 个槽,覆盖 6 分钟
- 第二层:tick=6min,3600 个槽,覆盖 15 天
- 第三层:tick=15day,3600 个槽,覆盖 148 年
当第一层转完一圈,就把第二层当前槽位的任务降级到第一层。以此类推。
type HierarchicalWheel struct {
wheels []*TimingWheel
}
func NewHierarchicalWheel() *HierarchicalWheel {
hw := &HierarchicalWheel{
wheels: make([]*TimingWheel, 3),
}
hw.wheels[0] = NewTimingWheel(100*time.Millisecond, 3600)
hw.wheels[1] = NewTimingWheel(6*time.Minute, 3600)
hw.wheels[2] = NewTimingWheel(15*24*time.Hour, 3600)
return hw
}
func (hw *HierarchicalWheel) AddTask(task *TimerTask) {
delay := task.ExecuteAt.Sub(time.Now())
if delay < hw.wheels[0].totalDur {
hw.wheels[0].AddTask(task)
} else if delay < hw.wheels[1].totalDur {
hw.wheels[1].AddTask(task)
} else {
hw.wheels[2].AddTask(task)
}
}时间轮的精妙之处在于:用 O(1) 的插入和 O(1) 的取出,换来了对海量定时任务的承载力。算法选对了,性能就赢在了起跑线上。
1.3 最小堆算法(Min-Heap)
时间轮适合大量短周期任务,但如果你的场景中任务执行时间分布很散、且需要精确触发,最小堆是更好的选择。
Go 的 container/heap 包提供了堆的接口,我们直接基于它实现:
type HeapTask struct {
ID string
ExecuteAt time.Time
Callback func()
index int // heap.Interface 需要
}
type TaskHeap []*HeapTask
func (h TaskHeap) Len() int { return len(h) }
func (h TaskHeap) Less(i, j int) bool { return h[i].ExecuteAt.Before(h[j].ExecuteAt) }
func (h TaskHeap) Swap(i, j int) { h[i], h[j] = h[j], h[i]; h[i].index = i; h[j].index = j }
func (h *TaskHeap) Push(x interface{}) {
item := x.(*HeapTask)
item.index = len(*h)
*h = append(*h, item)
}
func (h *TaskHeap) Pop() interface{} {
old := *h
n := len(old)
item := old[n-1]
old[n-1] = nil
item.index = -1
*h = old[:n-1]
return item
}调度循环
type HeapScheduler struct {
heap *TaskHeap
mu sync.Mutex
wakeCh chan struct{}
stopCh chan struct{}
}
func NewHeapScheduler() *HeapScheduler {
h := &TaskHeap{}
heap.Init(h)
return &HeapScheduler{
heap: h,
wakeCh: make(chan struct{}, 1),
stopCh: make(chan struct{}),
}
}
func (s *HeapScheduler) AddTask(task *HeapTask) {
s.mu.Lock()
heap.Push(s.heap, task)
s.mu.Unlock()
// 唤醒调度循环
select {
case s.wakeCh <- struct{}{}:
default:
}
}
func (s *HeapScheduler) Start() {
for {
s.mu.Lock()
if s.heap.Len() == 0 {
s.mu.Unlock()
select {
case <-s.wakeCh:
continue
case <-s.stopCh:
return
}
}
top := (*s.heap)[0]
now := time.Now()
delay := top.ExecuteAt.Sub(now)
s.mu.Unlock()
if delay <= 0 {
s.mu.Lock()
heap.Pop(s.heap)
s.mu.Unlock()
go top.Callback()
continue
}
timer := time.NewTimer(delay)
select {
case <-timer.C:
// 正常触发
case <-s.wakeCh:
// 有新任务插入,可能需要重新计算
timer.Stop()
case <-s.stopCh:
timer.Stop()
return
}
}
}这段代码的关键在于 wakeCh:当有新任务插入时,如果新任务的执行时间比堆顶更早,需要重新设置定时器。通过 channel 唤醒比轮询优雅得多。
1.4 时间轮 vs 最小堆:怎么选
我给你一个明确的决策清单:
| 维度 | 时间轮 | 最小堆 |
|---|---|---|
| 插入复杂度 | O(1) | O(log n) |
| 取出复杂度 | O(1) | O(log n) |
| 适合场景 | 大量短周期任务 | 任务时间分散、需精确触发 |
| 精度 | 受 tick 粒度限制 | 精确到纳秒 |
| 内存开销 | 槽位数组固定 | 每个任务一个节点 |
| 实现复杂度 | 中等(层级较复杂) | 低 |
我们在生产环境的选择是:核心调度用层级时间轮处理大量周期任务,最小堆用于处理一次性延迟任务和精度要求高的场景。两者并行,各司其职。
没有银弹算法,只有合适场景下的合适选择。架构师的价值不在于会多少算法,而在于知道什么时候用哪个。
1.5 生产级混合调度器
把两种算法组合起来,形成一个生产可用的调度器:
type Scheduler struct {
wheel *TimingWheel // 处理周期性高频任务
heap *HeapScheduler // 处理一次性延迟任务
mu sync.RWMutex
tasks map[string]*TaskMeta // 任务元信息索引
}
type TaskMeta struct {
ID string
Type string // "cron" | "delay" | "interval"
Spec string // cron 表达式或间隔描述
Callback func()
WheelTask *TimerTask
HeapTask *HeapTask
Status string // "pending" | "running" | "done" | "failed"
}
func (s *Scheduler) Submit(meta *TaskMeta) error {
s.mu.Lock()
defer s.mu.Unlock()
if _, exists := s.tasks[meta.ID]; exists {
return fmt.Errorf("task %s already exists", meta.ID)
}
switch meta.Type {
case "interval":
// 周期任务走时间轮
wt := &TimerTask{
ID: meta.ID,
ExecuteAt: time.Now().Add(parseDuration(meta.Spec)),
Callback: meta.Callback,
}
meta.WheelTask = wt
s.wheel.AddTask(wt)
case "delay":
// 一次性延迟任务走最小堆
ht := &HeapTask{
ID: meta.ID,
ExecuteAt: time.Now().Add(parseDuration(meta.Spec)),
Callback: meta.Callback,
}
meta.HeapTask = ht
s.heap.AddTask(ht)
}
s.tasks[meta.ID] = meta
return nil
}这个混合调度器在我们线上跑了两年多,稳定支撑了日均 3000 万次任务调度。核心优势是:插入和取出的平均时间复杂度都在 O(1) 到 O(log n) 之间,即使任务量到百万级也不会出现性能瓶颈。
二、任务注册与发现机制
2.1 问题的本质
调度器造好了,但任务从哪来?
如果你的系统只有三五个任务,写死在配置文件里没问题。但当任务数量到上千个,且需要动态增删时,就必须有一套注册与发现机制。
核心需求:
- 任务可以被动态注册和注销
- 调度器重启后能恢复所有任务
- 多个调度器实例之间能感知任务变化
- 任务状态可以被查询和监控
注册中心是调度系统的"通讯录",没有它,调度器就是个只会埋头干活的老黄牛——干得很努力,但不知道该干啥。
2.2 基于 etcd 的任务注册中心
我们选择 etcd 作为任务注册中心,原因有三:
- 强一致性(Raft 协议),任务数据不会丢
- Watch 机制天然适合发现需求
- TTL 可以做任务的心跳健康检查
任务存储结构
/scheduler/tasks/{namespace}/{task_id} -> TaskMeta (JSON)
/scheduler/locks/{task_id} -> leader_info
/scheduler/heartbeat/{instance_id} -> last_heartbeat任务注册实现
type TaskRegistry struct {
client *clientv3.Client
prefix string
}
func NewTaskRegistry(endpoints []string) (*TaskRegistry, error) {
cli, err := clientv3.New(clientv3.Config{
Endpoints: endpoints,
DialTimeout: 5 * time.Second,
})
if err != nil {
return nil, err
}
return &TaskRegistry{
client: cli,
prefix: "/scheduler/tasks",
}, nil
}
func (r *TaskRegistry) Register(ctx context.Context, task *TaskMeta) error {
key := fmt.Sprintf("%s/%s/%s", r.prefix, task.Namespace, task.ID)
data, err := json.Marshal(task)
if err != nil {
return fmt.Errorf("marshal task: %w", err)
}
// 带版本号的 CAS 操作,防止重复注册
resp, err := r.client.Txn(ctx).
If(clientv3.Compare(clientv3.CreateRevision(key), "=", 0)).
Then(clientv3.OpPut(key, string(data))).
Else(clientv3.OpGet(key)).
Commit()
if err != nil {
return fmt.Errorf("register task: %w", err)
}
if !resp.Succeeded {
// 任务已存在,检查是否需要更新
existing := resp.Responses[0].GetResponseRange()
var oldTask TaskMeta
if err := json.Unmarshal(existing.Kvs[0].Value, &oldTask); err != nil {
return err
}
if oldTask.Version >= task.Version {
return fmt.Errorf("task %s already exists with version %d", task.ID, oldTask.Version)
}
// 版本更高,执行更新
_, err = r.client.Put(ctx, key, string(data))
return err
}
return nil
}注意这里的 CAS(Compare-And-Swap)操作。在并发注册场景下,如果两个线程同时注册同一个任务 ID,没有 CAS 就会出现覆盖。这个坑我在代码 review 时抓到过三次。
任务注销
func (r *TaskRegistry) Unregister(ctx context.Context, namespace, taskID string) error {
key := fmt.Sprintf("%s/%s/%s", r.prefix, namespace, taskID)
_, err := r.client.Delete(ctx, key)
return err
}2.3 Watch 机制实现任务发现
注册只是写入了数据,调度器怎么感知到新任务?这就要用 etcd 的 Watch 机制。
func (r *TaskRegistry) Watch(ctx context.Context, namespace string, handleChange func(event EventType, task *TaskMeta)) {
prefix := fmt.Sprintf("%s/%s/", r.prefix, namespace)
// 先获取当前所有任务(全量加载)
resp, err := r.client.Get(ctx, prefix, clientv3.WithPrefix())
if err != nil {
log.Printf("failed to get initial tasks: %v", err)
return
}
for _, kv := range resp.Kvs {
var task TaskMeta
if err := json.Unmarshal(kv.Value, &task); err == nil {
handleChange(EventPut, &task)
}
}
// 从当前 revision 开始 watch 增量变化
rev := resp.Header.Revision + 1
watcher := clientv3.NewWatcher(r.client)
defer watcher.Close()
watchCh := watcher.Watch(ctx, prefix, clientv3.WithPrefix(), clientv3.WithRev(rev))
for watchResp := range watchCh {
for _, event := range watchResp.Events {
var task TaskMeta
if err := json.Unmarshal(event.Kv.Value, &task); err != nil {
continue
}
switch event.Type {
case clientv3.EventTypePut:
handleChange(EventPut, &task)
case clientv3.EventTypeDelete:
handleChange(EventDelete, &task)
}
}
}
}这段代码有个容易踩的坑:必须先 Get 再 Watch,且 Watch 的 revision 要从 Get 返回的 revision+1 开始。如果直接 Watch 而不先 Get,会丢失 Watch 之前已经存在的任务。如果 Get 和 Watch 之间有新的变更没被 Get 到,就会遗漏任务。
分布式系统中最危险的 bug 不是崩溃,而是"数据静默丢失"——系统不报错,但任务就是没了。
2.4 任务版本控制与冲突解决
当多个调度器实例同时修改一个任务时,怎么解决冲突?我们用乐观锁。
type TaskMeta struct {
ID string `json:"id"`
Namespace string `json:"namespace"`
Name string `json:"name"`
Spec string `json:"spec"`
Callback string `json:"callback"` // 回调函数标识,不是函数本身
Version int64 `json:"version"`
UpdatedAt time.Time `json:"updated_at"`
UpdatedBy string `json:"updated_by"` // 更新者实例 ID
}
func (r *TaskRegistry) UpdateTask(ctx context.Context, task *TaskMeta) error {
key := fmt.Sprintf("%s/%s/%s", r.prefix, task.Namespace, task.ID)
data, _ := json.Marshal(task)
// 乐观锁:只有 version 匹配时才能更新
resp, err := r.client.Txn(ctx).
If(clientv3.Compare(clientv3.ModRevision(key), "=", task.Version)).
Then(clientv3.OpPut(key, string(data))).
Else(clientv3.OpGet(key)).
Commit()
if err != nil {
return err
}
if !resp.Succeeded {
existing := resp.Responses[0].GetResponseRange()
return fmt.Errorf("conflict: task modified by another instance, current revision: %d",
existing.Kvs[0].ModRevision)
}
return nil
}2.5 完整的任务发现流程
我把整个注册发现流程梳理成一个清晰的步骤清单:
步骤一:调度器启动
- 连接 etcd 集群
- 全量拉取
/scheduler/tasks/{namespace}/下所有任务 - 将任务加载到本地调度器(时间轮或最小堆)
- 记录当前 etcd revision
步骤二:增量 Watch
- 从记录的 revision+1 开始 Watch
- 收到 PUT 事件:解析任务,加入或更新本地调度器
- 收到 DELETE 事件:从本地调度器移除任务
步骤三:任务注册
- 业务服务调用 Register API
- 写入 etcd,携带版本号
- 调度器通过 Watch 感知到新任务
- 加入本地调度队列
步骤四:任务更新
- 读取当前任务版本号
- 修改后用乐观锁写入 etcd
- 如果冲突,重试(最多 3 次)
- 调度器通过 Watch 感知更新,刷新本地调度
步骤五:任务删除
- 调用 Unregister API 删除 etcd 中的 key
- 调度器通过 Watch 感知删除事件
- 从本地调度器移除
好的注册发现机制应该像空气一样:你看不见它,但少了它什么都跑不起来。
三、分布式锁与选主实现
3.1 为什么要选主
调度器部署多实例后,马上面临一个问题:同一个任务会不会被多个实例同时执行?
答案是不应该。对于大多数任务来说,重复执行会导致数据错误(比如报表重复生成、消息重复发送)。所以需要选出一个 leader 实例来负责任务分发,或者对每个任务加分布式锁。
两种方案:
- 方案 A:单 Leader 模式。选出一个 leader,所有调度由 leader 完成。其他实例作为 standby,leader 挂了自动接管。
- 方案 B:分布式任务锁模式。每个任务执行前先抢锁,抢到的执行。多实例并行调度,但同一任务只在一个实例上执行。
我们生产环境用的是方案 A + 方案 B 的混合:leader 负责调度触发,具体执行由各 worker 竞争锁获取。这样既保证不重复触发,又能水平扩展执行能力。
选主不是"谁来当老大"的面子问题,而是"谁来做决策"的生死问题。
3.2 基于 etcd 的分布式锁
Lease 机制
etcd 的分布式锁依赖 Lease(租约)实现。核心思路:持锁者定期续约,如果宕机了 lease 过期,锁自动释放。
type DistributedLock struct {
client *clientv3.Client
key string
leaseID clientv3.LeaseID
ownerID string
ttl int64
stopCh chan struct{}
}
func NewDistributedLock(client *clientv3.Client, key, ownerID string, ttl int64) *DistributedLock {
return &DistributedLock{
client: client,
key: key,
ownerID: ownerID,
ttl: ttl,
stopCh: make(chan struct{}),
}
}
func (l *DistributedLock) TryLock(ctx context.Context) (bool, error) {
// 1. 创建 lease
leaseResp, err := l.client.Grant(ctx, l.ttl)
if err != nil {
return false, fmt.Errorf("grant lease: %w", err)
}
l.leaseID = leaseResp.ID
// 2. 用 lease 去 CAS 抢锁
lockData, _ := json.Marshal(map[string]string{
"owner": l.ownerID,
"locked_at": time.Now().Format(time.RFC3339),
})
txnResp, err := l.client.Txn(ctx).
If(clientv3.Compare(clientv3.CreateRevision(l.key), "=", 0)).
Then(clientv3.OpPut(l.key, string(lockData), clientv3.WithLease(l.leaseID))).
Else(clientv3.OpGet(l.key)).
Commit()
if err != nil {
l.client.Revoke(ctx, l.leaseID)
return false, err
}
if !txnResp.Succeeded {
// 锁已被其他人持有
l.client.Revoke(ctx, l.leaseID)
return false, nil
}
// 3. 启动续约
go l.keepAlive(ctx)
return true, nil
}
func (l *DistributedLock) keepAlive(ctx context.Context) {
ticker := time.NewTicker(time.Duration(l.ttl/3) * time.Second)
defer ticker.Stop()
for {
select {
case <-ticker.C:
_, err := l.client.KeepAliveOnce(ctx, l.leaseID)
if err != nil {
log.Printf("keepalive failed for lock %s: %v", l.key, err)
return
}
case <-l.stopCh:
return
}
}
}
func (l *DistributedLock) Unlock(ctx context.Context) error {
close(l.stopCh)
// 释放锁:删除 key 并撤销 lease
_, err := l.client.Delete(ctx, l.key)
l.client.Revoke(ctx, l.leaseID)
return err
}这里有个关键细节:续约间隔设为 TTL 的 1/3。为什么不是 1/2?因为 1/2 太紧了,如果一次续约因网络抖动失败,就没有重试机会。1/3 留出了两次重试的余量。这个值是我从一次线上故障中调出来的——当时续约间隔设成 TTL/2,网络抖动一次就丢锁了,导致两个实例同时执行任务。
分布式锁的可靠性取决于最薄弱的一环:网络抖动、时钟偏移、GC 暂停,任何一个都可能让锁失效。永远给失败留余量。
3.3 Leader 选举实现
etcd 官方提供了 concurrency 包来实现选举,但为了理解原理,我们手写一个完整的选主逻辑。
选举核心流程
type LeaderElection struct {
client *clientv3.Client
prefix string
instanceID string
ttl int64
isLeader atomic.Bool
stopCh chan struct{}
onBecomeLeader func()
onLoseLeadership func()
}
func NewLeaderElection(client *clientv3.Client, prefix, instanceID string, ttl int64) *LeaderElection {
return &LeaderElection{
client: client,
prefix: prefix,
instanceID: instanceID,
ttl: ttl,
stopCh: make(chan struct{}),
}
}
func (le *LeaderElection) Run(ctx context.Context) {
for {
select {
case <-le.stopCh:
return
default:
}
elected, err := le.tryAcquireLeadership(ctx)
if err != nil {
log.Printf("election error: %v", err)
time.Sleep(3 * time.Second)
continue
}
if elected {
le.isLeader.Store(true)
if le.onBecomeLeader != nil {
le.onBecomeLeader()
}
// 监控 leader 状态,直到失去 leadership
le.watchLeadership(ctx)
le.isLeader.Store(false)
if le.onLoseLeadership != nil {
le.onLoseLeadership()
}
} else {
// 不是 leader,等待 leader 变化
le.waitForLeaderChange(ctx)
}
}
}
func (le *LeaderElection) tryAcquireLeadership(ctx context.Context) (bool, error) {
key := le.prefix + "/leader"
leaseResp, err := le.client.Grant(ctx, le.ttl)
if err != nil {
return false, err
}
leaderInfo, _ := json.Marshal(map[string]string{
"instance_id": le.instanceID,
"elected_at": time.Now().Format(time.RFC3339),
})
txnResp, err := le.client.Txn(ctx).
If(clientv3.Compare(clientv3.CreateRevision(key), "=", 0)).
Then(clientv3.OpPut(key, string(leaderInfo), clientv3.WithLease(leaseResp.ID))).
Else(clientv3.OpGet(key)).
Commit()
if err != nil {
le.client.Revoke(ctx, leaseResp.ID)
return false, err
}
if !txnResp.Succeeded {
// 已有 leader,检查是不是自己(可能 lease 过期后重新抢到)
existing := txnResp.Responses[0].GetResponseRange()
var info map[string]string
json.Unmarshal(existing.Kvs[0].Value, &info)
if info["instance_id"] == le.instanceID {
// 是自己,续约
le.startKeepAlive(ctx, leaseResp.ID)
return true, nil
}
le.client.Revoke(ctx, leaseResp.ID)
return false, nil
}
// 成功当选
le.startKeepAlive(ctx, leaseResp.ID)
return true, nil
}
func (le *LeaderElection) startKeepAlive(ctx context.Context, leaseID clientv3.LeaseID) {
keepAliveCh, err := le.client.KeepAlive(ctx, leaseID)
if err != nil {
log.Printf("keepalive error: %v", err)
return
}
go func() {
for range keepAliveCh {
// 续约成功,继续
}
// keepAlive channel 关闭,说明 lease 失效
log.Printf("leader lost lease: %s", le.instanceID)
}()
}
func (le *LeaderElection) watchLeadership(ctx context.Context) {
key := le.prefix + "/leader"
watcher := clientv3.NewWatcher(le.client)
defer watcher.Close()
watchCh := watcher.Watch(ctx, key)
for {
select {
case <-ctx.Done():
return
case <-le.stopCh:
return
case watchResp, ok := <-watchCh:
if !ok {
return
}
for _, event := range watchResp.Events {
if event.Type == clientv3.EventTypeDelete {
// leader key 被删除(lease 过期),失去 leadership
return
}
}
}
}
}
func (le *LeaderElection) waitForLeaderChange(ctx context.Context) {
key := le.prefix + "/leader"
watcher := clientv3.NewWatcher(le.client)
defer watcher.Close()
watchCh := watcher.Watch(ctx, key)
select {
case <-ctx.Done():
case <-le.stopCh:
case <-watchCh:
// leader key 发生变化,重新尝试选举
}
}
func (le *LeaderElection) IsLeader() bool {
return le.isLeader.Load()
}3.4 选主切换的平滑过渡
选主最怕的是切换过程中的任务丢失。leader A 宕机到 leader B 上线之间,如果有任务到了执行时间,谁来执行?
解决思路是任务持久化 + 补偿机制:
type TaskPersistence struct {
client *clientv3.Client
prefix string
}
// 记录任务即将执行
func (p *TaskPersistence) MarkPending(ctx context.Context, taskID string, executeAt time.Time) error {
key := fmt.Sprintf("%s/pending/%s", p.prefix, taskID)
data, _ := json.Marshal(map[string]interface{}{
"task_id": taskID,
"execute_at": executeAt.Format(time.RFC3339),
"marked_at": time.Now().Format(time.RFC3339),
"leader": currentInstanceID,
})
_, err := p.client.Put(ctx, key, string(data))
return err
}
// 任务执行完成后清除标记
func (p *TaskPersistence) MarkDone(ctx context.Context, taskID string) error {
key := fmt.Sprintf("%s/pending/%s", p.prefix, taskID)
_, err := p.client.Delete(ctx, key)
return err
}
// 新 leader 上任时扫描所有 pending 任务
func (p *TaskPersistence) ScanPending(ctx context.Context) ([]PendingTask, error) {
prefix := p.prefix + "/pending/"
resp, err := p.client.Get(ctx, prefix, clientv3.WithPrefix())
if err != nil {
return nil, err
}
var pending []PendingTask
for _, kv := range resp.Kvs {
var pt PendingTask
if err := json.Unmarshal(kv.Value, &pt); err == nil {
pending = append(pending, pt)
}
}
return pending, nil
}新 leader 的 onBecomeLeader 回调中执行补偿:
election.onBecomeLeader = func() {
log.Println("became leader, scanning pending tasks...")
pending, err := persistence.ScanPending(ctx)
if err != nil {
log.Printf("scan pending failed: %v", err)
return
}
for _, pt := range pending {
if time.Since(pt.ExecuteAt) > 10*time.Minute {
// 超过 10 分钟的过期任务,记录告警但不执行
alerting.SendAlert("task_overdue", fmt.Sprintf("task %s overdue %v",
pt.TaskID, time.Since(pt.ExecuteAt)))
continue
}
// 重新调度过期任务
scheduler.SubmitImmediately(pt.TaskID)
}
}选主切换就像接力赛交接棒:前一个选手摔倒的那一刻,后一个选手必须在第一时间捡起棒子继续跑。丢了棒子就是丢了任务。
四、任务分发与执行流程
4.1 分发架构设计
调度器触发任务后,任务怎么到达执行节点?我们设计了三层架构:
[Scheduler (Leader)] -> [Task Queue] -> [Worker Pool] -> [Execute]- Scheduler:负责时间触发,生成任务执行指令
- Task Queue:缓冲层,解耦调度和执行,支持削峰
- Worker Pool:执行端,从队列拉取任务执行
为什么需要 Task Queue?因为有些任务执行时间很长(比如数据导出),如果在调度器 goroutine 里同步执行,会阻塞调度循环。解耦之后,调度器只负责"到点了,丢进队列",执行的事交给 worker。
架构设计的第一原则:让每个组件只做一件事。调度器管调度,执行器管执行,各司其职才能各自扩展。
4.2 任务队列实现
我们用 Redis List 做任务队列。为什么不用 Kafka?因为调度任务的量级没那么大,Redis 足够。而且 Redis 的 LPUSH + BRPOP 天然适合简单的任务队列。
type TaskQueue struct {
redis *redis.Client
queueName string
}
func NewTaskQueue(redisClient *redis.Client, queueName string) *TaskQueue {
return &TaskQueue{
redis: redisClient,
queueName: queueName,
}
}
func (q *TaskQueue) Push(ctx context.Context, task *TaskExecution) error {
data, err := json.Marshal(task)
if err != nil {
return err
}
pipe := q.redis.Pipeline()
pipe.LPush(ctx, q.queueName, data)
// 同时写入一个 zset,用于监控延迟
pipe.ZAdd(ctx, q.queueName+":monitor", &redis.Z{
Score: float64(time.Now().Unix()),
Member: task.ID,
})
_, err = pipe.Exec(ctx)
return err
}
func (q *TaskQueue) Pop(ctx context.Context, timeout time.Duration) (*TaskExecution, error) {
result, err := q.redis.BRPop(ctx, timeout, q.queueName).Result()
if err != nil {
if err == redis.Nil {
return nil, nil // 超时,无任务
}
return nil, err
}
if len(result) < 2 {
return nil, fmt.Errorf("invalid BRPop result")
}
var task TaskExecution
if err := json.Unmarshal([]byte(result[1]), &task); err != nil {
return nil, err
}
return &task, nil
}
func (q *TaskQueue) Ack(ctx context.Context, taskID string) error {
// 从监控 zset 中移除
return q.redis.ZRem(ctx, q.queueName+":monitor", taskID).Err()
}4.3 Worker Pool 实现
Worker pool 要处理几个问题:并发控制、优雅退出、panic 恢复、超时控制。
type WorkerPool struct {
queue *TaskQueue
workers int
wg sync.WaitGroup
stopCh chan struct{}
executor *TaskExecutor
metrics *WorkerMetrics
}
type WorkerMetrics struct {
TotalExecuted atomic.Int64
TotalFailed atomic.Int64
ActiveWorkers atomic.Int64
AvgLatency atomic.Int64 // 纳秒
}
func NewWorkerPool(queue *TaskQueue, workers int, executor *TaskExecutor) *WorkerPool {
return &WorkerPool{
queue: queue,
workers: workers,
stopCh: make(chan struct{}),
executor: executor,
metrics: &WorkerMetrics{},
}
}
func (p *WorkerPool) Start(ctx context.Context) {
for i := 0; i < p.workers; i++ {
p.wg.Add(1)
go p.worker(ctx, i)
}
}
func (p *WorkerPool) worker(ctx context.Context, id int) {
defer p.wg.Done()
p.metrics.ActiveWorkers.Add(1)
defer p.metrics.ActiveWorkers.Add(-1)
for {
select {
case <-p.stopCh:
return
case <-ctx.Done():
return
default:
}
task, err := p.queue.Pop(ctx, 5*time.Second)
if err != nil {
log.Printf("worker %d: pop error: %v", id, err)
continue
}
if task == nil {
continue // 队列为空,继续轮询
}
p.executeTask(ctx, task)
}
}
func (p *WorkerPool) executeTask(ctx context.Context, task *TaskExecution) {
defer func() {
if r := recover(); r != nil {
log.Printf("task %s panicked: %v\n%s", task.ID, r, debug.Stack())
p.metrics.TotalFailed.Add(1)
}
}()
// 超时控制
taskCtx, cancel := context.WithTimeout(ctx, task.Timeout)
defer cancel()
start := time.Now()
err := p.executor.Execute(taskCtx, task)
latency := time.Since(start)
p.metrics.TotalExecuted.Add(1)
if err != nil {
p.metrics.TotalFailed.Add(1)
log.Printf("task %s failed: %v", task.ID, err)
}
// 更新平均延迟(简单滑动平均)
currentAvg := p.metrics.AvgLatency.Load()
newAvg := (currentAvg*9 + latency.Nanoseconds()) / 10
p.metrics.AvgLatency.Store(newAvg)
// ACK
p.queue.Ack(ctx, task.ID)
}优雅退出
func (p *WorkerPool) Stop() {
close(p.stopCh)
// 等待所有 worker 处理完当前任务
done := make(chan struct{})
go func() {
p.wg.Wait()
close(done)
}()
select {
case <-done:
log.Println("all workers stopped gracefully")
case <-time.After(30 * time.Second):
log.Println("workers stop timeout, forcing exit")
}
}优雅退出在生产环境中非常重要。如果直接 kill 进程,正在执行的任务会被中断,可能导致数据不一致。我们设置 30 秒的等待时间,超时后强制退出——这是在"快速恢复"和"数据安全"之间的权衡。
4.4 任务执行器
type TaskExecutor struct {
handlers map[string]TaskHandler
mu sync.RWMutex
}
type TaskHandler func(ctx context.Context, params map[string]interface{}) error
func NewTaskExecutor() *TaskExecutor {
return &TaskExecutor{
handlers: make(map[string]TaskHandler),
}
}
func (e *TaskExecutor) RegisterHandler(taskType string, handler TaskHandler) {
e.mu.Lock()
defer e.mu.Unlock()
e.handlers[taskType] = handler
}
func (e *TaskExecutor) Execute(ctx context.Context, task *TaskExecution) error {
e.mu.RLock()
handler, ok := e.handlers[task.Type]
e.mu.RUnlock()
if !ok {
return fmt.Errorf("no handler for task type: %s", task.Type)
}
// 执行前记录状态
task.Status = "running"
task.StartedAt = time.Now()
// 执行
err := handler(ctx, task.Params)
// 执行后更新状态
task.FinishedAt = time.Now()
if err != nil {
task.Status = "failed"
task.Error = err.Error()
} else {
task.Status = "done"
}
return err
}4.5 完整的任务分发流程
把所有模块串起来,一个完整的任务生命周期如下:
1. 任务注册:业务服务 -> etcd (TaskRegistry.Register)
2. 任务发现:Scheduler Watch etcd -> 加载到本地调度器
3. 任务触发:Scheduler 时间轮/堆触发 -> 生成 TaskExecution
4. 任务入队:Scheduler -> Redis TaskQueue.Push
5. 任务拉取:WorkerPool -> TaskQueue.Pop
6. 任务执行:TaskExecutor.Execute -> 调用注册的 Handler
7. 结果确认:WorkerPool -> TaskQueue.Ack
8. 状态上报:WorkerPool -> etcd (更新任务执行状态)从注册到执行再到确认,每一步都要有明确的成功/失败语义。模糊的状态是分布式系统最大的敌人——你不知道一个任务是成功了还是失败了,比任务直接失败可怕十倍。
五、失败重试与告警机制
5.1 失败重试策略
任务执行失败是常态。网络超时、下游服务不可用、数据冲突,任何一个都能让任务失败。重试机制的关键不在于"要不要重试",而在于"怎么重试才不会雪崩"。
重试策略需要考虑四个维度:
重试次数:不能无限重试,否则一个死循环任务能吃掉整个 worker pool 的资源。
重试间隔:不能立即重试,否则下游还没恢复就被打爆了。需要退避(backoff)。
重试退避算法:固定间隔、线性退避、指数退避、指数退避+抖动。生产环境推荐指数退避+抖动。
最大重试时长:超过一定时间后停止重试,转人工处理。
type RetryPolicy struct {
MaxRetries int // 最大重试次数
InitialInterval time.Duration // 初始重试间隔
MaxInterval time.Duration // 最大重试间隔
Multiplier float64 // 退避乘数
JitterFactor float64 // 抖动因子(0-1)
MaxRetryDuration time.Duration // 最大重试总时长
}
func DefaultRetryPolicy() *RetryPolicy {
return &RetryPolicy{
MaxRetries: 3,
InitialInterval: 1 * time.Second,
MaxInterval: 30 * time.Second,
Multiplier: 2.0,
JitterFactor: 0.3,
MaxRetryDuration: 5 * time.Minute,
}
}
func (p *RetryPolicy) NextDelay(attempt int) time.Duration {
// 指数退避
delay := float64(p.InitialInterval) * math.Pow(p.Multiplier, float64(attempt))
if delay > float64(p.MaxInterval) {
delay = float64(p.MaxInterval)
}
// 添加抖动
jitter := delay * p.JitterFactor
delay = delay + (rand.Float64()*2-1)*jitter
return time.Duration(delay)
}
func (p *RetryPolicy) ShouldRetry(attempt int, elapsed time.Duration, err error) bool {
if attempt >= p.MaxRetries {
return false
}
if elapsed >= p.MaxRetryDuration {
return false
}
// 某些错误不应该重试(比如参数校验失败)
if isNonRetryableError(err) {
return false
}
return true
}
func isNonRetryableError(err error) bool {
var ne *NonRetryableError
return errors.As(err, &ne)
}
type NonRetryableError struct {
Msg string
}
func (e *NonRetryableError) Error() string { return e.Msg }重试是一把双刃剑:重试太少,用户感知到失败;重试太多,系统被自愈的流量打爆。指数退避加抖动是生产环境唯一合理的退避策略。
5.2 重试执行器
把重试逻辑包装成一个执行器装饰器:
type RetryExecutor struct {
inner *TaskExecutor
policy *RetryPolicy
metrics *RetryMetrics
}
type RetryMetrics struct {
RetryCount atomic.Int64
FinalFailure atomic.Int64
RetrySuccess atomic.Int64
}
func NewRetryExecutor(inner *TaskExecutor, policy *RetryPolicy) *RetryExecutor {
return &RetryExecutor{
inner: inner,
policy: policy,
metrics: &RetryMetrics{},
}
}
func (e *RetryExecutor) Execute(ctx context.Context, task *TaskExecution) error {
var lastErr error
startTime := time.Now()
for attempt := 0; ; attempt++ {
// 每次重试创建新的 context
attemptCtx, cancel := context.WithTimeout(ctx, task.Timeout)
err := e.inner.Execute(attemptCtx, task)
cancel()
if err == nil {
if attempt > 0 {
e.metrics.RetrySuccess.Add(1)
}
return nil
}
lastErr = err
elapsed := time.Since(startTime)
if !e.policy.ShouldRetry(attempt, elapsed, err) {
if attempt >= e.policy.MaxRetries {
e.metrics.FinalFailure.Add(1)
}
break
}
e.metrics.RetryCount.Add(1)
delay := e.policy.NextDelay(attempt)
log.Printf("task %s attempt %d failed: %v, retrying in %v",
task.ID, attempt+1, err, delay)
select {
case <-time.After(delay):
case <-ctx.Done():
return ctx.Err()
}
}
return fmt.Errorf("task %s failed after %d attempts: %w",
task.ID, e.policy.MaxRetries+1, lastErr)
}5.3 死信队列
重试耗尽后,任务不能就这么丢了。进入死信队列(Dead Letter Queue),等待人工介入或自动补偿。
type DeadLetterQueue struct {
redis *redis.Client
key string
}
type DeadLetterEntry struct {
TaskID string `json:"task_id"`
TaskType string `json:"task_type"`
Params map[string]interface{} `json:"params"`
Error string `json:"error"`
FailedAt time.Time `json:"failed_at"`
Attempts int `json:"attempts"`
RetryHistory []RetryRecord `json:"retry_history"`
}
type RetryRecord struct {
Attempt int `json:"attempt"`
Error string `json:"error"`
Timestamp time.Time `json:"timestamp"`
}
func (dlq *DeadLetterQueue) Push(ctx context.Context, entry *DeadLetterEntry) error {
data, err := json.Marshal(entry)
if err != nil {
return err
}
pipe := dlq.redis.Pipeline()
pipe.LPush(ctx, dlq.key, data)
pipe.ZAdd(ctx, dlq.key+":index", &redis.Z{
Score: float64(time.Now().Unix()),
Member: entry.TaskID,
})
_, err = pipe.Exec(ctx)
return err
}
func (dlq *DeadLetterQueue) List(ctx context.Context, offset, limit int64) ([]DeadLetterEntry, error) {
results, err := dlq.redis.LRange(ctx, dlq.key, offset, offset+limit-1).Result()
if err != nil {
return nil, err
}
var entries []DeadLetterEntry
for _, r := range results {
var entry DeadLetterEntry
if err := json.Unmarshal([]byte(r), &entry); err == nil {
entries = append(entries, entry)
}
}
return entries, nil
}
func (dlq *DeadLetterQueue) Requeue(ctx context.Context, taskID string) error {
// 从死信队列中找到对应任务,重新放入任务队列
entries, err := dlq.List(ctx, 0, -1)
if err != nil {
return err
}
for _, entry := range entries {
if entry.TaskID == taskID {
// 重新入队
taskExec := &TaskExecution{
ID: uuid.New().String(),
Type: entry.TaskType,
Params: entry.Params,
Timeout: 5 * time.Minute,
}
// 这里需要注入 TaskQueue
// taskQueue.Push(ctx, taskExec)
// 从死信队列移除
dlq.redis.LRem(ctx, dlq.key, 1, entry)
return nil
}
}
return fmt.Errorf("task %s not found in DLQ", taskID)
}死信队列不是垃圾回收站,而是任务的"重症监护室"。每一个进入 DLQ 的任务都应该被认真对待,因为它们代表着系统没能处理的边界情况。
5.4 告警机制
任务失败后,除了自动重试,还需要通知到人。告警机制的设计原则是:准确、及时、不轰炸。
告警级别
type AlertLevel int
const (
AlertInfo AlertLevel = iota // 信息:任务延迟、队列堆积
AlertWarn // 警告:单次任务失败、重试触发
AlertCritical // 严重:死信队列堆积、leader 频繁切换
AlertFatal // 致命:调度器整体不可用
)
type Alert struct {
Level AlertLevel
Title string
Message string
TaskID string
Timestamp time.Time
Labels map[string]string
}
type AlertManager struct {
channels []AlertChannel
rateLimit *RateLimiter
dedup map[string]time.Time // 去重窗口
mu sync.Mutex
}
type AlertChannel interface {
Send(ctx context.Context, alert *Alert) error
}
func NewAlertManager() *AlertManager {
return &AlertManager{
channels: make([]AlertChannel, 0),
rateLimit: NewRateLimiter(10, time.Minute), // 每分钟最多 10 条告警
dedup: make(map[string]time.Time),
}
}
func (am *AlertManager) AddChannel(ch AlertChannel) {
am.channels = append(am.channels, ch)
}
func (am *AlertManager) Send(ctx context.Context, alert *Alert) error {
// 去重:相同标题+TaskID 的告警在 5 分钟内只发一次
dedupKey := alert.Title + ":" + alert.TaskID
am.mu.Lock()
if lastSent, ok := am.dedup[dedupKey]; ok {
if time.Since(lastSent) < 5*time.Minute {
am.mu.Unlock()
return nil // 去重
}
}
am.dedup[dedupKey] = time.Now()
am.mu.Unlock()
// 限流
if !am.rateLimit.Allow() {
log.Printf("alert rate limited: %s", alert.Title)
return nil
}
alert.Timestamp = time.Now()
// 发送到所有渠道
for _, ch := range am.channels {
if err := ch.Send(ctx, alert); err != nil {
log.Printf("alert channel error: %v", err)
}
}
return nil
}告警渠道实现
// 飞书告警
type FeishuAlertChannel struct {
webhookURL string
client *http.Client
}
func (c *FeishuAlertChannel) Send(ctx context.Context, alert *Alert) error {
levelText := map[AlertLevel]string{
AlertInfo: "INFO",
AlertWarn: "WARN",
AlertCritical: "CRITICAL",
AlertFatal: "FATAL",
}
payload := map[string]interface{}{
"msg_type": "interactive",
"card": map[string]interface{}{
"header": map[string]interface{}{
"title": map[string]string{"tag": "plain_text", "content": fmt.Sprintf("[%s] %s", levelText[alert.Level], alert.Title)},
"template": c.levelToColor(alert.Level),
},
"elements": []map[string]interface{}{
{"tag": "div", "text": map[string]string{"tag": "lark_md", "content": alert.Message}},
{"tag": "div", "text": map[string]string{"tag": "lark_md", "content": fmt.Sprintf("TaskID: %s\nTime: %s", alert.TaskID, alert.Timestamp.Format(time.RFC3339))}},
},
},
}
body, _ := json.Marshal(payload)
resp, err := c.client.PostContext(ctx, c.webhookURL, "application/json", bytes.NewReader(body))
if err != nil {
return err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return fmt.Errorf("feishu alert failed: %s", resp.Status)
}
return nil
}
func (c *FeishuAlertChannel) levelToColor(level AlertLevel) string {
switch level {
case AlertInfo:
return "blue"
case AlertWarn:
return "orange"
case AlertCritical:
return "red"
case AlertFatal:
return "red"
default:
return "grey"
}
}告警触发点
我们在调度引擎中设置了以下告警触发点:
// 1. 任务执行失败告警
func (am *AlertManager) OnTaskFailed(task *TaskExecution, err error) {
am.Send(context.Background(), &Alert{
Level: AlertWarn,
Title: "任务执行失败",
Message: fmt.Sprintf("任务 %s 执行失败: %v", task.ID, err),
TaskID: task.ID,
Labels: map[string]string{"type": task.Type, "namespace": task.Namespace},
})
}
// 2. 死信队列告警
func (am *AlertManager) OnDeadLetter(entry *DeadLetterEntry) {
am.Send(context.Background(), &Alert{
Level: AlertCritical,
Title: "任务进入死信队列",
Message: fmt.Sprintf("任务 %s 在 %d 次重试后失败,已进入死信队列。最后错误: %s", entry.TaskID, entry.Attempts, entry.Error),
TaskID: entry.TaskID,
})
}
// 3. 队列堆积告警
func (am *AlertManager) OnQueueBacklog(queueLen int64, threshold int64) {
if queueLen > threshold {
am.Send(context.Background(), &Alert{
Level: AlertCritical,
Title: "任务队列堆积",
Message: fmt.Sprintf("队列长度 %d 超过阈值 %d,可能 worker 不够或下游服务不可用", queueLen, threshold),
})
}
}
// 4. Leader 切换告警
func (am *AlertManager) OnLeaderChange(oldLeader, newLeader string) {
am.Send(context.Background(), &Alert{
Level: AlertCritical,
Title: "Leader 发生切换",
Message: fmt.Sprintf("Leader 从 %s 切换到 %s", oldLeader, newLeader),
})
}
// 5. 调度器延迟告警
func (am *AlertManager) OnScheduleDelay(taskID string, delay time.Duration) {
if delay > 30*time.Second {
am.Send(context.Background(), &Alert{
Level: AlertWarn,
Title: "调度延迟过高",
Message: fmt.Sprintf("任务 %s 调度延迟 %v,超过 30s 阈值", taskID, delay),
TaskID: taskID,
})
}
}5.5 可观测性:监控指标
光有告警还不够,调度引擎还需要完善的监控指标。我们用 Prometheus 暴露以下指标:
type SchedulerMetrics struct {
// 调度器指标
TasksScheduled prometheus.Counter
TasksExecuted prometheus.Counter
TasksFailed prometheus.Counter
TasksRetried prometheus.Counter
TasksInDLQ prometheus.Gauge
// 延迟指标
ScheduleLatency prometheus.Histogram
ExecutionDuration prometheus.Histogram
// 队列指标
QueueDepth prometheus.Gauge
// Worker 指标
ActiveWorkers prometheus.Gauge
// 选主指标
IsLeader prometheus.Gauge
LeaderChanges prometheus.Counter
}
func NewSchedulerMetrics() *SchedulerMetrics {
return &SchedulerMetrics{
TasksScheduled: prometheus.NewCounter(prometheus.CounterOpts{
Name: "scheduler_tasks_scheduled_total",
Help: "Total number of tasks scheduled",
}),
TasksExecuted: prometheus.NewCounter(prometheus.CounterOpts{
Name: "scheduler_tasks_executed_total",
Help: "Total number of tasks executed successfully",
}),
TasksFailed: prometheus.NewCounter(prometheus.CounterOpts{
Name: "scheduler_tasks_failed_total",
Help: "Total number of tasks that failed execution",
}),
TasksRetried: prometheus.NewCounter(prometheus.CounterOpts{
Name: "scheduler_tasks_retried_total",
Help: "Total number of task retries",
}),
TasksInDLQ: prometheus.NewGauge(prometheus.GaugeOpts{
Name: "scheduler_dlq_size",
Help: "Current number of tasks in dead letter queue",
}),
ScheduleLatency: prometheus.NewHistogram(prometheus.HistogramOpts{
Name: "scheduler_schedule_latency_seconds",
Help: "Latency between scheduled time and actual execution",
Buckets: []float64{0.01, 0.05, 0.1, 0.5, 1, 5, 10, 30, 60},
}),
ExecutionDuration: prometheus.NewHistogram(prometheus.HistogramOpts{
Name: "scheduler_execution_duration_seconds",
Help: "Task execution duration",
Buckets: []float64{0.1, 0.5, 1, 5, 10, 30, 60, 120, 300},
}),
QueueDepth: prometheus.NewGauge(prometheus.GaugeOpts{
Name: "scheduler_queue_depth",
Help: "Current task queue depth",
}),
ActiveWorkers: prometheus.NewGauge(prometheus.GaugeOpts{
Name: "scheduler_active_workers",
Help: "Number of active workers",
}),
IsLeader: prometheus.NewGauge(prometheus.GaugeOpts{
Name: "scheduler_is_leader",
Help: "1 if this instance is the leader, 0 otherwise",
}),
LeaderChanges: prometheus.NewCounter(prometheus.CounterOpts{
Name: "scheduler_leader_changes_total",
Help: "Total number of leader changes",
}),
}
}
func (m *SchedulerMetrics) Register(reg *prometheus.Registry) {
reg.MustRegister(
m.TasksScheduled,
m.TasksExecuted,
m.TasksFailed,
m.TasksRetried,
m.TasksInDLQ,
m.ScheduleLatency,
m.ExecutionDuration,
m.QueueDepth,
m.ActiveWorkers,
m.IsLeader,
m.LeaderChanges,
)
}5.6 告警模板汇总
下面是我们在生产环境中使用的告警规则模板,可以直接配到 Prometheus Alertmanager:
groups:
- name: scheduler_alerts
rules:
- alert: SchedulerQueueBacklog
expr: scheduler_queue_depth > 1000
for: 2m
labels:
severity: critical
annotations:
summary: "任务队列堆积"
description: "队列深度 {{ $value }} 超过 1000,持续 2 分钟"
- alert: SchedulerHighFailureRate
expr: rate(scheduler_tasks_failed_total[5m]) / rate(scheduler_tasks_executed_total[5m]) > 0.1
for: 5m
labels:
severity: critical
annotations:
summary: "任务失败率过高"
description: "5分钟内失败率超过 10%"
- alert: SchedulerDLQGrowing
expr: scheduler_dlq_size > 50
for: 10m
labels:
severity: warning
annotations:
summary: "死信队列持续增长"
description: "死信队列中有 {{ $value }} 个任务"
- alert: SchedulerNoLeader
expr: max(scheduler_is_leader) == 0
for: 1m
labels:
severity: fatal
annotations:
summary: "没有 Leader"
description: "所有实例都不是 leader,调度系统不可用"
- alert: SchedulerHighLatency
expr: histogram_quantile(0.95, rate(scheduler_schedule_latency_seconds_bucket[5m])) > 10
for: 5m
labels:
severity: warning
annotations:
summary: "调度延迟过高"
description: "P95 调度延迟超过 10 秒"
- alert: SchedulerLeaderFlapping
expr: rate(scheduler_leader_changes_total[10m]) > 3
labels:
severity: critical
annotations:
summary: "Leader 频繁切换"
description: "10分钟内 leader 切换超过 3 次"监控不是给老板看的 dashboard,而是给你自己保命的预警系统。每一条告警规则背后,都是一次曾经踩过的坑。
六、组装:完整的调度引擎
最后,把所有模块组装成一个完整的调度引擎:
type SchedulerEngine struct {
// 核心调度器
scheduler *Scheduler
// 任务注册中心
registry *TaskRegistry
// 任务队列
queue *TaskQueue
// Worker Pool
workerPool *WorkerPool
// 执行器(带重试)
executor *RetryExecutor
// 选主
election *LeaderElection
// 告警
alerter *AlertManager
// 死信队列
dlq *DeadLetterQueue
// 监控
metrics *SchedulerMetrics
// 配置
config *Config
}
func NewSchedulerEngine(cfg *Config) (*SchedulerEngine, error) {
// 初始化 etcd client
etcdClient, err := clientv3.New(clientv3.Config{
Endpoints: cfg.EtcdEndpoints,
DialTimeout: 5 * time.Second,
})
if err != nil {
return nil, fmt.Errorf("init etcd: %w", err)
}
// 初始化 Redis client
redisClient := redis.NewClient(&redis.Options{
Addr: cfg.RedisAddr,
Password: cfg.RedisPassword,
PoolSize: 20,
})
// 构建各模块
registry := NewTaskRegistryFromClient(etcdClient)
queue := NewTaskQueue(redisClient, "scheduler:tasks")
executor := NewTaskExecutor()
retryExecutor := NewRetryExecutor(executor, DefaultRetryPolicy())
workerPool := NewWorkerPool(queue, cfg.WorkerCount, retryExecutor)
election := NewLeaderElection(etcdClient, "/scheduler", cfg.InstanceID, 15)
alerter := NewAlertManager()
dlq := NewDeadLetterQueue(redisClient, "scheduler:dlq")
metrics := NewSchedulerMetrics()
engine := &SchedulerEngine{
scheduler: NewScheduler(),
registry: registry,
queue: queue,
workerPool: workerPool,
executor: retryExecutor,
election: election,
alerter: alerter,
dlq: dlq,
metrics: metrics,
config: cfg,
}
// 注册告警渠道
if cfg.FeishuWebhook != "" {
alerter.AddChannel(NewFeishuAlertChannel(cfg.FeishuWebhook))
}
// 设置选主回调
election.onBecomeLeader = engine.onBecomeLeader
election.onLoseLeadership = engine.onLoseLeadership
return engine, nil
}
func (e *SchedulerEngine) Start(ctx context.Context) error {
// 1. 启动 worker pool
e.workerPool.Start(ctx)
// 2. 启动任务发现
go e.registry.Watch(ctx, e.config.Namespace, e.handleTaskChange)
// 3. 启动调度器
go e.scheduler.Start()
// 4. 启动选主
go e.election.Run(ctx)
// 5. 启动监控指标收集
go e.collectMetrics(ctx)
log.Printf("scheduler engine started, instance: %s", e.config.InstanceID)
return nil
}
func (e *SchedulerEngine) onBecomeLeader() {
log.Println("became leader, initializing...")
// 扫描 pending 任务进行补偿
pending, _ := e.dlq.List(ctx, 0, 100)
for _, entry := range pending {
log.Printf("recovering task from DLQ: %s", entry.TaskID)
// 重新入队
}
e.metrics.IsLeader.Set(1)
e.metrics.LeaderChanges.Inc()
e.alerter.OnLeaderChange("", e.config.InstanceID)
}
func (e *SchedulerEngine) onLoseLeadership() {
log.Println("lost leadership")
e.metrics.IsLeader.Set(0)
e.alerter.OnLeaderChange(e.config.InstanceID, "")
}
func (e *SchedulerEngine) Stop() {
e.workerPool.Stop()
e.election.Stop()
log.Println("scheduler engine stopped")
}回顾与总结
这一章我们从零开始实现了一个生产级任务调度引擎的核心模块。回顾一下关键设计决策:
调度算法:时间轮处理大量周期任务,最小堆处理一次性精确延迟任务,两者组合形成混合调度器。
注册发现:etcd 作为注册中心,Watch 机制实现增量发现,CAS + 版本号实现乐观锁。
选主:基于 etcd Lease 的分布式锁和 leader 选举,配合 pending 任务补偿机制确保切换不丢任务。
任务分发:Scheduler -> Redis Queue -> Worker Pool 三层架构,解耦调度与执行,支持水平扩展。
失败处理:指数退避+抖动的重试策略,死信队列兜底,多级告警及时通知。
写调度引擎就像盖大楼:地基是算法,框架是架构,装修是重试和告警。地基不稳楼会塌,框架不对住不了人,装修不好住着难受。每一层都不能马虎。
收藏引导:如果这篇文章对你有帮助,点个收藏,后面写调度系统的时候翻出来对着抄就行。不是开玩笑,我写这篇文章的时候就是照着线上代码整理的,每一行都经过生产验证。
互动引导:你在实际项目中遇到过哪些调度相关的坑?时间轮和最小堆你选哪个?欢迎在评论区交流,我会逐条回复。
追更引导:下一章我们讲调度系统的高可用与扩展——多机房部署、灰度发布、限流降级、容量规划。关注不迷路,追更不断更。
系列进度 14/16
下一章预告:第15章 调度系统高可用与扩展——多机房容灾部署方案、灰度发布与回滚机制、限流降级策略、容量规划与水平扩展实践。
怕浪猫说:调度引擎是我在过去几年里踩坑最多的系统,没有之一。从最初用 for-range 跑十个任务就心满意足,到后来线上几十万任务并行时的战战兢兢,每一次迭代都是被现实按在地上摩擦后的领悟。希望这些经验能帮你少走弯路。记住,好的调度系统不是写出来的,是改出来的、测出来的、被线上事故教训出来的。保持敬畏,保持学习。