Redis 3.0版本前后,分片与集群的实现

首先这篇博客对Redis的集群部署工作不予介绍,因为最近的开发涉及到了使用jedis客户端操作Redis,借此机会总结梳理一下jedis客户端操作Redis的三种模式:单机模式(Jedis)、分片模式(ShardedJedis)和集群模式(JedisCluster)。

首先当然是把依赖引进来:

<!-- redis客户端 -->
<dependency>
     <groupId>redis.clients</groupId>
     <artifactId>jedis</artifactId>
     <version>3.1.0</version>
</dependency>

1.单机模式(Jedis)

单机模式最简单,就一个redis实例嘛,连上用就是喽

//创建一个Jedis对象,需要服务ip和port
Jedis jedis = new Jedis("127.0.0.1",6379);
//调用redis命令对应的方法
jedis.set("testKey","1111");
String result = jedis.get("testKey");
//关闭连接
jedis.close();

规范一点的话从连接池中获取连接。

//连接池对象
JedisPool jedisPool = new JedisPool("127.0.0.1",6397);
//从连接池中获取连接
Jedis jedis = jedisPool.getResource();
//执行命令
jedis.set("testKey","1111");
String result = jedis.get("testKey");
//将连接归还给连接池
jedisPool.returnResource(jedis);

这是连接池对象最简单的创建方式,还可以使用JedisPoolConfig对象作为构造参数创建连接池对象,以设置最大连接数、超时时间等属性,这里不多介绍。

2.分片模式(ShardedJedis)和集群模式(JedisCluster)

这两类得一起介绍,也是本文的重点,因为这两个可以说是Redis以3.0版本为界的前世今生。单实例的Redis服务器的内存成了瓶颈,当数据量足够大时就不得不增加服务节点,分片和集群都是Redis多实例水平扩容的方案,而分片是Redis3.0之前的方案,集群是Redis3.0之后的方案,从扩容算法到部署方式都是不一样的,下面详细说:

(1)分片模式

Redis3.0之前,大家都很单纯,Redis实例一个不够用了,想到了用分片的方式来水平扩容。既然是要水平扩容,那就是要把一份数据分摊到N个Redis实例中,每个实例存有1/N个key。key天然是个字符串,最容易想到的方式当然是用key的hash值和节点数求模,即

int index = key.hashCode()/num

这样不同的key就会有一个自己的位置index,把Redis实例排排站再根据这个index不就知道该放在哪个实例了?获取这个key的数据时再来一遍算出这个index不就知道该去哪个实例上找了?

可是这么做有一个明显的问题,如果再扩容呢?num变了,如果去找扩容前的数据求index的时候,index和之前放这个数据时不一样了,哦吼,缓存没命中,而且是大面积失效,缓存最讲究的就是一个命中率,难不成每次扩容的时候还全部做一次数据迁移?这种方法显然不行。

简单的用String提供的hash不行,那就借鉴一下一致性hash的概念,这里贴一张一致性hash的百度词条:

这个百度词条纠正了我一个误区同时解答了一个疑惑。误区是我一直以为一致性hash是为了解决Redis分片扩容才出现的概念,其实人家1997年就有了,说的明明白白是为了解决hash算法在分布式哈希表中存在的动态伸缩问题。一个疑惑是在了解Redis分片时介绍到一致性hash都会画一个圈,范围0~2^32-1 ,一共2^32个整数,然后这么介绍下去,一直想不通为啥这个环的范围是0~2^32-1而不是更大,撸Jedis源码的时候也没找到答案,而且Jedis在分片时使用的算法是MURMUR_HASH,这个后面会介绍,这个算法得到的hash值是long型,大于2^32很容易啊。退一万步就算String重写的hashCode()返回的int型,int的取值范围是-2^31~2^31-1,取绝对值最大值也是2^31,一直很疑惑这个2^32哪里来的。百度词条让我恍然大悟,时间线先后关系,这个范围人家97年的时候就是这么定的,咋地啦?Jedis只是借鉴了一致性hash环的概念而已,来一段Sharded类的源码附注释就明白了:

//Shardeds主要用于在节点集群中根据key找到对应的Redis实例节点
//ShardInfo表示单个节点信息,R表示对该节点的连接
public class Sharded<R, S extends ShardInfo<R>> {

  //虚拟节点的权重,一个实例的虚拟节点数为160*DEFAULT_WEIGHT
  public static final int DEFAULT_WEIGHT = 1;
  
  //所有节点包括实例节点和虚拟节点都存在在这个TreeMap中,并不是想象中有一个环形数据结构
  //key是根据节点信息计算出来的hash值
  private TreeMap<Long, S> nodes;
  
  //分片采用的hash算法,默认为MURMUR_HASH
  private final Hashing algo;
  
  //key是节点信息,value是该节点的连接
  private final Map<ShardInfo<R>, R> resources = new LinkedHashMap<ShardInfo<R>, R>();

  //tagPattern分片算法所依据的key的形式。默认为查找匹配前后有两组花括号的字符串
  private Pattern tagPattern = null;
  public static final Pattern DEFAULT_KEY_TAG_PATTERN = Pattern.compile("\\{(.+?)\\}");

  //S继承了节点类ShardInfo,只用节点列表构造Sharded,也是在这里默认算法为MURMUR_HASH
  //其实还支持MD5,但是这里提醒我们别用...
  public Sharded(List<S> shards) {
    this(shards, Hashing.MURMUR_HASH); // MD5 is really not good as we works
  }

  //用节点列表和自选算法构造Sharded
  public Sharded(List<S> shards, Hashing algo) {
    this.algo = algo;
    initialize(shards);
  }

  //可以自定义算法所依据的key的形式。算法还是MURMUR_HASH
  //例如,可以不针对整个key的字符串做哈希计算,而是类似对'outStr{innerStr}'中包含在大括号内的字符串进行哈希计算。
  //这样通过合理命名key,可以将一组相关联的key放入同一个Redis节点,这在避免跨节点访问相关数据时很重要。
  public Sharded(List<S> shards, Pattern tagPattern) {
    this(shards, Hashing.MURMUR_HASH, tagPattern); // MD5 is really not good
  }

  //这下Pattern和算法都可以自定义了
  public Sharded(List<S> shards, Hashing algo, Pattern tagPattern) {
    this.algo = algo;
    this.tagPattern = tagPattern;
    initialize(shards);
  }

  //真正的初始化方法,前面的构造方法中也都有调用
  private void initialize(List<S> shards) {
    nodes = new TreeMap<Long, S>();
	
	//这个遍历就是往TreeMap里面放节点
	//注意循环里面还有一个循环,即每个节点都有160*weight个虚拟节点
	//虚拟节点的名字后面拼了递增的内循环的n,这样计算出来的hash值就都不一样了
    for (int i = 0; i != shards.size(); ++i) {
      final S shardInfo = shards.get(i);
      if (shardInfo.getName() == null) for (int n = 0; n < 160 * shardInfo.getWeight(); n++) {
        nodes.put(this.algo.hash("SHARD-" + i + "-NODE-" + n), shardInfo);
      }
      else for (int n = 0; n < 160 * shardInfo.getWeight(); n++) {
        nodes.put(this.algo.hash(shardInfo.getName() + "*" + n), shardInfo);
      }
	  
	  //把每个节点的节点信息对应连接放进resources这个map,理解成资源池
      resources.put(shardInfo, shardInfo.createResource());
    }
  }

  //先getShardInfo(key)根据key获取对应节点信息
  //再从资源map中获取该节点的连接
  //key是字节
  public R getShard(byte[] key) {
    return resources.get(getShardInfo(key));
  }

  //两种方式,这个key是字符串
  public R getShard(String key) {
    return resources.get(getShardInfo(key));
  }

  //根据key获取对应节点信息
  //这里就是一致性hash环概念的体现,最重要
  //这里也体现了为什么节点信息要放在TreeMap中,
  //因为这里TreeMap的key是根据节点信息计算出来的hash值。TreeMap天然支持通过tailMap(n)获取map中key值大于n的子map集合
  //理解起来就是先hash值找到大于数据key的hash值的所有节点服务器,
  //如果子集合不为空,取子集合中的第一个节点
  //如果没有节点的hash值比这个数据key的hash值大,那就取所有节点服务器的第一个节点,即环的起点/终点重合处
  //此刻脑子里应该想象一个环
  public S getShardInfo(byte[] key) {
    SortedMap<Long, S> tail = nodes.tailMap(algo.hash(key));
    if (tail.isEmpty()) {
      return nodes.get(nodes.firstKey());
    }
    return tail.get(tail.firstKey());
  }

  //key是字符串的环转成字节处理
  public S getShardInfo(String key) {
    return getShardInfo(SafeEncoder.encode(getKeyTag(key)));
  }

 
  public String getKeyTag(String key) {
    if (tagPattern != null) {
      Matcher m = tagPattern.matcher(key);
      if (m.find()) return m.group(1);
    }
    return key;
  }

  //两个获取所有节点信息的方法
  public Collection<S> getAllShardInfo() {
    return Collections.unmodifiableCollection(nodes.values());
  }
  public Collection<R> getAllShards() {
    return Collections.unmodifiableCollection(resources.values());
  }
}

重点是initialize()初始化方法,这是虚拟节点机制的体现,还有getShardInfo()根据数据key获取应该存放的节点信息,这是一致性hash环概念的体现,具体可以根据注释好好理解一下,从这里也可以看出来jedis只是借鉴了一致性hash环的概念,和97提出的时候自带的范围没有什么关系,节点信息也并不是真的存放在一个环形数据结构中,只不过在取数据的时候体现的环的概念。

引入一致性hash概念分片有什么好处呢?想象一下如果需要扩容,即在起点和节点A之间增加了节点B,如下图:

那失效需要重新分配的数据只有数据1,因为它是已经被放在节点A上了,现在增加节点后再去取数据1就会去节点B上去取,其他数据都没有影响,实现了最大程度的避免已分配缓存数据的失效。删除节点同理。

jedis中分片的基本使用方式参考如下:

//连接池的配置
JedisPoolConfig poolConfig = new JedisPoolConfig();
poolConfig.setMaxTotal(2);
poolConfig.setMaxIdle(1);
poolConfig.setMaxWaitMillis(2000);
poolConfig.setTestOnBorrow(false);
poolConfig.setTestOnReturn(false);

//创建多个redis实例
String host = "127.0.0.1";
JedisShardInfo shardInfo1 = new JedisShardInfo(host, 6379, 500);
shardInfo1.setPassword("password");
JedisShardInfo shardInfo2 = new JedisShardInfo(host, 6380, 500);
shardInfo2.setPassword("password");
JedisShardInfo shardInfo3 = new JedisShardInfo(host, 6381, 500);
shardInfo3.setPassword("password");

//初始化ShardedJedisPool连接池
List<JedisShardInfo> infoList = Arrays.asList(shardInfo1, shardInfo2, shardInfo3);
ShardedJedisPool jedisPool = new ShardedJedisPool(poolConfig, infoList);

//从连接池中获取连接,并利用try-withResource机制关闭连接
try(ShardedJedis jedis = jedisPool.getResource()) {
    jedis.set("testKey","1111");
    String result = jedis.get("testKey");
}

(2)集群模式

Redis3.0之后,时代不一样了,Redis集群的概念出来了。同时Redis集群的算法不再是一致性hash了,而是引入了hash槽的概念。Redis集群有16384个槽,每个数据key通过CRC16算法校验后对16384取模,从而确定放置在哪个糟,即

int index = CRC16(key)/16384

集群的每个节点负责一部分槽。使用hash槽的好处在于可以方便的添加或者移除节点,当需要增加节点时,只需要把其他节点的部分槽挪到新节点就行了,当需要删除节点时只需要把被删除的节点上的槽挪到其他节点就可以了。

Redis集群还引入主从实例和哨兵(sentinel)的概念,从服务是主服务的备份,当主服务挂掉后,哨兵会进行主从切换,将其中一台从服务变成主服务。使用示例如下:

JedisPoolConfig config = new JedisPoolConfig();
poolConfig.setMaxTotal(2);
poolConfig.setMaxIdle(1);
poolConfig.setMaxWaitMillis(2000);
poolConfig.setTestOnBorrow(false);
poolConfig.setTestOnReturn(false);

// 集群模式
JedisPoolConfig poolConfig = new JedisPoolConfig();

final HostAndPort hostAndPort1 = new HostAndPort("127.0.0.1", 6379);
final HostAndPort hostAndPort2 = new HostAndPort("127.0.0.1", 6380);
final HostAndPort hostAndPort3 = new HostAndPort("127.0.0.1", 6381);

Set<HostAndPort> nodes = new HashSet<HostAndPort>(){{
    add(hostAndPort1);
    add(hostAndPort2);
    add(hostAndPort3);
 }};
try(JedisCluster jedisCluster = new JedisCluster(nodes, poolConfig)){
    jedisCluster.set("testKey","123");
    String result = jedisCluster.get("testKey");
}

最后比较理解一下Redis的分片和集群,先截一张我看到的图:

我觉得这段话说的是有道理的, 本质上Redis的集群也是一种分片方式,不过Redis3.0之前用ShardedJedis实现分片是客户端实现分片,前面那段根据数据key找到对应的节点代码是在jedis客户端里面的,即客户端自己计算数据的key应该在哪个机器上存储,这个方法的好处是降低了服务器集群的复杂性,客户端自己分片,服务器节点之间是没有联系的,缺点也很明显,就是客户端要实时的知道当前所有节点的信息,因为当增加一个节点时得动态的重新分片啊。

但是Redis3.0之后的集群模式是服务端节点实现的数据分片,即客户端随意与集群中的任何节点通信,节点负责计算某个key在哪个机器上,把计算结果反馈给客户端,客户端再去指定的节点存储查询数据,这是一个重定向的过程。

上面说了是jedis客户端操作Redis,引入的依赖是客户端的jar包redis.clients。不过我们在用Spring boot开发时,boot也有一个操作Redis的starter:

<dependency>
     <groupId>org.springframework.boot</groupId>
     <artifactId>spring-boot-starter-data-redis</artifactId>
     <version>2.1.3.RELEASE</version>
</dependency>

这个starter内部实现了jedis和Lettuce两种客户端的封装,不过很遗憾,Spring boot默认是使用Lettuce,这是Spring boot的自动配置机制,即@EnableAutoConfiguration注解,从它的配置文件spring-autoconfigure-metadata.properties中可以看出:

这是默认配置的Lettuce,而使用Jedis客户端的条件是:

org.springframework.boot.autoconfigure.data.redis.JedisConnectionConfiguration.ConditionalOnClass=org.apache.commons.pool2.impl.GenericObjectPool,redis.clients.jedis.Jedis,org.springframework.data.redis.connection.jedis.JedisConnection

必须引入了redis.clients,而且这个starter里面由于已经封装了jedis客户端但是没有引入redis.clients客户端jar包,封装部分还在飘红...

 只要在引入这个starter的时候排除lettuce包,用jedis包替代就行了

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-data-redis</artifactId>
    <!-- 排除lettuce包,使用jedis代替-->
    <exclusions>
        <exclusion>
             <groupId>io.lettuce</groupId>
             <artifactId>lettuce-core</artifactId>
        </exclusion>
    </exclusions>
</dependency>
<!-- jedis -->
<dependency>
    <groupId>redis.clients</groupId>
    <artifactId>jedis</artifactId>
    <version>3.1.0</version>
</dependency>

 

  • 0
    点赞
  • 2
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值