多模式 Leader 选举实现

深入 go-ai-scheduler 的 Leader 选举设计,对比 etcd、MySQL GET_LOCK 和本地三种实现,分析优雅降级链路的工程取舍。

问题背景

go-ai-scheduler 的调度器需要保证全局只有一个实例在执行 trigger loop、retry loop 和任务分发。如果多个调度器实例同时运行,会导致:

  1. 任务重复触发:同一个 cron 任务被多个调度器同时扫描到,生成多个实例
  2. Worker 状态冲突:多个调度器同时修改 Worker 的 CurrentLoad
  3. 背压判断失真:每个调度器独立计算 pending 数,无法反映真实负载

Leader 选举是解决这个问题的标准方案。但生产环境的基础设施千差万别——有些团队已经有 etcd 集群,有些只有 MySQL,还有些只想单机跑一下。go-ai-scheduler 选择支持三种后端,通过统一的接口和自动降级来适应不同环境。

统一接口

所有 Leader 选举实现都遵循同一个接口:

// internal/scheduler/leader/elector.go
type Elector interface {
    Acquire(context.Context) error
}

Acquire 是阻塞式的:调用后要么成功获得领导权并返回 nil,要么在 context 取消时返回错误。调度器的 main.go 中是这样使用的:

elector := leader.New(resources.DB, cfg.EtcdAddrs, l)
if err := elector.Acquire(leaderCtx); err != nil {
    l.Error("acquire leadership", "error", err)
    os.Exit(1)
}
// 只有 Leader 才会执行到这里

工厂函数根据配置自动选择实现:

func New(db *sql.DB, etcdAddrs []string, logger *slog.Logger) Elector {
    if len(etcdAddrs) > 0 {
        return newEtcdElector(etcdAddrs, logger)
    }
    if db == nil {
        return &localElector{logger: logger}
    }
    return &mysqlElector{db: db, logger: logger, lockName: "go-ai-scheduler/leader"}
}

etcd 实现:分布式场景的推荐方案

etcd 是生产环境的首选。它使用 etcd 的 concurrency 包实现分布式锁:

func newEtcdElector(addrs []string, logger *slog.Logger) Elector {
    cli, err := clientv3.New(clientv3.Config{
        Endpoints:   addrs,
        DialTimeout: 5 * time.Second,
    })
    if err != nil {
        logger.Warn("failed to create etcd client, falling back to local", "error", err)
        return &localElector{logger: logger}
    }
    return &etcdElector{client: cli, prefix: "/go-ai-scheduler/leader"}
}

Campaign 竞选

func (e *etcdElector) Acquire(ctx context.Context) error {
    session, err := concurrency.NewSession(e.client, concurrency.WithTTL(5))
    if err != nil {
        e.logger.Warn("etcd session creation failed, falling back to local", "error", err)
        return (&localElector{logger: e.logger}).Acquire(ctx)
    }

    election := concurrency.NewElection(session, e.prefix)
    if err := election.Campaign(ctx, "scheduler"); err != nil {
        session.Close()
        e.logger.Warn("etcd campaign failed, falling back to local", "error", err)
        return (&localElector{logger: e.logger}).Acquire(ctx)
    }

    // 成功当选 Leader
    metrics.DefaultRegistry.IncCounter("leader_election_total",
        map[string]string{"backend": "etcd", "result": "acquired"})

    go func() {
        <-ctx.Done()
        _ = election.Resign(context.Background())
        session.Close()
        e.client.Close()
    }()

    return nil
}

TTL 与租约机制

concurrency.WithTTL(5) 创建一个 5 秒 TTL 的租约。如果 Leader 进程崩溃或网络分区,etcd 会在租约到期后自动释放锁,其他节点可以参与竞选。

<Callout type="info" title="为什么 TTL 设为 5 秒?"

TTL 的权衡在于故障恢复速度网络抖动容忍度。太短(1 秒)会导致短暂的 GC 停顿就触发重新选举;太长(60 秒)会导致 Leader 故障后系统长时间无 Leader。5 秒是一个经验值,在大多数网络环境下足够稳定。

优雅降级

注意 etcd 实现中有两处降级:

  1. 客户端创建失败:etcd 地址配置错误或服务不可达时,降级为 localElector
  2. Campaign 失败:可能是网络抖动导致,同样降级为 localElector

这种设计的假设是:etcd 不可用比没有 Leader 更糟糕。降级为 local 后,调度器仍能单机运行,只是失去了分布式互斥能力。

MySQL 实现:无额外基础设施的轻量方案

如果团队没有 etcd,MySQL 本身就是一个可用的协调后端。它利用 MySQL 的 GET_LOCK 函数实现互斥:

func (e *mysqlElector) Acquire(ctx context.Context) error {
    for {
        conn, err := e.db.Conn(ctx)
        if err != nil {
            return fmt.Errorf("open leader election connection: %w", err)
        }

        var acquired int
        err = conn.QueryRowContext(ctx, "SELECT GET_LOCK(?, 0)", e.lockName).Scan(&acquired)
        if err == nil && acquired == 1 {
            // 成功获得锁
            go func() {
                <-ctx.Done()
                _, _ = conn.ExecContext(context.Background(), "DO RELEASE_LOCK(?)", e.lockName)
                _ = conn.Close()
            }()
            return nil
        }

        _ = conn.Close()
        if err != nil {
            return fmt.Errorf("acquire mysql leader lock: %w", err)
        }

        // 锁被占用,等待 2 秒后重试
        select {
        case <-ctx.Done():
            return ctx.Err()
        case <-time.After(2 * time.Second):
        }
    }
}

GET_LOCK 的特性

MySQL 的 GET_LOCK 有以下几个关键特性:

  1. 会话级锁:锁与数据库连接绑定,连接断开时自动释放
  2. 非阻塞获取:第二个参数为 0 时,如果锁被占用立即返回 0,不会阻塞等待
  3. 命名锁:锁名是全局的字符串标识,不同连接竞争同一个名字

持有连接不退让

MySQL 实现的核心设计是:Leader 在持有锁期间不关闭数据库连接。这保证了:

  • 进程正常退出时,通过 ctx.Done() 触发 RELEASE_LOCK,立即释放
  • 进程异常崩溃时,MySQL 检测到连接断开,自动释放锁
go func() {
    <-ctx.Done()
    _, _ = conn.ExecContext(context.Background(), "DO RELEASE_LOCK(?)", e.lockName)
    _ = conn.Close()
}()

MySQL 的局限

局限影响
单点依赖MySQL 宕机则无法选举
无 Fencing Token旧 Leader 网络分区恢复后可能"脑裂"继续工作
重试间隔固定2 秒轮询在故障切换时不够快

因此 MySQL 方案适合中小规模、对切换速度不敏感的场景。对于大规模生产环境,etcd 仍是首选。

本地实现:开发测试的便利模式

本地模式是最简单的实现,直接返回成功:

type localElector struct {
    logger *slog.Logger
}

func (e *localElector) Acquire(_ context.Context) error {
    e.logger.Debug("leader election", "backend", "local", "role", "leader")
    return nil
}

它没有任何互斥能力,但让单机开发和测试变得非常简单——不需要 etcd,不需要 MySQL 的 GET_LOCK 权限,直接运行就能工作。

<Callout type="warning" title="localElector 也是降级终点"

注意观察降级链:etcd 失败 → localElector。这意味着即使配置了 etcd,如果连接不上,系统也不会报错退出,而是默默降级为单机模式。这在生产环境中可能是危险的——多节点同时运行时会出现脑裂。

三级降级链路

整个 Leader 选举的降级链路可以画成这样:

配置 etcd 地址?
  ├─ 是 → 尝试连接 etcd
  │         ├─ 成功 → etcdElector(分布式互斥)
  │         └─ 失败 → localElector(无互斥)
  └─ 否 → 有数据库连接?
            ├─ 是 → mysqlElector(单点互斥)
            └─ 否 → localElector(无互斥)

这个设计体现了 go-ai-scheduler 的一个工程哲学:optional 依赖,graceful 降级。系统不会因为缺少某个基础设施就拒绝启动,而是用尽可能弱的一致性保证继续运行。

可观测性

三种实现都上报了 Prometheus 指标:

metrics.DefaultRegistry.IncCounter("leader_election_total",
    map[string]string{"backend": "etcd", "result": "acquired"})

metrics.DefaultRegistry.IncCounter("leader_election_total",
    map[string]string{"backend": "mysql", "result": "contended"})

通过 Grafana 可以监控:

  • 当前 Leader 的 backend 类型
  • MySQL 锁竞争的频率
  • etcd 降级到 local 的次数

总结

go-ai-scheduler 的 Leader 选举设计提供了三个层次的互斥保证:

方案互斥强度额外依赖适用场景
etcd强(分布式)etcd 集群生产多节点
MySQL中(单点)MySQL中小规模
Local开发测试

降级链路让系统在不同环境下都能启动,但也带来了风险:etcd 降级到 local 后可能出现脑裂。生产部署时建议通过监控告警发现降级事件,并尽快恢复 etcd 连接。

下一篇会分析负载感知路由和背压控制,探讨调度器如何在动态 Worker 集群中做出合理的分发决策。