Redis 的主从复制模式下,一旦主节点不可达,需要人工干预进行故障转移,无论对于 Redis 的使用方还是运维方都带来了极大不便。对应用方来说无法感知主节点的变化,可能会造成数据丢失或读错误,也可能会短暂的服务不可用。对于运维来说,整个故障转移需要人工介入,实时性和准确性都无法保障。

Redis Sentinel(哨兵) 就是为了解决这些问题而生的,它是 Redis 2.8 引入的高可用实现方案,包括故障发现、故障转移、配置中心、客户端通知等。

本文的关注点是哨兵下的客户端连接问题。

本文大部分摘录自:Redis开发与运维

哨兵是一个配置提供者,而不是代理。在引入哨兵之后,客户端会先连接哨兵,再获取到主节点之后,客户端会和主节点直接通信。如果发生了故障转移,哨兵会通知到客户端。这也需要客户端对哨兵的显式支持。

最了解主节点信息的就是哨兵节点集合,各个主节点是用 进行标识。所以无论是哪种编程语言的客户端,都需要知道哨兵节点集合和 masterName 两个参数才能正确连接哨兵。

哨兵客户端的基本原理

实现一个哨兵客户端的基本步骤如下:

  1. 遍历哨兵集合获取到一个可用的哨兵节点。因为哨兵节点之间是共享数据的,任意节点都可以获取到主节点的信息。

  2. 通过 sentinel get-master-addr-by-name master-name API 来获取对应主节点的信息。

  3. 验证获取到的主节点是不是真正的主节点,防止故障转移期间主节点的变化。

  4. 保持和哨兵节点集合的联系,时刻获取关于主节点的相关信息。

从上面的模型可以看出,哨兵客户端只在初始化和切换主节点时才需要和哨兵集合通信,来获取主节点的信息。

那么,哨兵是如何发现主节点变化的呢?答案是通过定时监控任务。

哨兵监控任务

一套合理的监控机制是哨兵节点判定节点不可达的重要保证,Redis 哨兵通过三个定时监控任务完成对各个节点发现和监控:

1. 每隔10秒,每个哨兵节点会向主节点和从节点发送 info 命令获取最新的拓扑结构。

这个定时任务的作用具体可以表现在三个方面:

  • 通过向主节点执行 info 命令,获取从节点的信息,这也是为什么哨兵节点不需要显式配置监控从节点。
  • 当有新的从节点加入时都可以立刻感知出来。
  • 节点不可达或者故障转移后,可以通过 info 命令实时更新节点拓扑信息。

2. 每隔2秒,每个哨兵节点会向 Redis 数据节点的 __sentinel__:hello 频道上发送该哨兵节点对于主节点的判断以及当前哨兵节点的信息,同时每个哨兵节点也会订阅该频道,来了解其他哨兵节点以及它们对主节点的判断。

这个定时任务可以完成以下两个工作:

  • 发现新的哨兵节点:通过订阅主节点的 __sentinel__:hello 了解其他的哨兵节点信息,如果是新加入的哨兵节点,将该哨兵节点信息保存起来,并与该哨兵节点创建连接。
  • 哨兵节点之间交换主节点的状态,作为后面客观下线以及领导者选举的依据。

3. 每隔1秒,每个哨兵节点会向主节点、从节点、其余哨兵节点发送一条 ping 命令做一次心跳检测,来确认这些节点当前是否可达。

通过上面的定时任务,哨兵节点对主节点、从节点、其余哨兵节点都建立起连接,实现了对每个节点的监控,这个定时任务是节点失败判定的重要依据。

Jedis 操作哨兵

Jedis 针对 Redis 哨兵给出了一个 JedisSentinelPool,很显然这个连接池保存的连接还是针对主节点的。Jedis 给出很多构造方法,其中最全的如下所示:

public JedisSentinelPool(String masterName, Set<String> sentinels,
    final GenericObjectPoolConfig poolConfig, final int connectionTimeout,
    final int soTimeout,
    final String password, final int database,
    final String clientName)

其中,

  • masterName:主节点名。
  • sentinels:哨兵节点集合。

JedisSentinelPool 的初始化方法。

public JedisSentinelPool(String masterName, Set<String> sentinels,
    final GenericObjectPoolConfig poolConfig, final int connectionTimeout,
    final int soTimeout, final String password, final int database,
    final String clientName) {
        
        HostAndPort master = initSentinels(sentinels, masterName);
        initPool(master);
        
}

下面的代码就是 JedisSentinelPool 初始化代码的重要函数 initSentinels(Set<String>sentinels,final String masterName) 包含了哨兵节点集合和 masterName 参数,用来获取指定主节点的 ip 和端口。

private HostAndPort initSentinels(Set<String> sentinels, final String masterName) {
    // 主节点
    HostAndPort master = null;
    // 遍历所有 哨兵 节点
    for (String sentinel : sentinels) {
        // 连接 哨兵 节点
        HostAndPort hap = toHostAndPort(Arrays.asList(sentinel.split(":")));
        Jedis jedis = new Jedis(hap.getHost(), hap.getPort());
        // 使用 sentinel get-master-addr-by-name masterName 获取主节点信息
        List<String> masterAddr = jedis.sentinelGetMasterAddrByName(masterName);
        // 命令返回列表为空或者长度不为 2 ,继续从下一个  节点查询
        if (masterAddr == null || masterAddr.size() != 2) {
            continue;
        }
        // 解析 masterAddr 获取主节点信息
        master = toHostAndPort(masterAddr);
        // 找到后直接跳出 for 循环
        break;
    }

    if (master == null) {
        // 直接抛出异常,
        throw new Exception();
    }

    // 为每个哨兵节点开启主节点 switch 的监控线程
    for (String sentinel : sentinels) {
        final HostAndPort hap = toHostAndPort(Arrays.asList(sentinel.split(":")));
        MasterListener masterListener = new MasterListener(masterName, hap.getHost(),
        hap.getPort());
        masterListener.start();
    }

    // 返回结果
    return master;
}

具体过程如下:

  1. 遍历节点集合,找到一个可用的节点,如果找不到就从节点集合中去找下一个,如果都找不到直接抛出异常给客户端:
  2. 找到一个可用的节点,执行 sentinelGetMasterAddrByName(masterName),找到对应主节点信息:
  3. JedisSentinelPool 中没有发现对主节点角色验证的代码,这是因为 get-master-addr-by-name master-name 这个API本身就会自动获取真正的主节点(例如故障转移期间)。
  4. 为每一个节点单独启动一个线程,利用 Redis 的发布订阅功能,每个线程订阅哨兵节点上切换主节点的相关频道 +switch-master

下面代码就是 MasterListener 的核心监听代码,代码中比较重要的部分就是订阅节点的 +switch-master 频道,它就是 Redis 在结束对主节点故障转移后会发布切换主节点的消息,哨兵节点基本将故障转移的各个阶段发生的行为都通过这种发布订阅的形式对外提供,开发者只需订阅感兴趣的频道即可。

Jedis sentinelJedis = new Jedis(sentinelHost, sentinelPort);
// 客户端订阅哨兵节点上"+switch-master"(切换主节点)频道
sentinelJedis.subscribe(new JedisPubSub() {
    @Override
    public void onMessage(String channel, String message) {
        String[] switchMasterMsg = message.split(" ");
        if (switchMasterMsg.length > 3) {
            // 判断是否为当前 masterName
            if (masterName.equals(switchMasterMsg[0])) {
                // 发现当前 masterName 发生 switch ,使用 initPool 重新初始化连接池
                initPool(toHostAndPort(switchMasterMsg[3], switchMasterMsg[4]));
            }
        }
    }
}, "+switch-master");

Reference