Leader 选举:MySQL GET_LOCK 与 etcd 的双backend设计

深入分析 go-ai-scheduler 的 Leader 选举实现,对比 MySQL GET_LOCK 与 etcd Campaign 两种方案,理解降级策略与多实例部署模式。

为什么需要 Leader 选举

go-ai-scheduler 的 Scheduler 服务可以启动多个实例,但触发循环、重试循环、分发决策只能由一个实例执行。否则:

  • 同一 Cron 任务可能被多个 Scheduler 同时触发,导致重复执行
  • 重试循环可能把同一个失败实例分发给多个 Worker
  • 任务状态更新产生竞态条件

Leader 选举的目标是:保证任何时刻只有一个 Scheduler 在干活,其余实例休眠等待

Scheduler-1          Scheduler-2          Scheduler-3
    │                    │                    │
    │  Acquire Leader    │                    │
    │◄───────────────────│                    │
    │                    │                    │
 Leader (运行中)      Follower (等待)      Follower (等待)
    │                    │                    │
    │  心跳丢失 / 崩溃    │                    │
    │───────────────────►│                    │
    │                    │  Acquire Leader    │
    │                    │◄───────────────────│
    │                    │                    │
  不可用              Leader (接管)         Follower

双 Backend 设计

项目支持两种 Leader 选举后端:MySQL GET_LOCKetcd,通过配置自动选择。

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, lockName: "go-ai-scheduler/leader"}
}

选择优先级:etcd > MySQL > local。如果 etcd 配置失败,会自动降级到 local(单实例模式)。

MySQL GET_LOCK 方案

type mysqlElector struct {
    db       *sql.DB
    logger   *slog.Logger
    lockName string
}

func (e *mysqlElector) Acquire(ctx context.Context) error {
    for {
        conn, err := e.db.Conn(ctx)
        // 尝试获取锁,超时时间为 0(不等待)
        var acquired int
        err = conn.QueryRowContext(ctx, "SELECT GET_LOCK(?, 0)", e.lockName).Scan(&acquired)

        if acquired == 1 {
            // 获取成功,成为 Leader
            go func() {
                <-ctx.Done()
                _, _ = conn.ExecContext(context.Background(), "DO RELEASE_LOCK(?)", e.lockName)
                _ = conn.Close()
            }()
            return nil
        }

        // 获取失败,2 秒后重试
        select {
        case <-ctx.Done():
            return ctx.Err()
        case <-time.After(2 * time.Second):
        }
    }
}

关键点

  1. 每个实例持有独立连接GET_LOCK 是连接级别的锁,锁的生命周期与连接绑定
  2. 锁获取不等待timeout = 0 意味着立即返回,不会阻塞
  3. 自动释放:连接断开时 MySQL 自动释放锁
  4. 退避重试:获取失败后 2 秒再试,避免 CPU 空转

优点与局限

优点局限
无需额外中间件依赖 MySQL 连接稳定性
实现简单,代码 40 行锁是连接级的,连接池可能意外释放
任何 MySQL 版本都支持需要保证 db.Conn() 返回独立连接

etcd Campaign 方案

func (e *etcdElector) Acquire(ctx context.Context) error {
    session, err := concurrency.NewSession(e.client, concurrency.WithTTL(5))
    election := concurrency.NewElection(session, "/go-ai-scheduler/leader")

    // 参与选举,阻塞直到成为 Leader
    if err := election.Campaign(ctx, "scheduler"); err != nil {
        // 竞选失败,降级到 local
        return (&localElector{logger: e.logger}).Acquire(ctx)
    }

    // 成为 Leader
    go func() {
        <-ctx.Done()
        _ = election.Resign(context.Background())
        session.Close()
        e.client.Close()
    }()
    return nil
}

关键点

  1. Session TTL = 5 秒:如果 Leader 崩溃,5 秒后锁自动释放
  2. 阻塞式竞选Campaign 会阻塞直到成为 Leader,与 MySQL 的轮询不同
  3. 自动降级:etcd 连接失败时自动降级到 localElector,保证系统可用

优点与局限

优点局限
分布式原生,高可用需要额外部署 etcd
自动故障转移(TTL 机制)实现相对复杂
支持跨数据中心网络分区时需要处理脑裂

两种方案对比

维度MySQL GET_LOCKetcd
额外依赖无(复用已有 MySQL)etcd 集群
故障检测连接断开检测TTL 超时(5秒)
获取方式轮询(2秒间隔)阻塞等待
代码量~40 行~50 行
适用场景中小型部署,已有 MySQL大型部署,需要跨 AZ

Local 降级模式

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

当既没有 etcd 也没有 MySQL 时,Scheduler 以 local 模式运行,总是认为自己就是 Leader。这适用于单实例开发/测试环境。

多实例部署模式

                    ┌─────────────┐
                    │   Load      │
                    │  Balancer   │
                    └──────┬──────┘
                           │
           ┌───────────────┼───────────────┐
           │               │               │
    ┌──────▼──────┐ ┌──────▼──────┐ ┌──────▼──────┐
    │ Scheduler-1 │ │ Scheduler-2 │ │ Scheduler-3 │
    │  (Follower) │ │  (Leader)   │ │  (Follower) │
    └─────────────┘ └─────────────┘ └─────────────┘
           │               │               │
           └───────────────┼───────────────┘
                           │
                    ┌──────▼──────┐
                    │   MySQL     │
                    │  GET_LOCK   │
                    └─────────────┘

三个 Scheduler 实例共享同一个 MySQL,通过 GET_LOCK 竞争 Leader。只有 Leader 运行触发循环和重试循环,Follower 只做一件事:不断尝试 Acquire

工程启示

  1. 渐进式依赖:从 local → MySQL → etcd,每层都可以独立工作
  2. 自动降级:etcd 失败不 panic,降级到 local 保证可用性
  3. 接口抽象Elector 接口统一两种实现,调用方无感知
  4. 连接即锁:MySQL 方案充分利用了连接级锁的语义,简洁有效
type Elector interface {
    Acquire(context.Context) error
}

整个 Leader 选举模块不到 150 行代码,却覆盖了从单机开发到分布式生产的全部场景。这是 Go 项目里典型的"够用且简单"的设计。