Redis高可用集群构架原理及高并发

一、集群方案比较

1.1 哨兵模

redis高可用集群水平扩展 redis集群高可用原理_redis高可用集群水平扩展

 

哨兵模式

在Redis3.0以前的版本要实现集群一般是借助哨兵sentinel工具来监控master节点的状态,如果master节点异常,则会做主从切换,将某一台slave作为master,哨兵的配置略微复杂,并且性能和高可用性等各方面表现一般,特别是在主从切换的瞬间存在访问瞬断的情况。

1.2 高可用集群模式

 

redis高可用集群水平扩展 redis集群高可用原理_System_02

高可用集群模式

Redis集群是一个由多个主从节点群组成的分布式服务器群,它具有复制、高可用和分片特性。Redis集群不需要sentinel哨兵也能完成节点移除和故障转移的功能。需要将每个节点设置成集群模式,这种集群模式没有中心节点,可水平扩展,据官方文档称可以线性扩展到1000节点。Redis集群的性能和高可用性均优于之前版本的哨兵模式,且集群配置非常简单。

二、集群搭建

Redis 3.0(2012年发布3.0,2017年发布4.0)版本后支持使用Redis-Cluster来搭建集群,本文将介绍在Ubuntu 18.04 64下搭建Redis集群。因为Redis集群中至少应该有奇数个主节点,所以本文将创建6个Redis节点,其中3个为主节点,3个为从属节点,用于从主节点拉取数据进行备份。这里搭建的是伪集群模式,当然真正的分布式集群的配置方法几乎一样,搭建伪集群的步骤如下

2.1 Redis安装

参考Ubuntu安装Redis安装成功后开始进行集群搭建。

2.2 配置并启动6个节点

创建集群需要的目录


# 在redis目录下创建一个cluster目录
mkdir -p /etc/redis/cluster

# 然后在该目录下创建六个目录,分别命名为7770、7771、7772、7773、7774和7775
cd /etc/redis/cluster
mkdir 7770
mkdir 7771
mkdir 7772
mkdir 7773
mkdir 7774
mkdir 7775

复制redis.conf配置并修改对应配置项


cp /etc/redis/redis.conf /etc/redis/cluster/redis.conf

修改配置文件中的下面选项


port 7770
daemonize yes
pidfile /var/run/redis/redis_7770.pid
cluster-enabled yes
cluster-config-file nodes_7770.conf
cluster-node-timeout 15000
appendonly yes
appendfilename "appendonly.7770.aof"

各项配置的含义如下

  • port 7770:Redis节点的端口号为7770;
  • daemonize yes:以后台服务的形式开启Redis;
  • pidfile /var/run/redis/redis_7770.pid:以该配置启动Redis后将在/var/run/redis目录下创建一个redis_port.pid文件;
  • cluster-enabled yes:是否开启集群,yes;
  • cluster-config-file nodes_7770.conf:集群配置文件,启动后自动生成,文件名称为nodes_7770.conf。该文件将保持集群配置信息,以保证重启该Redis节点后能够保持集群状态;
  • cluster-node-timeout 15000:请求超时时间,默认为15秒;
  • appendonly yes:是否开启aof日志,开启后每次写操作都记录一条日志。

修改完redis.conf配置文件中的这些配置项之后把这个配置文件分别拷贝到7770/7771/7772/7773/7774/7775目录下面


cd /etc/redis

redis-server cluster/7770/redis.conf
redis-server cluster/7771/redis.conf
redis-server cluster/7772/redis.conf
redis-server cluster/7773/redis.conf
redis-server cluster/7774/redis.conf
redis-server cluster/7775/redis.conf

查看是否启动成功


ps -ef|grep redis

 

redis高可用集群水平扩展 redis集群高可用原理_Redis_03

redis查看启动情况

2.3 创建并启动集群

现在我们已经有了6个正在运行中的Redis实例,接下来我们需要使用这些实例来创建集群。接着使用redis-trib.rb创建集群,该文件使用ruby编写,所以使用redis-trib.rb之前得先安装ruby


apt-get install ruby
gem install redis

如果Redis是通过下载源码编译安装的方式,在Reids的src目录下有个redis-trib.rb文件,将该文件复制到/etc/redis目录下。本文Redis是通过apt-get install的方式直接安装的Redis,因此我先找到redis-trib.rb文件在哪个位置,然后在复制到/etc/redis目录下


# 查看redis-trib.rb文件所在位置
locate redis-trib.rb

# 返回位置
/usr/share/doc/redis-tools/examples/redis-trib.rb


# 复制到/etc/redis目录下
cp /usr/share/doc/redis-tools/examples/redis-trib.rb /etc/redis/redis-trib.rb

安装好ruby后,输入以下命令开启集群


# 进入目录
cd /etc/redis

# 集群启动命令
./redis-trib.rb create --replicas 1 127.0.0.1:7770 127.0.0.1:7771 127.0.0.1:7772 127.0.0.1:7773 127.0.0.1:7774 127.0.0.1:7775

选项--replicas 1表示 主节点数与从节点数的比值,即 Master数/Slave数,3/3=1,3主3从。如果想给每个主节点配置2个从节点,则3台主节点,6台从节点,3/6=0.5,则需要配置的值是0.5。

之后跟着的其他参数则是这个集群实例的地址列表:127.0.0.1:7770 、127.0.0.1:7771 、127.0.0.1:7772、 127.0.0.1:7773、127.0.0.1:7774、127.0.0.1:7775

现在有一个问题了,这6台机怎么分主从节点,以及每一台主节点配置哪一个为从节点?

Redis(redis-trib.rb脚本)已经已经规定了:
1、前面3个节点为主节点,后面3个节点为从节点
2、主节点的第一台,对应从节点的第一台

因此这6个节点的主从关系为

  • 7770(master) 对应 7773(slave)
  • 7771(master) 对应 7774(slave)
  • 7772(master) 对应 7775(slave)

如下图:

redis高可用集群水平扩展 redis集群高可用原理_Redis_04

 

集群主从信息

2.4 集群测试

查看集群目前状况


redis-cli -c -p 7770

cluster info


root@iZm5eetszs07500os8erolZ:/etc/redis# redis-cli -c -p 7770
127.0.0.1:7770> cluster info
cluster_state:ok
cluster_slots_assigned:16384
cluster_slots_ok:16384
cluster_slots_pfail:0
cluster_slots_fail:0
cluster_known_nodes:6
cluster_size:3
cluster_current_epoch:6
cluster_my_epoch:1
cluster_stats_messages_ping_sent:727
cluster_stats_messages_pong_sent:751
cluster_stats_messages_sent:1478
cluster_stats_messages_ping_received:746
cluster_stats_messages_pong_received:727
cluster_stats_messages_meet_received:5
cluster_stats_messages_received:1478

可发现,存值的操作并不是在7770节点完成的,存值的过程只在主节点下完成,并且每次set操作Redis都会输出Redirected to slot [xxxx] located at的提示。Redis集群有16384个哈希槽,每次set key时,Redis内部通过CRC16校验后对16384取模来决定放置哪个哈希槽。正如上面所说的,集群的每个主节点负责一部分哈希槽。


127.0.0.1:7770> set name alanchen
-> Redirected to slot [5798] located at 127.0.0.1:7771
OK
127.0.0.1:7771> set age 18
-> Redirected to slot [741] located at 127.0.0.1:7770
OK
127.0.0.1:7770> get name
-> Redirected to slot [5798] located at 127.0.0.1:7771
"alanchen"
127.0.0.1:7771> get age
-> Redirected to slot [741] located at 127.0.0.1:7770
"18"
127.0.0.1:7770>
2.5 主从测试

开头说过,在集群过程中可以通过主从的分配来提高Redis的可用性。比如这个例子,集群有7770、7771和7772这3个主节点,如果这3个节点都没有从节点,假设7771宕机了,那么整个集群就会因为缺少7771节点范围的哈希槽而变得不可用。

所以我们在集群建立的时候,一定要为每个主节点都添加了从节点, 比如像上面的例子那样,集群包含主节点7770、7771和7772以及从节点7773、7774和7775, 那么即使7770宕系统也可以继续正常工作。

当7770这个主节点宕机后,Redis集群将会选择7770的从节点7773作为新的主节点以确保集群正常的工作。当重新启动7770后,其自动变为了7773的从节点,角色完成了转换。

为了验证这个理论,下面将7770节点杀死,然后观察


root@iZm5eetszs07500os8erolZ:/etc/redis# ps -ef|grep redis
root      2429     1  0 12:02 ?        00:00:15 redis-server *:7770 [cluster]
root      4492     1  0 14:09 ?        00:00:04 redis-server *:7771 [cluster]
root      4500     1  0 14:09 ?        00:00:05 redis-server *:7772 [cluster]
root      4508     1  0 14:09 ?        00:00:05 redis-server *:7773 [cluster]
root      4513     1  0 14:09 ?        00:00:04 redis-server *:7774 [cluster]
root      4518     1  0 14:09 ?        00:00:04 redis-server *:7775 [cluster]
root      6572  4459  0 15:41 pts/1    00:00:00 grep --color=auto redis
root@iZm5eetszs07500os8erolZ:/etc/redis# kill -9 2429
root@iZm5eetszs07500os8erolZ:/etc/redis# redis-cli -c -p 7770
Could not connect to Redis at 127.0.0.1:7770: Connection refused
Could not connect to Redis at 127.0.0.1:7770: Connection refused
not connected>
root@iZm5eetszs07500os8erolZ:/etc/redis# redis-cli -c -p 7773
127.0.0.1:7773> get age
"18"
127.0.0.1:7773>

之前age是存储在7770节点上的,现在把7770服务停止了,再来获得age发现已经从7773上获取到age的值了

2.6 Java操作集群

引入Jedis依赖


<dependency>
     <groupId>redis.clients</groupId>
     <artifactId>jedis</artifactId>
     <version>2.9.0</version>
</dependency>

Java代码


import redis.clients.jedis.HostAndPort;
import redis.clients.jedis.JedisCluster;
import redis.clients.jedis.JedisPoolConfig;

import java.io.IOException;
import java.util.HashSet;
import java.util.Set;

/**
 * @author Alan Chen
 * @description 操作Redis集群
 * @date 2020/11/30
 */
public class RedisCluster {

    public static void main(String[] args) throws IOException {

        Set<HostAndPort> jedisClusterNode = new HashSet<HostAndPort>();

        jedisClusterNode.add(new HostAndPort("47.105.146.74",7770));
        jedisClusterNode.add(new HostAndPort("47.105.146.74",7771));
        jedisClusterNode.add(new HostAndPort("47.105.146.74",7772));
        jedisClusterNode.add(new HostAndPort("47.105.146.74",7773));
        jedisClusterNode.add(new HostAndPort("47.105.146.74",7774));
        jedisClusterNode.add(new HostAndPort("47.105.146.74",7775));

        JedisPoolConfig config = new JedisPoolConfig();
        config.setMaxTotal(100);
        config.setMaxIdle(10);
        config.setTestOnBorrow(true);

        JedisCluster jedisCluster = new JedisCluster(jedisClusterNode,6000,6000,10,"password_123",config);

        System.out.println(jedisCluster.set("student","alan"));

        System.out.println(jedisCluster.get("student"));

        jedisCluster.close();
    }
}

注:密码在redis.conf中配置


requirepass password_123

三、高并发场景库存重复扣减问题分析

先看以下这段扣减库存的代码


import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;

/**
 * @author Alan Chen
 * @description 高并发场景库存重复扣减问题模拟
 * @date 2020/12/1
 */
@RestController
public class StockController {

    @Autowired
    StringRedisTemplate stringRedisTemplate;

    @RequestMapping("/deduct_stock")
    public String deductStock(){
        int stock = Integer.parseInt(stringRedisTemplate.opsForValue().get("stock"));
        if(stock >0){
            int realStock = stock -1;
            stringRedisTemplate.opsForValue().set("stock",realStock+"");
            System.out.println("扣减成功,剩余库存:"+realStock+"");
        }else{
            System.out.println("扣减失败,库存不足");
        }
        return "end";
    }
}

通过分析代码我们发现,当在高并发情况下,有多个请求来扣减库存时,就会出现库存重复扣减的问题,出现商品超卖的情况。为了解决并发问题,我们很自然的给这段代码加上同步锁synchronized ,如下


@RestController
public class StockController {

    @Autowired
    StringRedisTemplate stringRedisTemplate;

    @RequestMapping("/deduct_stock")
    public String deductStock(){
        synchronized (this){
            int stock = Integer.parseInt(stringRedisTemplate.opsForValue().get("stock"));
            if(stock >0){
                int realStock = stock -1;
                stringRedisTemplate.opsForValue().set("stock",realStock+"");
                System.out.println("扣减成功,剩余库存:"+realStock+"");
            }else{
                System.out.println("扣减失败,库存不足");
            }
            return "end";
        }
    }
}

加同步锁synchronized在单体应用下是有效的,可以解决并发问题,但如果应用哪天升级成了集群部署或分布式部署后就无效了,比如,用Nginx搭配两台服务做负载均衡形成集群架构,两台tomcat同时提供服务时,synchronized就无效了,需要通过分布式锁来解决。

备注:synchronized只能在当前的JVM里生效

 

redis高可用集群水平扩展 redis集群高可用原理_System_05

image.png

四、分布式架构下如何实现Redis分布式锁

我们可以利用Redis中的setnx key value 命令来简单实现Redis分布式锁

4.1 setnx key value

可用版本:>=1.0.0
时间复杂度:O(1)

只在键key不存在的情况下,将键key的值设置为value;若key已经存在,则setnx命令不做任何动作。

setnx是set if Not eXists 的简写。

命令在设置成功时返回1,设置失败时返回0。

4.2 Redis实现分布式简单版


/**
 * @author Alan Chen
 * @description 高并发场景库存重复扣减问题-Redis实现分布式简单版
 * @date 2020/12/1
 */
@RestController
public class StockController {

    @Autowired
    StringRedisTemplate stringRedisTemplate;

    @RequestMapping("/deduct_stock")
    public String deductStock(){

        //加锁
        String lockKey = "product_001";
        // 与jedis.setnx(key,value)同效果;
        Boolean result = stringRedisTemplate.opsForValue().setIfAbsent(lockKey, "iPhone");
        if(!result){
            return "抢购人数太多,请稍后重试";
        }

        int stock = Integer.parseInt(stringRedisTemplate.opsForValue().get("stock"));
        if(stock >0){
            int realStock = stock -1;
            stringRedisTemplate.opsForValue().set("stock",realStock+"");
            System.out.println("扣减成功,剩余库存:"+realStock+"");
        }else{
            System.out.println("扣减失败,库存不足");
        }

        //释放锁
        stringRedisTemplate.delete(lockKey);

        return "end";
    }
}
4.3 优化版本1

通过分析上面的代码,我们可以发行一些潜在的问题,比如,刚加完锁,执行后面的业务代码时出现了异常,那么后面的释放锁的代码就不会执行,刚才加的锁没有被释放出现死锁问题。要解决该问题,我们可以把释放锁的代码放到finally代码块里


/**
 * @author Alan Chen
 * @description 高并发场景库存重复扣减问题--Redis实现分布式优化版本1
 * @date 2020/12/1
 */
@RestController
public class StockController {

    @Autowired
    StringRedisTemplate stringRedisTemplate;

    @RequestMapping("/deduct_stock")
    public String deductStock(){
        String lockKey = "product_001";
        try{
            //加锁
            // 与jedis.setnx(key,value)同效果;
            Boolean result = stringRedisTemplate.opsForValue().setIfAbsent(lockKey, "iPhone");
            if(!result){
                return "抢购人数太多,请稍后重试";
            }

            int stock = Integer.parseInt(stringRedisTemplate.opsForValue().get("stock"));
            if(stock >0){
                int realStock = stock -1;
                stringRedisTemplate.opsForValue().set("stock",realStock+"");
                System.out.println("扣减成功,剩余库存:"+realStock+"");
            }else{
                System.out.println("扣减失败,库存不足");
            }
        }finally {
            //释放锁
            stringRedisTemplate.delete(lockKey); 
        }
        return "end";
    }
}
4.4 优化版本2

通过分析,以上代码其实还有问题,比如在极端情况下,在执行try代码块里的代码时,服务器出现宕机或者程序发布被运维人员手动kill -9结束程序,那finally代码块里的释放锁的代码依然得不到执行,锁没有被释放出现死锁问题。解决这个问题,我们可以为锁加一个超时时间,到了超时时间自动释放锁。


//加锁
 // 与jedis.setnx(key,value)同效果;
  Boolean result = stringRedisTemplate.opsForValue().setIfAbsent(lockKey, "iPhone");
  //设置超时时间,自动释放锁
  stringRedisTemplate.expire(lockKey,10, TimeUnit.SECONDS);

这种设置超时时间的方式,依然是有问题的,比如刚执行完加锁代码,还没来得及设置超时时间,服务器出现宕机或者程序发布被运维人员手动kill -9结束程序,依然会有死锁问题。因此,我们用下面这种方式来设置超时时间


Boolean result = stringRedisTemplate.opsForValue().setIfAbsent(lockKey, "iPhone",10,TimeUnit.SECONDS);

加锁的同时设置超时时间,Redis底层会保证这两步的原子性。完成代码如下


/**
 * @author Alan Chen
 * @description 高并发场景库存重复扣减问题--Redis实现分布式优化版本2
 * @date 2020/12/1
 */
@RestController
public class StockController {

    @Autowired
    StringRedisTemplate stringRedisTemplate;

    @RequestMapping("/deduct_stock")
    public String deductStock(){
        String lockKey = "product_001";
        try{
            //加锁的同时设置超时时间  
            Boolean result = stringRedisTemplate.opsForValue().setIfAbsent(lockKey, "iPhone",10,TimeUnit.SECONDS);

            if(!result){
                return "抢购人数太多,请稍后重试";
            }

            int stock = Integer.parseInt(stringRedisTemplate.opsForValue().get("stock"));
            if(stock >0){
                int realStock = stock -1;
                stringRedisTemplate.opsForValue().set("stock",realStock+"");
                System.out.println("扣减成功,剩余库存:"+realStock+"");
            }else{
                System.out.println("扣减失败,库存不足");
            }
        }finally {
            //释放锁
            stringRedisTemplate.delete(lockKey);
        }
        return "end";
    }
}
4.5 优化版本3

以上代码依然有一些潜在问题,比如一个线程加了锁,被另外一个线程释放了的问题,场景描述如下图所示:

 

redis高可用集群水平扩展 redis集群高可用原理_redis_06

一个线程加了锁,被另外一个线程释放

解决这个问题的思路是:本线程加的锁,只能被本线程释放


/**
 * @author Alan Chen
 * @description 高并发场景库存重复扣减问题--Redis实现分布式优化版本3
 * @date 2020/12/1
 */
@RestController
public class StockController {

    @Autowired
    StringRedisTemplate stringRedisTemplate;

    @RequestMapping("/deduct_stock")
    public String deductStock(){
        String lockKey = "product_001";
        String clientId = UUID.randomUUID().toString();
        try{
            //加锁的同时设置超时时间
            Boolean result = stringRedisTemplate.opsForValue().setIfAbsent(lockKey, clientId,10,TimeUnit.SECONDS);

            if(!result){
                return "抢购人数太多,请稍后重试";
            }

            int stock = Integer.parseInt(stringRedisTemplate.opsForValue().get("stock"));
            if(stock >0){
                int realStock = stock -1;
                stringRedisTemplate.opsForValue().set("stock",realStock+"");
                System.out.println("扣减成功,剩余库存:"+realStock+"");
            }else{
                System.out.println("扣减失败,库存不足");
            }
        }finally {
            //释放锁(本线程加的锁,只能被本线程释放)
            if(clientId.equals(stringRedisTemplate.opsForValue().get(lockKey)))
            stringRedisTemplate.delete(lockKey);
        }
        return "end";
    }
}
4.5 优化版本4

关于这个超时时间有这样一个问题,某一个请求(线程)遇到慢查询或者GC等特殊情况,执行时间就是超过了设置的超时时间,就会提前释放锁,如果每次执行都超过了设置的超时时间,那每次都会提前释放锁,这个锁就永久失效失去意义了。那这个超时时间,到底设置多长合适呢?其实设置多少都不合适,设置太短就有上面提到的问题,如果设置太长(设置几个小时、几天)就失去了设置超时时间的意义,出现死锁的时候需要等待长时间才能自动解锁,出现长时间系统功能不可用。

要解决这个锁被提前释放的问题,可以加一个看门狗,或开启一个定时器去帮锁续命,重新设置一次超时时间。

自己实现分布式锁,细节和潜在的坑比较多,我们完全可以用现成的且成熟的分布式锁框架来帮我们实现分布式锁,这个框架就是下面要介绍的Redisson框架

五、基于Redisson框架实现分布式锁

Redisson官网

5.1 Redisson框架实现分布式锁

1、导入Redisson依赖


<dependency>
         <groupId>org.redisson</groupId>
         <artifactId>redisson</artifactId>
         <version>3.6.5</version>
 </dependency>

2、配置Redisson bean 到Spring容器


@SpringBootApplication
public class Application {

    public static void main(String[] args) {
        SpringApplication.run(Application.class, args);
    }
    
    @Bean
    public Redisson redisson(){
        Config config = new Config();
        config.useSingleServer().setAddress("redis://127.0.0.1:6379").setDatabase(0);
        return (Redisson) Redisson.create(config);
    }
}

3、Redisson实现分布式锁代码


/**
 * @author Alan Chen
 * @description 高并发场景库存重复扣减问题-Redisson实现分布式锁
 * @date 2020/12/1
 */
@RestController
public class StockController {

    @Autowired
    StringRedisTemplate stringRedisTemplate;

    @Autowired
    Redisson redisson;

    @RequestMapping("/deduct_stock")
    public String deductStock(){

        String lockKey = "product_001";

        //获得锁
        RLock redissonLock = redisson.getLock(lockKey);
        try{
            //加锁并设置超时时间
            redissonLock.lock(30,TimeUnit.SECONDS);

            int stock = Integer.parseInt(stringRedisTemplate.opsForValue().get("stock"));
            if(stock >0){
                int realStock = stock -1;
                stringRedisTemplate.opsForValue().set("stock",realStock+"");
                System.out.println("扣减成功,剩余库存:"+realStock+"");
            }else{
                System.out.println("扣减失败,库存不足");
            }

        }finally {
            // 释放锁
            redissonLock.unlock();
        }
        return "end";
    }
}

4、Redisson分布式锁实现原理

redis高可用集群水平扩展 redis集群高可用原理_System_07

 

Redisson分布式锁实现原理

5.2 性能优化

redissonLock.lock锁住了库存,解决了分布式并发问题(通过排队实现了串行),但会损失一部分性能,在需要提高性能的情况下,可以通过以下方式提供高性能

5.2.1 分段加锁

例如将商品001的100个库存,分成10段,分别对这10段进行加锁,理论上性能就可以提升10倍。但要注意在每段库存数量不够时,需要合并分段的库存。

5.2.2 用Redis集群

 

redis高可用集群水平扩展 redis集群高可用原理_Redis_08

Redis集群

六、Redis缓存与数据库双写不一致问题及解决方案

6.1 双写不一致场景描述

 

redis高可用集群水平扩展 redis集群高可用原理_redis高可用集群水平扩展_09

场景一

 

redis高可用集群水平扩展 redis集群高可用原理_redis高可用集群水平扩展_10

场景二

6.2 解决方案

方案一:采用延时双删策略,但这种方案延时时间并不好把握,因此不能百分之百解决问题。

方案二:采用内存队列进行排队,但这种方案容易产生积压阻塞,性能较差,容易出问题。

方案三:采用canal框架

redis高可用集群水平扩展 redis集群高可用原理_Redis_11

 

canal

方案四:采用分布式锁来解决(更严谨且推荐)。采用分布式锁有性能问题,可用通过分段加锁、采用Redis集群、读写锁(Redisson提供了ReadWriteLock,Redis的使用场景一般都是读多写少)