简单共识选主实现自定义分布式锁

一些任务只需要一个实例执行,由于高可用要求,需要多台实例。那么多实例通信就成问题,而一些情况下环境比较苛刻,没有组件可以借用,简直为难老实人。

共识算法

有多个实例想要达成共识,那么可分为两个阵营:拜占庭将军问题和非拜占庭将军问题。由于咱们环境比较苛刻,换句话就是都可信,那么就是非拜占庭节点,降低思考难度。接着常见的分布式一致性协议有:

  • Paxos
  • Bully
  • Raft
  • Zab
  • Gossip

这里 Paxos(难实现),Zab(不如Raft通用),这样又可以缩减下分析目标。接着考虑Gossip,实际上很快,不过理论上可以暂时不一致,所以也移除。分析下bully的选举:长者为尊,leader挂了,节点通知长者进行选举,没回应轮到自己。听起来就简单霸道易实现,缺点也能感觉到,长者加入退出都要触发选举,速度也有点慢。再看看历史,Mongo以及ES早期都是用Bully,后期都转成了类Raft,基本筛选完毕。

Raft简介

Raft算法选主中集群各个节点的角色,一共有3中角色:

  • Leader: 为主节点,同一时刻只有一个Leader节点,负责整个集群的节点间的协调和管理。
  • Candidate: 候选节点,只有角色为候选者的节点才可以被选为新的Leader,每个节点 都有可以成为候选者。
  • Follower: Leader的跟随者,这个角色的时候不可以发起选主。

选举流程:

  1. 初始化时,各个节点均为Follower状态。
  2. 开始选主时,所有节点的Follower状态转为Candidate状态,并向其他节点发送选主请求。
  3. 其他节点根据收到的选主请求的先后顺序,进行回复是否同意其成为主节点;每个节点只能投一张票。
  4. 发起选主的节点如果得到一半以上的投票,则会成为主节点,状态变为Leader,其他几点则会由Candidate转为Follower状态,此时Leader和Follower将保持心跳检测。
  5. 如果Leader节点的任期到了,Leader则会降为Follower,进行新一轮选主。

总结与剪裁

咱们所有节点每个都是平等的,不存在状态问题,因为任何时候任何节点都时可以当选主;那么Raft中需要多数人的投票就可以参考bully算法,通过任期与自身ID判定,且任期永远有效。就这么直接,那么步骤3的回复也可以省略,消息结构体也只需要一种,结构如下:

  • Term 节点毛遂自荐时候的时间戳,当做任期
  • Current 节点发送心跳时候的时间
  • Id 节点ID

选举流程:

  1. 初始化时,各个节点均为Follower状态。
  2. 开始选主时,所有节点的Follower状态转为Candidate状态,并向其他节点发送自身心跳。
  3. 其他节点收到心跳,对比自己心跳,Term比自身小,或者相同是ID比自身大,则节点降为Follower,不在发送心跳。
  4. 当Candidate持续心跳有效期时间N内未收到其他节点的心跳,则晋升为Leader,周期发送心跳。

新节点加入:

  1. 节点加入一个检测周期后,收到有效心跳,则沉默为Follower,否则进入Candidate。
  2. 参考初始选举步骤3,新加入任期一定更大,所以不会有波动。

Leader挂了:

  1. Follower节点经过多个检测周期,直到有效期N失效后,各自进入Candidate,开始互发心跳。
  2. 参考初始选举步骤3。

还有其他场景,咱们也理理:非Leader节点挂了不影响;Leader假死则回归那就是上任归来,现任让位;如果脑裂回归,那也是直接PK,谁老谁连任。这样简单的选择,大部分场景都满足了。

代码实现

RPC选择

心跳带有有效期,因此过期的没意义,且Leader会一直发,则丢了一两个也行。总结下咱们采用UDP,逻辑更简单,效率更高。第二个问题就是Socket Recv阻塞,有两个简单方法:丢个回调函数,起个线程recv然后callback;第二个来个Queue,起个线程recv然后压入。资源都是一个线程,不过Queue的话,框架只是数据读取不处理,逻辑更清晰,所以咱们选择后者。

import marshal
import socket
import threading


class PeerSocket(object):

    def __init__(self, endpoint, queue):
        self.endpoint = endpoint
        self.sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
        self.queue = queue
        self.start()

    def listen(self):
        self.sock.bind(self.endpoint)
        while True:
            content = self.sock.recv(256)
            data = marshal.loads(content)
            self.queue.put(data)

    def send(self, data, addr):
        content = marshal.dumps(data)
        self.sock.sendto(content, addr)

    def start(self):
        task = threading.Thread(target=self.listen)
        task.setDaemon(True)
        task.start()

接着咱们设计需要共享的心跳以及状态:

    def reset(self):
        self.heartbeat = {
            "term": float('inf'),
            "current_ts": float('-inf'),
            'id': -255
        }
        self.status = Role.follower.value

基于流程,咱们合并所有操作,得出主逻辑如下:

    def watch(self):
        while True:
            with self.lock:
                if self.status == Role.leader.value:
                    self.send_followers()
                elif self.status == Role.candidate.value:
                    self.recv_leader()
                    self.send_followers()
                else:
                    self.recv_leader()
                self.clean()
            time.sleep(0.5)

咱们也可以换个写法,概括一下就是(通常在不影响性能的情况下,咱们更喜欢直白的写法,后期回看好看懂):

  1. 当角色不是Follower,则需要发送心跳
  2. 当角色不是Leader,则需要接收心跳
  3. 每个检测周期,都是检测心跳是否过期,更新自身状态

这样花费2个线程,少量代码,老实人也能在苛刻环境下实现节点高可用了。源代码不多,参考地址: https://github.com/maycap/simpleleader

posted @ 2022-05-24 17:00  last_coding  阅读(177)  评论(0编辑  收藏  举报