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_LOCK 和 etcd,通过配置自动选择。
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):
}
}
}
关键点
- 每个实例持有独立连接:
GET_LOCK是连接级别的锁,锁的生命周期与连接绑定 - 锁获取不等待:
timeout = 0意味着立即返回,不会阻塞 - 自动释放:连接断开时 MySQL 自动释放锁
- 退避重试:获取失败后 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
}
关键点
- Session TTL = 5 秒:如果 Leader 崩溃,5 秒后锁自动释放
- 阻塞式竞选:
Campaign会阻塞直到成为 Leader,与 MySQL 的轮询不同 - 自动降级:etcd 连接失败时自动降级到
localElector,保证系统可用
优点与局限
| 优点 | 局限 |
|---|---|
| 分布式原生,高可用 | 需要额外部署 etcd |
| 自动故障转移(TTL 机制) | 实现相对复杂 |
| 支持跨数据中心 | 网络分区时需要处理脑裂 |
两种方案对比
| 维度 | MySQL GET_LOCK | etcd |
|---|---|---|
| 额外依赖 | 无(复用已有 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。
工程启示
- 渐进式依赖:从 local → MySQL → etcd,每层都可以独立工作
- 自动降级:etcd 失败不 panic,降级到 local 保证可用性
- 接口抽象:
Elector接口统一两种实现,调用方无感知 - 连接即锁:MySQL 方案充分利用了连接级锁的语义,简洁有效
type Elector interface {
Acquire(context.Context) error
}
整个 Leader 选举模块不到 150 行代码,却覆盖了从单机开发到分布式生产的全部场景。这是 Go 项目里典型的"够用且简单"的设计。