在一些高并发+大数据量的场景中,经常会用到redis的cluster集群模式,此篇文章对redis的客户端jedis、jediscluster进行讲解,主要讲明白以下几个问题:
1、Jedis客户端是非线程安全的,为什么?需要注意什么?
2、JedisCluster的初始化过程,,和执行JedisCluster.get等指令经过了哪些流程
3、为什么cluster模式下,客户端无法支持pipline和mget等指令?但是某些场景下mget又是可以执行成功?
1、Jedis客户端是非线程安全的,为什么?需要注意什么?
原理解析: Jedis的请求流和响应流都是一个全局变量,如果同一个jedis同时被多个线程使用的话,比如A线程执行了jedis.get("a") B线程执行了jedis.get("b"),那么完全有可能出线,get("a")的指令拿到b结果的情况,会出现数据错乱。其实知道了有个全局变量之后,相信线程不安全的原因就很好理解了。
例子如下:
public static void main(String[] args) {
Jedis jedis = new Jedis("localhost");
int inti = 0;
new Thread(()->{
for (int j = 0;j<10;j++){
jedis.set("a" + inti,String.valueOf(inti));
System.out.println("a" + inti +" is:" + jedis.get("a" + inti));
}
}).start();
int intij = 1;
new Thread(()->{
for (int j = 0;j<10;j++){
jedis.set("a" + intij,String.valueOf(intij));
System.out.println("a" + intij +" is:" + jedis.get("a" + intij));
}
}).start();
}
结果如下:
预期结果 应该是“a0 is 0”或“a1 is 1” ,但是出现了“a0 is OK” “a0 is 1” 的情况,这显然就是一个set指令的内容也被作为了get的指令了,a0 is 1也同理,B线程的结果反而被A线程收到了
怎么避免这个问题?
当然是一个线程用一个jedis,同一个jedis实例同一时刻只会被一个客户端线程使用即可。考虑到反复创建jedis是一个耗时操作,所以建议是使用池化技术,比如jedispool,这也是在哨兵模式下最常用的一个池化技术。但是,如果是jediscluster的话,单一的一个jedispool也就不够用了。
2、JedisCluster的初始化过程,,和执行JedisCluster.get等指令经过了哪些流程
上一部分讲解了,在哨兵模式下,使用jedispool来解决jedis多线程下线程不安全的问题,我们知道在哨兵模式下,其实只是有一个主节点的,如果没有额外程序控制读写分离的话,其实从节点只是作为备份(会有主从复制和故障转移),而不会被真正业务使用到的。这也说明一个问题:其实客户端只是跟一个实例节点在交互而已,这时使用一个jedispool,然后jedispool中的所有jedis对象都指向同一个主节点实例的ip和port,当然没有问题。但是在cluster集群模式下,情况就不一样了,因为此时是有多个主节点了,每个主节点还占据了一部分的槽位,那么也就意味着客户端在和redis交互的时候,是需要和多个主节点交互的,比如get("a")这个指令,可能是到了ip1:port1这个主节点,get("b")这个指令,是需要到ip2:port2这个主节点上执行的(要知道,最终都会归于jedis这个客户端),此时也就带来一个问题,客户端需要通过key值,来确认到底是要用哪个ip:port的jedis客户端,也就是说jediscluster的客户端至少需要维护一个主节点和jedispool的一个map:
Map<String,JedisPool> nodes
然后这个map的key值其实就是cluster每个主实例的ip:port(如果集群模式有3个主节点,那么这就是一个size=3的map),然后map的value其实就是对应ip:port的jedispool(本质大概就是一个200个指定ip:port 的jedis对象)
然后又考虑到,其实客户端(业务端)在执行指令的时候,其实是不会直接知道这个key到底会到哪个主实例上去执行的,客户端知道的只是一个key,而我们通过key,通过CRC16算法,是可以算的槽位的,然后我们知道key对应的槽位之后,也就能够反找到对应的主实例节点,所以会想到维护另一个Map对象
Map<Integer,Jedispool> slots
这个map的key值就是1-16384这个key,假设cluster是3个主节点的话,那么其实1-5461 5462-10922 10923-16384 为三组数据,然后第一组数据对应的jedispool其实都是nodes节点中的第一个ip:port组成的jedispool,以此类推。通过这个slots对象,客户端执行的get set指令的时候,通过客户端传入的key值,通过crc16(key)%16384,就可以找到对应jedispool,然后拿到其中的一个jedis客户端,进行指令的执行。
上面的这些原理,其实正是JedisCluster 、 JedisClusterConnectionhandler、JedisClusterCacheInfo的执行步骤:
我们初始化一个JedisCLuster往往是通过这么一个步骤:
Set<HostAndPort> jedisClusterNode = new HashSet<HostAndPort>();
jedisClusterNode.add(new HostAndPort("127.0.0.1", 7379));
JedisCluster jc = new JedisCluster(jedisClusterNode, DEFAULT_TIMEOUT, DEFAULT_TIMEOUT,
DEFAULT_REDIRECTIONS, "cluster", DEFAULT_CONFIG);
jc.set("foo", "bar");
assertEquals("bar", jc.get("foo"));
翻看源码,逐步跟进去构造方法,你可以发现这么一个时序图,这个就是jediscluster的真正的初始化过程:
这里面,关键点在于:在初始化时,虽然只传递了一个主节点的信息(我们知道:redis cluster是区中心化的,传递一个节点就足够了),但是客户端通过initializeSlotCache方法会和redis集群做交互(具体可以看initializeSlotCache方法的执行步骤),通过一个command,拿到所有的主节点相关信息,以及每个主节点分别含有哪些槽位的信息,从而可以构造出上述我们说的Map<String,JedisPool> nodes 、Map<Integer,Jedispool> slots这两个map,从而为后续客户端的指令执行打下基础
一个客户端指令的执行过程
关键点在于:redis客户端通过crc16(key)%16384找到对应的槽位后,通过getConnectionFromSlot方法,可以拿到对应的jedispool,然后执行execute方法(其实就是jedis get set方法),如果执行失败,大概率可能是出现了槽位的重新分配,那么此时需要更新替换操作,renewSlotCache之后再执行客户端指令。
3、为什么cluster模式下,客户端无法支持pipline和mget等指令?但是某些场景下mget又是可以执行成功?
问题1:为什么redis集群模式不支持pipline?
我们知道,pipline主要是为了解决多次网络IO的问题,将一系列指令发送到一个服务节点进行执行:
Jedis jedis = new Jedis(String, int);
Pipeline p = jedis.pipelined(); //pipline本质上是单个jedis的行为,所以只会有一个目标ip:port
p.set(key,value); //每个操作 都发送请求给redis-server
p.get(key,value);
p.sync(); // 这段代码获取所有的response
但是通过上面讲解,我们也知道在redis cluster模式下,会有很多个实例节点,而pipline的一系列指令中,必然包含了一系列的key值,这些key通过crc16(key)%16384算的的槽位完全可能不在同一个节点上,所以pipline指令在redis cluster模式下,天然不支持(当然,可以通过一些改造的方式实现比如Lettuce框架,但是至少从原理上来说的确pipline就是不是特别适合在redis 集群模式下使用的)
问题2:为什么redis集群模式,有时候又可以执行mget,有时候不行?
不行的原因,其实和pipline是类似的,无非就是多个key,对应的槽位是不在同一个实例节点上的
为什么有时候又可以执行mget这种批量指令?????
原因就是hash_tag,只要你的key中包含了 { } 这个标识符,那么在计算crc16的时候,就只会拿{}里面的内容进行计算,那么只要你保持{} 中的字符串是一样的,那么这些key就一定会落在同一个实例节点上,那么执行mget指令理所当然就没有问题了, 实践如下:
这里{%s}其实就是代表的一个用户id(比如openid),那么通过这种hash_tag,你就可以将一个用户的各类特征都存储在同一个redis实例中,在一些业务场景中,可能每个请求都需要拿到当前用户对应的各类特征,而这些特征存在于不同的key中,如果不用hashtag,那么必然意味着需要多次IO,分别去获取数据,但是使用了hashtag之后,不仅key变得比较有规则,而且还可以使用mget批量操作指令,高效获取批量特征,从而降低系统的整体延迟,提高cpu利用率