记一次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()
中line10
的getConnectionFromSlot()
方法
public Jedis getConnectionFromSlot(int slot) {
//获取Jeids连接池
JedisPool connectionPool = cache.getSlotPool(slot);
//省略大量代码...
}
打断点查看connectionPool
数据,发现异常。
初始化的connecionPool
是192.168.1.86:6379
,一个不存在的节点,而不是应该访问的节点47.93.231.115:6379
。
因为访问键go
,应该访问节点47.93.231.115:6379
,而不是192.168.1.86:6379
这个不存在的节点。
connecionPool
由cache.getSlotPool(slot)
而来,查看cache
中槽位与节点存在对应关系。
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
源码打的断点。
从图中可看到:
①处表明此时要存的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
长度为6,其中每个元素长度为4。
查看其元素的细节:
每个元素的长度为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 = 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);
}
打断点发现此处subCommand
为slots
,即表明jedis.clusterSlots()
通过调用cluster slots
命令获取节点与哈希槽的对应关系。
- 在节点
47.93.231.115
上执行cluster slots
- 在节点
47.93.231.115
上执行ifconfig