记一次JedisCluster开发问题


记一次JedisCluster开发问题

集群架构

节点数:12(6主6从)

47.93.231.115:6378,47.93.231.115:6379,47.93.231.115:6380,47.93.231.115:6381

106.13.124.4:6378,106.13.124.4:6379,106.13.124.4:6380,106.13.124.4:6381

119.3.221.0:6378,119.3.221.0:6379,119.3.221.0:6380,119.3.221.0:6381

p.s. 6378&6379为主节点,6380&6381为对应服务器的从节点。

集群配置

#Redis配置
spring.redis.jedis.pool.max-idle=8
spring.redis.jedis.pool.min-idle=0
spring.redis.jedis.pool.max-wait=5000
spring.redis.cluster.nodes=47.93.231.115:6378,47.93.231.115:6379,47.93.231.115:6380,47.93.231.115:6381,106.13.124.4:6378,106.13.124.4:6379,106.13.124.4:6380,106.13.124.4:6381,119.3.221.0:6378,119.3.221.0:6379,119.3.221.0:6380,119.3.221.0:6381
#最大请求重试次数
spring.redis.cluster.max-redirects=10
#读取数据超时时间
spring.redis.timeout=5000
#链接超时时间
redis.connection.timeout=5000

JedisCluster配置

@Configuration
public class RedisConfig {

    @Value("${spring.redis.cluster.nodes}")
    private String clusterNodes;

    @Value("${spring.redis.timeout}")
    private int timeout;

    @Value("${spring.redis.jedis.pool.max-idle}")
    private int maxIdle;

    @Value("${spring.redis.jedis.pool.max-wait}")
    private long maxWaitMillis;

    @Value("${redis.connection.timeout}")
    private int connectionTimeout;

    @Value("${spring.redis.cluster.max-redirects}")
    private int maxRedirects;

    /**
     * JedisCluster方式操作集群
     * @return
     */
    @Bean
    public JedisCluster getJedisCluster() {
        Set<HostAndPort> nodes = new HashSet<>();
        String[] hostAndPort = clusterNodes.split(",");
        for (String node : hostAndPort) {
            nodes.add(HostAndPort.parseString(node));
        }
        JedisPoolConfig jedisPoolConfig = new JedisPoolConfig();
        jedisPoolConfig.setMaxIdle(maxIdle);
        jedisPoolConfig.setMaxWaitMillis(maxWaitMillis);
        JedisCluster jedisCluster = new JedisCluster(nodes, connectionTimeout, timeout, maxRedirects, jedisPoolConfig);
        return jedisCluster;
    }
}

BUG现象

使用JedisCluster操作集群,操作到节点47.93.231.115:6379(对应槽位2730-5459)上的数据时总是上报redis.clients.jedis.exceptions.JedisClusterMaxAttemptsException: No more cluster attempts left.异常。

原因分析

环境排查

  • 集群状态正常
  • 通过RedisTemplate操作集群正常
  • JedisCluster操作其他节点正常

源码分析

  • 执行System.out.println(jedisCluster.get("go" ));报异常,键go在节点47.93.231.115:6379上。jedisCluster.get()底层调用runWithRetries()方法。
  • runWithRetries()方法
//类JedisClusterCommand 省略大量代码
private T runWithRetries(final int slot, int attempts, boolean tryRandomNode, JedisRedirectionException redirect) {
    if (attempts <= 0) {
      //重试次数逐渐递减 为0后抛出异常
      throw new JedisClusterMaxAttemptsException("No more cluster attempts left.");
    }
    Jedis connection = null;
    try { 
      //尝试获取对应槽位的节点的链接  
      connection = connectionHandler.getConnectionFromSlot(slot);  
      //获取到链接 执行命令
      return execute(connection);
    } catch (JedisConnectionException jce) {
      // 捕获链接异常
      releaseConnection(connection);
      connection = null;
      if (attempts <= 1) {
        this.connectionHandler.renewSlotCache();
      }
      return runWithRetries(slot, attempts - 1, tryRandomNode, redirect);
    }
  }

打断点后发现执行line10无法创建连接,被line13捕获异常。

捕获异常

如此循环往复直到attempts <= 0抛出异常。

  • getConnectionFromSlot() 方法

继续分析runWithRetries()line10getConnectionFromSlot() 方法

 public Jedis getConnectionFromSlot(int slot) {
   //获取Jeids连接池
   JedisPool connectionPool = cache.getSlotPool(slot);
//省略大量代码...
 }

打断点查看connectionPool数据,发现异常。

connectionPool

初始化的connecionPool192.168.1.86:6379,一个不存在的节点,而不是应该访问的节点47.93.231.115:6379

因为访问键go,应该访问节点47.93.231.115:6379,而不是192.168.1.86:6379这个不存在的节点。

  • connecionPoolcache.getSlotPool(slot)而来,查看cache中槽位与节点存在对应关系。

cacheDetails

slots大小为16384,包含了每个槽位与节点的对应关系,上图为slot0对应的节点关系。

槽位节点对应关系

上图发现槽位2730的节点对应IP已出现错误,正好是节点47.93.231.115:6379对应槽位的开头,后面槽位对应的也是相同的错误IP。

接下来分析cache中的槽位节点映射关系为什么会产生错误。

  • JedisCluster在初始化时,会将集群中各节点和哈希槽的映射关系进行缓存
//1、初始化JedisCluster
//类RedisConfig
JedisCluster jedisCluster = new JedisCluster(nodes, connectionTimeout, timeout, maxRedirects, jedisPoolConfig);
//2、方法调用至此
//类JedisClusterConnectionHandler 只展示了必要参数
  public JedisClusterConnectionHandler(Set<HostAndPort> nodes, int connectionTimeout, 		int soTimeout...) {
    //调用该方法初始化节点和槽位映射关系的缓存 
    initializeSlotsCache(nodes, connectionTimeout, soTimeout...);
  }
  • initializeSlotsCache方法
//类JedisClusterConnectionHandler
//初始化节点与槽映射关系 只展示必要参数
  private void initializeSlotsCache(Set<HostAndPort> startNodes, 
      int connectionTimeout, int soTimeout...) {
      //遍历Set集合中的IP与端口
      for (HostAndPort hostAndPort : startNodes) {
      Jedis jedis = null;
      try {
        //创建一个新连接 整个初始化过程中第一次创建连接 
        jedis = new Jedis(hostAndPort.getHost(), hostAndPort.getPort(), connectionTimeout, soTimeout...);
        //省略部分代码...
        //传入连接 通过调用Redis的cluster slots命令获取节点与哈希槽的映射关系 并进行分配
        cache.discoverClusterNodesAndSlots(jedis);
        break;
      } catch (JedisConnectionException e) {
        // try next nodes
      } finally {
        if (jedis != null) {
          jedis.close();
        }
      }
    }
  }

//HostAndPort类重写了hashCode()方法
//当然 即使不重写,String类自己的hashCode()方法对相同字符串hash结果也一定都是相同的
  @Override
  public int hashCode() {return 31 * convertHost(host).hashCode() + port;}

打断点发现,每次第一次创建连接,都连接节点47.93.231.115:6379,即每次遍历Set的第一个元素都是47.93.231.115:6379该节点。

  • 复习HeahSet的遍历

由于HashSet底层使用的是HashMap,当调用set.add(),方法时,底层会调用map.put()方法。

整个调用流程为:

具体细节请移步:Java遍历HashSet为什么输出是有序的?

HashSet add()HashMap put()HashMap hash()HashMap (tab.length - 1) & hash;

下面是执行nodes.add(HostAndPort.parseString(node));时对HashMap源码打的断点。

HashMap断点

从图中可看到:

①处表明此时要存的key是节点47.93.231.115:6379

②处表明角标i(n - 1) & hash而来

③处表明此时i = 0

HashMap要将元素放入哈希表时,通过 (tab.length - 1) & hash确定元素存放的角标。

节点确定→ 节点的hashCode确定→ (tab.length - 1) & hash值确定→ 元素角标确定→ 遍历Set从最小角标开始

总结:每次遍历Set集合的第一个节点都是固定的,即角标为0的那个节点。

因此每次第一次遍历到的节点都是47.93.231.115:6379

  • discoverClusterNodesAndSlots()方法(initializeSlotsCache方法的line13)
public void discoverClusterNodesAndSlots(Jedis jedis) {
 //加锁等操作  省略...
 //获取节点与slots的映射集合
    List<Object> slots = jedis.clusterSlots();
    //根据映射关系初始化到缓存等操作...
}

打断点查看slot的数据结构:

slots

可以看到slots长度为6,其中每个元素长度为4。

查看其元素的细节:

slotsDetails

每个元素的长度为4,其中:

①包含节点对应划分的哈希槽范围,如图5460-8189

②处元素0为哈希槽对应主节点的ip,元素1为port,元素2为节点id。

49 48 54 46 49 51 46 49 50 52 46 52 → 106.13.124.4
98 48 55 100 49 57 54 48 55 100 50 97 101 98 97 57 57 49 52 56 99 48 102 48 52 50 53 55 98 55 54 54 50 56 98 97 52 52 57 48 →
b07d19607d2aeba99148c0f04257b76628ba4490

③处元素0为哈希槽对应从节点的ip,元素1为port,元素2为节点id。

  • 发现异常

根据集群配置:节点47.93.231.115:6379对应哈希槽为2730-5459

4088d812e351785600c774978959a45b0e31f7a5 47.93.231.115:6379@16379 master - 0 1588776197225 1 connected 2730-5459

查看slots中槽位分布为2730-5459的信息

ip错误

②处的ip = 192.168.1.86,并非集群中的47.93.231.115登录47.93.231.115服务器,ifconfig发现192.168.1.86是服务器的内网IP,并非公网IP。内网IP无法访问,因此每次访问该服务器上的数据均无法建立连接,导致超时重试,超过重试次数后报异常。

总结

JedisCluster初始化时会将节点与哈希槽映射关系进行缓存;

节点与哈希槽的映射关系数据需要通过cluster slots命令获取,因此获取该数据要先创建链接;

创建的链接对应的节点是Set<HostAndPort>中角标为0的节点;

在该节点上执行cluster slots命令,该节点获取的IP是自己网卡的IP,即服务器的内网IP,而非公网 IP。

因此JedisCluster在缓存该节点与哈希槽映射关系时,节点信息存成了内网IP:端口号,

导致JedisCluster操作该节点数据时,通过内网IP:端口号访问Redis,

导致无法访问,连接超时,

导致JedisCluster不断重试,

超过最大重试次数后,报redis.clients.jedis.exceptions.JedisClusterMaxAttemptsException: No more cluster attempts left异常

验证

  • 查看List<Object> slots = jedis.clusterSlots();底层代码
public void cluster(final String subcommand) {
  final byte[][] arg = new byte[1][];
  arg[0] = SafeEncoder.encode(subcommand);
  cluster(arg);
}

打断点发现此处subCommandslots,即表明jedis.clusterSlots()通过调用cluster slots命令获取节点与哈希槽的对应关系。

subCommand

  • 在节点47.93.231.115上执行cluster slots

cluster slots

  • 在节点47.93.231.115上执行ifconfig

ifconfig


文章作者: Wendell
版权声明: 本博客所有文章除特別声明外,均采用 CC BY 4.0 许可协议。转载请注明来源 Wendell !
评论
  目录