如何使用Zookeeper实现选主逻辑

684 阅读11分钟

一、业务场景

对于一些业务来说,为了避免多机器并发执行造成的并发问题,例如顺序、数据不一致等,需要在多个机器中选举出一个主节点作为核心流程执行者。

具体业务场景包括但不限于:

  1. 数据分布式集群,如Kafka、Hbase,需要选举出主节点处理读写问题。
  2. 定时任务,要求只能单台实例运行。
  3. 对顺序有强制要求的业务。

二、通用方案

通用方案可以抽象为两种:

  1. 单实例部署
  2. 借助外部数据组件协调实例之间的关系

2.1 单实例部署

解决这些问题最简单的方法,就是只部署一台实例来处理业务。但是单实例的服务可靠性太低,如果业务非常重要,必须需要做到多个副本冷备,随时替换掉宕机的主节点,进而实现高可用。

2.2 多实例部署

对于多实例部署,我理解的实现成本较低的方法为:

  1. 分布式锁
  2. Zookeeper

分布式锁

分布式锁的产生就是为了解决多实例并发问题,无论是使用redis、mysql等数据库。这就是天然的解决方案。 例如以Redis为例,有两种场景:

1.经典分布式锁场景

执行前获取锁,执行完毕后释放锁。

最大的问题就是:例如每次业务调用都需要申请、释放分布式锁,会产生不少的网络性能开销。对于并发较高的服务不太适合。

2.获取锁之后,不再释放锁

通过异步新起一个线程,每隔一段时间恢复该key的ttl,避免锁失效。只要这个锁存在,就代表这个实例为主节点,其他实例取不到锁就自循环。当主节点宕机后,由于ttl不再被补充,过段时间锁就会失效,其他实例抢到锁后成为新的主节点,即redis实现的选主流程。

但使用redis选主这种场景,也会有一些痛点:

例如实例A获取到了锁但突然发生了宕机,实例B需要等ttl时间后才能获取到锁继续任务。所以如何设计ttl成为了一个问题。设置过大,会导致宕机时长时间的停止服务,设置过小,有可能守护线程还没来得及补充ttl就提前释放了锁,导致其他实例抢夺发生并发问题。

Zookeeper

zookeeper能力有很多,包括配置管理、命名服务、集群管理、分布式通知等。其实的重点功能就是选主能力,通过watch监听的机制保证各个节点能够获取到其他节点的状态,进而选举出新的master来执行任务。zookeeper极其成熟可靠,接入zookeeper的成本和复杂度也比较低,是针对高可用服务选主最好的方案。(这里不过多介绍zookeeper的机制和实现原理)

分布式锁和选主流程本质上有很多的相同点,针对不同的场景要使用更适合的工具

三、CuratorFramework

在介绍实际代码前,需要对CuratorFrameWork工具类进行介绍,因为下面的大部分代码都需要用到这个。

CuratorFrameWork是Java针对zookeeper更高级的抽象API,封装了原始Client的一些不太友好的使用逻辑。我通过举例的方式介绍:

3.1 启动客户端

// 重试策略,下面的表示要重试三次,间隔10秒 
RetryPolicy retryPolicy = new ExponentialBackoffRetry(10000, 3); 
// 创建客户端,最基本要求域名端口和重试策略
final CuratorFramework client = CuratorFrameworkFactory.builder() 
    .connectString("127.0.0.1:2181") 
    .retryPolicy(retryPolicy) 
    .build(); // 启动 client.start();

3.2 创建节点

// 链式调用创建节点 
client.create() .creatingParentContainersIfNeeded() // 如果不存在父节点,就先创建持久父节点。否则创建时未找到父节点会报错 
    .withProtection() // 避免因网络波动造成客户端重试创建,生成了多个相同节点,造成僵尸节点的情况。简单的原理就是在节点前缀增加唯一id,在重试创建时会判断服务端是否已存在该节点,若存在则复用该节点 
    .withMode(CreateMode.EPHEMERAL_SEQUENTIAL) // 节点类型,PERSISTENT、PERSISTENT_SEQUENTIAL、EPHEMERAL、EPHEMERAL_SEQUENTIAL 
    .inBackground((client,event)->{ // 异步回调函数,会异步返回执行结果,可以对event做自定义处理 //xxx })) 
    .forPath("/test",data); // 创建test节点,数据为data

3.3 事件监听

ZooKeeper原生通过注册Watcher来进行事件监听,但是使用非常不方便,因为watcher只能执行一次,需要开发人员自己反复注册Watcher。

Curator 引入了Cache来实现对ZooKeeper服务端事件的监听。Cache是Curator 中对事件监听的包装,其对事件的监听其实可以近似看作是一个本地缓存视图和远程ZooKeeper视图的对比过程(底层仍是原生API的watch机制),同时Curator能够自动为开发人员处理反复注册监听,从而大大简化了原生API开发的繁琐过程。Cache分为两类监听类型:节点监听和子节点监听。

NodeCache

NodeCache不仅可以用于监听数据节点的内容变更,也能监听指定节点是否存在。主要用于节点数据变动的监听。

// 创建对/test节点的缓存
final NodeCache cache = new NodeCache(client,"/test"); 
cache.start(); 
cache.getListenable().addListener(new NodeCacheListener() { 
    // 引入监听器,当node变动时会执行对应nodeChanged代码 
    @Override public void nodeChanged() throws Exception { 
    System.out.println("cache: "+cache.getCurrentData().getData()); 
    } 
});

PathChildrenCache

PathChildrenCache用于监听指定ZooKeeper数据节点的子节点变化情况。

当指定节点的子节点发生变化时,就会回调该方法。PathChildrenCacheEvent类中定义了所有的事件类型,主要包括新增子节点(CHILD_ADDED)、子节点数据变更(CHILD_UPDATED)和子节点删除(CHILD_REMOVED)三类。

final PathChildrenCache cache = new PathChildrenCache(client, "/test");  // 创建PathChildrenCache
cache.start();
PathChildrenCacheListener pathLisener = new PathChildrenCacheListener() {    // 创建PathChildrenCache监听器,test下的子节点的变动后会调用对应方法

    @Override
    public void childEvent(CuratorFramework client, PathChildrenCacheEvent event)
            throws Exception {
        try {
            switch (event.getType()) {
                case CHILD_ADDED: {
                    break;
                }
                case CHILD_UPDATED: {
                    break;
                }
                case CHILD_REMOVED: {
                    break;
                }
            }
        }catch (Exception e){
        }
    }
};
cache.getListenable().addListener(pathLisener);        // 绑定监听器

连接监听

这个接口用于实现客户端与zk集群连接的状态发生变化时执行回调监听功能。

一般用于:当客户端因为网络等原因断开连接,当网络恢复后,zk客户端与集群会重新连接,但临时节点并不能自动创建。这种情况下我们可以监听重连事件,在重连后手动创建临时节点。

client.getConnectionStateListenable().addListener((client1, newState) -> {    // 创建连接监听器
    if (newState == ConnectionState.LOST) {                                   // 判断当前连接状态,若为失联状态
        // 状态丢失时需要重新注册
        while (true) {
            try {
                if (client1.getZookeeperClient().blockUntilConnectedOrTimedOut()) {    // 尝试连接,连接过程为阻塞状态。若超时则返回false
                    
                    break;
                }
            } catch (Exception e) {
            }
        }
    }
});

四、ZK实现选主

4.1 通过CuratorFramework自主实现ZK选主业务逻辑

4.1.1 创建客户端以及根节点、实例对应的临时节点

// 创建客户端
CuratorFramework client = CuratorFrameworkFactory.builder()
        .connectString(zookeeperServer)
        .retryPolicy(retryPolicy)
        .build();

client.start();

// 创建根节点
Stat stat = client.checkExists().forPath("/test");
if(stat == null){
    client.create()
            .withMode(CreateMode.PERSISTENT)//节点类型,持久节点
            .forPath("test");
}
// 全局线程安全变量,记录该实例注册节点的实际名字
volatile String nodeName;
// 创建该实例对应的临时节点
createTempNode();
// 具体实现如下
void createTempNode(client){
    client.create()
        .withMode(CreateMode.EPHEMERAL_SEQUENTIAL)
        .inBackground((client1, event) -> {
            // 如果回调结果正常,存储返回实际节点名字,例如:/test/node-1
            if (event.getResultCode() == KeeperException.Code.OK.intValue()) {
                nodeName = event.getName();
            }
        })
        .forPath("/test/node");
}

目前实现了初始化的步骤,创建了根节点和实例对应的临时节点。

当该实例断连时(宕机或者网络不通),zookeeper服务端获取不到实例的心跳,就会将对应的临时节点删除掉。我们代码接下来就要实现:当临时节点删除时,我们监听到对应事件,并开始选主。

4.1.2 创建监听器

// 创建连接监听器
client.getConnectionStateListenable().addListener((client1, newState) -> {
    // 当连接为断连时
    if (newState == ConnectionState.LOST) {
        // 阻塞尝试连接,直到连接成功,可以通过参数设置最大重试次数
        client1.blockUntilConnected();
        // 重新创建临时节点。因为如果实例断连,zk服务端会将这个实例对应的临时节点删除掉,所以需要重新创建。(代码在4.1.1)
        createTempNode();
        // 获取当前节点在根节点/test下的位置index(代码实现在下面,nodename为全局变量在4.1.1)
        int index = getIndex(nodeName);
        // 尝试选举leader
        tryElectLead(index)
    }
});

// 创建子节点列表监听器
PathChildrenCache cache = new PathChildrenCache(client, "/test", false);
cache.start();
PathChildrenCacheListener pathListener = (client1, event) -> {
    Integer curIndex = null;
    switch (event.getType()) {
        case CHILD_ADDED, CHILD_UPDATED, CHILD_REMOVED: {
             // 获取当前节点在根节点/test下的位置index(代码实现在下面,nodename为全局变量在4.1.1)
            int index = getIndex(nodeName);
            break;
        }
    }
    // 尝试选举leader
    tryElectLead(index)
};
cache.getListenable().addListener(pathListener);

 
int getIndex(String nodeName){
    // 获取/test下的全部子节点
    List<String> childrens = client.getChildren().forPath("/test");
    // 筛选子节点名称为node,并且按照序号排序
    children = children.stream()
            .filter(s -> s.startsWith("node"))
            .sorted()
            .collect(Collectors.toList());
    // 获取nodeName在子节点的位置
    int curIndex = children.indexOf(nodeName.substring(nodeName.lastIndexOf("/") + 1));
    return curIndex;
}
// 全局变量,表示该实例是否为主节点
volatile boolean leader = false;

void tryElectLead(int index){
    // 选举的逻辑很简单,只要这个节点是子节点最靠前的节点,就为主节点
    if(index == 0){
        leader = true;
    }
}

这些代码实现了:当实例宕机或网络等原因宕机时,zk会释放对应临时节点,其他实例会监听到该事件,尝试进行新主节点的选举。

我这里实现的选举逻辑很简单,就是最早创建的子节点为主节点。使用leader变量来标识该节点是否为主节点,具体需单例运行业务的逻辑可以通过该变量来判断。

4.1.3 优缺点

优点:自己实现逻辑,可以做到一些自定义的操作。例如中间插入一些日志告警、断连的特殊处理逻辑、自定义实现选举算法等。灵活性比较高。

缺点:如果节点部署非常多,PathCache会导致任意节点的上下线都会占用zk的大量网络资源来做事件通知。

4.2 LeaderLatch

为了高效解决zk主要使用场景选主,CuratorFramework有Leader Latch和Leader Election两种选举工具类,可以直接使用。(最简单的方法,一步到位)

代码

    LeaderLatch latch = new LeaderLatch(client, "/test");

    //给latch添加监听,在
    latch.addListener(new LeaderLatchListener() {

      @Override
      public void notLeader() {
        //如果不是leader
        System.out.println("Client [" + thread + "] I am the follower !");
      }

      @Override
      public void isLeader() {
        //如果是leader
        System.out.println("Client [" + thread + "] I am the leader !");
      }
    });

    //开始选取 leader
    latch.start();

4.2.1 底层实现逻辑

底层代码整体抽象上和4.1的自定义写法差不多,核心就是监听到事件变动后执行我们自己实现的notLeader和isLeader方法。

稍微有点区别的就是LeaderLatch并没有使用Cache的方式来监听事件,而是通过异步callback和原生watch来做的监听:每个节点都只会监听上一个节点。当一个节点宕机后,下游只有一个节点会被通知到,然后尝试进行选主操作。这样的好处就是,当实例非常多的情况下,也不会因为某个节点的上下线造成zk对所有实例的通知操作,减轻了zk的压力。而这种复杂的写法全部由工具类来实现,我们只需要调用就可以了。

private void checkLeadership(List<String> children) throws Exception
{
    final String localOurPath = ourPath.get();
    List<String> sortedChildren = LockInternals.getSortedChildren(LOCK_NAME, sorter, children);
    int ourIndex = (localOurPath != null) ? sortedChildren.indexOf(ZKPaths.getNodeFromPath(localOurPath)) : -1;
    if ( ourIndex < 0 )
    {
        log.error("Can't find our node. Resetting. Index: " + ourIndex);
        reset();
    }
    else if ( ourIndex == 0 )
    {
        setLeadership(true);
    }
    else
    {
        String watchPath = sortedChildren.get(ourIndex - 1);
        Watcher watcher = new Watcher()
        {
            @Override
            public void process(WatchedEvent event)
            {
                if ( (state.get() == State.STARTED) && (event.getType() == Event.EventType.NodeDeleted) && (localOurPath != null) )
                {
                    try
                    {
                        getChildren();
                    }
                    catch ( Exception ex )
                    {
                        ThreadUtils.checkInterrupted(ex);
                        log.error("An error occurred checking the leadership.", ex);
                    }
                }
            }
        };

        BackgroundCallback callback = new BackgroundCallback()
        {
            @Override
            public void processResult(CuratorFramework client, CuratorEvent event) throws Exception
            {
                if ( event.getResultCode() == KeeperException.Code.NONODE.intValue() )
                {
                    // previous node is gone - reset
                    reset();
                }
            }
        };
        // use getData() instead of exists() to avoid leaving unneeded watchers which is a type of resource leak
        client.getData().usingWatcher(watcher).inBackground(callback).forPath(ZKPaths.makePath(latchPath, watchPath));
    }
}

4.2.2 优缺点

优点:开发非常简单,代码非常严谨,不会出现编写错误而造成线上问题。执行的效率也很高。

缺点:灵活度较差,不能完全满足业务需求。

五、如何选择

对于重要性较低的服务,例如刷数的定时任务等,可以使用单实例部署,做好实例重启监控重跑就可以。

对于高可用的服务,更推荐使用zk来实现,具体的代码可以根据业务需求来选择。如果开发时间紧张就用LeaderLatch,如果业务需求不能满足就自定义选主逻辑。