你不知道的Redis:RedisCluster与JedisCluster

前言

Redis Cluster是Redis官方提供的集群解决方案。因为业务的飞速增加,单机模式总会遇到内存、性能等各类瓶颈,这个时候咱们总会喊,上集群啊。就跟我家热得快炸了,你总喊开空调呀同样。的确,上集群能够解决大多数问题,可是在使用集群的过程当中,不可避免会遇到这样那样的问题,这个时候怎么办呢,各类百度各类群里去问吗?NO,做为开发人员,在享受第三方提供的方便前,有必要去了解其基本的工做机制,这样才能在遇到问题时快速定位,方便下手。本篇文章主要是梳理Redis集群的原理和Java客户端JedisCluster的工做流程及源码分析,虽万字长文,但原理通俗易懂,源码条理清晰。html

1、RedisCluster

有关redis集群的基本介绍及搭建教程请移步:Redis 集群教程java

1.1 数据如何读写

在单个的 redis节点中,咱们都知道redis把数据已 k-v 结构存储在内存中,使得 redis 对数据的读写很是之快。Redis Cluster 是去中心化的,它将全部数据分区存储。也就是说当多个 Redis 节点搭建成集群后,每一个节点只负责本身应该管理的那部分数据,相互之间存储的数据是不一样的。node

Redis Cluster 将所有的键空间划分为16384块,每一块空间称之为槽(slot),又将这些槽及槽所对应的 k-v 划分给集群中的每一个主节点负责。以下图: redis

槽位分布图
key -> slot 的算法选择上,Redis Cluster 选择的算法是 hash(key) mod 16383,即便用CRC16算法对key进行hash,而后再对16383取模,结果即是对应的slot。

常见的数据分区方法:算法

  • 节点取余分区:对特定数据取hash值再对节点数取余来决定映射到哪个节点。优势是简单,缺点是扩容或收缩时需从新计算映射结果,极端状况下会致使数据全量迁移。
  • 一致性哈希分区:给每一个节点分配一个0~2^32的token,使其构成一个环,数据命中规则为根据key的hash值,顺时针找到第一个token大于等于该hash的节点。优势是加减节点只影响相邻的节点,缺点是节点少的时候优势变缺点,反倒会影响环中大部分数据,同时加减节点时候会致使部分数据没法命中。
  • 虚拟槽分区:使用分散度良好的hash函数将数据映射到一个固定范围的整数集合,这些整数即是槽位,再分给具体的节点管理。Redis Cluster使用的即是虚拟槽分区。

上面主要介绍了下集群中数据是如何分布在各节点上的,但实际上客户端是如何读写数据的呢?Redis Cluster 采用了直接节点的方式。集群模式下,客户端去操做集群是直连到一个具体的节点上操做的。当该节点接收到任何键操做命令时,会先计算键对应的slot,而后根据slot找出对应节点(这里如何找后面会提到),若是对应的节点是自身,则执行键操做命令,返回结果;若是不是自身,会返回给客户端MOVED重定向错误,告诉客户端应该请求具体哪一个节点,由客户端发起二次请求到正确的节点,完成本次键操做。MOVED错误信息以下图所示: spring

MOVED错误信息

当使用redis-cli 直连集群中节点时,使用 -c 参数,redis-cli会自动重定向链接到目标节点进行键操做。须要注意的是,这个自动重定向功能是redis-cli实现的,跟redis节点自己无关,节点自己依旧返回了MOVED错误给客户端。api

在键操做命令中,除了对单个键值的操做,还有多键值以及批量操做。Redis 集群实现了全部在非分布式版本中出现的处理单一键值的命令,可是在使用多个键值的操做,因为集群跟客户端的通讯方式是直连节点,对于多键的操做倒是须要遍历全部节点,所以是不支持的,通常由客户端在代码中实现须要的功能。对于批量操做,一方面能够由客户端代码计算槽位,针对单个节点进行分档,最后批量操做,另外一方面,Redis Cluster 提供了hashtag 的功能,经过为key打上hashtag,让一类key在存储时就位于同一个slot,达到存储于同一个节点的效果。缓存

hashtag: 是Cluster为了知足用户让特定Key绑定到特定槽位的需求而实现的一个功能。在计算key的slot时,若是key中包括花括号{},而且花括号中内容不为空,便会计算花括号中标志对应的slot。若是不包括{}或是其中内容为空,则计算整个key对应的slot。能够利用这个功能,在特定需求中将一类key绑定到一个槽位上,但不可滥用,毕竟自己数据是分区存的,全这么搞会致使各节点内存占用不平衡,影响集群性能。安全

注意:lua脚本执行、事务中key操做,前提都是所涉及的key在一个节点上,若是在使用集群时没法避免这些操做,能够考虑使用hashtag,而后客户端经过这台节点的链接去操做。bash

1.2 节点间的信息共享

集群中会有多个节点,每一个节点负责一部分slot以及对应的k-v数据,而且经过直连具体节点的方式与客户端通讯。那么问题来了,你向我这里请求一个key的value,这个key对应的slot并不归我负责,但我又要须要告诉你MOVED到目标节点,我如何知道这个目标节点是谁呢?

Redis Cluster使用Gossip协议维护节点的元数据信息,这种协议是P2P模式的,主要指责就是信息交换。节点间不停地去交换彼此的元数据信息,那么总会在一段时间后,你们都知道彼此是谁,负责哪些数据,是否正常工做等等。节点间信息交换是依赖于彼此发出的Gossip消息的。经常使用的通常是如下四种消息:

  • meet消息 会通知接收该消息的节点,发送节点要加入当前集群,接收者进行响应。
  • ping消息 是集群中的节点按期向集群中其余节点(部分或所有)发送的链接检测以及信息交换请求,消息包含发送节点信息以及发送节点知道的其余节点信息。
  • pong消息是在节点接收到meet、ping消息后回复给发送节点的响应消息,告诉发送方本次通讯正常,消息包含当前节点状态。
  • fail消息 是在节点认为集群内另外某一节点下线后向集群内全部节点广播的消息。

在集群启动的过程当中,有一个重要的步骤是节点握手,其本质就是在一个节点上向其余全部节点发送meet消息,消息中包含当前节点的信息(节点id,负责槽位,节点标识等等),接收方会将发送节点信息存储至本地的节点列表中。消息体中还会包含与发送节点通讯的其余节点信息(节点标识、节点id、节点ip、port等),接收方也会解析这部份内容,若是本地节点列表中不存在,则会主动向新节点发送meet消息。接收方处理完消息后,也会回复pong消息给发送者节点,发送者也会解析pong消息更新本地存储节点信息。所以,虽然只是在一个节点向其余全部节点发送meet消息,最后全部节点都会有其余全部节点的信息。

集群启动后,集群中各节点也会定时往其余部分节点发送ping消息,用来检测目标节点是否正常以及发送本身最新的节点负槽位信息。接收方一样响应pong消息,由发送方更新本地节点信息。当在与某一节点通讯失败(故障发现策略后面会说)时,则会主动向集群内节点广播fail消息。考虑到频繁地交换信息会加剧带宽(集群节点越多越明显)和计算的负担,Redis Cluster内部的定时任务每秒执行10次,每次遍历本地节点列表,对最近一次接受到pong消息时间大于cluster_node_timeout/2的节点立马发送ping消息,此外每秒随机找5个节点,选里面最久没有通讯的节点发送ping消息。同时 ping 消息的消息投携带自身节点信息,消息体只会携带1/10的其余节点信息,避免消息过大致使通讯成本太高。

cluster_node_timeout 参数影响发送消息的节点数量,调整要综合考虑故障转移、槽信息更新、新节点发现速度等方面。通常带宽资源特别紧张时,能够适当调大一点这个参数,下降通讯成本。

1.3 槽位迁移与集群伸缩

Redis Cluster 支持在集群正常服务过程当中,下线或是新增集群节点。但不管是集群扩容仍是收缩,本质上都是槽及其对应数据在不一样节点上的迁移。通常状况下,槽迁移完成后,每一个节点负责的槽数量基本上差很少,保证数据分布知足理论上的均匀。

经常使用的有关槽的命令以下:

  • CLUSTER ADDSLOTS slot1 [slot2]...[slotN] —— 为当前节点分配要负责的槽,通常用于集群建立过程。
  • CLUSTER DELSLOTS slot1 [slot2]...[slotN] —— 将特定槽从当前节点的责任区移除,和ADDSLOTS命令同样,执行成功后会经过节点间通讯将最新的槽位信息向集群内其余节点传播。
  • CLUSTER SETSLOT slotNum NODE nodeId —— 给指定ID的节点指派槽,通常迁移完成后在各主节点上执行,告知各主节点迁移完成。
  • CLUSTER SETSLOT slotNum IMPORTING sourceNodeId —— 在槽迁移的目标节点上执行该命令,意思是这个槽将由原节点迁移至当前节点,迁移过程当中,当前节点(即目标节点)只会接收asking命令链接后的被设为IMPORTING状态的slot的命令。
  • CLUSTER SETSLOT slotNum MIGRATING targetNodeId —— 在槽迁移的原节点上执行该命令,意思是这个槽将由当前节点迁移至目标节点,迁移过程当中,当前节点(即原节点)依旧会接受设为MIGRATING的slot相关的请求,若具体的key依旧存在于当前节点,则处理返回结果,若不在,则返回一个带有目标节点信息的ASK重定向错误。其余节点在接受到该槽的相关请求时,依旧会返回到原节点的MOVED重定向异常。

实际上迁移槽的核心是将槽对应的k-v数据迁移到目标节点。因此在完成slot在原节点和目标节点上状态设置(即上面最后两条命令)后,就要开始进行具体key的迁移。

  • CLUSTER GETKEYSINSLOT slot total —— 该命令返回指定槽指定个数的key集合
  • MIGRATE targetNodeIp targetNodePort key dbId timeout [auth password] —— 该命令在原节点执行,会链接到目标节点,将key及其value序列化后发送过去,在收到目标节点返回的ok后,删除当前节点上存储的key。整个操做是原子性的。因为集群模式下使用各节点的0号db,因此迁移时dbId这个参数只能是0。
  • MIGRATE targetNodeIp targetNodePort "" 0 timeout [auth password] keys key1 key2... —— 该命令是上面迁移命令基于pipeline的批量版本。

在整个slot的key迁移完成后,须要在各主节点分别执行CLUSTER SETSLOT slotNum NODE nodeId来通知整个slot迁移完成。redis-trib.rb 提供的reshard功能即是基于官方提供的上述命令实现的。

集群的扩展过程实际上就是启动一个新节点,加入集群(经过gossip协议进行节点握手、通讯),最后从以前各节点上迁移部分slot到新节点上。

集群的收缩过程除了除了将待下线节点的槽均匀迁移到其余主节点以外,还有对节点的下线操做。官方提供了CLUSTER FORGET downNodeId命令,用于在其余节点上执行以忘记下线节点,不与其交换信息,须要注意的是该命令有效期为60s,超过期间后会恢复通讯。通常建议使用redis-trib.rb 提供的del-node功能。

1.4 高可用

Redis集群牺牲了数据强一致性原则,追求最大的性能。上文中一直未提到从节点,主要都是从主节点出发去梳理数据存储、集群伸缩的一些原理。要保证高可用的前提是离不开从节点的,一旦某个主节点由于某种缘由不可用后,就须要一个一直默默当备胎的从节点顶上来了。通常在集群搭建时最少都须要6个实例,其中3个实例作主节点,各自负责一部分槽位,另外3个实例各自对应一个主节点作其从节点,对主节点的操做进行复制(本文对于主从复制的细节不进行详细说明)。Redis Cluster在给主节点添加从节点时,不支持slaveof命令,而是经过在从节点上执行命令cluster replicate masterNodeId 。完整的redis集群架构图以下:

图片

Cluster的故障发现也是基于节点通讯的。每一个节点在本地存储有一个节点列表(其余节点信息),列表中每一个节点元素除了存储其ID、ip、port、状态标识(主从角色、是否下线等等)外,还有最后一次向该节点发送ping消息的时间、最后一次接收到该节点的pong消息的时间以及一个保存其余节点对该节点下线传播的报告链表。节点与节点间会定时发送ping消息,彼此响应pong消息,成功后都会更新这个时间。同时每一个节点都有定时任务扫描本地节点列表里这两个消息时间,若发现pong响应时间减去ping发送时间超过cluster-node-timeout配置时间(默认15秒,该参数用来设置节点间通讯的超时时间)后,便会将本地列表中对应节点的状态标识为PFAIL,认为其有可能下线。

节点间通讯(ping)时会携带本地节点列表中部分节点信息,若是其中包括标记为PFAIL的节点,那么在消息接收方解析到该节点时,会找本身本地的节点列表中该节点元素的下线报告链表,看是否已经存在发送节点对于该故障节点的报告,若是有,就更新接收到发送ping消息节点对于故障节点的报告的时间,若是没有,则将本次报告添加进链表。下线报告链表的每一个元素结构只有两部份内容,一个是报告本地这个故障节点的发送节点信息,一个是本地接收到该报告的时间(存储该时间是由于故障报告是有有效期的,避免误报)。因为每一个节点的下线报告链表都存在于各自的信息结构中,因此在浏览本地节点列表中每一个节点元素时,能够清晰地知道,有其余哪些节点跟我说,兄弟,你正在看的这个节点我觉的凉凉了。

故障报告的有效期是 cluster-node-timeout * 2

消息接收方解析到PFAIL节点,而且更新本地列表中对应节点的故障报告链表后,会去查看该节点的故障报告链表中有效的报告节点是否超过全部主节点数的一半。若是没超过,便继续解析ping消息;若是超过,表明超过半数的节点认为这个节点可能下线了,当前节点就会将PFAIL节点本地的节点信息中的状态标识标记为FAIL,而后向集群内广播一条fail消息,集群内的全部节点接收到该fail消息后,会把各自本地节点列表中该节点的状态标识修改成FAIL。在全部节点对其标记未FAIL后,该FAIL节点对应的从节点就会发起转正流程。在转正流程完成后,这个节点就会正式下线,等到其恢复后,发现本身的槽已经被分给某个节点,便会将本身转换成这个节点的从节点而且ping集群内其余节点,其余节点接到恢复节点的ping消息后,便会更新其状态标识。此外,恢复的节点若发现本身的槽仍是由本身负责,就会跟其余节点通讯,其余主节点发现该节点恢复后,就会拒绝其从节点的选举,最终清除本身的FAIL状态。

1.5 从节点坎坷晋升路

在集群中如果某个主节点发生故障,被其余主节点标价为FAIL状态,为了集群的正常使用,这时会由其对应的从节点中晋升一个为新的主节点,负责原主节点的一切工做。

并非全部从节点都有被提名的资格,这个跟普通职员的晋升同样。只有从节点与主节点的链接断线不超过必定时间,才会初步具有被提名的资格。该时间通常为cluster-node-timeout *10,10是从节点的默认有效因子。

通常来讲,故障主节点会有多个符合晋升要求的从节点,那么怎么从这些从节点中选出一个最合适的来晋升为主节点恢复工做呢?从节点的做用是做为主节点的备份,每一个对于主节点的操做都会异步在多个从节点上备份,但受具体的主从节点结构决定,通常每一个从节点对于主节点的通不程度是不一样的。为了能更好的替代原主节点工做,就必须从这些从节点中选举一个最接近甚至彻底同步主节点数据的从节点来完成最终晋升

从节点晋升的发起点是从节点。从节点在定时任务中与其余节点通讯,当发现主节点FAIL后,会判断资深是否有晋升提名资格。若是有的话,则会根据相关规则设置一个选举本身的时间。在到达那个设置的时间点后,再发起针对本身晋升的选举流程,选票则由集群中其余正常主节点选投。若本身得到的选票超过正常主节点数的一半时,则会执行替换原主节点工做,完成本次选举晋升。

设置选举时间规则:发现主节点FAIL后并不会立马发起选举。而是通过 固定延时(500ms)+ 随机延时(0-500ms)+ 从节点复制偏移量排名1000ms 后发起针对本身的选举流程。其中 固定延时 是保证主节点的FAIL状态被全部主节点获知,随机延时是为了尽可能避免发生多个从节点同时发起选举的状况,最后的排名1000ms是为了保证复制偏移量最大也就是最接近于原主节点数据的从节点最早发起选举。所以通常来讲,从节点晋升选举一次就会成功。主节点是没有区分哪一个从节点是最适合晋升的规则的,主要靠这里的选举发起时间来让最合适的一次成功。

从节点发起选举主要分为两步

  • 自增集群的全局配置纪元,并更新为当前节点的epoch(配置纪元这里不详细介绍,不懂的能够先简单理解为版本号,每一个节点都有本身的epoch而且集群有一个全局的epoch);
  • 向集群内广播选举消息FAILOVER_AUTH_REQUEST,消息内会包含当前节点的epoch。

从节点广播选举消息后,在NODE_TIMEOUT*2时间内等待主节点的响应FAILOVER_AUTH_ACK。若收到大多数主节点的响应,表明选举成功,则会经过ping\pong消息来宣誓主权。若未收到足够响应则会中断本次选举,由其余节点从新发起选举。

主节点在每一个全局配置纪元中有且只有一张选票,一旦投给某个从节点便会忽视其余节点的选举消息。通常同一个配置纪元多个从节点竞争的状况只有极小几率会发生,这是由从节点的选举时间以及选举步骤决定的。主节点的投票响应FAILOVER_AUTH_ACK消息中会返回接收到的选举消息同样的epoch,从节点也只会承认跟节点当前epoch一致的投票响应,这样能够避免由于网络延迟等因素致使承认迟来的历史承认消息。

从节点成功晋升后,在替换原主节点时,还须要进行最后三步:

  • 取消当前节点的复制工做,变身为主节点;
  • 撤销原主节点负责的槽,并把这些槽委派给本身;
  • 广播pong消息,通知全部节点本身已经完成转正以及转正后负责的槽信息。

2、JedisCluster

Jedis是redis的java客户端,JedisCluster则是Jedis根据Redis集群的特性提供的集群客户端。上文介绍过了redis集群下操做key的详细流程,通常经过redis-cli启动客户端链接具体的节点时,要操做的key若不在这个节点上时,服务端会返回MOVED重定向错误,这时须要手动链接至重定向节点才能继续操做。或者redis-cli链接服务节点时加上-c 参数,就可使用redis-cli提供的自动重定向机制,在操做其余服务节点的key时会进行自动重定向,避免客户端手动重定向。JedisCluster做为操做Redis集群的java客户端,一样遵照RedisCluster提供的客户端链接规范,本节从源码的角度去看其具体是怎么作的。

2.1 初始化工做

不管你使用spring集成jedis或是直接使用jedis,第一步都是客户端的初始化工做,这里直接从JedisCluster着手去看。JedisCluster其实是一个高级客户端,它继承了BinaryJedisCluster,客户端的初始化工做实际上都是由该类负责,此外还实现了JedisCommands、MultiKeyJedisClusterCommands和JedisClusterScriptingCommands三个接口,封装了单键命令、多键操做命令以及脚本执行命令等具体的方法供开发人员调用。

图片

JedisCluster的构造器有不少,但最终都是调用了父类BinaryJedisCluster的构造,实际上这里是初始化了一个链接处理器,而且设置了最大重试次数。

public BinaryJedisCluster(Set<HostAndPort> jedisClusterNode, 
int connectionTimeout, int soTimeout, int maxAttempts, 
String password, GenericObjectPoolConfig poolConfig) {

  this.connectionHandler = new JedisSlotBasedConnectionHandler(jedisClusterNode, poolConfig,
          connectionTimeout, soTimeout, password);
  this.maxAttempts = maxAttempts;

}
复制代码

JedisSlotBasedConnectionHandler实际上又调用了父类JedisClusterConnectionHandler 的构造器,而这里才是JedisCluster初始化的核心。

public JedisClusterConnectionHandler(Set<HostAndPort> nodes,
                                     final GenericObjectPoolConfig poolConfig, int connectionTimeout, int soTimeout, String password) {
  
  // 建立集群信息的缓存对象
  this.cache = new JedisClusterInfoCache(poolConfig, connectionTimeout, soTimeout, password);

  // 初始化链接池与缓存信息
  initializeSlotsCache(nodes, poolConfig, password);
}
复制代码

建立JedisClusterInfoCache 实例的时候看其构造能够知道只是将链接配置信息赋值给实例属性,并没有其余操做。那么它究竟缓存了哪些信息呢?查看其源码能够发现以下两个重要的属性,分别存放了节点与其对应链接池的映射关系和槽位与槽位所在节点对应链接池的映射。

JedisClusterInfoCache.java

private final Map<String, JedisPool> nodes = new HashMap<String, JedisPool>();
private final Map<Integer, JedisPool> slots = new HashMap<Integer, JedisPool>();
复制代码

初始化缓存数据则是经过遍历全部节点,建立每一个节点的jedis实例,依次链接获取节点及负责槽位数据。通常来讲,是根据配置中第一个节点链接后获取相关信息就会跳出遍历。initializeSlotsCache方法代码以下:

JedisClusterConnectionHandler.java

private void initializeSlotsCache(Set<HostAndPort> startNodes, GenericObjectPoolConfig poolConfig, String password) {
  for (HostAndPort hostAndPort : startNodes) {
    Jedis jedis = new Jedis(hostAndPort.getHost(), hostAndPort.getPort());
    if (password != null) {
      jedis.auth(password);
    }
    try {
      // 获取节点及所负责的槽位信息
      cache.discoverClusterNodesAndSlots(jedis);
      break;
    } catch (JedisConnectionException e) {
      // try next nodes
    } finally {
      if (jedis != null) {
        jedis.close();
      }
    }
  }
}
复制代码

关于缓存数据的获取及更新实际是由JedisClusterInfoCache的discoverClusterNodesAndSlots方法实现,主要是经过cluster slots 命令获取集群内的槽位分布数据,而后解析该命令的返回结果,为每一个主节点初始化一个链接池,而后将节点与链接池、节点负责的全部槽位与链接池的映射关系缓存到上面说的两个map中。源码以下:

JedisClusterInfoCache.java

private final ReentrantReadWriteLock rwl = new ReentrantReadWriteLock();
// 使用读写锁控制缓存更新时的线程安全
private final Lock r = rwl.readLock();
private final Lock w = rwl.writeLock();
// cluster slots 命令返回结果的每一个元素中第三部分为主节点信息,后面的都是从节点信息
private static final int MASTER_NODE_INDEX = 2;

public void discoverClusterNodesAndSlots(Jedis jedis) {
  w.lock();
  try {
    reset();   // 销毁链接池、清空缓存
    // 根据cluster slots 命令获取槽位分布信息
    List<Object> slots = jedis.clusterSlots();
    
    for (Object slotInfoObj : slots) {
      List<Object> slotInfo = (List<Object>) slotInfoObj;

      if (slotInfo.size() <= MASTER_NODE_INDEX) {
        continue;
      }
      // 获取当前槽位节点负责的全部槽位
      List<Integer> slotNums = getAssignedSlotArray(slotInfo);

      // hostInfos
      int size = slotInfo.size();
      for (int i = MASTER_NODE_INDEX; i < size; i++) {
        // 获取节点信息数据
        List<Object> hostInfos = (List<Object>) slotInfo.get(i);
        if (hostInfos.size() <= 0) {
          continue;
        }
        // 生成节点对象
        HostAndPort targetNode = generateHostAndPort(hostInfos);
        // 初始化节点链接池,并将节点与其链接池缓存
        setupNodeIfNotExist(targetNode);
        if (i == MASTER_NODE_INDEX) {
           // 若节点是主节点,则将其负责的每一个槽位与其链接池创建映射关系缓存
          assignSlotsToNode(slotNums, targetNode);
        }
      }
    }
  } finally {
    w.unlock();
  }
}
复制代码

上面discoverClusterNodesAndSlots方法主要是解析cluster slots命令的返回结果,这块不熟悉的话建议链接到集群中的一个节点执行下该命令,对照着结果来看就会很明白。回过头来看,这里的初始化主要分为一下几部分:

  • 链接一个节点执行cluster slots命令,获取槽位分布以及集群节点信息;
  • 为每个节点都初始化一个链接池,并跟节点创建映射关系缓存;
  • 将每一个主节点负责的槽位一一与主节点链接池创建映射缓存。

初始化工做中缓存的映射信息,在JedisCluster的使用过程当中起到了相当重要的做用。但也正是由于JedisCluster在本地内存中缓存节点数据而且为每一个节点维护一个链接池,在使用节点特别多的庞大集群时,客户端也会消耗更多内存。

2.2 键操做详解

JedisCluster实现了JedisCommands接口封装的单key命令,这里分析单键操做命令的详细流程以set为例,其代码以下:

JedisCluster.java

@Override
public String set(final String key, final String value) {
  return new JedisClusterCommand<String>(connectionHandler, maxAttempts) {
    @Override
    public String execute(Jedis connection) {
      return connection.set(key, value);
    }
  }.run(key);
}
复制代码

经过代码能够看出,实际的set操做仍是依赖于jedis。上文在初始化部分提到,会为集群的每一个节点都建立一个jedisPool,同时初始化时建立的connectionHandler在这里被JedisClusterCommand的实现类所使用,那么不难理解,connectionHandler根据JedisClusterInfoCache的缓存数据,对外提供链接获取服务。要么你给我个节点,我给你个jedis实例,要么你给我个slot,我给你一个jedis实例。这点去看JedisClusterConnectionHand-ler的源码即可以获得证实。所以,JedisClusterCommand在操做key时必定会处理相关信息,获得获取链接的必要参数。下面即是run(key)方法的实现(代码略长,可是逻辑清晰,注释详细):

JedisClusterCommand.java
// 存放当前操做的ask重定向后的链接
private ThreadLocal<Jedis> askConnection = new ThreadLocal<Jedis>();

public T run(String key) {
  if (key == null) {
    throw new JedisClusterException("No way to dispatch this command to Redis Cluster.");
  }

  return runWithRetries(SafeEncoder.encode(key), this.maxAttempts, false, false);
}

private T runWithRetries(byte[] key, int attempts, boolean tryRandomNode, boolean asking) {
  if (attempts <= 0) {
    throw new JedisClusterMaxRedirectionsException("Too many Cluster redirections?");
  }

  Jedis connection = null;
  try {
    if (asking) {
      // 如果ask重定向操做,则从ThreadLocal中获取重定向后的jedis
      connection = askConnection.get();
      connection.asking();
      asking = false;    // 若ask重定向成功,撤销ask重定向标记
    } else {
      if (tryRandomNode) {  // 随机链接至某个ping-pong正常的节点
        connection = connectionHandler.getConnection();
      } else {
        connection = connectionHandler.getConnectionFromSlot(JedisClusterCRC16.getSlot(key));    // 根据槽位算法计算key对应的slot,再根据slot获取对应节点的jedis
      }
    }

    return execute(connection);

  } catch (JedisNoReachableClusterNodeException jnrcne) {
    throw jnrcne;
  } catch (JedisConnectionException jce) {
    // 发生链接异常时,释放链接,开始递归重试
    releaseConnection(connection);
    connection = null;

    if (attempts <= 1) {
      // 重试次数递减到1次时,表明目标节点可能发生故障,更新缓存数据,抛出原始异常
      this.connectionHandler.renewSlotCache();
      throw jce;
    }
    // 递减重试次数开始重试
    return runWithRetries(key, attempts - 1, tryRandomNode, asking);
    
  } catch (JedisRedirectionException jre) {   // 发生了重定向异常
    // 释放当前占用链接
    releaseConnection(connection);
    connection = null;

    if (jre instanceof JedisAskDataException) {
      // ASK重定向表明当前槽位正在迁移,直接获取ask异常信息里的目标节点的jedis实例放入ThreadLocal,设置asking标志,重试请求目标节点操做
      asking = true;     
      askConnection.set(this.connectionHandler
              .getConnectionFromNode(jre.getTargetNode()));
              
    } else if (jre instanceof JedisMovedDataException) {
      // MOVED重定向表明本地缓存的槽位数据跟集群不一致,须要更新缓存数据后重试
      this.connectionHandler.renewSlotCache(connection);
      
    } else {
      throw new JedisClusterException(jre);
    }
    return runWithRetries(key, attempts - 1, false, asking); // 重试
  } finally {
    releaseConnection(connection);
  }
}
复制代码

看完上述代码,咱们不难梳理出JedisCluster对键操做的基本流程计算key的slot -》 从缓存中根据slot拿到目标节点的jedis -》 执行键操做。在这个过程当中,若是发生链接异常,则会重试配置的最大重试次数-1次,若链接依旧存在问题,则更新缓存信息,抛出链接的原始异常;若是发生重定向异常,再根据具体的重定向异常作不一样处理。接收到MOVED重定向时会去更新缓存,而后重试。而接收到ASK重定向时是直接解析目标节点并获取一个链接,而后重试走ask分支,并不更新缓存。这是由于发生ASK重定向异常时,slot正在迁移,并未完成,该slot的一部分key在目标节点,一部分又在原节点,没法准确地将slot与某个节点绑定,因此不会更新缓存,等到迁移结束后,用旧的缓存去请求key时,这时就会接收到redis返回的MOVED重定向异常,那会才会更新缓存,维持缓存数据的准确性。

发生链接异常时,先重试max-1次再更新缓存。一方面避免因网络、读写阻塞等缘由误判节点故障,中断请求;另外一方面避免频繁更新缓存,为保证缓存数据在多线程场景下的线程安全,采用了读写锁控制缓存的读取及更新,频繁更新势必致使大多数读请求被阻塞,影响性能。connectionHandler的renewSlotCache方法内部都是调用了JedisClusterInfoCache的renewClusterSlots(Jedis jedis)方法。不一样的是无参时传递的jedis实例为null。

JedisClusterInfoCache.java

public void renewClusterSlots(Jedis jedis) {
  //该变量默认false,当须要更新集群缓存信息时,如有一个线程得到写锁,便会设置该标志为true,这样在更新期间,其余线程便不须要阻塞等待写锁,直接返回重试,在读锁出等待该线程更新完成。持有锁的线程更新完缓存后,会在释放锁前恢复该标志为false
  if (!rediscovering) {
    try {
      w.lock();
      rediscovering = true;

      if (jedis != null) {
        try {
          // 经过cluster slots命令获取新的槽位信息,更新缓存
          discoverClusterSlots(jedis);
          return;
        } catch (JedisException e) {
          // 若是当前链接更新缓存发生JedisException,则从全部节点重试更新
        }
      }

      for (JedisPool jp : getShuffledNodesPool()) {
        try {
          jedis = jp.getResource();
          discoverClusterSlots(jedis);
          return;
        } catch (JedisConnectionException e) {
          // 重试下一个节点
        } finally {
          if (jedis != null) {
            jedis.close();
          }
        }
      }
    } finally {
      // 恢复标志位,释放锁
      rediscovering = false;
      w.unlock();
    }
  }
}
复制代码

JedisCluster使用读写锁保证cache数据的线程安全,因此在某个线程更新cache的时候,其余线程在读取cache中的槽位映射时会被阻塞。《Redis开发与运维》书中,付磊大大认为此处尚可优化,将cluster slots命令执行放在加写锁前,同时与本地缓存判断是否相同,不一样则意味着必须更新,这时再去加写锁,从而缩短对其余线程的阻塞时间,尽可能减小对操做槽位的缓存无误部分的影响。

2.3 多键操做与脚本执行

在初始化工做部分看JedisCluster的类图时提到过,其实现了MultiKeyJedisClusterCommands和JedisClusterScriptingCommands两个接口规定的多键操做命令和脚本执行命令。到这里你们都知道集群模式下不一样key可能存储于不一样的槽位上,那么一次操做涉及多个key就意味着可能涉及多个节点。

JedisCluster执行命令的模式是从connectionHandler获取连接,由JedisClusterCommand的匿名内部类去拿连接(Jedis实例)执行具体的命令,这个流程跟单键命令是一致的。不一样的是,多key操做调用的是JedisClusterCommand.run(keys.length, keys)方法。相同的是,最终都是由 runWithRetries(byte[] key, **int **attempts, **boolean **tryRandomNode, **boolean **asking) 完成操做。

这里以多个key的exists命令为例,代码以下:

JedisCluster.java

@Override
public Long exists(final String... keys) {
  return new JedisClusterCommand<Long>(connectionHandler, maxAttempts) {
    @Override
    public Long execute(Jedis connection) {
      return connection.exists(keys);
    }
  }.run(keys.length, keys);
}

JedisClusterCommand.java

public T run(int keyCount, String... keys) {
  if (keys == null || keys.length == 0) {
    throw new JedisClusterException("No way to dispatch this command to Redis Cluster.");
  }

  if (keys.length > 1) {
    int slot = JedisClusterCRC16.getSlot(keys[0]);
    for (int i = 1; i < keyCount; i++) {
      int nextSlot = JedisClusterCRC16.getSlot(keys[i]);
      if (slot != nextSlot) {
        throw new JedisClusterException("No way to dispatch this command to Redis Cluster "
            + "because keys have different slots.");
      }
    }
  }

  return runWithRetries(SafeEncoder.encode(keys[0]), this.maxAttempts, false, false);
}
复制代码

代码很简单易懂,对于多个key它会先检查是否是位于一个槽位,肯定是一个槽位后就会拿着第一个key去计算slot并向connectionHandler要jedis实例。所以JedisCluster不支持不在同一个槽位的多key操做(实际上redis集群本就不提供此功能)。若调用多key命令方法时传入的多个key不是同一个slot,会抛出JedisClu-sterException,而且告诉你没办法调度命令去集群执行,由于这些key位于不一样的slot。

在实际开发中,若是明确知道某类key会存在多键操做,咱们能够在存储时便经过打hashtag的方式强制其位于同一个slot同一个节点。另外,若真正须要操做多节点上的key时,能够经过遍历cache中缓存的节点到链接池的映射,在每一个主节点上一次执行。

脚本的执行实际上也是依赖于jedis去作的,这里不深刻jedis去说了。脚本的执行也分涉及单个key和多个key两种状况,但其原理和上述一致。所以,JedisCluster也不支持涉及不一样slot上多个key的脚本

2.4 类结构回顾

JedisCluster涉及的几个类以下图:

图片
JedisCommand封装集群命令的执行抽象出两种基本模式,单key和多key。我的理解这里的编码思想采用了模板方法模式,封装基本执行流程,具体的执行由实现类去根据具体的需求调用实际的api作实现。

JedisCluster是面向开发人员的API类,实现三类命令接口,提供友好的方法供业务代码调用。

JedisClusterConnectionHandler负责多个链接池的路由工做,根据缓存的映射关系,肯定一个正确的链接池并返回其引用给上层。JedisSlotBasedConnectionHandler实际上只是基于父类的基本功能进行加工,提供给上层友好的调用方法,直接返回上层须要的链接。

总结

Redis集群经过分片存储、主从数据复制以及合理科学的故障转移策略,提供了更强的性能、更好的扩展性以及可用性,知足了CAP定理的AP两个特性。对于一致性,集群模式配合客户端策略能够说实现了“弱一致性”。笔者认为实际开发中,是真的有必要去把这些东西都搞清楚再去使用,这样能够提早避免不少线上问题的产生。本篇文章重在梳理,我的感受哪怕是根据已有的资料,去梳理出一篇通过本身多方验证、深度思考的文章比只是去看会对相关技术理解的更为深入。

参考

相关文章
相关标签/搜索