分布式锁与领导者选举

50 阅读 2433 字 · 约 9 分钟

多节点环境下,谁说了算?这不是哲学问题,是工程问题。

一个操作要修改共享资源,多个节点不能同时动手——需要一把锁。一个集群要决定哪个节点当 leader——需要选主。两个场景看起来不同,但本质上都在做同一件事:排他性地指定一个节点,让它说了算。

为什么锁和选主是一回事

分布式锁是临时的排他。锁住一个资源,操作完释放。生命周期是操作级别的——几秒、几十秒。

领导者选举是长期的排他。选出一个 leader,集群生命周期内所有协调工作都由它来。挂了才换。生命周期是集群级别的——可能是小时、天。

但机制是相通的。锁需要一个地方记录「谁持有」,选主需要一个地方记录「谁是主」。锁要防死锁(持有者挂了),选主要防脑裂(两个节点都觉得自己是主)。锁要 fencing(拦住过期的持有者),选主要 fencing token(拦住旧 leader 继续写)。两个问题在不同生命周期上,解决机制高度重合。

锁是短周期排他、选主是长期排他,机制相通;共同陷阱是持有者失联——用 fencing token 兜底:带单调递增编号,存储端只认最新

分布式锁的实现方式

MySQL 锁。 最原始的方案。用一张表的一行记录当锁——INSERT 成功就是拿到锁,DELETE 释放。或者用 SELECT ... FOR UPDATE 做行级锁,靠数据库事务保证互斥。

好处是简单——不需要额外组件,现有数据库直接顶上。坏处是数据库不是为锁设计的:没有自动过期机制,持有者挂了锁就死在那。你需要手动加心跳、加超时清理,代码量不小。而且锁的争抢打在数据库上,流量大时数据库成瓶颈。

Redis 锁。 Redis 的 SET NX EX 天然适合做锁:key 是锁名,value 是持有者标识,NX 保证只新增,EX 设过期时间。

单节点 Redis 锁很简单。但 Redis 是主从复制、异步同步——主挂了切从,锁可能丢了。Redlock 算法(Redisson 实现了)用多个独立 Redis 节点,超过半数拿到锁才算获取成功,试图补上主从切换的窗口。但 Redlock 有争议——Martin Kleppmann 指出,如果锁持有者的 GC 停顿超过锁过期时间,任何基于时间的锁都不可靠。Redis 作者 Antirez 回了,双方你来我往,结论是:用 Redlock 的多数派机制比单节点可靠,但真正确性的上限还是共识协议。

etcd / Zookeeper 锁。 基于共识协议,正确性最高。用临时租约(lease)加自动续约:客户端创建 lease,到期前定期续约,过期自动释放。etcd 的 lease 机制和 Raft 集成——锁的状态经过 Raft 的共识,不会丢。

Zookeeper 用临时顺序节点:每个客户端在锁路径下创建一个临时有序节点,序号最小的拿到锁。锁释放时删节点,Zookeeper 通知下一个等待者。这个设计的好处是:公平排队——先来的先拿锁。坏处是:羊群效应——锁释放时所有等待者同时被叫醒,只有一个能拿到。不过 Zookeeper 的 watch 机制可以只通知下一个,不是全量。

领导者选举

选主比锁更长期,但核心一样:争排他权。

Raft 选举。 Leader 定期发心跳。Follower 等不到心跳就变 Candidate,给自己投票,请求其他节点投票。拿到多数票变 Leader。如果票数分散(两个 Candidate 同时发起),随机超时重试——谁先超时谁先发起。

Zookeeper 选主。 用临时顺序节点:所有候选者在选举路径下创建临时节点,序号最小的当 Leader。Leader 挂了临时节点自动消失,下一个序号的接任。这个设计比 Raft 选举更依赖外部存储——Zookeeper 是独立的协调服务,选主逻辑在客户端而不是协议内部。

Kafka 的 controller 选举。 每个 partition 有一个 leader,所有 partition 的 leader 分布在不同的 broker 上。Controller 是集群级别的协调者——负责 partition leader 的分配。Controller 选举用 Zookeeper(老版本)或 KRaft(新版本),也是临时节点 + 序号。Kafka 的特殊之处在于:Controller 只有一个,但 partition leader 有很多——是两级选举。

HDFS NameNode HA。 两个 NameNode,一主一备。主挂了备用接管。选主靠 Zookeeper 的临时节点——谁持有所对应的锁节点谁就是主。备用 NameNode 持续拉取主 NameNode 的 edit log,保证挂了能无缝接上。

锁和选主的共同陷阱

过期了怎么办。 锁有超时,选主有心跳。锁过期了但持有者还在操作——数据就脏了。选举时旧 leader 以为自己还是主,还在写——脑裂。

解决靠 fencing token。锁和选主都适用:每次获取锁或当选时,带上一个单调递增的 fencing token。写入时带上 token,存储端只接受最新的 token——旧 token 的操作直接拒绝。etcd 的 lease 机制带 ID,就是 fencing token 的一种实现。

网络分区。 锁持有者和锁存储网络断了,锁超时释放,另一个客户端拿到锁,原来的客户端还以为自己持锁。两个同时写——数据不一致。

GC 停顿。 更隐蔽。不是网络断了,是进程自己 GC 卡了几十秒。卡完醒来,锁早过期了,别人拿了新锁。你拿着旧锁继续写——fencing token 能拦住,前提是存储端实现了 token 校验。

这些陷阱不分锁还是选主——只要涉及排他性决策,同一个坑等着。所以分布式锁不是「锁住就行」,要考虑持有者如果失联了怎么保数据正确。

小结

分布式锁和领导者选举是共识问题的两个应用。锁是短周期的「谁操作」,选主是长周期的「谁兜底」。实现上,MySQL 锁是数据库兼职,Redis 锁是缓存兼职,etcd/Zookeeper 锁是基于共识的正牌。选主靠临时节点、lease 和心跳。

但锁和选主的真正难点不在获取——在持有。拿到了之后,持有者挂了、网络分区了、GC 停顿了,怎么保证没有两个人同时认为自己说了算。fencing token 是答案:每次获取锁/当选主都带一个递增编号,存储端只认最新的。分布式系统里,「谁说了算」的答案永远是「带版本号,而且版本号要单调递增」。