第15章:调度系统高可用与扩展——从单机到分布式的生产级实践
凌晨三点,你被电话炸醒。线上调度系统挂了,几十万个定时任务全部堆积,核心业务流程断裂。你爬起来登录服务器,发现是单点调度节点OOM了。重启之后,任务洪水般的涌入,又把机器打挂了。这种"一挂全挂、一恢复就雪崩"的困境,是每一个调度系统走向生产级必须迈过的坎。
我是怕浪猫,一个在分布式调度坑里摸爬滚打多年的老兵。从前几年用crontab裸跑到后来基于各种开源调度框架做二次开发,我踩过的坑可能比你写过的代码都多。今天这一章,我把调度系统高可用与扩展的实战经验一次性讲透,从架构设计到分片策略,从动态扩缩容到监控运维,全是生产环境验证过的干货。
调度系统的高可用不是"加个备份"那么简单,而是从架构层面消灭单点、从策略层面消化故障、从运维层面感知异常。
一、调度系统高可用架构
1.1 为什么单机调度是定时炸弹
我见过太多团队用一台机器跑crontab或者单个调度进程,觉得"任务不多,够用了"。但生产环境永远会给你惊喜:
- 调度进程OOM崩溃,所有任务停止执行
- 机器硬件故障,调度数据全部丢失
- 网络抖动导致任务重复触发,业务数据被污染
- 任务堆积导致雪崩,恢复后无法承接突发流量
单机调度系统的可靠性上限就是那台机器的MTBF(平均无故障时间),而生产环境要求的是系统级的高可用。
1.2 多节点高可用架构设计
核心思路:调度节点多实例部署,通过分布式锁保证同一任务在同一时刻只被一个节点执行。
+------------------+
| 配置中心/DB |
| (任务定义+调度规则)|
+--------+---------+
|
+--------------+--------------+
| | |
+-----+----+ +-----+----+ +-----+----+
| 调度节点A | | 调度节点B | | 调度节点C |
+-----+----+ +-----+----+ +-----+----+
| | |
+--------------+--------------+
|
+--------+---------+
| 执行器集群(Worker) |
+------------------+先看调度节点的核心选主逻辑:
package scheduler
import (
"context"
"fmt"
"time"
"go.etcd.io/etcd/client/v3/concurrency"
)
// LeaderElector 选主管理器
type LeaderElector struct {
client *clientv3.Client
nodeID string
isLeader bool
onLeader func()
onFollower func()
cancel context.CancelFunc
}
func NewLeaderElector(client *clientv3.Client, nodeID string) *LeaderElector {
return &LeaderElector{
client: client,
nodeID: nodeID,
}
}
func (le *LeaderElector) Start(ctx context.Context) error {
ctx, le.cancel = context.WithCancel(ctx)
session, err := concurrency.NewSession(le.client,
concurrency.WithTTL(10))
if err != nil {
return fmt.Errorf("create session failed: %w", err)
}
election := concurrency.NewElection(session, "/scheduler/leader")
go func() {
for {
select {
case <-ctx.Done():
session.Close()
return
default:
// 尝试竞选
if err := election.Campaign(ctx, le.nodeID); err != nil {
fmt.Printf("campaign failed: %v, retry...\n", err)
time.Sleep(3 * time.Second)
continue
}
le.isLeader = true
fmt.Printf("node %s became leader\n", le.nodeID)
if le.onLeader != nil {
le.onLeader()
}
// 等待session过期或失去leader身份
<-session.Done()
le.isLeader = false
if le.onFollower != nil {
le.onFollower()
}
fmt.Printf("node %s lost leader, re-campaign...\n", le.nodeID)
// 重新建立session
session, err = concurrency.NewSession(le.client,
concurrency.WithTTL(10))
if err != nil {
fmt.Printf("recreate session failed: %v\n", err)
time.Sleep(3 * time.Second)
continue
}
election = concurrency.NewElection(session, "/scheduler/leader")
}
}
}()
return nil
}
func (le *LeaderElector) IsLeader() bool {
return le.isLeader
}
func (le *LeaderElector) Stop() {
if le.cancel != nil {
le.cancel()
}
}选主不是目的,无缝故障转移才是。Leader挂了到新Leader接管,这个时间窗口决定了对业务的影响程度。
1.3 分布式锁实现任务互斥
选主解决了"谁来调度"的问题,但有些场景下我们希望所有节点都能调度,只是同一任务不能被多个节点同时触发。这就需要分布式锁:
package scheduler
import (
"context"
"fmt"
"time"
"go.etcd.io/etcd/client/v3"
)
// DistributedLock 基于etcd的分布式锁
type DistributedLock struct {
client *clientv3.Client
lease clientv3.Lease
leaseID clientv3.LeaseID
key string
ttl int64
}
func NewDistributedLock(client *clientv3.Client, key string, ttl int64) *DistributedLock {
return &DistributedLock{
client: client,
key: key,
ttl: ttl,
}
}
// TryLock 尝试获取锁,非阻塞
func (dl *DistributedLock) TryLock(ctx context.Context) (bool, error) {
// 创建lease
lease, err := dl.client.Grant(ctx, dl.ttl)
if err != nil {
return false, fmt.Errorf("grant lease failed: %w", err)
}
dl.leaseID = lease.ID
// 原子性的CreateIfNotExists
txn := dl.client.Txn(ctx).
If(clientv3.Compare(clientv3.CreateRevision(dl.key), "=", 0)).
Then(clientv3.OpPut(dl.key, "locked", clientv3.WithLease(dl.leaseID))).
Else(clientv3.OpGet(dl.key))
resp, err := txn.Commit()
if err != nil {
dl.client.Revoke(ctx, dl.leaseID)
return false, fmt.Errorf("txn commit failed: %w", err)
}
if !resp.Succeeded {
dl.client.Revoke(ctx, dl.leaseID)
return false, nil
}
// 启动keepalive
go dl.keepAlive(ctx)
return true, nil
}
// Lock 阻塞式获取锁
func (dl *DistributedLock) Lock(ctx context.Context) error {
for {
ok, err := dl.TryLock(ctx)
if err != nil {
return err
}
if ok {
return nil
}
select {
case <-ctx.Done():
return ctx.Err()
case <-time.After(500 * time.Millisecond):
}
}
}
// Unlock 释放锁
func (dl *DistributedLock) Unlock(ctx context.Context) error {
_, err := dl.client.Revoke(ctx, dl.leaseID)
return err
}
func (dl *DistributedLock) keepAlive(ctx context.Context) {
ticker := time.NewTicker(time.Duration(dl.ttl/3) * time.Second)
defer ticker.Stop()
for {
select {
case <-ctx.Done():
return
case <-ticker.C:\n dl.client.KeepAliveOnce(ctx, dl.leaseID)\n }\n }\n}\n```\n\n### 1.4 故障转移策略\n\n当调度节点宕机时,需要做到秒级故障转移。这里的关键是心跳检测和任务接管:\n\n```go\npackage scheduler
import (
"context"
"fmt"
"time"
)
// FailoverManager 故障转移管理器
type FailoverManager struct {
etcdClient *clientv3.Client
nodeID string
heartbeatInterval time.Duration
heartbeatTTL int64
}
// RegisterNode 注册调度节点
func (fm *FailoverManager) RegisterNode(ctx context.Context) error {
key := fmt.Sprintf("/scheduler/nodes/%s", fm.nodeID)
lease, err := fm.etcdClient.Grant(ctx, fm.heartbeatTTL)
if err != nil {
return err
}
_, err = fm.etcdClient.Put(ctx, key,
fmt.Sprintf(`{"node_id":"%s","status":"active","registered_at":"%s"}`,
fm.nodeID, time.Now().Format(time.RFC3339)),
clientv3.WithLease(lease.ID))
if err != nil {
return err
}
// 心跳保活
go func() {
ticker := time.NewTicker(fm.heartbeatInterval)
defer ticker.Stop()
for {
select {
case <-ctx.Done():
fm.etcdClient.Revoke(ctx, lease.ID)
return
case <-ticker.C:\n fm.etcdClient.KeepAliveOnce(ctx, lease.ID)\n }\n }\n }()\n \n return nil\n}\n\n// WatchNodes 监听节点变化,执行故障转移\nfunc (fm *FailoverManager) WatchNodes(ctx context.Context, onNodeDown func(nodeID string)) {
watchCh := fm.etcdClient.Watch(ctx, "/scheduler/nodes/", clientv3.WithPrefix())
go func() {
for watchResp := range watchCh {
for _, ev := range watchResp.Events {
if ev.Type == clientv3.EventTypeDelete {
// 节点心跳过期,触发故障转移
downNodeID := extractNodeID(string(ev.Kv.Key))
fmt.Printf("[FAILOVER] node %s is down, triggering failover\n", downNodeID)
// 查找该节点上正在执行的任务
fm.reassignTasks(ctx, downNodeID)
if onNodeDown != nil {
onNodeDown(downNodeID)
}
}
}
}
}()
}
// reassignTasks 重新分配故障节点上的任务
func (fm *FailoverManager) reassignTasks(ctx context.Context, downNodeID string) {
// 查找故障节点上分配的任务
resp, err := fm.etcdClient.Get(ctx,
fmt.Sprintf("/scheduler/assignments/%s/", downNodeID),
clientv3.WithPrefix())
if err != nil {
fmt.Printf("get assignments failed: %v\n", err)
return
}
for _, kv := range resp.Kvs {
taskID := extractTaskID(string(kv.Key))
// 删除旧分配
fm.etcdClient.Delete(ctx, string(kv.Key))
// 重新分配到健康的节点
fm.reassignToHealthyNode(ctx, taskID)
}
}
func (fm *FailoverManager) reassignToHealthyNode(ctx context.Context, taskID string) {
// 获取健康节点列表
resp, err := fm.etcdClient.Get(ctx, "/scheduler/nodes/", clientv3.WithPrefix())
if err != nil || len(resp.Kvs) == 0 {
fmt.Printf("no healthy nodes available for task %s\n", taskID)
return
}
// 简单的负载均衡:选择任务数最少的节点
targetNode := fm.selectLeastLoadedNode(ctx, resp.Kvs)
// 写入新的分配关系
assignKey := fmt.Sprintf("/scheduler/assignments/%s/%s", targetNode, taskID)
fm.etcdClient.Put(ctx, assignKey, time.Now().Format(time.RFC3339))
fmt.Printf("[FAILOVER] task %s reassigned to node %s\n", taskID, targetNode)
}
func (fm *FailoverManager) selectLeastLoadedNode(ctx context.Context, nodes []clientv3.KeyValue) string {
minLoad := int64(1<<63 - 1)
targetNode := ""
for _, node := range nodes {
nodeID := extractNodeID(string(node.Key))
// 统计该节点当前的任务数
resp, _ := fm.etcdClient.Get(ctx,
fmt.Sprintf("/scheduler/assignments/%s/", nodeID),
clientv3.WithPrefix(), clientv3.WithCountOnly())
if resp.Count < minLoad {
minLoad = resp.Count
targetNode = nodeID
}
}
return targetNode
}故障转移的核心不是"发现故障",而是"转移状态"。任务状态、执行上下文、幂等保障,缺一不可。
1.5 任务幂等性保障
故障转移后,任务可能在两个节点上各执行一次。幂等性是兜底保障:
package scheduler
import (
"context"
"fmt"
"time"
)
// IdempotencyManager 幂等管理器
type IdempotencyManager struct {
etcdClient *clientv3.Client
}
// ExecuteWithIdempotency 幂等执行
func (im *IdempotencyManager) ExecuteWithIdempotency(
ctx context.Context,
taskID string,
triggerTime time.Time,
executeFunc func() error,
) error {
// 生成幂等key: taskID + 触发时间(精确到秒)
idempotencyKey := fmt.Sprintf("/scheduler/idempotency/%s/%d",
taskID, triggerTime.Unix())
// 尝试创建幂等记录
txn := im.etcdClient.Txn(ctx).
If(clientv3.Compare(clientv3.CreateRevision(idempotencyKey), "=", 0)).
Then(clientv3.OpPut(idempotencyKey, "running", clientv3.WithLease(3600))).
Else(clientv3.OpGet(idempotencyKey))
resp, err := txn.Commit()
if err != nil {
return fmt.Errorf("idempotency check failed: %w", err)
}
if !resp.Succeeded {
// 已经有其他节点在执行或已执行完成
state := string(resp.Responses[0].GetResponseRange().Kvs[0].Value)
if state == "completed" {
fmt.Printf("task %s already completed by another node, skip\n", taskID)
return nil
}
if state == "running" {
// 检查是否超时(执行节点可能挂了)
fmt.Printf("task %s is running on another node, skip\n", taskID)
return nil
}
}
// 执行任务
err = executeFunc()
// 更新状态
finalState := "completed"
if err != nil {
finalState = "failed"
// 执行失败时删除幂等记录,允许重试
im.etcdClient.Delete(ctx, idempotencyKey)
return err
}
im.etcdClient.Put(ctx, idempotencyKey, finalState, clientv3.WithLease(86400))
return nil
}二、任务分片与并行执行
2.1 为什么需要分片
我接过一个需求:每天凌晨处理3000万条用户数据,单线程跑要6个小时。业务方要求2小时内跑完。加机器?单任务没法并行。拆任务?改业务代码成本太高。
任务分片就是解决这类问题的利器:把一个大任务拆成多个子任务,分配到不同节点并行执行。
分片不是把任务切小,而是把时间切短。3000万条数据拆成10片,每片300万,10台机器同时跑,理论耗时降到原来的1/10。
2.2 分片策略设计
package scheduler
import (
"fmt"
"hash/fnv"
)
// ShardingStrategy 分片策略接口
type ShardingStrategy interface {
// 计算分片:返回分片索引和总分片数
Shard(key string, totalShards int) int
}
// HashSharding 哈希分片
type HashSharding struct{}
func (h *HashSharding) Shard(key string, totalShards int) int {
hash := fnv.New32a()
hash.Write([]byte(key))
return int(hash.Sum32()) % totalShards
}
// RangeSharding 范围分片
type RangeSharding struct {
MinValue int64
MaxValue int64
}
func (r *RangeSharding) Shard(key string, totalShards int) int {
// 按ID范围分片
var value int64
fmt.Sscanf(key, "%d", &value)
rangeSize := (r.MaxValue - r.MinValue) / int64(totalShards)
if rangeSize == 0 {
return 0
}
return int((value - r.MinValue) / rangeSize)
}
// ConsistentHashSharding 一致性哈希分片
type ConsistentHashSharding struct {
ring *ConsistentHashRing
}
func NewConsistentHashSharding(nodes []string, virtualNodes int) *ConsistentHashSharding {
ring := NewConsistentHashRing(virtualNodes)
for _, node := range nodes {
ring.AddNode(node)
}
return &ConsistentHashSharding{ring: ring}
}
func (c *ConsistentHashSharding) Shard(key string, totalShards int) int {
node := c.ring.GetNode(key)
// 将节点映射到分片索引
hash := fnv.New32a()
hash.Write([]byte(node))
return int(hash.Sum32()) % totalShards
}2.3 分片任务执行框架
package scheduler
import (
"context"
"fmt"
"sync"
"time"
)
// ShardedTask 分片任务定义
type ShardedTask struct {
TaskID string
Name string
TotalShards int
ShardingKey string // 分片字段名
ExecuteFunc func(ctx context.Context, shardIndex, totalShards int) error
Timeout time.Duration
RetryCount int
}
// ShardedTaskExecutor 分片任务执行器
type ShardedTaskExecutor struct {
etcdClient *clientv3.Client
nodeID string
}
// ExecuteShardedTask 执行分片任务
func (e *ShardedTaskExecutor) ExecuteShardedTask(
ctx context.Context,
task *ShardedTask,
) error {
// 获取当前节点负责的分片
myShards := e.getAssignedShards(ctx, task.TaskID, task.TotalShards)
fmt.Printf("node %s assigned shards: %v\n", e.nodeID, myShards)
if len(myShards) == 0 {
fmt.Printf("node %s has no shards for task %s\n", e.nodeID, task.TaskID)
return nil
}
var wg sync.WaitGroup
errChan := make(chan error, len(myShards))
for _, shardIndex := range myShards {
wg.Add(1)
go func(idx int) {
defer wg.Done()
shardCtx, cancel := context.WithTimeout(ctx, task.Timeout)
defer cancel()
// 幂等执行单个分片
err := e.executeShardWithRetry(shardCtx, task, idx)
if err != nil {
errChan <- fmt.Errorf("shard %d failed: %w", idx, err)
}
}(shardIndex)
}
// 等待所有分片完成
go func() {
wg.Wait()
close(errChan)
}()
// 收集错误
var errs []error
for err := range errChan {
errs = append(errs, err)
}
if len(errs) > 0 {
return fmt.Errorf("task %s completed with %d shard failures, first error: %w",
task.TaskID, len(errs), errs[0])
}
fmt.Printf("task %s all shards completed successfully\n", task.TaskID)
return nil
}
// getAssignedShards 获取当前节点负责的分片
func (e *ShardedTaskExecutor) getAssignedShards(
ctx context.Context,
taskID string,
totalShards int,
) []int {
// 获取所有活跃节点
resp, err := e.etcdClient.Get(ctx, "/scheduler/nodes/", clientv3.WithPrefix())
if err != nil {
return nil
}
var nodes []string
for _, kv := range resp.Kvs {
nodes = append(nodes, extractNodeID(string(kv.Key)))
}
if len(nodes) == 0 {
return nil
}
// 使用一致性哈希分配分片
myShards := []int{}
for i := 0; i < totalShards; i++ {
shardKey := fmt.Sprintf("%s-shard-%d", taskID, i)
assignedNode := consistentHash(shardKey, nodes)
if assignedNode == e.nodeID {
myShards = append(myShards, i)
}
}
return myShards
}
// executeShardWithRetry 带重试的分片执行
func (e *ShardedTaskExecutor) executeShardWithRetry(
ctx context.Context,
task *ShardedTask,
shardIndex int,
) error {
var lastErr error
for attempt := 0; attempt <= task.RetryCount; attempt++ {
if attempt > 0 {
fmt.Printf("shard %d retry attempt %d/%d\n", shardIndex, attempt, task.RetryCount)
time.Sleep(time.Duration(attempt*attempt) * time.Second)
}
err := task.ExecuteFunc(ctx, shardIndex, task.TotalShards)
if err == nil {
return nil
}
lastErr = err
fmt.Printf("shard %d attempt %d failed: %v\n", shardIndex, attempt, err)
}
return lastErr
}分片执行最容易被忽视的问题:分片不均匀。某个分片数据量远超其他分片,整体耗时被最慢的分片拖死。
2.4 分片均衡与动态调整
package scheduler
import (
"context"
"fmt"
"sync"
"time"
)
// ShardRebalancer 分片再均衡器
type ShardRebalancer struct {
etcdClient *clientv3.Client
checkInterval time.Duration
threshold float64 // 不均衡阈值
}
// Rebalance 分片再均衡
func (r *ShardRebalancer) Rebalance(ctx context.Context) {
ticker := time.NewTicker(r.checkInterval)
defer ticker.Stop()
for {
select {
case <-ctx.Done():
return
case <-ticker.C:\n r.doRebalance(ctx)\n }\n }\n}\n\nfunc (r *ShardRebalancer) doRebalance(ctx context.Context) {
// 收集各节点的分片负载
nodeLoad := make(map[string]int64)
resp, err := r.etcdClient.Get(ctx, "/scheduler/load/", clientv3.WithPrefix())
if err != nil {
return
}
for _, kv := range resp.Kvs {
nodeID := extractNodeID(string(kv.Key))
load := parseLoadFromValue(string(kv.Value))
nodeLoad[nodeID] = load
}
if len(nodeLoad) < 2 {
return
}
// 计算不均衡度
maxLoad, minLoad := int64(0), int64(1<<63-1)
var totalLoad int64
for _, load := range nodeLoad {
if load > maxLoad {
maxLoad = load
}
if load < minLoad {
minLoad = load
}
totalLoad += load
}
avgLoad := totalLoad / int64(len(nodeLoad))
if avgLoad == 0 {
return
}
imbalance := float64(maxLoad-minLoad) / float64(avgLoad)
if imbalance <= r.threshold {
return // 负载均衡,无需调整
}
fmt.Printf("[REBALANCE] imbalance detected: %.2f, rebalancing...\n", imbalance)
// 从高负载节点迁移分片到低负载节点
r.migrateShards(ctx, nodeLoad, avgLoad)
}
func (r *ShardRebalancer) migrateShards(
ctx context.Context,
nodeLoad map[string]int64,
avgLoad int64,
) {
type migration struct {
fromNode string
toNode string
shardID string
}
var migrations []migration
// 找出高负载和低负载节点
var overloaded, underloaded []string
for node, load := range nodeLoad {
if load > int64(float64(avgLoad)*1.3) {
overloaded = append(overloaded, node)
} else if load < int64(float64(avgLoad)*0.7) {
underloaded = append(underloaded, node)
}
}
// 计算迁移计划
for _, fromNode := range overloaded {
// 获取该节点的分片
resp, _ := r.etcdClient.Get(ctx,
fmt.Sprintf("/scheduler/assignments/%s/", fromNode),
clientv3.WithPrefix())
for _, kv := range resp.Kvs {
if len(underloaded) == 0 {
break
}
shardID := extractTaskID(string(kv.Key))
toNode := underloaded[0]
migrations = append(migrations, migration{
fromNode: fromNode,
toNode: toNode,
shardID: shardID,
})
// 更新负载估算
nodeLoad[fromNode]--
nodeLoad[toNode]++
// 如果目标节点已经达到平均负载,换下一个
if nodeLoad[toNode] >= avgLoad {
underloaded = underloaded[1:]
}
}
}
// 执行迁移
var wg sync.WaitGroup
for _, m := range migrations {
wg.Add(1)
go func(mig migration) {
defer wg.Done()
oldKey := fmt.Sprintf("/scheduler/assignments/%s/%s", mig.fromNode, mig.shardID)
newKey := fmt.Sprintf("/scheduler/assignments/%s/%s", mig.toNode, mig.shardID)
// 原子性迁移
txn := r.etcdClient.Txn(ctx).
If(clientv3.Compare(clientv3.CreateRevision(oldKey), ">", 0)).
Then(
clientv3.OpDelete(oldKey),
clientv3.OpPut(newKey, "migrated"),
)
_, err := txn.Commit()
if err != nil {
fmt.Printf("migrate shard %s from %s to %s failed: %v\n",
mig.shardID, mig.fromNode, mig.toNode, err)
} else {
fmt.Printf("[REBALANCE] migrated shard %s: %s -> %s\n",
mig.shardID, mig.fromNode, mig.toNode)
}
}(m)
}
wg.Wait()
}2.5 分片任务的Barrier机制
有些分片任务需要分阶段执行:所有分片完成阶段一后,才能开始阶段二。这就是Barrier机制:
package scheduler
import (
"context"
"fmt"
"sync"
"time"
)
// BarrierManager 阶段屏障管理器
type BarrierManager struct {
etcdClient *clientv3.Client
}
// WaitForAllShards 等待所有分片完成当前阶段
func (bm *BarrierManager) WaitForAllShards(
ctx context.Context,
taskID string,
stage int,
totalShards int,
timeout time.Duration,
) error {
barrierKey := fmt.Sprintf("/scheduler/barrier/%s/stage-%d", taskID, stage)
// 当前分片到达barrier,注册完成信号
// 实际使用时在分片执行完成后调用
doneKey := fmt.Sprintf("%s/done", barrierKey)
ctx, cancel := context.WithTimeout(ctx, timeout)
defer cancel()
// 轮询检查是否所有分片都已完成
ticker := time.NewTicker(2 * time.Second)
defer ticker.Stop()
for {
select {
case <-ctx.Done():
return fmt.Errorf("barrier wait timeout for task %s stage %d", taskID, stage)
case <-ticker.C:\n resp, err := bm.etcdClient.Get(ctx, doneKey, clientv3.WithPrefix())
if err != nil {
continue
}
if len(resp.Kvs) >= totalShards {
fmt.Printf("[BARRIER] all %d shards reached stage %d\n",
totalShards, stage)
return nil
}
completed := len(resp.Kvs)
fmt.Printf("[BARRIER] stage %d: %d/%d shards completed, waiting...\n",
stage, completed, totalShards)
}
}
}
// SignalShardComplete 分片完成信号
func (bm *BarrierManager) SignalShardComplete(
ctx context.Context,
taskID string,
stage int,
shardIndex int,
) error {
doneKey := fmt.Sprintf("/scheduler/barrier/%s/stage-%d/done/%d",
taskID, stage, shardIndex)
lease, err := bm.etcdClient.Grant(ctx, 3600)
if err != nil {
return err
}
_, err = bm.etcdClient.Put(ctx, doneKey, "1", clientv3.WithLease(lease.ID))
return err
}
// PipelineShardedTask 流水线分片任务
type PipelineShardedTask struct {
TaskID string
TotalShards int
Stages []PipelineStage
}
type PipelineStage struct {
Name string
ExecuteFunc func(ctx context.Context, shardIndex, totalShards int) error
Timeout time.Duration
}
// ExecutePipeline 执行流水线分片任务
func (e *ShardedTaskExecutor) ExecutePipeline(
ctx context.Context,
task *PipelineShardedTask,
barrier *BarrierManager,
) error {
for stageIdx, stage := range task.Stages {
fmt.Printf("[PIPELINE] task %s starting stage %d: %s\n",
task.TaskID, stageIdx, stage.Name)
// 获取当前节点负责的分片
myShards := e.getAssignedShards(ctx, task.TaskID, task.TotalShards)
var wg sync.WaitGroup
errChan := make(chan error, len(myShards))
for _, shardIndex := range myShards {
wg.Add(1)
go func(idx int) {
defer wg.Done()
stageCtx, cancel := context.WithTimeout(ctx, stage.Timeout)
defer cancel()
err := stage.ExecuteFunc(stageCtx, idx, task.TotalShards)
if err != nil {
errChan <- err
return
}
// 通知barrier当前分片完成
barrier.SignalShardComplete(ctx, task.TaskID, stageIdx, idx)
}(shardIndex)
}
wg.Wait()
close(errChan)
for err := range errChan {
if err != nil {
return fmt.Errorf("stage %d failed: %w", stageIdx, err)
}
}
// 等待所有分片完成当前阶段
err := barrier.WaitForAllShards(ctx, task.TaskID, stageIdx,
task.TotalShards, stage.Timeout)
if err != nil {
return fmt.Errorf("barrier wait failed at stage %d: %w", stageIdx, err)
}
fmt.Printf("[PIPELINE] task %s stage %d completed\n",
task.TaskID, stageIdx)
}
return nil
}流水线分片的精髓:不是让所有分片跑完全程,而是让每个阶段像工厂流水线一样流转,上一道工序全完才能进下一道。
三、动态扩缩容方案
3.1 为什么静态扩容不够用
有一次双十一前,业务方说要扩容调度节点。我加了5台机器,配好调度服务,上线。然后双十一过了,这5台机器就一直闲着吃灰。每月多花好几千块,老板问我能不能缩容。手动缩容那天又差点出事——摘掉的节点上还有正在执行的任务。
静态扩容的问题:
- 扩容慢:从感知到压力到机器就绪,至少十几分钟
- 容易出错:手动配置容易遗漏
- 资源浪费:高峰过后无法及时释放
- 风险高:摘节点可能丢失正在执行的任务
动态扩缩容不是"多加几台机器",而是让调度系统具备感知负载、自动决策、安全伸缩的能力。
3.2 负载感知与扩缩容决策
package scheduler
import (
"context"
"fmt"
"math"
"time"
)
// LoadCollector 负载收集器
type LoadCollector struct {
etcdClient *clientv3.Client
interval time.Duration
}
// NodeMetrics 节点指标
type NodeMetrics struct {
NodeID string
CPUUsage float64
MemoryUsage float64
TaskQueueLen int
ActiveTasks int
AvgExecuteTime float64 // 毫秒
ErrorRate float64
}
// CollectNodeMetrics 收集节点指标
func (lc *LoadCollector) CollectNodeMetrics(ctx context.Context) map[string]*NodeMetrics {
resp, err := lc.etcdClient.Get(ctx, "/scheduler/metrics/", clientv3.WithPrefix())
if err != nil {
return nil
}
metrics := make(map[string]*NodeMetrics)
for _, kv := range resp.Kvs {
nodeID := extractNodeID(string(kv.Key))
m := parseMetrics(string(kv.Value))
m.NodeID = nodeID
metrics[nodeID] = m
}
return metrics
}
// ScaleDecision 扩缩容决策
type ScaleDecision struct {
Action string // "scale-up", "scale-down", "no-action"
Reason string
TargetCount int
NodesToAdd []string
NodesToRemove []string
}
// AutoScaler 自动扩缩容器
type AutoScaler struct {
loadCollector *LoadCollector
minNodes int
maxNodes int
scaleUpThreshold float64
scaleDownThreshold float64
cooldownPeriod time.Duration
lastScaleTime time.Time
}
// NewAutoScaler 创建自动扩缩容器
func NewAutoScaler(
lc *LoadCollector,
minNodes, maxNodes int,
scaleUpThreshold, scaleDownThreshold float64,
cooldown time.Duration,
) *AutoScaler {
return &AutoScaler{
loadCollector: lc,
minNodes: minNodes,
maxNodes: maxNodes,
scaleUpThreshold: scaleUpThreshold,
scaleDownThreshold: scaleDownThreshold,
cooldownPeriod: cooldown,
}
}
// Evaluate 评估是否需要扩缩容
func (as *AutoScaler) Evaluate(ctx context.Context) *ScaleDecision {
// 冷却期检查
if time.Since(as.lastScaleTime) < as.cooldownPeriod {
return &ScaleDecision{
Action: "no-action",
Reason: fmt.Sprintf("in cooldown period, last scale at %s", as.lastScaleTime),
}
}
metrics := as.loadCollector.CollectNodeMetrics(ctx)
if len(metrics) == 0 {
return &ScaleDecision{
Action: "no-action",
Reason: "no metrics available",
}
}
// 计算集群整体负载
var totalCPU, totalMem, totalQueue float64
var totalActiveTasks int
for _, m := range metrics {
totalCPU += m.CPUUsage
totalMem += m.MemoryUsage
totalQueue += float64(m.TaskQueueLen)
totalActiveTasks += m.ActiveTasks
}
nodeCount := len(metrics)
avgCPU := totalCPU / float64(nodeCount)
avgMem := totalMem / float64(nodeCount)
avgQueue := totalQueue / float64(nodeCount)
// 扩容判断
if avgCPU > as.scaleUpThreshold || avgMem > as.scaleUpThreshold || avgQueue > 100 {
if nodeCount >= as.maxNodes {
return &ScaleDecision{
Action: "no-action",
Reason: fmt.Sprintf("already at max nodes (%d), cannot scale up", nodeCount),
}
}
// 计算需要扩容多少节点
targetCount := nodeCount + int(math.Ceil(avgCPU/as.scaleUpThreshold))-1
if targetCount > as.maxNodes {
targetCount = as.maxNodes
}
return &ScaleDecision{
Action: "scale-up",
Reason: fmt.Sprintf("avgCPU=%.1f%%, avgMem=%.1f%%, avgQueue=%.0f", avgCPU, avgMem, avgQueue),
TargetCount: targetCount,
}
}
// 缩容判断
if avgCPU < as.scaleDownThreshold && avgMem < as.scaleDownThreshold && avgQueue < 10 {
if nodeCount <= as.minNodes {
return &ScaleDecision{
Action: "no-action",
Reason: fmt.Sprintf("already at min nodes (%d), cannot scale down", nodeCount),
}
}
// 选择负载最低的节点进行缩容
targetCount := nodeCount - 1
nodesToRemove := as.selectNodesToRemove(metrics, 1)
return &ScaleDecision{
Action: "scale-down",
Reason: fmt.Sprintf("avgCPU=%.1f%%, avgMem=%.1f%%, avgQueue=%.0f", avgCPU, avgMem, avgQueue),
TargetCount: targetCount,
NodesToRemove: nodesToRemove,
}
}
return &ScaleDecision{
Action: "no-action",
Reason: fmt.Sprintf("load is normal: avgCPU=%.1f%%, avgMem=%.1f%%", avgCPU, avgMem),
}
}
// selectNodesToRemove 选择要移除的节点(负载最低的)
func (as *AutoScaler) selectNodesToRemove(metrics map[string]*NodeMetrics, count int) []string {
type nodeScore struct {
nodeID string
score float64
}
var scores []nodeScore
for nodeID, m := range metrics {
// 综合评分:CPU + 内存 + 任务数
score := m.CPUUsage*0.4 + m.MemoryUsage*0.3 + float64(m.ActiveTasks)*0.3
scores = append(scores, nodeScore{nodeID: nodeID, score: score})
}
// 按评分排序,选择最低的
for i := 0; i < len(scores)-1; i++ {
for j := i + 1; j < len(scores); j++ {
if scores[j].score < scores[i].score {
scores[i], scores[j] = scores[j], scores[i]
}
}
}
result := []string{}
for i := 0; i < count && i < len(scores); i++ {
result = append(result, scores[i].nodeID)
}
return result
}3.3 安全缩容流程
缩容最大的风险是摘掉正在执行任务的节点。完整的安全缩容流程:
package scheduler
import (
"context"
"fmt"
"time"
)
// SafeScaleDown 安全缩容
type SafeScaleDown struct {
etcdClient *clientv3.Client
drainTimeout time.Duration
healthChecker *HealthChecker
}
// DrainNode 排空节点
func (sd *SafeScaleDown) DrainNode(ctx context.Context, nodeID string) error {
fmt.Printf("[SCALE-DOWN] start draining node %s\n", nodeID)
// 步骤1:标记节点为draining状态,不再分配新任务
nodeKey := fmt.Sprintf("/scheduler/nodes/%s", nodeID)
_, err := sd.etcdClient.Put(ctx, nodeKey,
fmt.Sprintf(`{"node_id":"%s","status":"draining","drain_start":"%s"}`,
nodeID, time.Now().Format(time.RFC3339)))
if err != nil {
return fmt.Errorf("mark node draining failed: %w", err)
}
// 步骤2:等待正在执行的任务完成
drainCtx, cancel := context.WithTimeout(ctx, sd.drainTimeout)
defer cancel()
ticker := time.NewTicker(5 * time.Second)
defer ticker.Stop()
for {
select {
case <-drainCtx.Done():
// 超时,强制迁移剩余任务
fmt.Printf("[SCALE-DOWN] drain timeout for node %s, force migrating tasks\n", nodeID)
sd.forceMigrateTasks(ctx, nodeID)
goto done
case <-ticker.C:\n // 检查节点上是否还有活跃任务\n activeCount := sd.getActiveTaskCount(ctx, nodeID)
if activeCount == 0 {
fmt.Printf("[SCALE-DOWN] node %s drained, no active tasks\n", nodeID)
goto done
}
fmt.Printf("[SCALE-DOWN] node %s still has %d active tasks, waiting...\n",
nodeID, activeCount)
}
}
done:
// 步骤3:迁移分片到其他节点
sd.migrateShards(ctx, nodeID)
// 步骤4:从节点注册中删除
sd.etcdClient.Delete(ctx, nodeKey)
// 步骤5:清理相关数据
sd.etcdClient.Delete(ctx,
fmt.Sprintf("/scheduler/assignments/%s/", nodeID),
clientv3.WithPrefix())
sd.etcdClient.Delete(ctx,
fmt.Sprintf("/scheduler/metrics/%s", nodeID))
fmt.Printf("[SCALE-DOWN] node %s safely removed\n", nodeID)
return nil
}
func (sd *SafeScaleDown) getActiveTaskCount(ctx context.Context, nodeID string) int {
resp, err := sd.etcdClient.Get(ctx,
fmt.Sprintf("/scheduler/active/%s/", nodeID),
clientv3.WithPrefix(), clientv3.WithCountOnly())
if err != nil {
return -1
}
return int(resp.Count)
}
func (sd *SafeScaleDown) forceMigrateTasks(ctx context.Context, nodeID string) {
resp, err := sd.etcdClient.Get(ctx,
fmt.Sprintf("/scheduler/active/%s/", nodeID),
clientv3.WithPrefix())
if err != nil {
return
}
for _, kv := range resp.Kvs {
taskID := extractTaskID(string(kv.Key))
// 标记任务需要重新执行
retryKey := fmt.Sprintf("/scheduler/retry/%s", taskID)
sd.etcdClient.Put(ctx, retryKey, "force-migrated")
sd.etcdClient.Delete(ctx, string(kv.Key))
}
}
func (sd *SafeScaleDown) migrateShards(ctx context.Context, nodeID string) {
resp, err := sd.etcdClient.Get(ctx,
fmt.Sprintf("/scheduler/assignments/%s/", nodeID),
clientv3.WithPrefix())
if err != nil {
return
}
// 获取健康节点列表
healthyResp, err := sd.etcdClient.Get(ctx, "/scheduler/nodes/", clientv3.WithPrefix())
if err != nil {
return
}
var healthyNodes []string
for _, kv := range healthyResp.Kvs {
if !isDraining(string(kv.Value)) && extractNodeID(string(kv.Key)) != nodeID {
healthyNodes = append(healthyNodes, extractNodeID(string(kv.Key)))
}
}
if len(healthyNodes) == 0 {
fmt.Printf("[SCALE-DOWN] no healthy nodes to migrate shards to\n")
return
}
for _, kv := range resp.Kvs {
taskID := extractTaskID(string(kv.Key))
// 轮询分配到健康节点
targetNode := healthyNodes[len(taskID)%len(healthyNodes)]
newKey := fmt.Sprintf("/scheduler/assignments/%s/%s", targetNode, taskID)
sd.etcdClient.Put(ctx, newKey, "migrated")
sd.etcdClient.Delete(ctx, string(kv.Key))
fmt.Printf("[SCALE-DOWN] migrated shard %s: %s -> %s\n",
taskID, nodeID, targetNode)
}
}缩容的核心原则:先停新任务、等完老任务、迁移分片、最后摘节点。任何跳步都是生产事故的导火索。
3.4 扩缩容完整决策流水线
package scheduler
import (
"context"
"fmt"
"time"
)
// ScalePipeline 扩缩容决策流水线
type ScalePipeline struct {
autoScaler *AutoScaler
safeScaleDown *SafeScaleDown
nodeProvisioner *NodeProvisioner
interval time.Duration
}
// NodeProvisioner 节点供给器(对接云平台或K8s)
type NodeProvisioner struct {
// 对接AWS/K8s/阿里云等
}
func (np *NodeProvisioner) ProvisionNode(ctx context.Context) (string, error) {
// 实际实现对接云平台API创建实例
// 这里用模拟逻辑
nodeID := fmt.Sprintf("node-%d", time.Now().UnixNano()%100000)
fmt.Printf("[PROVISION] new node %s provisioned\n", nodeID)
// 等待节点就绪
time.Sleep(30 * time.Second) // 模拟启动时间
return nodeID, nil
}
func (np *NodeProvisioner) DeprovisionNode(ctx context.Context, nodeID string) error {
fmt.Printf("[PROVISION] deprovisioning node %s\n", nodeID)
// 实际实现调用云平台API释放实例
return nil
}
// Run 运行扩缩容流水线
func (sp *ScalePipeline) Run(ctx context.Context) {
ticker := time.NewTicker(sp.interval)
defer ticker.Stop()
for {
select {
case <-ctx.Done():
return
case <-ticker.C:\n sp.evaluateAndScale(ctx)\n }\n }\n}\n\nfunc (sp *ScalePipeline) evaluateAndScale(ctx context.Context) {
decision := sp.autoScaler.Evaluate(ctx)
switch decision.Action {
case "scale-up":
fmt.Printf("[SCALE] scale-up triggered: %s\n", decision.Reason)
currentCount := sp.getCurrentNodeCount(ctx)
toAdd := decision.TargetCount - currentCount
for i := 0; i < toAdd; i++ {
nodeID, err := sp.nodeProvisioner.ProvisionNode(ctx)
if err != nil {
fmt.Printf("[SCALE] provision node failed: %v\n", err)
break
}
// 注册新节点到调度集群
sp.registerNode(ctx, nodeID)
}
sp.autoScaler.lastScaleTime = time.Now()
fmt.Printf("[SCALE] scale-up completed, added %d nodes\n", toAdd)
case "scale-down":
fmt.Printf("[SCALE] scale-down triggered: %s\n", decision.Reason)
for _, nodeID := range decision.NodesToRemove {
// 安全排空节点
err := sp.safeScaleDown.DrainNode(ctx, nodeID)
if err != nil {
fmt.Printf("[SCALE] drain node %s failed: %v\n", nodeID, err)
continue
}
// 释放机器资源
err = sp.nodeProvisioner.DeprovisionNode(ctx, nodeID)
if err != nil {
fmt.Printf("[SCALE] deprovision node %s failed: %v\n", nodeID, err)
}
}
sp.autoScaler.lastScaleTime = time.Now()
fmt.Printf("[SCALE] scale-down completed, removed %d nodes\n",
len(decision.NodesToRemove))
case "no-action":
// 正常情况,不打日志避免刷屏
}
}
func (sp *ScalePipeline) getCurrentNodeCount(ctx context.Context) int {
resp, err := sp.autoScaler.loadCollector.etcdClient.Get(ctx,
"/scheduler/nodes/", clientv3.WithPrefix())
if err != nil {
return 0
}
return len(resp.Kvs)
}
func (sp *ScalePipeline) registerNode(ctx context.Context, nodeID string) {
fm := &FailoverManager{
etcdClient: sp.autoScaler.loadCollector.etcdClient,
nodeID: nodeID,
heartbeatInterval: 5 * time.Second,
heartbeatTTL: 15,
}
fm.RegisterNode(ctx)
fmt.Printf("[SCALE] node %s registered to cluster\n", nodeID)
}四、调度系统性能优化
4.1 调度引擎性能瓶颈分析
我在优化一个调度系统时,遇到过这样的性能曲线:任务数从1万涨到10万,调度延迟从100ms涨到5秒。表面看是任务太多,实际profile之后发现:
- 60%的时间花在数据库查询任务定义
- 25%的时间花在分布式锁竞争
- 10%的时间花在序列化/反序列化
- 5%的时间花在网络通信
性能优化的第一定律:不要猜,profile。你以为的瓶颈和真正的瓶颈往往差了十万八千里。
4.2 任务存储层优化
package scheduler
import (
"context"
"encoding/json"
"fmt"
"sync"
"time"
)
// TaskStorage 任务存储接口
type TaskStorage interface {
GetTask(ctx context.Context, taskID string) (*TaskDefinition, error)
ListTasks(ctx context.Context, filter *TaskFilter) ([]*TaskDefinition, error)
SaveTask(ctx context.Context, task *TaskDefinition) error
DeleteTask(ctx context.Context, taskID string) error
}
// TaskDefinition 任务定义
type TaskDefinition struct {
TaskID string `json:"task_id"`
Name string `json:"name"`
Cron string `json:"cron"`
Command string `json:"command"`
Params map[string]string `json:"params"`
Timeout time.Duration `json:"timeout"`
RetryCount int `json:"retry_count"`
ShardingNum int `json:"sharding_num"`
Status string `json:"status"`
}
// CachedTaskStorage 带缓存的任务存储
type CachedTaskStorage struct {
primary TaskStorage // 数据库
cache map[string]*TaskDefinition
cacheMu sync.RWMutex
cacheTTL time.Duration
lastRefresh time.Time
refreshMu sync.Mutex
// 本地文件缓存(冷启动加速)
snapshotPath string
}
func NewCachedTaskStorage(primary TaskStorage, cacheTTL time.Duration, snapshotPath string) *CachedTaskStorage {
cts := &CachedTaskStorage{
primary: primary,
cache: make(map[string]*TaskDefinition),
cacheTTL: cacheTTL,
snapshotPath: snapshotPath,
}
// 从本地快照恢复缓存(冷启动优化)
cts.restoreFromSnapshot()
return cts
}
func (cts *CachedTaskStorage) GetTask(ctx context.Context, taskID string) (*TaskDefinition, error) {
// 先查内存缓存
cts.cacheMu.RLock()
task, ok := cts.cache[taskID]
cts.cacheMu.RUnlock()
if ok {
return task, nil
}
// 缓存未命中,查数据库
task, err := cts.primary.GetTask(ctx, taskID)
if err != nil {
return nil, err
}
// 写入缓存
cts.cacheMu.Lock()
cts.cache[taskID] = task
cts.cacheMu.Unlock()
return task, nil
}
func (cts *CachedTaskStorage) ListTasks(ctx context.Context, filter *TaskFilter) ([]*TaskDefinition, error) {
// 检查缓存是否过期
cts.cacheMu.RLock()
expired := time.Since(cts.lastRefresh) > cts.cacheTTL
cacheSize := len(cts.cache)
cts.cacheMu.RUnlock()
if !expired && cacheSize > 0 {
// 从缓存返回
return cts.filterFromCache(filter), nil
}
// 刷新缓存
cts.refreshMu.Lock()
defer cts.refreshMu.Unlock()
// 双重检查
cts.cacheMu.RLock()
expired = time.Since(cts.lastRefresh) > cts.cacheTTL
cts.cacheMu.RUnlock()
if expired {
tasks, err := cts.primary.ListTasks(ctx, &TaskFilter{})
if err != nil {
return nil, err
}
cts.cacheMu.Lock()
cts.cache = make(map[string]*TaskDefinition)
for _, t := range tasks {
cts.cache[t.TaskID] = t
}
cts.lastRefresh = time.Now()
cts.cacheMu.Unlock()
// 异步写快照
go cts.saveSnapshot(tasks)
}
return cts.filterFromCache(filter), nil
}
func (cts *CachedTaskStorage) SaveTask(ctx context.Context, task *TaskDefinition) error {
// 先写数据库
if err := cts.primary.SaveTask(ctx, task); err != nil {
return err
}
// 更新缓存
cts.cacheMu.Lock()
cts.cache[task.TaskID] = task
cts.cacheMu.Unlock()
return nil
}
func (cts *CachedTaskStorage) DeleteTask(ctx context.Context, taskID string) error {
if err := cts.primary.DeleteTask(ctx, taskID); err != nil {
return err
}
cts.cacheMu.Lock()
delete(cts.cache, taskID)
cts.cacheMu.Unlock()
return nil
}
func (cts *CachedTaskStorage) filterFromCache(filter *TaskFilter) []*TaskDefinition {
cts.cacheMu.RLock()
defer cts.cacheMu.RUnlock()
var result []*TaskDefinition
for _, task := range cts.cache {
if filter == nil || filter.Match(task) {
result = append(result, task)
}
}
return result
}
// saveSnapshot 保存缓存快照到本地文件
func (cts *CachedTaskStorage) saveSnapshot(tasks []*TaskDefinition) {
data, err := json.Marshal(tasks)
if err != nil {
return
}
// 写入文件(实际实现需要原子写入)
writeAtomic(cts.snapshotPath, data)
}
// restoreFromSnapshot 从本地快照恢复
func (cts *CachedTaskStorage) restoreFromSnapshot() {
data, err := readFile(cts.snapshotPath)
if err != nil {
return
}
var tasks []*TaskDefinition
if err := json.Unmarshal(data, &tasks); err != nil {
return
}
cts.cacheMu.Lock()
for _, t := range tasks {
cts.cache[t.TaskID] = t
}
cts.lastRefresh = time.Now()
cts.cacheMu.Unlock()
fmt.Printf("[STORAGE] restored %d tasks from snapshot\n", len(tasks))
}
func writeAtomic(path string, data []byte) error {
// 原子写入实现:先写临时文件,再rename
tmpPath := path + ".tmp"
if err := writeFile(tmpPath, data); err != nil {
return err
}
return renameFile(tmpPath, path)
}缓存是把双刃剑。用好了,性能提升十倍;用不好,数据不一致引发各种灵异问题。一致性保障比缓存本身更重要。
4.3 调度轮次优化
传统的调度器每秒扫一次数据库,找出到期的任务。当任务量大时,这个查询本身就成了瓶颈。优化方案:时间轮 + 延迟队列。
package scheduler
import (
"container/heap"
"context"
"fmt"
"sync"
"time"
)
// TimeWheel 时间轮
type TimeWheel struct {
slots [][]*TaskInstance
current int
tickSize time.Duration
wheelSize int
mu sync.Mutex
ctx context.Context
execute func(*TaskInstance)
// 溢出轮(处理超过一轮周期的任务)
overflow *DelayedQueue
}
// TaskInstance 任务实例
type TaskInstance struct {
TaskID string
TaskName string
FireTime time.Time
Round int // 在时间轮中还要转多少圈
Execute func()
}
// NewTimeWheel 创建时间轮
func NewTimeWheel(tickSize time.Duration, wheelSize int,
ctx context.Context, execute func(*TaskInstance)) *TimeWheel {
tw := &TimeWheel{
slots: make([][]*TaskInstance, wheelSize),
tickSize: tickSize,
wheelSize: wheelSize,
ctx: ctx,
execute: execute,
overflow: NewDelayedQueue(),
}
for i := range tw.slots {
tw.slots[i] = make([]*TaskInstance, 0)
}
go tw.run()
return tw
}
// AddTask 添加任务
func (tw *TimeWheel) AddTask(task *TaskInstance) {
tw.mu.Lock()
defer tw.mu.Unlock()
delay := time.Until(task.FireTime)
ticks := int(delay / tw.tickSize)
if ticks >= tw.wheelSize {
// 超过一轮,放入溢出队列
task.Round = ticks / tw.wheelSize
tw.overflow.Push(task)
return
}
if ticks <= 0 {
// 立即执行
go tw.execute(task)
return
}
slot := (tw.current + ticks) % tw.wheelSize
task.Round = 0
tw.slots[slot] = append(tw.slots[slot], task)
}
func (tw *TimeWheel) run() {
ticker := time.NewTicker(tw.tickSize)
defer ticker.Stop()
for {
select {
case <-tw.ctx.Done():
return
case <-ticker.C:\n tw.tick()\n }\n }\n}\n\nfunc (tw *TimeWheel) tick() {
tw.mu.Lock()
defer tw.mu.Unlock()
tw.current = (tw.current + 1) % tw.wheelSize
slot := tw.slots[tw.current]
var remaining []*TaskInstance
for _, task := range slot {
if task.Round == 0 {
// 到时间了,执行
go tw.execute(task)
} else {
task.Round--
remaining = append(remaining, task)
}
}
tw.slots[tw.current] = remaining
// 检查溢出队列
for {
task := tw.overflow.Peek()
if task == nil || time.Until(task.FireTime) > time.Duration(tw.wheelSize)*tw.tickSize {
break
}
tw.overflow.Pop()
// 重新加入时间轮
ticks := int(time.Until(task.FireTime) / tw.tickSize)
slot := (tw.current + ticks) % tw.wheelSize
task.Round = 0
tw.slots[slot] = append(tw.slots[slot], task)
}
}
// DelayedQueue 延迟队列(基于最小堆)
type DelayedQueue struct {
items []*TaskInstance
mu sync.Mutex
}
func NewDelayedQueue() *DelayedQueue {
return &DelayedQueue{items: make([]*TaskInstance, 0)}
}
func (dq *DelayedQueue) Push(task *TaskInstance) {
dq.mu.Lock()
defer dq.mu.Unlock()
heap.Push(dq, task)
}
func (dq *DelayedQueue) Pop() *TaskInstance {
dq.mu.Lock()
defer dq.mu.Unlock()
if dq.Len() == 0 {
return nil
}
return heap.Pop(dq).(*TaskInstance)
}
func (dq *DelayedQueue) Peek() *TaskInstance {
dq.mu.Lock()
defer dq.mu.Unlock()
if dq.Len() == 0 {
return nil
}
return dq.items[0]
}
func (dq *DelayedQueue) Len() int { return len(dq.items) }
func (dq *DelayedQueue) Less(i, j int) bool {
return dq.items[i].FireTime.Before(dq.items[j].FireTime)
}
func (dq *DelayedQueue) Swap(i, j int) {
dq.items[i], dq.items[j] = dq.items[j], dq.items[i]
}
func (dq *DelayedQueue) Push(x interface{}) {
dq.items = append(dq.items, x.(*TaskInstance))
}
func (dq *DelayedQueue) Pop() interface{} {
old := dq.items
n := len(old)
item := old[n-1]
dq.items = old[0 : n-1]
return item
}4.4 任务执行优化
package scheduler
import (
"context"
"fmt"
"runtime"
"sync"
"time"
)
// WorkerPool 工作线程池
type WorkerPool struct {
taskQueue chan *TaskInstance
workerCount int
wg sync.WaitGroup
ctx context.Context
cancel context.CancelFunc
// 指标
totalExecuted int64
totalFailed int64
queueDepth int
mu sync.Mutex
}
// NewWorkerPool 创建工作线程池
func NewWorkerPool(workerCount int, queueSize int) *WorkerPool {
ctx, cancel := context.WithCancel(context.Background())
// 根据CPU核心数自动调整worker数量
if workerCount <= 0 {
workerCount = runtime.NumCPU() * 2
}
return &WorkerPool{
taskQueue: make(chan *TaskInstance, queueSize),
workerCount: workerCount,
ctx: ctx,
cancel: cancel,
}
}
// Start 启动worker池
func (wp *WorkerPool) Start() {
for i := 0; i < wp.workerCount; i++ {
wp.wg.Add(1)
go wp.worker(i)
}
fmt.Printf("[WORKER-POOL] started %d workers\n", wp.workerCount)
}
func (wp *WorkerPool) worker(id int) {
defer wp.wg.Done()
for {
select {
case <-wp.ctx.Done():
return
case task := <-wp.taskQueue:
wp.processTask(id, task)
}
}
}
func (wp *WorkerPool) processTask(workerID int, task *TaskInstance) {
start := time.Now()
defer func() {
if r := recover(); r != nil {
fmt.Printf("[WORKER-%d] task %s panicked: %v\n", workerID, task.TaskID, r)
wp.mu.Lock()
wp.totalFailed++
wp.mu.Unlock()
}
duration := time.Since(start)
if duration > 5*time.Second {
fmt.Printf("[WORKER-%d] task %s took %v (slow)\n",
workerID, task.TaskID, duration)
}
wp.mu.Lock()
wp.totalExecuted++
wp.mu.Unlock()
}()
// 执行任务
if task.Execute != nil {
task.Execute()
}
}
// Submit 提交任务
func (wp *WorkerPool) Submit(task *TaskInstance) error {
select {
case wp.taskQueue <- task:
wp.mu.Lock()
wp.queueDepth = len(wp.taskQueue)
wp.mu.Unlock()
return nil
default:
return fmt.Errorf("task queue is full, current depth: %d", len(wp.taskQueue))
}
}
// SubmitBlocking 阻塞式提交
func (wp *WorkerPool) SubmitBlocking(ctx context.Context, task *TaskInstance) error {
select {
case wp.taskQueue <- task:
return nil
case <-ctx.Done():
return ctx.Err()
}
}
// Stop 停止worker池
func (wp *WorkerPool) Stop() {
wp.cancel()
wp.wg.Wait()
fmt.Printf("[WORKER-POOL] stopped, total executed: %d, failed: %d\n",
wp.totalExecuted, wp.totalFailed)
}
// GetMetrics 获取指标
func (wp *WorkerPool) GetMetrics() map[string]interface{} {
wp.mu.Lock()
defer wp.mu.Unlock()
return map[string]interface{}{
"worker_count": wp.workerCount,
"queue_depth": wp.queueDepth,
"total_executed": wp.totalExecuted,
"total_failed": wp.totalFailed,
"success_rate": wp.calculateSuccessRate(),
}
}
func (wp *WorkerPool) calculateSuccessRate() float64 {
if wp.totalExecuted == 0 {
return 100.0
}
return float64(wp.totalExecuted-wp.totalFailed) / float64(wp.totalExecuted) * 100
}4.5 性能优化清单
以下是我整理的调度系统性能优化清单,按优先级排序:
调度系统性能优化清单(按优先级执行)
[第一优先级:存储层]
1. 任务定义全量缓存到内存,避免每次调度都查数据库
2. 使用本地快照加速冷启动
3. 任务执行日志异步写入,不阻塞调度主流程
4. 数据库索引优化:cron表达式、下次执行时间、任务状态
[第二优先级:调度引擎]
5. 用时间轮替代轮询扫描,将调度复杂度从O(n)降到O(1)
6. 调度主线程不做任何IO操作,纯内存计算
7. 批量获取到期任务,减少锁竞争次数
8. 任务优先级队列,高优先级任务优先调度
[第三优先级:执行层]
9. Worker池复用goroutine,避免频繁创建销毁
10. 任务执行超时控制,防止僵尸任务占用worker
11. 任务结果异步回调,不阻塞worker线程
12. 合理设置worker数量:CPU密集型=CPU核数,IO密集型=CPU核数*2~4
[第四优先级:网络层]
13. 执行器与调度器之间使用长连接
14. 任务结果压缩传输
15. 批量心跳替代单任务心跳
16. gRPC替代HTTP,减少序列化开销优化的本质是消除浪费。先profile找到最大的浪费点,集中精力消灭它,然后再找下一个。不要同时优化所有层。
五、监控与运维
5.1 监控体系设计
调度系统的监控要覆盖三个维度:调度层、执行层、业务层。我见过太多团队只监控"调度节点是否存活",结果任务一直在报错却没人知道。
package scheduler
import (
"context"
"fmt"
"sync"
"time"
)
// MetricsCollector 指标收集器
type MetricsCollector struct {
mu sync.RWMutex
// 调度层指标
ScheduleLatency *HistogramMetric
ScheduleSuccess *CounterMetric
ScheduleFail *CounterMetric
ActiveNodes *GaugeMetric
LeaderChanges *CounterMetric
// 执行层指标
ExecuteLatency *HistogramMetric
ExecuteSuccess *CounterMetric
ExecuteFail *CounterMetric
ExecuteTimeout *CounterMetric
QueueDepth *GaugeMetric
WorkerUtilization *GaugeMetric
// 业务层指标
TaskBacklog *GaugeMetric
TaskRetryCount *CounterMetric
ShardImbalance *GaugeMetric
FailoverCount *CounterMetric
// 告警规则
alertRules []*AlertRule
exporter MetricExporter
}
// HistogramMetric 直方图指标
type HistogramMetric struct {
name string
buckets []float64
counts []int64
sum float64
count int64
mu sync.Mutex
}
func NewHistogramMetric(name string, buckets []float64) *HistogramMetric {
return &HistogramMetric{
name: name,
buckets: buckets,
counts: make([]int64, len(buckets)+1),
}
}
func (h *HistogramMetric) Observe(value float64) {
h.mu.Lock()
defer h.mu.Unlock()
h.sum += value
h.count++
for i, bound := range h.buckets {
if value <= bound {
h.counts[i]++
return
}
}
h.counts[len(h.counts)-1]++
}
func (h *HistogramMetric) GetPercentile(p float64) float64 {
h.mu.Lock()
defer h.mu.Unlock()
if h.count == 0 {
return 0
}
target := int64(float64(h.count) * p)
var cumul int64
for i, count := range h.counts {
cumul += count
if cumul >= target {
if i < len(h.buckets) {
return h.buckets[i]
}
return h.buckets[len(h.buckets)-1]
}
}
return h.buckets[len(h.buckets)-1]
}
// CounterMetric 计数器指标
type CounterMetric struct {
name string
value int64
mu sync.Mutex
}
func (c *CounterMetric) Inc() {
c.mu.Lock()
c.value++
c.mu.Unlock()
}
func (c *CounterMetric) Add(delta int64) {
c.mu.Lock()
c.value += delta
c.mu.Unlock()
}
func (c *CounterMetric) Get() int64 {
c.mu.Lock()
defer c.mu.Unlock()
return c.value
}
// GaugeMetric 瞬时值指标
type GaugeMetric struct {
name string
value float64
mu sync.Mutex
}
func (g *GaugeMetric) Set(value float64) {
g.mu.Lock()
g.value = value
g.mu.Unlock()
}
func (g *GaugeMetric) Get() float64 {
g.mu.Lock()
defer g.mu.Unlock()
return g.value
}
// AlertRule 告警规则
type AlertRule struct {
Name string
Metric string
Operator string // ">", "<", "=="
Threshold float64
Duration time.Duration
Severity string // "critical", "warning", "info"
Message string
lastTriggered time.Time
}
// CheckAlerts 检查告警
func (mc *MetricsCollector) CheckAlerts() []*AlertRule {
mc.mu.RLock()
defer mc.mu.RUnlock()
var triggered []*AlertRule
for _, rule := range mc.alertRules {
value := mc.getMetricValue(rule.Metric)
if mc.evaluateRule(rule, value) {
if time.Since(rule.lastTriggered) > rule.Duration {
triggered = append(triggered, rule)
rule.lastTriggered = time.Now()
fmt.Printf("[ALERT] %s: %s (metric=%s, value=%.2f, threshold=%.2f)\n",
rule.Severity, rule.Message, rule.Metric, value, rule.Threshold)
}
}
}
return triggered
}
func (mc *MetricsCollector) getMetricValue(metricName string) float64 {
switch metricName {
case "schedule_latency_p99":
return mc.ScheduleLatency.GetPercentile(0.99)
case "schedule_fail":
return float64(mc.ScheduleFail.Get())
case "execute_fail":
return float64(mc.ExecuteFail.Get())
case "queue_depth":
return mc.QueueDepth.Get()
case "task_backlog":
return mc.TaskBacklog.Get()
case "worker_utilization":
return mc.WorkerUtilization.Get()
case "shard_imbalance":
return mc.ShardImbalance.Get()
default:
return 0
}
}
func (mc *MetricsCollector) evaluateRule(rule *AlertRule, value float64) bool {
switch rule.Operator {
case ">":
return value > rule.Threshold
case "<":
return value < rule.Threshold
case "==":
return value == rule.Threshold
default:
return false
}
}5.2 告警规则配置模板
package scheduler
import "time"
// DefaultAlertRules 默认告警规则
func DefaultAlertRules() []*AlertRule {
return []*AlertRule{
{
Name: "调度延迟过高",
Metric: "schedule_latency_p99",
Operator: ">",
Threshold: 1000, // 1秒
Duration: 2 * time.Minute,
Severity: "warning",
Message: "调度P99延迟超过1秒,可能存在性能瓶颈",
},
{
Name: "调度失败率激增",
Metric: "schedule_fail",
Operator: ">",
Threshold: 10, // 2分钟内超过10次失败
Duration: 2 * time.Minute,
Severity: "critical",
Message: "调度失败次数异常,可能节点故障或存储问题",
},
{
Name: "任务队列堆积",
Metric: "queue_depth",
Operator: ">",
Threshold: 500,
Duration: 3 * time.Minute,
Severity: "warning",
Message: "任务队列深度超过500,worker可能不足",
},
{
Name: "任务积压",
Metric: "task_backlog",
Operator: ">",
Threshold: 1000,
Duration: 5 * time.Minute,
Severity: "critical",
Message: "任务积压超过1000,调度系统可能无法跟上负载",
},
{
Name: "Worker利用率过高",
Metric: "worker_utilization",
Operator: ">",
Threshold: 90, // 90%
Duration: 5 * time.Minute,
Severity: "warning",
Message: "Worker利用率持续超过90%,需要扩容",
},
{
Name: "分片不均衡",
Metric: "shard_imbalance",
Operator: ">",
Threshold: 0.5, // 50%
Duration: 10 * time.Minute,
Severity: "info",
Message: "分片负载不均衡度超过50%,建议检查分片策略",
},
{
Name: "故障转移触发",
Metric: "failover_count",
Operator: ">",
Threshold: 0,
Duration: 1 * time.Minute,
Severity: "critical",
Message: "触发了故障转移,有节点可能宕机",
},
}
}5.3 运维仪表盘
package scheduler
import (
"context"
"encoding/json"
"fmt"
"net/http"
"time"
)
// DashboardServer 运维仪表盘HTTP服务
type DashboardServer struct {
collector *MetricsCollector
storage TaskStorage
server *http.Server
}
// NewDashboardServer 创建仪表盘服务
func NewDashboardServer(collector *MetricsCollector, storage TaskStorage, addr string) *DashboardServer {
ds := &DashboardServer{
collector: collector,
storage: storage,
}
mux := http.NewServeMux()
mux.HandleFunc("/dashboard", ds.handleDashboard)
mux.HandleFunc("/metrics", ds.handleMetrics)
mux.HandleFunc("/tasks", ds.handleTasks)
mux.HandleFunc("/alerts", ds.handleAlerts)
mux.HandleFunc("/health", ds.handleHealth)
ds.server = &http.Server{
Addr: addr,
Handler: mux,
}
return ds
}
func (ds *DashboardServer) Start() error {
fmt.Printf("[DASHBOARD] server starting on %s\n", ds.server.Addr)
return ds.server.ListenAndServe()
}
func (ds *DashboardServer) handleDashboard(w http.ResponseWriter, r *http.Request) {
// 返回HTML仪表盘页面
w.Header().Set("Content-Type", "text/html; charset=utf-8")
html := ds.generateDashboardHTML()
w.Write([]byte(html))
}
func (ds *DashboardServer) handleMetrics(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
metrics := map[string]interface{}{
"schedule_latency_p50": ds.collector.ScheduleLatency.GetPercentile(0.50),
"schedule_latency_p95": ds.collector.ScheduleLatency.GetPercentile(0.95),
"schedule_latency_p99": ds.collector.ScheduleLatency.GetPercentile(0.99),
"schedule_success": ds.collector.ScheduleSuccess.Get(),
"schedule_fail": ds.collector.ScheduleFail.Get(),
"active_nodes": ds.collector.ActiveNodes.Get(),
"leader_changes": ds.collector.LeaderChanges.Get(),
"execute_latency_p50": ds.collector.ExecuteLatency.GetPercentile(0.50),
"execute_latency_p95": ds.collector.ExecuteLatency.GetPercentile(0.95),
"execute_latency_p99": ds.collector.ExecuteLatency.GetPercentile(0.99),
"execute_success": ds.collector.ExecuteSuccess.Get(),
"execute_fail": ds.collector.ExecuteFail.Get(),
"execute_timeout": ds.collector.ExecuteTimeout.Get(),
"queue_depth": ds.collector.QueueDepth.Get(),
"worker_utilization": ds.collector.WorkerUtilization.Get(),
"task_backlog": ds.collector.TaskBacklog.Get(),
"task_retry_count": ds.collector.TaskRetryCount.Get(),
"shard_imbalance": ds.collector.ShardImbalance.Get(),
"failover_count": ds.collector.FailoverCount.Get(),
"timestamp": time.Now().Format(time.RFC3339),
}
json.NewEncoder(w).Encode(metrics)
}
func (ds *DashboardServer) handleTasks(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
ctx, cancel := context.WithTimeout(r.Context(), 5*time.Second)
defer cancel()
tasks, err := ds.storage.ListTasks(ctx, &TaskFilter{})
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
json.NewEncoder(w).Encode(tasks)
}
func (ds *DashboardServer) handleAlerts(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
triggered := ds.collector.CheckAlerts()
json.NewEncoder(w).Encode(triggered)
}
func (ds *DashboardServer) handleHealth(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
health := ds.checkHealth()
if health["status"] == "unhealthy" {
w.WriteHeader(http.StatusServiceUnavailable)
}
json.NewEncoder(w).Encode(health)
}
func (ds *DashboardServer) checkHealth() map[string]interface{} {
health := map[string]interface{}{
"status": "healthy",
"timestamp": time.Now().Format(time.RFC3339),
"checks": map[string]interface{}{},
}
checks := health["checks"].(map[string]interface{})
// 检查调度延迟
latency := ds.collector.ScheduleLatency.GetPercentile(0.99)
if latency > 2000 {
checks["schedule_latency"] = fmt.Sprintf("degraded: p99=%.0fms", latency)
health["status"] = "unhealthy"
} else {
checks["schedule_latency"] = fmt.Sprintf("ok: p99=%.0fms", latency)
}
// 检查队列深度
queueDepth := ds.collector.QueueDepth.Get()
if queueDepth > 1000 {
checks["queue_depth"] = fmt.Sprintf("degraded: depth=%.0f", queueDepth)
health["status"] = "unhealthy"
} else {
checks["queue_depth"] = fmt.Sprintf("ok: depth=%.0f", queueDepth)
}
// 检查活跃节点数
activeNodes := ds.collector.ActiveNodes.Get()
if activeNodes < 2 {
checks["active_nodes"] = fmt.Sprintf("warning: only %.0f nodes", activeNodes)
if health["status"] == "healthy" {
health["status"] = "degraded"
}
} else {
checks["active_nodes"] = fmt.Sprintf("ok: %.0f nodes", activeNodes)
}
return health
}
func (ds *DashboardServer) generateDashboardHTML() string {
return `<!DOCTYPE html>
<html>
<head>
<title>调度系统监控面板</title>
<meta http-equiv="refresh" content="5">
<style>
body { font-family: monospace; margin: 20px; background: #1a1a2e; color: #e0e0e0; }
.metric { display: inline-block; margin: 10px; padding: 15px;
background: #16213e; border-radius: 8px; min-width: 200px; }
.metric h3 { color: #0f3460; margin: 0 0 10px 0; }
.metric .value { font-size: 24px; color: #e94560; }
.section { margin: 20px 0; }
.section h2 { color: #e94560; border-bottom: 1px solid #333; padding-bottom: 5px; }
</style>
</head>
<body>
<h1>调度系统监控面板</h1>
<div class="section">
<h2>调度层</h2>
<div class="metric"><h3>调度P99延迟</h3><div class="value" id="sched_p99">-</div></div>
<div class="metric"><h3>调度成功</h3><div class="value" id="sched_ok">-</div></div>
<div class="metric"><h3>调度失败</h3><div class="value" id="sched_fail">-</div></div>
<div class="metric"><h3>活跃节点</h3><div class="value" id="nodes">-</div></div>
</div>
<div class="section">
<h2>执行层</h2>
<div class="metric"><h3>执行P99延迟</h3><div class="value" id="exec_p99">-</div></div>
<div class="metric"><h3>队列深度</h3><div class="value" id="queue">-</div></div>
<div class="metric"><h3>Worker利用率</h3><div class="value" id="worker">-</div></div>
</div>
<div class="section">
<h2>业务层</h2>
<div class="metric"><h3>任务积压</h3><div class="value" id="backlog">-</div></div>
<div class="metric"><h3>重试次数</h3><div class="value" id="retry">-</div></div>
<div class="metric"><h3>分片不均衡</h3><div class="value" id="imbalance">-</div></div>
</div>
<script>
async function fetchMetrics() {
const resp = await fetch('/metrics');
const data = await resp.json();
document.getElementById('sched_p99').textContent = data.schedule_latency_p99.toFixed(0) + 'ms';
document.getElementById('sched_ok').textContent = data.schedule_success;
document.getElementById('sched_fail').textContent = data.schedule_fail;
document.getElementById('nodes').textContent = data.active_nodes;
document.getElementById('exec_p99').textContent = data.execute_latency_p99.toFixed(0) + 'ms';
document.getElementById('queue').textContent = data.queue_depth;
document.getElementById('worker').textContent = data.worker_utilization.toFixed(1) + '%';
document.getElementById('backlog').textContent = data.task_backlog;
document.getElementById('retry').textContent = data.task_retry_count;
document.getElementById('imbalance').textContent = (data.shard_imbalance * 100).toFixed(1) + '%';
}
fetchMetrics();
setInterval(fetchMetrics, 5000);
</script>
</body>
</html>`
}5.4 日志规范与链路追踪
package scheduler
import (
"context"
"fmt"
"log/slog"
"os"
"time"
)
// TaskLogger 任务日志器
type TaskLogger struct {
logger *slog.Logger
}
// LogContext 日志上下文
type LogContext struct {
TraceID string
TaskID string
TaskName string
ShardIndex int
NodeID string
Stage string
}
func NewTaskLogger() *TaskLogger {
handler := slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{
Level: slog.LevelInfo,
})
return &TaskLogger{
logger: slog.New(handler),
}
}
func (tl *TaskLogger) LogSchedule(ctx context.Context, lc *LogContext, msg string, args ...any) {
attrs := []slog.Attr{
slog.String("trace_id", lc.TraceID),
slog.String("task_id", lc.TaskID),
slog.String("task_name", lc.TaskName),
slog.String("node_id", lc.NodeID),
slog.String("stage", lc.Stage),
slog.String("event", "schedule"),
slog.Time("timestamp", time.Now()),
}
tl.logger.With(attrs...).InfoContext(ctx, fmt.Sprintf(msg, args...))
}
func (tl *TaskLogger) LogExecute(ctx context.Context, lc *LogContext, msg string, args ...any) {
attrs := []slog.Attr{
slog.String("trace_id", lc.TraceID),
slog.String("task_id", lc.TaskID),
slog.String("task_name", lc.TaskName),
slog.Int("shard_index", lc.ShardIndex),
slog.String("node_id", lc.NodeID),
slog.String("stage", lc.Stage),
slog.String("event", "execute"),
slog.Time("timestamp", time.Now()),
}
tl.logger.With(attrs...).InfoContext(ctx, fmt.Sprintf(msg, args...))
}
func (tl *TaskLogger) LogError(ctx context.Context, lc *LogContext, err error, msg string, args ...any) {
attrs := []slog.Attr{
slog.String("trace_id", lc.TraceID),
slog.String("task_id", lc.TaskID),
slog.String("task_name", lc.TaskName),
slog.String("node_id", lc.NodeID),
slog.String("stage", lc.Stage),
slog.String("event", "error"),
slog.String("error", err.Error()),
slog.Time("timestamp", time.Now()),
}
tl.logger.With(attrs...).ErrorContext(ctx, fmt.Sprintf(msg, args...))
}
func (tl *TaskLogger) LogFailover(ctx context.Context, fromNode, toNode, taskID string) {
attrs := []slog.Attr{
slog.String("event", "failover"),
slog.String("from_node", fromNode),
slog.String("to_node", toNode),
slog.String("task_id", taskID),
slog.Time("timestamp", time.Now()),
}
tl.logger.With(attrs...).WarnContext(ctx, "failover triggered")
}
// TraceIDGenerator 链路追踪ID生成器
type TraceIDGenerator struct{}
func (t *TraceIDGenerator) Generate(taskID string, fireTime time.Time) string {
return fmt.Sprintf("%s-%d", taskID, fireTime.UnixNano())
}
// TraceSpan 链路追踪span
type TraceSpan struct {
TraceID string
SpanID string
Operation string
StartTime time.Time
EndTime time.Time
Tags map[string]string
Status string
}
// TraceRecorder 链路追踪记录器
type TraceRecorder struct {
spans []*TraceSpan
mu sync.Mutex
}
func (tr *TraceRecorder) StartSpan(traceID, operation string) *TraceSpan {
span := &TraceSpan{
TraceID: traceID,
SpanID: generateSpanID(),
Operation: operation,
StartTime: time.Now(),
Tags: make(map[string]string),
Status: "ok",
}
return span
}
func (tr *TraceRecorder) FinishSpan(span *TraceSpan) {
span.EndTime = time.Now()
tr.mu.Lock()
tr.spans = append(tr.spans, span)
tr.mu.Unlock()
}
func (tr *TraceRecorder) GetTrace(traceID string) []*TraceSpan {
tr.mu.Lock()
defer tr.mu.Unlock()
var result []*TraceSpan
for _, span := range tr.spans {
if span.TraceID == traceID {
result = append(result, span)
}
}
return result
}
func generateSpanID() string {
return fmt.Sprintf("span-%d", time.Now().UnixNano())
}监控不是越多越好,而是越有效越好。一个能准确反映系统健康状态的指标,胜过一百个没人看的仪表盘。
5.5 运维SOP标准操作流程
调度系统的日常运维需要标准化的操作流程。以下是我团队在用的SOP模板:
调度系统运维SOP
一、日常巡检(每日执行)
1. 检查调度节点健康状态:GET /health
2. 检查任务积压情况:queue_depth < 100
3. 检查调度延迟:P99 < 500ms
4. 检查告警历史:是否有未处理的告警
5. 检查存储空间:日志和任务历史不超过80%
二、节点故障处理
1. 确认节点状态:是否真的下线
2. 检查故障转移是否自动触发
3. 验证任务是否正常迁移到其他节点
4. 修复或替换故障节点
5. 将新节点加入集群并验证
三、任务积压处理
1. 查看任务积压数量和增长趋势
2. 检查worker利用率是否过高
3. 手动触发扩容(如果自动扩容未触发)
4. 检查是否有慢任务阻塞worker
5. 必要时暂停低优先级任务
四、存储故障处理
1. 确认etcd/数据库是否可用
2. 如果etcd不可用,切换到备份etcd集群
3. 如果数据库不可用,调度器降级到只读模式
4. 恢复后检查数据一致性
5. 验证任务状态是否正确
五、版本发布流程
1. 新版本灰度发布到一个节点
2. 观察该节点任务执行情况(至少30分钟)
3. 逐步滚动更新其他节点
4. 每更新一个节点,观察5分钟
5. 如有异常,立即回滚该节点
6. 全部更新完成后,进行冒烟测试5.6 容灾与恢复
package scheduler
import (
"context"
"encoding/json"
"fmt"
"io"
"os"
"path/filepath"
"time"
)
// BackupManager 备份管理器
type BackupManager struct {
etcdClient *clientv3.Client
backupDir string
maxBackups int
interval time.Duration
}
// Backup 备份调度系统状态
func (bm *BackupManager) Backup(ctx context.Context) error {
timestamp := time.Now().Format("20060102-150405")
backupPath := filepath.Join(bm.backupDir, fmt.Sprintf("scheduler-backup-%s.json", timestamp))
// 收集所有状态数据
state := make(map[string]interface{})
// 1. 任务定义
tasks, err := bm.etcdClient.Get(ctx, "/scheduler/tasks/", clientv3.WithPrefix())
if err != nil {
return fmt.Errorf("backup tasks failed: %w", err)
}
var taskDefs []map[string]string
for _, kv := range tasks.Kvs {
taskDefs = append(taskDefs, map[string]string{
"key": string(kv.Key),
"value": string(kv.Value),
})
}
state["tasks"] = taskDefs
// 2. 节点信息
nodes, _ := bm.etcdClient.Get(ctx, "/scheduler/nodes/", clientv3.WithPrefix())
var nodeInfos []map[string]string
for _, kv := range nodes.Kvs {
nodeInfos = append(nodeInfos, map[string]string{
"key": string(kv.Key),
"value": string(kv.Value),
})
}
state["nodes"] = nodeInfos
// 3. 分片分配
assignments, _ := bm.etcdClient.Get(ctx, "/scheduler/assignments/", clientv3.WithPrefix())
var assigns []map[string]string
for _, kv := range assignments.Kvs {
assigns = append(assigns, map[string]string{
"key": string(kv.Key),
"value": string(kv.Value),
})
}
state["assignments"] = assigns
// 4. 调度配置
configs, _ := bm.etcdClient.Get(ctx, "/scheduler/config/", clientv3.WithPrefix())
var configVals []map[string]string
for _, kv := range configs.Kvs {
configVals = append(configVals, map[string]string{
"key": string(kv.Key),
"value": string(kv.Value),
})
}
state["configs"] = configVals
state["backup_time"] = time.Now().Format(time.RFC3339)
// 写入备份文件
data, err := json.MarshalIndent(state, "", " ")
if err != nil {
return fmt.Errorf("marshal backup failed: %w", err)
}
if err := os.MkdirAll(bm.backupDir, 0755); err != nil {
return fmt.Errorf("create backup dir failed: %w", err)
}
if err := os.WriteFile(backupPath, data, 0644); err != nil {
return fmt.Errorf("write backup file failed: %w", err)
}
fmt.Printf("[BACKUP] saved to %s (%d bytes)\n", backupPath, len(data))
// 清理旧备份
bm.cleanOldBackups()
return nil
}
// Restore 从备份恢复
func (bm *BackupManager) Restore(ctx context.Context, backupPath string) error {
data, err := os.ReadFile(backupPath)
if err != nil {
return fmt.Errorf("read backup file failed: %w", err)
}
var state map[string]interface{}
if err := json.Unmarshal(data, &state); err != nil {
return fmt.Errorf("unmarshal backup failed: %w", err)
}
fmt.Printf("[RESTORE] restoring from %s\n", backupPath)
// 恢复任务定义
if tasks, ok := state["tasks"].([]interface{}); ok {
for _, t := range tasks {
taskMap := t.(map[string]interface{})
key := taskMap["key"].(string)
value := taskMap["value"].(string)
bm.etcdClient.Put(ctx, key, value)
}
fmt.Printf("[RESTORE] restored %d tasks\n", len(tasks))
}
// 恢复配置
if configs, ok := state["configs"].([]interface{}); ok {
for _, c := range configs {
configMap := c.(map[string]interface{})
key := configMap["key"].(string)
value := configMap["value"].(string)
bm.etcdClient.Put(ctx, key, value)
}
fmt.Printf("[RESTORE] restored %d configs\n", len(configs))
}
// 注意:节点和分片分配不恢复,因为节点可能已经变化
// 让系统重新进行节点注册和分片分配
fmt.Printf("[RESTORE] completed\n")
return nil
}
// cleanOldBackups 清理旧备份
func (bm *BackupManager) cleanOldBackups() {
files, err := os.ReadDir(bm.backupDir)
if err != nil {
return
}
var backups []os.DirEntry
for _, f := range files {
if !f.IsDir() && len(f.Name()) > 20 {
backups = append(backups, f)
}
}
if len(backups) <= bm.maxBackups {
return
}
// 按文件名排序(文件名包含时间戳,天然有序)
for i := 0; i < len(backups)-bm.maxBackups; i++ {
path := filepath.Join(bm.backupDir, backups[i].Name())
os.Remove(path)
fmt.Printf("[BACKUP] cleaned old backup: %s\n", path)
}
}
// StartPeriodicBackup 启动定期备份
func (bm *BackupManager) StartPeriodicBackup(ctx context.Context) {
ticker := time.NewTicker(bm.interval)
defer ticker.Stop()
for {
select {
case <-ctx.Done():
return
case <-ticker.C:\n if err := bm.Backup(ctx); err != nil {
fmt.Printf("[BACKUP] periodic backup failed: %v\n", err)
}
}
}
}容灾不是"出了事怎么办",而是"出了事之后多快能恢复"。备份是为了恢复,不是为了备份本身。
5.7 压测与容量规划
package scheduler
import (
"context"
"fmt"
"sync/atomic"
"time"
)
// LoadTest 压力测试
type LoadTest struct {
scheduler *Scheduler
taskCount int
duration time.Duration
rps int // 每秒提交任务数
}
// Run 执行压测
func (lt *LoadTest) Run(ctx context.Context) *LoadTestResult {
result := &LoadTestResult{
StartTime: time.Now(),
}
var submitted, succeeded, failed int64
ticker := time.NewTicker(time.Second / time.Duration(lt.rps))
defer ticker.Stop()
endTimer := time.After(lt.duration)
for i := 0; i < lt.taskCount; i++ {
select {
case <-ctx.Done():
goto done
case <-endTimer:
goto done
case <-ticker.C:\n atomic.AddInt64(&submitted, 1)\n \n go func(taskNum int) {\n start := time.Now()
task := &TaskInstance{
TaskID: fmt.Sprintf("loadtest-%d", taskNum),
FireTime: time.Now(),
Execute: func() {
// 模拟任务执行
time.Sleep(50 * time.Millisecond)
},
}
err := lt.scheduler.SubmitTask(task)
duration := time.Since(start)
if err != nil {
atomic.AddInt64(&failed, 1)
result.RecordLatency(duration, false)
} else {
atomic.AddInt64(&succeeded, 1)
result.RecordLatency(duration, true)
}
}(i)
}
}
done:
result.EndTime = time.Now()
result.Submitted = atomic.LoadInt64(&submitted)
result.Succeeded = atomic.LoadInt64(&succeeded)
result.Failed = atomic.LoadInt64(&failed)
result.CalculatePercentiles()
return result
}
// LoadTestResult 压测结果
type LoadTestResult struct {
StartTime time.Time
EndTime time.Time
Submitted int64
Succeeded int64
Failed int64
latencies []time.Duration
successLatencies []time.Duration
P50Latency time.Duration
P95Latency time.Duration
P99Latency time.Duration
MaxLatency time.Duration
QPS float64
}
func (r *LoadTestResult) RecordLatency(d time.Duration, success bool) {
r.latencies = append(r.latencies, d)
if success {
r.successLatencies = append(r.successLatencies, d)
}
}
func (r *LoadTestResult) CalculatePercentiles() {
if len(r.latencies) == 0 {
return
}
// 排序
sortDurations(r.latencies)
n := len(r.latencies)
r.P50Latency = r.latencies[n/2]
r.P95Latency = r.latencies[int(float64(n)*0.95)]
r.P99Latency = r.latencies[int(float64(n)*0.99)]
r.MaxLatency = r.latencies[n-1]
duration := r.EndTime.Sub(r.StartTime).Seconds()
if duration > 0 {
r.QPS = float64(r.Succeeded) / duration
}
}
func (r *LoadTestResult) PrintReport() {
fmt.Println("\n========== 压测报告 ==========")
fmt.Printf("持续时间: %v\n", r.EndTime.Sub(r.StartTime))
fmt.Printf("提交任务: %d\n", r.Submitted)
fmt.Printf("成功: %d\n", r.Succeeded)
fmt.Printf("失败: %d\n", r.Failed)
fmt.Printf("成功率: %.2f%%\n", float64(r.Succeeded)/float64(r.Submitted)*100)
fmt.Printf("QPS: %.2f\n", r.QPS)
fmt.Printf("P50延迟: %v\n", r.P50Latency)
fmt.Printf("P95延迟: %v\n", r.P95Latency)
fmt.Printf("P99延迟: %v\n", r.P99Latency)
fmt.Printf("最大延迟: %v\n", r.MaxLatency)
fmt.Println("==============================")
}
func sortDurations(d []time.Duration) {
for i := 1; i < len(d); i++ {
key := d[i]
j := i - 1
for j >= 0 && d[j] > key {
d[j+1] = d[j]
j--
}
d[j+1] = key
}
}容量规划建议基于压测结果来做。一般我会关注这几个指标:
- 单节点最大承载任务数(QPS)
- 不同分片数下的吞吐量变化
- worker池大小与延迟的关系
- 网络带宽占用
容量规划的本质是回答一个问题:在满足SLA的前提下,系统能承载多少负载?这个问题不能用感觉回答,只能用数据回答。
总结与思考
这一章我们从五个维度讲了调度系统的高可用与扩展:
架构层面:多节点部署 + 选主 + 分布式锁 + 故障转移,消灭单点故障。核心是保证Leader挂了能秒级切换,任务不会丢失也不会重复执行。
分片层面:把大任务拆成小任务并行执行。关键是分片策略要均匀、分片失败要可重试、分阶段执行要有Barrier。
扩缩容层面:根据负载自动增减节点。扩容相对简单,缩容的核心是安全排空:停新任务、等完老任务、迁移分片、摘节点。
性能层面:缓存任务定义、时间轮调度、worker池复用。先profile找瓶颈,再针对性优化,不要盲目优化。
运维层面:三维监控(调度/执行/业务)+ 告警规则 + SOP + 容灾备份 + 压测。监控要有效,告警要准确,恢复要快速。
高可用不是一堆技术的堆砌,而是一套完整的体系:架构上消灭单点、策略上消化故障、监控上感知异常、运维上快速恢复。
如果你觉得这篇文章对你有帮助,点个收藏,以后遇到调度系统的问题时翻出来看看。有什么问题或者踩坑经验,评论区交流,我看评论比写文章还认真。
下一章是整个系列的最后一章,我会把分布式调度系统的核心要点做个总结,并且对整个Go专家课程做个全面复盘。写了这么多章,终于要到收尾了。
系列进度 15/16
下章预告:第16章——分布式调度系统总结与课程总复盘。我会把16章内容串成一条线,从基础语法到并发编程到分布式系统,回顾整个学习路径,梳理知识体系,给出进阶建议。最后一章,我们好好收个尾。
怕浪猫说:调度系统是我做过最"刺激"的系统之一。它的特点是平时风平浪静,一旦出事就是大事。凌晨被叫醒的滋味,我尝过太多次了。但正是这些深夜的故障,逼着我把架构想清楚、把代码写扎实、把运维做到位。技术人的成长,很多时候不是来自写过的代码,而是来自填过的坑。希望你读完这章,能在自己的调度系统里少踩几个坑。共勉。