多模式 Leader 选举实现
深入 go-ai-scheduler 的 Leader 选举设计,对比 etcd、MySQL GET_LOCK 和本地三种实现,分析优雅降级链路的工程取舍。
问题背景
go-ai-scheduler 的调度器需要保证全局只有一个实例在执行 trigger loop、retry loop 和任务分发。如果多个调度器实例同时运行,会导致:
- 任务重复触发:同一个 cron 任务被多个调度器同时扫描到,生成多个实例
- Worker 状态冲突:多个调度器同时修改 Worker 的
CurrentLoad - 背压判断失真:每个调度器独立计算 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 实现中有两处降级:
- 客户端创建失败:etcd 地址配置错误或服务不可达时,降级为
localElector - 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 有以下几个关键特性:
- 会话级锁:锁与数据库连接绑定,连接断开时自动释放
- 非阻塞获取:第二个参数为 0 时,如果锁被占用立即返回 0,不会阻塞等待
- 命名锁:锁名是全局的字符串标识,不同连接竞争同一个名字
持有连接不退让
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 集群中做出合理的分发决策。