Docker创建Redis集群,并整合SpringBoot

Docker创建Redis集群,并整合SpringBoot

redis的多机数据库实现,主要分为以下三种:

  • Redis哨兵(Sentinel)
  • Redis复制(主从)
  • Redis集群

为提高存储容量和响应速度,一般公司都会搭建Redis集群来保证服务高可用。本文主要记录下如何基于Docker快速搭建Redis集群并在SpringBoot中整合使用。

还有各种喜闻乐见的坑:如使用lua脚本报错、redis-cli版本问题等。

涉及到的技术和相关版本如下:

Docker 19.03
Docker Compose 1.25
Redis 5.0.5
SpringBoot 2.2
CentOS 7

Redis集群不同一致性哈希,它用一种不同的分片形式,在这种形式中,每个key都是一个概念性(hash slot)的一部分。Redis集群中的每个节点负责一部分hash slots,并允许添加和删除集群节点。

哈希槽(hash slot)

来自Redis Cluster的概念, 但在各种集群方案都有使用。 哈希槽是一个key的集合,Redis集群共有16384个哈希槽,每个key通过CRC16散列然后对16384进行取模来决定该key应当被放到哪个槽中,集群中的每个节点负责一部分哈希槽。

以有三个节点的集群为例:
节点A包含0到5500号哈希槽
节点B包含5501到11000号哈希槽
节点C包含11001到16384号哈希槽


这样的设计有利于对集群进行横向伸缩,若要添加或移除节点只需要将该节点上的槽转移到其它节点即可。在某些集群方案中,涉及多个key的操作会被限制在一个slot中,如Redis Cluster中的mget/mset操作。此时需要HashTag来帮助实现相关操作,详见下文。

为了保持可用,Redis集群采用master-slave模式,每个hash slot就有1到N个副本。而redis-cluster规定,至少需要3个master和3个slave。3个master-slave对
当我们给每个master节点添加一个slave节点以后,我们的集群最终会变成由A、B、C三个master节点和A1、B1、C1三个slave节点组成,这个时候如果B失败了,系统仍然可用。节点B1是B的副本,如果B失败了,集群会将B1提升为新的master,从而继续提供服务。然而,如果B和B1同时失败了,那么整个集群将不可用。
Redis集群不能保证强一致性。换句话说,Redis集群可能会丢失一些写操作,原因是因为它用异步复制。

1、准备工作

1.1 安装Docker Compose

Docker Compose
是一个用于定义和运行多个docker容器应用的工具。使用Compose可以用YAML文件来配置你的应用服务,然后只需要使用一个命令,就可以部署配置好的所有服务。github: https://github.com/docker/compose

curl -L https://get.daocloud.io/docker/compose/releases/download/1.25.1/docker-compose-`uname -s`-`uname \
 -m` -o /usr/local/bin/docker-compose

修改执行权限:

chmod +x /usr/local/bin/docker-compose

查看是否安装成功:

docker-compose --version

1.2 准备配置文件

这里使用1个普通redis实例+5个dockerRedis实例的方式来搭建,机器里就有一个单例redis直接拿来用了,其实…正常来说要么分两台机器,一台3个Master一台3个slave来组建,或者直接一台机器起6个docker。但由于我就俩机器,另一个还被十多个服务挤爆空间不够。。所以就凑合用一台 = =。在生产环境下应该是一台一个实例的。

1.3 创建目录和相关配置文件:

在服务器/home/路径下创建 redis-cluster 文件夹。并在里面创建下述 5个redis的conf文件、1个docker-compose.yml和1个redis.sh文件。

先为五个docker实例准备redis.conf文件,这五个docker实例启动端口分别为 6380,6381,6382,7001,7002 ,还有个本机的6379

rdis.conf文件有两种常见方式:

1、使用Includ方式,只配置关键的配置点

include /your_redis_conf_file_path
port 6380
cluster-enabled yes   
cluster-config-file nodes-6380.conf 
cluster-node-timeout 15000 
# redis默认使用rdb来持久化,这里不做任何改动,若需要aof请自行配置。

2、使用完整.conf文件也是配置如上几个点,二者其实没区别。

然后准备 docke compose 用到的配置文件 docker-compose.yml

version: "3"
services:
  redis-master2:
    image: redis:5.0              # 基础镜像
    container_name: redis-master2 # 容器名称
    working_dir: /config          # 切换工作目录
    environment:                  # 环境变量
      - PORT=6380                 # 会使用config/nodes-${PORT}.conf这个配置文件
    ports:                        # 映射端口,对外提供服务
      - 6380:6380                 # redis的服务端口
      - 16391:16391               # redis集群监控端口
    stdin_open: true              # 标准输入打开
    tty: true                     # 后台运行不退出
    network_mode: host            # 使用host模式
    privileged: true              # 拥有容器内命令执行的权限
    volumes:
      # 配置文件目录映射到宿主机
      # 将容器中的/config配置目录映射到了宿主机的/home/redis-cluster目录
      - /home/redis-cluster:/config
    entrypoint:                   # 设置服务默认的启动程序
      - /bin/bash
      - redis.sh
  redis-master3:
    image: redis:5.0
    working_dir: /config
    container_name: redis-master3
    environment:
      - PORT=6381
    ports:
      - 6381:6381
      - 16395:16395
    stdin_open: true
    network_mode: host
    tty: true
    privileged: true
    volumes:
      - /home/redis-cluster:/config
    entrypoint:
      - /bin/bash
      - redis.sh
  redis-slave1:
    image: redis:5.0
    working_dir: /config
    container_name: redis-slave1
    environment:
      - PORT=6382
    ports:
      - 6382:6382
      - 16395:16395
    stdin_open: true
    network_mode: host
    tty: true
    privileged: true
    volumes:
      - /home/redis-cluster:/config
    entrypoint:
      - /bin/bash
      - redis.sh
  redis-slave2:
    image: redis:5.0
    container_name: redis-slave2
    working_dir: /config
    environment:
      - PORT=7001
    ports:
      - 7001:7001
      - 16394:16394
    stdin_open: true
    network_mode: host
    tty: true
    privileged: true
    volumes:
      - /home/redis-cluster:/config
    entrypoint:
      - /bin/bash
      - redis.sh
  redis-slave3:
    image: redis:5.0
    working_dir: /config
    container_name: redis-slave3
    environment:
      - PORT=7002
    ports:
      - 7002:7002
      - 16395:16395
    stdin_open: true
    network_mode: host
    tty: true
    privileged: true
    volumes:
      - /home/redis-cluster:/config
    entrypoint:
      - /bin/bash
      - redis.sh

最后是 redis.sh 的脚本

#!/bin/bash
echo "启动: nodes-${PORT}.conf"
redis-server  ./node-${PORT}.conf

最后这个目录下应该有这些文件:

1.4 一键启动docker

cd进 /home/redis-cluster 路径,键入如下命令:

docke-compose up -d

若一切正常,使用ps命令可以看到:

加上本机已经启动的6379,已经构成了可以搭建集群的基本要素。

2、搭建集群和启动

关键命令为 :

redis-cli --cluster

Redis Cluster 在5.0之后取消了ruby脚本 redis-trib.rb的支持(手动命令行添加集群的方式不变),将trib集成到了redis-cli里,避免了再安装ruby的相关环境,经历过的人都知道ruby是多么折腾。关于ruby可以参考这里

直接使用redis-clit的参数–cluster 来取代。关于Cluster的相关说明可以查看官网
附 help 命令:

redis-cli --cluster help
Cluster Manager Commands:
  create         host1:port1 ... hostN:portN   #创建集群
                 --cluster-replicas <arg>      #从节点个数
  check          host:port                     #检查集群
                 --cluster-search-multiple-owners #检查是否有槽同时被分配给了多个节点
  info           host:port                     #查看集群状态
  fix            host:port                     #修复集群
                 --cluster-search-multiple-owners #修复槽的重复分配问题
  reshard        host:port                     #指定集群的任意一节点进行迁移slot,重新分slots
                 --cluster-from <arg>          #需要从哪些源节点上迁移slot,可从多个源节点完成迁移,以逗号隔开,传递的是节点的node id,还可以直接传递--from all,这样源节点就是集群的所有节点,不传递该参数的话,则会在迁移过程中提示用户输入
                 --cluster-to <arg>            #slot需要迁移的目的节点的node id,目的节点只能填写一个,不传递该参数的话,则会在迁移过程中提示用户输入
                 --cluster-slots <arg>         #需要迁移的slot数量,不传递该参数的话,则会在迁移过程中提示用户输入。
                 --cluster-yes                 #指定迁移时的确认输入
                 --cluster-timeout <arg>       #设置migrate命令的超时时间
                 --cluster-pipeline <arg>      #定义cluster getkeysinslot命令一次取出的key数量,不传的话使用默认值为10
                 --cluster-replace             #是否直接replace到目标节点
  rebalance      host:port                                      #指定集群的任意一节点进行平衡集群节点slot数量 
                 --cluster-weight <node1=w1...nodeN=wN>         #指定集群节点的权重
                 --cluster-use-empty-masters                    #设置可以让没有分配slot的主节点参与,默认不允许
                 --cluster-timeout <arg>                        #设置migrate命令的超时时间
                 --cluster-simulate                             #模拟rebalance操作,不会真正执行迁移操作
                 --cluster-pipeline <arg>                       #定义cluster getkeysinslot命令一次取出的key数量,默认值为10
                 --cluster-threshold <arg>                      #迁移的slot阈值超过threshold,执行rebalance操作
                 --cluster-replace                              #是否直接replace到目标节点
  add-node       new_host:new_port existing_host:existing_port  #添加节点,把新节点加入到指定的集群,默认添加主节点
                 --cluster-slave                                #新节点作为从节点,默认随机一个主节点
                 --cluster-master-id <arg>                      #给新节点指定主节点
  del-node       host:port node_id                              #删除给定的一个节点,成功后关闭该节点服务
  call           host:port command arg arg .. arg               #在集群的所有节点执行相关命令
  set-timeout    host:port milliseconds                         #设置cluster-node-timeout
  import         host:port                                      #将外部redis数据导入集群
                 --cluster-from <arg>                           #将指定实例的数据导入到集群
                 --cluster-copy                                 #migrate时指定copy
                 --cluster-replace                              #migrate时指定replace
  help           

–cluster create创建集群

创建方式有两种。

一种是直接使用 create + 全部ip+port的方式让其自动分配主从节点。
还有一种是自己指定主节点机器,手动添加从节点。

这里采用第二种。

先看第一种的命令:

/redis-cli --cluster create 192.168.163.132:6379 192.168.163.132:6380 192.168.163.132:6381 192.168.163.132:6382 192.168.163.132:6383 192.168.163.132:6384 --cluster-replicas 1

下面是第二种手动指定三个主节点 6379,6380,6381:

/usr/local/redis/bin/redis-cli --cluster create 192.168.31.105:6379 192.168.31.105:6380 192.168.31.105:6381

然后分别添加从节点6382,7001,7002到主节点6379, 6380,6381

/usr/local/redis/bin/redis-cli --cluster add-node 192.168.31.105:6382 192.168.31.105:6379 --cluster-slave
# 从6382 到 主6379
# 同样命令执行 7001 到 6380 和 7002 到 6381

查看状态:

/usr/local/redis/bin/redis-cli --cluster check 192.168.31.105:6380 --cluster-search-multiple-owners

手滑配错的话,还可以 使用 del-node + ip + port + 实例id 来 删除某个节点,注意删除后此节点直接shutdown呦。

至此集群搭建完成。

来随便玩几个。。。

可以看到, Redirected to slot [xxxx] located at xxxx,证明了每个节点负责一部分hash slots 。

使用check命令,发现会有红色报错:
[ERR] Not all 16384 slots are covered by nodes
大部分原因是由于主node移除了,但是并没有移除node上面的slot,从而导致了slot总数没有达到16384,其实也就是slots分布不正确。所以在删除节点的时候一定要注意删除的是否是Master主节点。

解决:

redis-cli --cluster fix 192.168.31.105:6397 --cluster-search-multiple-owners

1、删除此节点的redis启动时生成的.aof文件和.rdb文件;
2、将对应节点的“nodes-xxx。conf”文件(在上文配置的/home/ redis-cluster 文件夹中 nodes- xxx .conf)删除
3、(万不得已的选择)先flushDB然后shutdown,把新鲜出炉的空的dump.rdb放进bin目录下。

3、整合springBoot

项目依赖

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

yml配置:

spring:
  redis:
    timeout: 5000           # 链接超时时间 单位 ms(毫秒)
    cluster:
      nodes:
        - 192.168.31.105:6379
        - 192.168.31.105:6380
        - 192.168.31.105:6381
        - 192.168.31.105:6382
        - 192.168.31.105:7001
        - 192.168.31.105:7002
      max-redirects: 3
    lettuce:
      pool:                 # Redis 线程池设置
        max-active: 8       # 连接池最大连接数(使用负值表示没有限制) 默认 8
        max-wait: -1        # 连接池最大阻塞等待时间(使用负值表示没有限制) 默认 -1
        max-idle: 8         # 连接池中的最大空闲连接 默认 8
        min-idle: 0         # 连接池中的最小空闲连接 默认 0

用一个以前的测试方法。
5分钟内连续登录错误5此,锁定5分钟,以此测试redis集群是否正常。

@Test
    public void loginTest() {
        String input_n = "lzm";  // 假设输入的用户名
        String input_p = "1";    // 假设输入的密码

        // 正确的账号为 lzm,密码http://lzyz.fun   
        System.out.println("登录时间:" +  DateUtil.date() ); 
        if( !(input_n.equals("lzm") && input_p.equals("http://lzyz.fun")) ){
    
            if( redisUtils.hasKey("logErr", input_n) ){
                Object errcount = redisUtils.hget("logErr", input_n, "errcount");
                Object state    = redisUtils.hget("logErr", input_n, "state");
                int count = Integer.parseInt(String.valueOf(errcount));          
                if("lock".equals(String.valueOf(state))) {
                    long expire = redisUtils.getExpire("logErr", input_n);
                    System.out.println("此账号被锁定,请"+expire+"秒后再试。");
                    return;
                }
                int nums = ++count;
                System.out.println(input_n + "错误次数:" +nums );
                Map<String, Object> map = new HashMap<>();
                if( nums < 5 ){  
                    map.put("errcount", nums );
                    map.put("state", "intime" );
                    redisUtils.hmset("logErr",  input_n, map );
                }else{
                    map.put("errcount", 5 );
                    map.put("state", "lock" );
                    redisUtils.hmset("logErr",  input_n, map , 300 );
                    System.out.println("账号已锁定。");
                }
            }
            else{  
                Map<String, Object> map = new HashMap<>();
                map.put("errcount", 1 );
                map.put("state", "intime" );
                // 第一次错误即设置5分钟。
                redisUtils.hmset( "logErr", input_n , map, 300 );
                System.out.println(input_n + "登录错误。" );
            }
            return;
        }
        
        System.out.println(" O(∩_∩)O  登陆成功!");
        redisUtils.del("logErr", input_n );

    }

在连续错误5次后。。控制台输出

再看redis中:

锁了。

RedisTemplate正常不代表运行lua就正常,因为在redis集群模式下lua脚本很容易会爆出两个错:

1、(error) CROSSSLOT Keys in request don’t hash to the same slot

2、Lua script attempted to access a non local key in a cluster node

先说错误1,是因为:
Redis要求单个Lua脚本操作的key必须在同一个节点上,但是Cluster会将数据自动分布到不同的节点(虚拟的16384个slot),阿里云集群版的官网其实也有对应说明:在Redis集群版实例中,事务、脚本等命令要求所有的key必须在同一个slot中,如果不在同一个slot中将返回以下错误信息(:command keys must in same slot)

如何解决?
CLUSTER KEYSLOT key的文档中提供了解决方法:你需将把key中的一部分使用{}包起来。
redis将通过{}中间你自定义的内容作为计算slot的key,类似key1{haha}、key2{haha}
取键中的一段字符来计算 hash,计算落入那个槽中,这样同一个功能不同的 key 就可以落入同一个槽位了,hash tag 就是通过{}这对括号括起来的字符串来找到家~
但是也有缺点,比如不能平滑的过度老业务,可能需要修改原来使用的key。

举个栗子,有这样一段代码:

redisTemplate.execute((RedisConnection connection) -> connection.eval(
                redisScript.getScriptAsString().getBytes(),
                ReturnType.INTEGER,
                2,
                key1.getBytes(),
                key2.getBytes()
        ));

我们会把key1和key2作为参数传入lua脚本,如果你直接传参,绝对报错。所以需要对key做一下处理:

        String lua_key1 = key1+"{lua}";
        String lua_key2 = key2+"{lua}";
redisTemplate.execute((RedisConnection connection) -> connection.eval(
                redisScript.getScriptAsString().getBytes(),
                ReturnType.INTEGER,
                2,
                lua_key1.getBytes(),
                lua_key2.getBytes()
        ));

干了什么?在key后边加了个字符串{lua},这样, 就可以落入同一个槽位了,hash tag 是通过{}这对括号括起来的字符串来判断的。

这里就可以引出错误2,会遇到我明明加了{}还是不行,此时就需要检查里面的字符串是否一致,而且,不能有数字,我到现在还在查阅相关文档这是为啥,还没查出来,希望路过的大神指点一下。

至此,关于docker搭建redis集群和相关的一些坑和总结就写完了。

感谢阅读~

3 Comments

  1. 非常感谢你分享这篇文章,我从中学到了很多新的知识。

  2. 鸟叔来串门,通过虫洞穿梭至此,期待回访!

发表回复

您的电子邮箱地址不会被公开。 必填项已用 * 标注