应用层选主:用 etcd client/v3 concurrency 做 Leader Election 的代码与取舍
很多分布式服务需要“同一时刻只有一个实例干活”:定时任务调度、灰度开关、配置热更、单写者消费。这类场景不一定非要自己实现共识,使用 go.etcd.io/etcd 提供的 client/v3/concurrency 包做 leader election 更稳。实现源码见 etcd-io/etcd,Raft 论文原稿可参考 ongardie/dissertation。
为什么用 etcd 而不是 Redis SETNX
- Redis
SETNX + expire抢锁做主选有致命缺陷:锁过期时任务还在跑,另一个实例又抢到锁,造成双活。 - etcd 选主基于租约(lease)+ 版本号(revision),竞选结果强一致且选举序号单调递增,能规避脑裂。代价是需要维护一整套 etcd 集群,吞吐不如 Redis。
- 工程取舍取决于对“选错主”后果的容忍度:定时任务错跑一次可能无所谓,账务回放、消息重复消费这类任务不能接受双主。
核心代码:client/v3/concurrency
用 concurrency.NewSession 建会话并设置 TTL(lease 时长),用 concurrency.NewElection 执行 Campaign / Resign / Observe。
下面是最小示例结构:
import (
"context"
"log"
"time"
clientv3 "go.etcd.io/etcd/client/v3"
"go.etcd.io/etcd/client/v3/concurrency"
)
cli, _ := clientv3.New(clientv3.Config{
Endpoints: []string{"http://127.0.0.1:2379"},
DialTimeout: 5 * time.Second,
})
defer cli.Close()
// TTL 是租约时长,必须明显大于业务心跳/任务最长耗时
sess, _ := concurrency.NewSession(cli, concurrency.WithTTL(10))
defer sess.Close()
e := concurrency.NewElection(sess, "/app/scheduler/leader")
if err := e.Campaign(context.Background(), "instance-01"); err != nil {
log.Fatal(err)
}
// Campaign 返回时本实例已是 leader,此时才开始干活
续约是自动的,但任务时长要小于 TTL
concurrency.Session 后台会自动续约 lease(keepalive),所以 TTL 决定的是“与 etcd 失联多久后放弃领导权”。
需要注意:如果单次任务执行超过 TTL,且中间网络抖动导致一次续约失败,etcd 会认为租约过期、锁被释放,另一个实例拿到主,此时当前任务还在跑,就是隐性双主。
工程上两条路:
- 把 TTL 调大到能容忍一次续约失败 + 单任务时长;
- 或把任务拆小,每步通过
Observe检查自己还是不是 leader。
优雅让位:Resign 与防误杀
主动让位用 e.Resign(),避免被 etcd 侧因 term/lease 变化被动踢掉时留下“我以为还是主”的空窗。停机时 defer Resign 比直接退出干净。
失联与脑裂规避
etcd 选主由多数派机制保证,只有拿到 quorum 的候选才能当选。分区后少数派侧的 leader 无法续约,会自动退位,系统在多数派侧重新选主,这是和 Redis 方案的本质差异。
只读请求也建议带 clientv3.WithRequireLeader(true),让读操作在多数派上执行,避免读到过期 lease。
调参参考
- Session TTL:建议至少为业务心跳间隔的 3 倍。
- 客户端
DialTimeout、AutoSyncInterval影响断线感知速度。 - etcd 服务端 raft 层 heartbeat/election timeout:默认约 heartbeat 100ms / election 1000ms 量级。etcd v3 实际以 tick 配置,flagger 等框架另算,业务选主一般不用动它。
结论 / 取舍
业务要的是“选主”语义,不是共识协议本身。用 etcd concurrency 可以把 Leader Election、lease 续约、多数派保证直接接好。生产环境不要手写 Raft,也不要为省事用裸 Redis SETNX 扛强一致。真正需要自研共识的,是确实要把写路径做成分布式日志复制的系统。
参考资料:etcd-io/etcd 仓库 client/v3/concurrency 源码,go.etcd.io/etcd client/v3,Raft 论文原稿 ongardie/dissertation。