用户您好!请先登录!

Redis的高可用机制:哨兵和集群

Redis的高可用机制:哨兵和集群

我们在讨论分布式系统的时候,曾经谈过分布式系统要解决的是高并发、大数量和快速响应的问题。事实上,在互联网中,大部分的业务还是以查询数据为主,而非更改数据为主。在互联网出现高并发的时刻,查询关系数据库,会造成关系数据库的压力增大,容易导致系统宕机的严重后果。为了解决这个问题,一些开发者提出了数据缓存技术,数据缓存和关系数据库最大的不同在于,缓存的数据是保存在计算机内存上的,而关系数据库的数据主要保存在磁盘上。计算机检索内存的速度是远超过检索磁盘的,所以缓存技术可以在很大程度上提高整个系统的性能,降低数据库的压力。

使用缓存技术最大的问题是数据的一致性问题,缓存中存储的数据是关系数据库中数据的副本,因为缓存机制与数据库机制不同,所以它们的数据未必是同步的。虽然我们可以使用弱一致性去同步数据,但是现实很少会那么做,因为在互联网系统中,往往查询是可以允许部分数据不实时的,甚至是失真的,例如,一件商品的真实库存是100件,而现在显示是99件,这并不会妨碍用户继续购买。如果使用弱一致性,一方面会造成性能损失,另外一方面也会造成开发者工作量的大量增加。

缓存技术可以极大提升读写数据的速度,但是也有弊端,这就如同人类发明的水库一样。在平时,对水库进行蓄水,当干旱时,就可以把水库的水放出来,维持正常的工作和生活。如果水库设计得太大,那么显然会造成资源的浪费。水库还有另外一个功能,就是当水量过大,在下游有被淹没的危险的时候,关闭闸门,不让水流淹没下游。不过水库也会造成威胁,当水量实在太大,超过有限的水库容量的时候,就会溢出,这时会以更强的冲击力冲毁下游。缓存技术也是一样的,因为它是基于内存的,内存的大小要比磁盘小得多,同时成本也比磁盘高得多。因此缓存太大会浪费资源,过小,则在面临高并发的时候,可能会被快速填满,从而导致内存溢出、缓存服务失败,进而引发一系列的严重问题。

在一般情况下,单服务器缓存已经很难满足分布式系统大数量的要求,因为单服务器的内存空间是有限的,所以当前也会使用分布式缓存来应对。分布式缓存的分片算法和分布式数据库系统的算法大同小异,这里就不再讨论分片算法了。一般情况下,缓存技术使用起来比关系数据库简单,因为分布式数据库还会有事务和协议,而缓存数据一般不要求一致性,数据类型也远不如关系数据库丰富。缓存数据的用途大多是查询,查询和更新不同,对实时性没有那么高的要求,允许有一定的失真,这就给性能的优化带来了更大的空间。

当然相对关系数据库来说,缓存技术速度更快,正常来说,使用Redis的速度会是使用MySQL的几倍到十几倍。可见缓存能极大地优化分布式系统的性能,但是并不是说缓存可以代替关系数据库。首先,缓存主要基于内存的形式存储数据,而关系数据库主要是基于磁盘;内存空间相对有限,价格相对较高,而磁盘空间相对较大,价格相对较低。其次,内存一旦失去电源,数据就会丢失。虽然Redis提供了快照(RDB)和记录追加写命令(AOF)这两种形式进行持久化,但是机制相对简单,难以保证数据不丢失。关系数据库则有其完整的理论和实现,能够有效使用事务和其他机制保证数据的完整性和一致性。因此,当前用缓存技术代替关系数据库技术是不太现实的,但是可以使用缓存技术来实现网站常见的数据查询,这能大幅度地提升性能。一般来说,适合使用缓存的场景包含以下几种。

  • 大部分是读业务数据的系统(一般互联网系统都满足该条件)。
  • 需要快速响应的系统。
  • 需要预备数据(在系统或者某项业务前准备那些经常访问的数据到缓存中,以便于系统开始就能够快速响应,也称为预热数据)的系统。
  • 对数据安全和一致性要求不太严格的系统。

有适合使用缓存的场景,当然也会有不适合使用缓存的场景。

  • 读业务数据少且写入频繁的系统。
  • 对数据安全和一致性有严格要求的系统。

在使用缓存前,我会从3个方面进行考虑。

  • 业务数据常用吗?后续命中率如何?命中率很低的数据,没有必要写入缓存。
  • 该业务数据是读的多还是写的多,如果是写的多,需要频繁写入关系数据库,也没有必要使用缓存。
  • 业务数据大小如何?如果要存储很庞大的内容,就会给缓存系统带来很大的压力,有没有必要?能截取最有价值的部分进行缓存而不全部缓存吗?

经过以上考虑,觉得有必要使用缓存,就可以启动缓存了。在当前互联网中,缓存系统一般由Redis来完成,所以后续我们会集中讨论Redis,就不再讨论其他缓存系统了。这里我讨论的是Redis的5.0.5版本,如果采用别的版本,在配置项上会有少量不同,不过也大同小异,不会有太大的问题。

1. Redis的高可用

在Redis中,缓存的高可用分两种,一种是哨兵,另外一种是集群,下面我们会用两节分别讨论它们。不过在讨论它们之前,需要引入对Redis的依赖。

代码清单:引入spring-boot-redis依赖

<dependency>
   <groupId>org.springframework.boot</groupId>
   <artifactId>spring-boot-starter-data-redis</artifactId>
   <exclusions>
      <!--不依赖Redis的异步客户端lettuce-->
      <exclusion>
         <groupId>io.lettuce</groupId>
         <artifactId>lettuce-core</artifactId>
      </exclusion>
   </exclusions>
</dependency>
<!--引入Redis的客户端驱动jedis-->
<dependency>
   <groupId>redis.clients</groupId>
   <artifactId>jedis</artifactId>
</dependency>

这里引入了Redis的依赖,并且选用Jedis作为客户端,没有使用Lettuce。这里解释一下不使用Lettuce的原因。Lettuce是一个可伸缩的线程安全的Redis客户端,多个线程可以共享同一个Redis连接,因为线程安全,所以会牺牲一部分的性能。但是一般来说,使用缓存并不需要很高的线程安全,更注重的是性能。Jedis是一种多线程非安全的客户端,具备更高的性能,所以企业选择的时候往往还是以使用它为主。

1.1 哨兵模式

在Redis的服务中,可以有多台服务器,还可以配置主从服务器,通过配置使得从机能够从主机同步数据。在这种配置下,当主Redis服务器出现故障时,只需要执行故障切换(failover)即可,也就是作废当前出故障的主Redis服务器,将从Redis服务器切换为主Redis服务器即可。这个过程可以由人工完成,也可以由程序完成,如果由人工完成,则需要增加人力成本,且容易产生人工错误,还会造成一段时间的程序不可用,所以一般来说,我们会选择使用程序完成。这个程序就是我们所说的哨兵(sentinel),哨兵是一个程序进程,它运行于系统中,通过发送命令去检测各个Redis服务器(包括主从Redis服务器),如图所示。

Redis的高可用:哨兵和集群

 

图中有2个Redis从服务器,它们会通过复制Redis主服务器的数据来完成同步。此外还有一个哨兵进程,它会通过发送命令来监测各个Redis主从服务器是否可用。当主服务器出现故障不可用时,哨兵监测到这个故障后,就会启动故障切换机制,作废当前故障的主Redis服务器,将其中的一台Redis从服务器修改为主服务器,然后将这个消息发给各个从服务器,使得它们也能做出对应的修改,这样就可以保证系统继续正常工作了。通过这段论述大家可以看出,哨兵进程实际就是代替人工,保证Redis的高可用,使得系统更加健壮。

然而有时候单个哨兵也可能不太可靠,因为哨兵本身也可能出现故障,所以Redis还提供了多哨兵模式。多哨兵模式可以有效地防止单哨兵不可用的情况,如图16-2所示。

Redis的高可用:哨兵和集群

在图中,多个哨兵会相互监控,使得哨兵模式更为健壮,在这个机制中,即使某个哨兵出现故障不可用,其他哨兵也会监测整个Redis主从服务器,使得服务依旧可用。不过,故障切换方式和单哨兵模式的完全不同,这里我们通过假设举例进行说明。假设Redis主服务器不可用,哨兵1首先监测到了这个情况,这个时候哨兵1不会立即进行故障切换,而是仅仅自己认为主服务器不可用而已,这个过程被称为主观下线。因为Redis主服务器不可用,跟着后续的哨兵(如哨兵2和3)也会监测到这个情况,所以它们也会做主观下线的操作。如果哨兵的主观下线达到了一定的数量,各个哨兵就会发起一次投票,选举出新的Redis主服务器,然后将原来故障的主服务器作废,将新的主服务器的信息发送给各个从Redis服务器做调整,这个时候就能顺利地切换到可用的Redis服务器,保证系统持续可用了,这个过程被称为客观下线

下面我们需要对各个服务进行配置。首先修改Redis主服务器配置(192.168.224.131)的内容,在Redis安装目录中找到redis.config文件,打开它,可以发现有很多配置项和注释。只需要对某些配置项进行修改即可,需要修改的配置项代码如下:

# 禁用保护模式
protected-mode no
# 修改可以访问的IP,0.0.0.0代表可以跨域访问
bind 0.0.0.0
# 设置Redis服务密码
requirepass 123456

然后再修改两台从服务器的配置,请注意,它们俩的配置是相同的。在Redis安装目录中找到redis.config文件,然后也是对相关的配置项进行修改,代码如下:

# 禁用保护模式
protected-mode no
# 修改可以访问的IP,0.0.0.0代表可以跨域访问
bind 0.0.0.0
# 设置Redis服务密码
requirepass 123456
# 配置从哪里复制数据(也就是配置主Redis服务器) 
replicaof 192.168.224.131 6379
# 配置主Redis服务器密码 
masterauth 123456

以上的配置都有清晰的注释,请自行参考。从服务器的配置只是比主服务器多了replicaof和masterauth两个配置项。

上述的两个配置只是在配置Redis的服务器,此外我们还需要配置哨兵。同样,在Redis安装目录下,找到sentinel.conf文件,然后把3个哨兵服务的配置都改成以下配置。

# 禁止保护模式
protected-mode no

# 配置监听的主服务器,这里sentinel monitor 代表监控,
# mymaster 代表服务器名称,可以自定义
# 192.168.224.131 代表监控的主服务器
# 6379代表端口
# 2 代表只有在2个或者2个以上的哨兵认为主服务器不可用的时候,才进行客观下线
sentinel monitor mymaster 192.168.224.131 6379 2

# sentinel auth-pass定义服务的密码
# mymaster 服务名称
# 123456 Redis服务器密码
sentinel auth-pass mymaster 123456

上述的配置只是在原有的其他配置项上按需进行修改。代码中已经给出了清晰的注释,请读者自行参考。

有了这些配置,我们就可以进入Redis的安装目录,使用下面的命令启动服务了。

# 启动Redis服务
./src/redis-server ./redis.conf

# 启动哨兵进程服务
./src/redis-sentinel ./sentinel.conf

需要注意的是启动的顺序,首先是主Redis服务器,然后是从Redis服务器,最后才是3个哨兵。启动之后,观察最后一个启动的哨兵,可以下图所示的信息。

Redis的高可用:哨兵和集群

 

从图中可以看到主从服务器和哨兵的相关信息,说明我们的多哨兵模式已经搭建好了。

上述的哨兵模式配置好后,就可以在Spring Boot环境中使用了。首先需要配置YAML文件,如代码清单所示。

代码清单:在Spring Boot中配置哨兵

spring:
  redis:
    # 配置哨兵
    sentinel:
      # 主服务器名称
      master: mymaster
      # 哨兵节点
      nodes: 192.168.224.131:26379,192.168.224.133:26379,192.168.224.134:26379
    # 登录密码
    password: 123456
    # Jedis配置
    jedis:
      # 连接池配置
      pool:
        # 最大等待1秒
        max-wait: 1s
        # 最大空闲连接数
        max-idle: 10
        # 最大活动连接数
        max-active: 20
        # 最小空闲连接数
        min-idle: 5

这样就配置好了哨兵模式下的Redis,为了测试它,可以修改Spring Boot的启动类,如代码清单所示。

代码清单:测试哨兵

/**** imports ****/
@SpringBootApplication
@RestController
@RequestMapping("/redis")
public class Chapter16Application {

   public static void main(String[] args) {
       SpringApplication.run(Chapter16Application.class, args);
   }

   // 注入StringRedisTemplate对象,该对象操作字符串,由Spring Boot机制自动装配
   @Autowired
   private StringRedisTemplate stringRedisTemplate = null;

   // 测试Redis写入
   @GetMapping("/write")
   public Map<String, String> testWrite() {
      Map<String, String> result = new HashMap<>();
      result.put("key1", "value1");
      stringRedisTemplate.opsForValue().multiSet(result);
      return result;
   }

   // 测试Redis读出
   @GetMapping("/read")
   public Map<String, String> testRead() {
      Map<String, String> result = new HashMap<>();
      result.put("key1", stringRedisTemplate.opsForValue().get("key1"));
      return result;
   }    
}

这里的testWrite方法是写入一个键值对,testRead方法是读出键值对。我们先在浏览器请求
http://localhost:8080/redis/write,然后到各个Redis主从服务器中查看,都可以看到键值对(key1->value1)。当某个哨兵、Redis服务器或者主Redis服务器出现故障时,哨兵都会进行监测,并且通过主观下线或者客观下线进行修复,使得Redis服务能够具备高可用的特性。只是,在进行客观下线的时候,也需要一个时间间隔进行修复,这是我们需要注意的。默认是30秒,可以通过Redis的sentinel.conf文件的sentinel down-after-milliseconds进行修改,例如修改为60秒:

sentinel down-after-milliseconds mymaster 60000
1.2 Redis集群

除了可以使用哨兵模式外,还可以使用Redis集群(cluster)技术来实现高可用,不过Redis集群是3.0版本之后才提供的,所以在使用集群前,请注意你的Redis版本。不过在学习Redis集群前,我们需要了解哈希槽(slot)的概念,为此先看一下图16-4。

Redis的高可用:哨兵和集群

 

图中有整数1~6的图形为一个哈希槽,哈希槽中的数字决定了数据将发送到哪台主Redis服务器进行存储。每台主服务器会配置1台到多台从Redis服务器,从服务器会同步主服务器的数据。那么它的工作机制是什么样的呢?下面我们来进行解释。

我们知道Redis是一个key-value缓存,假如计算key的哈希值,得到一个整数,记为hashcode。如果此时执行:

n = hashcode % 6 + 1

得到的n就是一个1到6之间的整数,然后通过哈希槽就能找到对应的服务器。例如,n=4时就会找到主服务器1的Redis服务器,而从服务器1就是其从服务器,会对数据进行同步。

在Redis集群中,大体也是通过相同的机制定位服务器的,只是Redis集群的哈希槽大小为(214=16 384),也就是取值范围为区间[0, 16383],最多能够支持16 384个节点,Redis设计师认为这个节点数已经足够了。对于key,Redis集群会采用CRC16算法计算key的哈希值,关于CRC16算法,本书就不论述了,感兴趣的读者可以自行查阅其他资料进行了解。当计算出key的哈希值(记为hashcode)后,通过对16 384求余就可以得到结果(记为n),根据它来寻找哈希槽,就可以找到对应的Redis服务器进行存储了。它们的计算公式为:

# key为Redis的键,通过CRC16算法求哈希值
hashcode = CRC16(key);
# 求余得到哈希槽中的数字,从而找到对应的Redis服务器 
n = hashcode % 16384

这样n就会落入Redis集群哈希槽的区间[0, 16383]内,从而进一步找到数据。下面举例进行说明,如图16-5所示。

Redis的高可用:哨兵和集群

Redis集群工作原理

这里假设有3个Redis主服务器(或者称为节点),用来存储缓存的数据,每一个主服务器都有一个从服务器,用来复制主服务器的数据,保证高可用。其中哈希槽分配如下。

  • Redis主服务器1:分配哈希槽区间为[0, 5460]。
  • Redis主服务器2:分配哈希槽区间为[5461, 10922]。
  • Redis主服务器3:分配哈希槽区间为[10923, 16383]。

这样通过CRC16算法求出key的哈希值,再对16 384求余数,就知道n会落入哪个哈希槽里,进而决定数据存储在哪个Redis主服务器上。

注意,集群中各个Redis服务器不是隔绝的,而是相互连通的,采用的是PING-PONG机制,内部使用了二进制协议优化传输速度和带宽,如图16-6所示。

从图中可以看出,客户端与Redis节点是直连的,不需要中间代理层,并且不需要连接集群所有节点,只需连接集群中任何一个可用节点即可。在Redis集群中,要判定某个主节点不可用,需要各个主节点进行投票,如果半数以上主节点认为该节点不可用,该节点就会从集群中被剔除,然后由其从节点代替,这样就可以容错了。因为这个投票机制需要半数以上,所以一般来说,要求节点数大于3,且为单数。因为如果是双数,如4,投票结果可能会为2:2,就会陷入僵局,不利于这个机制的执行。

Redis的高可用:哨兵和集群

Redis集群中各个节点是联通的

在某些情况下,Redis集群会不可用,当集群不可用时,所有对集群的操作做都不可用。那么什么时候集群不可用呢?一般来说,分为两种情况。

  • 如果某个主节点被认为不可用,并且没有从节点可以代替它,那么就构建不成哈希槽区间[0, 16383],此时集群将不可用。
  • 如果原有半数以上的主节点发生故障,那么无论是否存在可代替的从节点,都认为该集群不可用。

Redis集群是不保证数据一致性的,这也就意味着,它可能存在一定概率的数据丢失现象,所以更多地使用它作为缓存,会更加合理。

有了上述的理论知识,下面让我们来搭建Redis集群环境。我使用的是Ubuntu来搭建Redis环境,首先进入root用户,然后执行以下命令:

cd /usr
# 创建Redis目录,并进入目录
mkdir redis
cd ./redis 
# 下载Redis
wget http://download.redis.io/releases/redis-5.0.5.tar.gz
# 解压缩安装包
tar xzf redis-5.0.5.tar.gz
# 进入安装目录
cd redis-5.0.5
# 编译安装Redis
make

执行上述命令就安装好了Redis,然后在/usr/redis/redis-5.0.5下创建文件夹cluster,并在其下面创建目录7001、7002、7003、7004、7005和7006,接着将
/usr/redis/redis-5.0.5/redis.conf文件复制到目录7001、7002、7003、7004、7005下,最后执行如下命令。

# 进入安装目录
cd /usr/redis/redis-5.0.5
# 创建文件夹cluster和其子目录
mkdir cluster
cd ./cluster 
mkdir 7001 7002 7003 7004 7005 7006
# 复制文件
cp ../redis.conf ./7001
cp ../redis.conf ./7002
cp ../redis.conf ./7003
cp ../redis.conf ./7004
cp ../redis.conf ./7005
cp ../redis.conf ./7006
# 赋予目录下所有文件全部权限
chmod -R 777 ./

这样从7001到7006的目录下就都有一份Redis的启动配置文件了,之所以让目录起名为这些数字,是因为我将会使用这些数字作为端口来分别启动Redis服务。下面,我们首先来修改7001下的redis.conf文件,只修改文件的部分配置,修改的内容如下:

# 关闭保护模式
protected-mode no
# 允许跨域访问
bind 0.0.0.0
# 主机密码
masterauth 123456
# 登录密码
requirepass 123456
# 端口7001
port 7001
# 启用集群模式
cluster-enabled yes
# 集群配置文件
cluster-config-file nodes-7001.conf
# 和集群节点通信的超时时间
cluster-node-timeout 5000
# 采用添加写命令的模式备份
appendonly yes
# 备份文件名称
appendfilename "appendonly-7001.aof"
# 采用后台运行Redis服务
daemonize yes
# PID命令文件
pidfile /var/run/redis_7001.pid

然后再修改7002到7006目录下的redis.conf文件,修改时将所有配置项中的“7001”替换为对应的数字即可,这样我们就可以得到6个启动Redis服务的配置文件了。

接下来就是配置和创建集群了,这里Redis 5也为此提供了工具,并且放在Redis安装目录的子文件夹/utils/create-cluster(我使用的系统全路径为
/usr/redis/redis-5.0.5/utils/create-cluster)中。打开这个目录,就可以发现一个create-cluster文件,我们修改它的权限(命令chmod 777 eate-cluster),然后打开它,修改它的内容,代码如下:

#!/bin/bash

# Settings
# 端口,从7000开始,SHELL会自动加1后,找到70017006的Redis服务实例
PORT=7000
# 创建超时时间
TIMEOUT=2000
# Redis节点数
NODES=6
# 每台主机的从机数
REPLICAS=1 # ①
# 密码,和我们配置的一致
PASSWORD=123456

......
#### 以下给redis-cli 命令添加配置的密码 #### 
if [ "$1" == "create" ]
then
   HOSTS=""
   while [ $((PORT < ENDPORT)) != "0" ]; do
      PORT=$((PORT+1))
      HOSTS="$HOSTS 192.168.224.135:$PORT"
   done
   ../../src/redis-cli --cluster create $HOSTS -a $PASSWORD --cluster-replicas $REPLICAS
   exit 0
fi

if [ "$1" == "stop" ]
then
   while [ $((PORT < ENDPORT)) != "0" ]; do
      PORT=$((PORT+1))
      echo "Stopping $PORT"
      ../../src/redis-cli -p $PORT -a $PASSWORD shutdown nosave
   done
   exit 0
fi

if [ "$1" == "watch" ]
then
   PORT=$((PORT+1))
   while [ 1 ]; do
      clear
      date
      ../../src/redis-cli -p $PORT -a $PASSWORD cluster nodes | head -30
      sleep 1
   done
   exit 0
fi
......
if [ "$1" == "call" ]
then
   while [ $((PORT < ENDPORT)) != "0" ]; do
      PORT=$((PORT+1))
      ../../src/redis-cli -p $PORT -a $PASSWORD $2 $3 $4 $5 $6 $7 $8 $9
   done
   exit 0
fi
...... 

这段配置看起来挺复杂,实际是很简单的,我修改的是代码中加粗的部分,其余的并未改动。首先修改了端口,例如,端口从7000开始遍历,这样循环加1,就可以找到7001到7006的服务实例。其次给redis-cli命令,加入配置的密码,修改IP。这里尽量不要使用localhost和127.0.01指向本机IP,应该使用该服务器在网络中的IP,否则不在本机客户端登录时,就会出现一些没有必要的错误。至此,所有的配置就都完成了。

跟着我们需要编写脚本,使得我们能够创建、停止和启动集群。为此,在Linux中以root用户登录,然后执行以下命令:

# 进入集群目录
cd /usr/redis/redis-5.0.5/cluster
# 创建3个脚本文件
touch create.sh start.sh shutdown.sh
# 赋予脚本文件全部权限
chmod 777 *.sh

从命令中可以看出,我们创建了3个Shell脚本文件。

  • create.sh:用来启动Redis服务,然后创建集群。
  • start.sh:用来在集群关闭后,启动集群的各个节点。
  • shutdown.sh:关闭运行中的集群的各个节点。

跟着来编写start.sh,代码如下:

# 进入集群工具目录
cd /usr/redis/redis-5.0.5/utils/create-cluster
# 启动集群各个Redis实例,参数为start
./create-cluster start

这个脚本是运行集群的各个节点,只是此时集群还没有被创建,所以还不能运行这个脚本。跟着是shutdown.sh的编写,代码如下:

# 进入集群工具目录
cd /usr/redis/redis-5.0.5/utils/create-cluster
# 停止集群各个Redis实例,参数为stop
./create-cluster stop

这个脚本是停止集群中的各个实例,当然集群现在没有创建和运行,所以它暂时也不能运行。

为了让start.sh和shutdown.sh能够运行,我们需要创建Redis集群,下面编写create.sh,内容如下:

# 在不同端口启动各个Redis服务 ①
/usr/redis/redis-5.0.5/src/redis-server /usr/redis/redis-5.0.5/cluster/7001/redis.conf

/usr/redis/redis-5.0.5/src/redis-server /usr/redis/redis-5.0.5/cluster/7002/redis.conf

/usr/redis/redis-5.0.5/src/redis-server /usr/redis/redis-5.0.5/cluster/7003/redis.conf

/usr/redis/redis-5.0.5/src/redis-server /usr/redis/redis-5.0.5/cluster/7004/redis.conf

/usr/redis/redis-5.0.5/src/redis-server /usr/redis/redis-5.0.5/cluster/7005/redis.conf

/usr/redis/redis-5.0.5/src/redis-server /usr/redis/redis-5.0.5/cluster/7006/redis.conf

# 创建集群,使用参数create  ②
cd /usr/redis/redis-5.0.5/utils/create-cluster
./create-cluster create 

这里分为两段,其中第①段是让Redis在各个端口下启动实例,第②段是创建集群。然后我们运行create.sh脚本,就可以看到图16-7所示的提示。

Redis的高可用:哨兵和集群

创建Redis集群的提示信息

注意图中框中的信息,信息类型大致分为两种。第一种是哈希槽的分配情况,这里提示了分为3个主节点,然后第一个的哈希槽区间为[0, 5460],第二个的为[5461, 10922],第三个的为[10923, 16383]。第二种是从节点的情况,7005端口为7001端口的从节点,7006端口为7002端口的从节点,7004端口为7003端口的从节点。然后它询问我们是否接受该配置,只要输入“yes”回车后,稍等一会儿,它就会创建Redis集群了。

创建好了Redis集群,可以通过命令来验证它,我们先通过redis-cli登录集群,在Linux中执行如下命令。

# 进入目录
cd /usr/redis/redis-5.0.5
# 登录Redis集群:
# -c代表以集群方式登录 
# -p 选定登录的端口 
# -a 登录集群的密码
./src/redis-cli -c -p 7001 -a 123456

这样就能够登录Redis集群了,然后我们可以执行几个Redis的命令来观察执行的情况,执行的命令如下:

set key1 value1
Set key2 value2
set key3 value3
Set key4 value4
set key5 value5

我执行的结果如图所示。

Redis的高可用:哨兵和集群

验证集群

在图中可以看到,在执行命令的时候,Redis会打印出一个哈希槽的数字,然后重新定位到具体的Redis服务器。这些都是Redis集群机制完成的,对于客户端来说,一切都是透明的。

至此,Redis集群我们就搭建成功了。当我们想停止集群的时候,可以执行之前创建好的shutdown.sh。当我们需要启动已经停止的集群的时候,只需要执行start.sh即可。

上述我们搭建了Redis的集群,跟着就要在Spring Boot中使用它了。在Spring Boot中使用它并不麻烦,只需要先注释掉代码清单16-3中的配置,然后在application.yml文件中加入代码清单所示的代码即可。

代码清单:Spring Boot配置Redis集群

spring:
  redis:
    # 登录密码
    # Jedis配置
    jedis:
      # 连接池配置
      pool:
        # 最大等待1秒
        max-wait: 1s
        # 最大空闲连接数
        max-idle: 10     
        # 最大活动连接数
        max-active: 20
        # 最小空闲连接数
        min-idle: 5
    # 配置Redis集群信息
    cluster:
       # 集群节点信息
      nodes: 192.168.224.135:7001,192.168.224.135:7002,192.168.224.135:7003,192.168.224.135:7004,192.168.224.135:7005,192.168.224.135:7006
      # 最大重定向数,一般设置为5,
      # 不建议设置过大,过大容易引发重定向过多的异常
      max-redirects: 5
    password: 123456

这样就在Spring Boot中配置好了,可以像往常一样通过RedisTemplate或者StringRedisTemplate来操作Redis集群了。

2. 使用一致性哈希(ShardedJedis)

在我们讨论了Redis集群后,大家可以知道,集群实际包含了高可用,也包含了缓存分片两个功能。但是对于集群来说,分片算法是固定且不透明的,可能会因为某种原因使得多数的数据,落入同一个Redis服务中,使负荷不同。有时候,我们还希望使用一致性哈希算法,关于该算法,我们在分布式数据库分片算法中也进行了详尽的介绍,所以这里就不再重复了。在Jedis中还提供了类ShardedJedis,有了这个类,我们可以很容易地在Jedis客户端中使用一致性哈希算法。

ShardedJedis内部已经采用了一致性哈希算法,并且为每个Redis服务器提供了虚拟节点(虚拟节点个数为权重×160)。下面让我们通过代码来学习如何使用ShardedJedis。首先,我们需要创建一个ShardedJedis连接池,于是在Spring Boot的启动类中加入代码清单所示的代码。

代码清单:使用ShardedJedis

// ShardedJedis 连接池
private ShardedJedisPool pool = null;

@Bean
public ShardedJedisPool initJedisPool() {
    // 端口数组
    int[] ports = {7001, 7002, 7003};
    // 权重数组
    int[] weights = {1, 2, 1};
    // 服务器
    String host = "192.168.224.136";
    // 密码
    String password = "123456";
    // 连接超时时间
    int connectionTimeout = 2000;
    // 读超时时间
    int soTimeout = 2000; 
    List<JedisShardInfo> shardList = new ArrayList<>();
    for (int i=0; i < ports.length; i++) { 
        // 创建JedisShard信息 
        JedisShardInfo shard = new JedisShardInfo(
             host, ports[i], connectionTimeout, soTimeout,weights[i]); //①
        // 设置密码
        shard.setPassword(password);
        // 加入到列表中
        shardList.add(shard);
    }
    // 连接池配置
    JedisPoolConfig poolCfg = new JedisPoolConfig();
    poolCfg.setMaxIdle(10);
    poolCfg.setMinIdle(5);
    poolCfg.setMaxIdle(10);
    poolCfg.setMaxTotal(30);
    poolCfg.setMaxWaitMillis(2000);
    // 创建ShardedJedis连接池
    pool = new ShardedJedisPool(poolCfg, shardList); // ②
    return pool;
}

这里我在一台机器上模拟了3个Redis服务,它们的端口分别为7001、7002和7003。现实中每台服务器的性能都可能是不同的,这里假设7002端口的服务性能要好很多,所以在权重数组中将它的权重设置为2,这样数据缓存到7002服务中的概率就更高了。在代码①处,创建了单个JedisShardInfo对象,然后将它放到一个列表中。代码②处创建了一个JedisShard连接池对象。

上面的代码创建了JedisShard连接池,这样就可以从中取出ShardedJedis对象去操作Redis了。下面让我们在启动类(Chapter16Application.java)中加入代码清单所示的代码来进行演示。

代码清单:使用ShardedJedis

// 测试Redis写入
@GetMapping("/test2")
public Map<String, String> test2() {
   Map<String, String> result = new HashMap<>();
   ShardedJedis jedis = null;
   try {
      // 获得ShardedJedis对象    ①
      jedis = pool.getResource(); 
      // 写入Redis
      jedis.set("key1", "value1"); 
      // 从Redis读出
      result.put("key1", jedis.get("key1")); 
      return result;
   } finally {
      // 最后释放连接
      jedis.close(); // ②
   }
}

代码也比较简单,其中①处是获取ShardedJedis对象,然后设置一个键值对,再从中读出来放到Map中。②处是关闭连接,以避免过多的空闲连接得不到释放。

ShardedJedis使用起来也比较方便,但是无法与Spring提供RedisTemplate和StringRedisTemplate结合。同时,也没有类似哨兵模式和集群模式下主从机主动修复的机制,所以在高可用方面较差。因为它的缺点,所以选择它时需要慎重。

ShardedJedis的原理其实也不难,我们知道Redis是键值对(key-value)缓存,要操作数据就必须要有键(key),所以在做Redis命令操作时,会先根据key求出其哈希值(hashcode),然后再根据哈希值和一致性哈希算法,选择具体的Redis节点。在ShardedJedis的一致性哈希算法中,会给每一个真实的Redis节点制造出“160×权重”个虚拟节点,使数据尽可能平均地分布到每一个节点中。

3.分布式缓存实践

在分布式缓存中,还会遇到许多的问题。例如,保存的对象过大,网络传输较慢,又如缓存雪崩等,所以要用好分布式缓存也需要考虑一些常见的问题。

3.1 大对象的缓存

在Java中,有些对象可能很大,尤其是那些读取文件的对象。对于大的对象,一次性读出来需要使用很多的网络传输资源,这样会引发性能瓶颈。在Redis官网中,建议我们使用Redis的哈希(Hash)结构去缓存大对象的内容,把它的属性保存到哈希结构的字段(field)中。在读取很大的对象时,往往只需要先读取部分内容,后续再根据需要读取对应的字段即可,如图所示。

Redis的高可用:哨兵和集群

将大对象以哈希结构缓存到Redis中

也许还有一种可能,就是哈希结构中的某个字段的值也是大对象,例如一本书有几十万字。一般来说,这个时候会做两方面的考虑。一方面是有必要全部保存吗?是否保存部分最常用的即可?另一方面,可以拆分字符串,将原有的字段拆分为多个字段,拿图16-3来说,假如field3需要存储的是很大的字符串,我们可以将其拆分为field3_1, field3_2, …, field3_n,分段保存字符串,然后读取的时候,也分段读取即可。

3.2 缓存穿透、并发和雪崩

当客户端通过一个键去访问缓存时,缓存没有数据,跟着又去访问数据库,数据库也没有数据,这时因为数据库返回也为空,所以不会将该数据放到缓存中,我们把这样的情况称为缓存穿透,如图16-10所示。

Redis的高可用:哨兵和集群

缓存穿透

如果我们再次请求这个的键,还是会按照此流程再走一遍。如果出现高并发访问这个键的情况,数据就会频繁访问数据库,给数据库带来很大的压力,甚至可能导致数据库出现故障,这便是缓存穿透带来的危害。

为了解决这个问题,相信大家很快想到,如果在访问数据库后也得到控制,可以在缓存中记录一个字符串(如“null”,代表是空值),即可解决这个问题。但是这样会引发一个问题,就是在很多时候我们访问数据库也得不到数据,这样就会在缓存中存储大量的空值,这显然也会给缓存带来一定的浪费。为此可以增加一个判断,就是判断该键是否是一个常用的数据,如果是常用的,就将它也写入缓存中,这样就不会出现缓存穿透导致数据库被频繁访问的情况了,如图所示。

Redis的高可用:哨兵和集群

解决缓存穿透问题

在使用缓存的过程中,我们往往还会设置超时时间,当数据超时的时候,就不能从缓存中读取数据了,而是到数据库中读取。有些数据是热点数据,例如我们最畅销的产品,假如在高并发期间,这个产品和它的关联信息在缓存中超时失效了,就会导致大量的请求访问数据库,给数据库带来很大的压力,甚至可能导致数据库宕机,类似这样的情况,我们称为缓存并发,如图所示。

Redis的高可用:哨兵和集群

缓存并发

为了防止出现缓存并发的情况,一般来说,我们可以采用以下几种方式避免缓存并发。

  • 限流:也就是防止过多的请求来访问缓存系统,从而导致压垮数据库,例如使用Resilience4j进行限流,但是这会影响并发线程数量。
  • 加锁:对缓存数据加锁,使得线程只能一条条地通过去访问,而不能并发访问,这样就能避免缓存并发的现象,但是分布式锁比较难以实现,所以一般来说我们不会考虑这个办法。
  • 错峰失效:网站一般是在上网高峰期或者热门商品抢购时,才会出现高并发现象,而这是有规律的,所以可以自己设置那些需要经常访问的缓存,错过这段时间失效,一般就不会出现缓存并发的现象了,这个做法的成本相对低,也容易实现,所以我比较推荐它。

上述我们谈了缓存穿透和缓存并发,事实上,还有一种缓存雪崩,那什么是缓存雪崩呢?典型的情况是,我们在启动系统的时候,一般会把最常用的数据放入缓存中,并且设置一个固定的超时时间,这便是我们常说的预热数据,它有助于系统性能的提高。但是,因为设置了一个固定的超时时间,所以会导致在某个时间点有大量缓存的键值对数据超时,如果在这个时间点出现高并发,就会导致请求大量访问数据库,造成数据库压力过大,甚至宕机,这便是缓存雪崩,如图所示。

Redis的高可用:哨兵和集群

缓存雪崩

这里容易混淆的是缓存并发和缓存雪崩的概念,缓存并发是针对一个键值对来说的,而缓存雪崩是针对多个键值对在某个时间点同时超时来说的。一般来说,为了避免缓存雪崩,我们需要在预热数据的时候,防止所有数据都在一个时间点上超时。为此,可以设置不同的超时时间,来避免多个键值对同时失效。例如,key1失效是1小时,key2是1.5小时、key3是30分钟……这样就能够避免数据同时失效了。

3.3 缓存实践的一些建议

对于缓存的使用,我们需要遵循一定的规则,避免一些没有必要的麻烦。下面是我的一些建议。

  • 对于采用了微服务架构的系统,建议缓存服务器只存储某项业务的数据,不掺杂其他业务的数据,这样可以避免业务数据的耦合。
  • 对于存入缓存的预热数据,尽量设置不同的超时时间,以避免同时超时引发缓存雪崩。
  • 在使用缓存前,要判断应不应该使用缓存。
  • 对于大数据对象的缓存,应该考虑分而治之的办法,化简为零。
  • 缓存会造成数据的不一致,也可能存在一定的失真,但是性能好,能够支持高并发的访问,所以多用于读取数据,而对于更新数据,一定要以数据库为基准,不要轻信缓存。
  • 对于热门数据,应该考虑错峰失效,错峰更新,避免出现缓存并发现象。
  • 在需要大量操作Redis的时候,可以考虑采用流水线(pipeline)的方式,这样可以在很大程度上提高传输的效率。
  • 在读数据的时候,先读缓存再读数据库。在写数据的时候,先写数据库再写缓存。

有了这些良好的习惯,相信在使用分布式缓存的时候,会减少许多不必要的麻烦。

 

行走的code
行走的code

要发表评论,您必须先登录