Redis Cluster

Redis Cluster

简介

Redis Cluster是Redis的“亲儿子”,它是Redis作者自己提供的Redis集群化方法。

Redis Cluster提供了一种运行Redis安装的方法,其中数据 在多个Redis节点之间自动分片

Redis Cluster还在分区期间提供一定程度的可用性,实际上是在某些节点发生故障或无法通信时继续运行的能力。但是,如果发生较大的故障(例如,当大多数主设备不可用时),集群将停止运行。

集群数据分片

Redis Cluster是去中心化的,如下图所示,该集群由三个Redis节点组成,每个节点负责整个集群的一部分数据,每个节点负责的数据多少可能不一样,其中:

  • 节点A包含从0到5461的散列槽。
  • 节点B包含从5462到10922的散列槽。
  • 节点C包含从10923到16383的散列槽。

这允许轻松添加和删除集群中的节点。例如,如果想添加一个新节点D,需要将一些哈希槽从节点A、B、C移动到D。同样,如果想从集群中删除节点A,只需移动节点A的哈希槽到B和C。当节点A为空时,完全可以从集群中删除它。

Redis Cluster

Redis Cluster将所有数据划分为16384个槽位(为什么设计成16384个槽?),每个节点负责其中一部分槽位。槽位的信息存储于每个节点中。当Redis Cluster的客户端来连接集群时,也会得到一份集群的槽位配置信息。这样当客户要查找某个key时,可以直接定位到目标节点。

客户端为了可以直接定位某个具体的key所在的节点,需要缓存槽位相关信息,这样才可以准确快速地定位到相应的节点。同时因为可能会存在客户端与服务器存储槽位的信息不一致的情况,还需要纠正机制来实现槽位信息的效验调整。

另外,Redis Cluster的每个节点会将集群的配置信息持久化到配置文件中,所以必须确保配置文件是可写的,而且尽量不要依靠人工修改配置文件。

槽定位算法

Redis Cluster默认会对key值使用crc16算法进行hash,得到一个整数值,然后用这个整数值对16384进行取模来得到具体槽位。

Redis Cluster还允许用户强制把某个key挂在特定槽位上。通过在key字符串里面嵌入tag标记,这就可以强制key所挂的槽位等于tag所在的槽位。例如,保证this{foo}keyanother{foo}key位于相同的哈希槽中,可以使多个键在一个参数的命令中一起使用。

下面是用C语言实现定位槽位样例代码。

unsigned int HASH_SLOT(char *key, int keylen) {
    int s, e; /* start-end indexes of { and } */

    /* Search the first occurrence of ‘{‘. */
    for (s = 0; s < keylen; s++)
        if (key[s] == ‘{‘) break;

    /* No ‘{‘ ? Hash the whole key. This is the base case. */
    if (s == keylen) return crc16(key,keylen) & 16383;

    /* ‘{‘ found? Check if we have the corresponding ‘}‘. */
    for (e = s+1; e < keylen; e++)
        if (key[e] == ‘}‘) break;

    /* No ‘}‘ or nothing between {} ? Hash the whole key. */
    if (e == keylen || e == s+1) return crc16(key,keylen) & 16383;

    /* If we are here there is both a { and a } on its right. Hash
     * what is in the middle between { and }. */
    return crc16(key+s+1,e-s-1) & 16383;
}

跳转

当客户端向一个错误的节点发出指令后,该节点会发现指令的key所在的槽位并不归自己管理,这时它会向客户端发送一个特殊的指令携带目标操作的节点地址,告诉客户端去连接这个节点以获取数据。

GET x
-MOVED 3999 127.0.0.1:6381

MOVED指令的第一个参数是3999key对用的槽位编号,后面是目标节点地址。MOVED指令前面有一个减号,表示该命令是一个错误消息。

客户端在收到MOVED指令后,要立即纠正本地的槽位映射表。后续所有key将使用新的槽位映射表。

主从模型

为了在主节点子集发生故障或无法与大多数节点通信时保持可用,Redis Cluster使用主从模型,其中每个散列槽从1(主节点本身)到N个副本(N-1个额外的从节点)。

在具有节点A、B、C的示例集群中,如果节点B发生故障,则集群无法继续提供服务,因为我们不再能够在5501-11000范围内提供服务哈希位置的方法。

然而,当创建集群时(或稍后),我们向每个主节点添加一个从节点,以便最终集群由作为主节点的A、B、C和作为从节点的A1、B1、C1组成。如果节点B出现故障,系统就能继续运行。

节点B1复制节点B的数据,当节点B出现故障时,集群将节点B1升级为主节点,并将继续正常运行。但请注意,如果节点B和B1同时发生故障,Redis Cluster将无法继续运行。

集群一致性保证

Redis集群节点间使用异步冗余备份(asynchronous replication),所以在分区过程中总是存在一些时间段,在写时间段里容易丢失写入数据。但是一个连接到绝大部分主节点的客户端的时间段,与一个连接到极小部分主节点的客户端的时间段是相等不同的。Redis集群会努力尝试保存所有与大多数节点连接的客户端执行的写入操作,但以下两种情况除外:

  1. 一个写入操作能到达一个主节点,但当主节点要回复客户端的时候,这个写入可能没有通过主从节点间的异步冗余备份传播到从节点那里。如果在某个写入操作没有到达从节点的时候主节点已经宕机了,那么该写入会永远地丢失掉,以防主节点长时间不可用而它的一个从节点已经被提升为主节点。
  2. 另一个理论上可能会丢失写入操作的模式是:
    • 因为分区是一个主节点不可达。
    • 故障转移(fail over)到主节点的一个从节点。(即从节点被提升为主节点)
    • 过一段时间后主节点再次变得不可达。

Redis Cluster无法保证强一致性。实际上,这意味着在某些条件下,Redis Cluster可能会丢失客户端写入的数据。

Redis Cluster会努力尝试保存所有与大多数节点连接的客户端执行的写入操作,但以下两种情况除外:

  1. 一个写入操作能到达一个主节点,但当主节点要回复客户端的时候,这个写入可能没有通过主从节点间的异步冗余备份传播到从节点那里。如果在某个写入操作没有到达从节点的时候主节点已经宕机了,那么该写入会永远地丢失掉,以防主节点长时间不可用从而它的一个从节点已经被提升为主节点。

    这与配置为每秒将数据刷新到磁盘的大多数数据库所发生的的情况非常相似,因此,由于过去使用不涉及分布式系统的传统数据库系统的经验,因此您已经能够推断这种情况。同样,可以通过再回复客户端之前强制数据刷新到磁盘上的方法来提高一致性,但这通常会导致性能过低。在Redis Cluster中,这相当于同步复制。

    基本上需要在性能和一致性之间进行权衡。

    Redis Cluster在绝对需要时支持同步写入,通过WAIT命令实现,这使得丢失写入的可能性大大降低,但请注意,即使使用同步复制,Redis Cluster也不会实现强一致性,在更复杂的情况下总是可以出现失败场景,无法接受写入的slave被选为master。

  2. 还有一种情况发生在网络分区中,其中客户端与少数实例(至少包括主节点)隔离。

    以6个节点为例,包括A、B、C、A1、B1、C1,3主3从。还有一个客户端,称之为Z1。在发生分区后,可能在分区的一侧有A、C、A1、B1、C1,在另一侧有B和Z1。Z1仍然可以写入B,它将接受其写入。如果分区在很短的时间内恢复,集群将继续正常运行。但是,如果分区持续足够多的时间是B1在分区的多数侧被提升为主节点,则Z1发送给B的写入将丢失。

    请注意,Z1将能够发送到B的写入量存在最大窗口:如果分区的多数方面已经有足够的时间将从节点选为主节点,则少数端的每个主节点都会停止接受写入。

    这段时间是Redis Cluster的一个非常重要的配置指令,称为节点超时(cluster-node-timeout)。节点超时后,主节点被视为不可用,可以由一个从节点替换。类似地,在节点超时已经过去而主节点无法感知大多数其他主节点之后,它进入错误状态并停止接受写入。

可能下线(PFail)与确定下线(Fail)

因为Redis Cluster是去中心化的,一个节点认为某个节点失联了并不代表所有的节点都认为它失联了,所以集群还得经过一次协商的过程,只有当大多数节点都认定某个节点失联了,集群才认为该节点需要进行主从切换来容错。

Redis集群节点采用Gossip协议来广播自己的状态以及改变对整个集群的认知。比如一个节点发现了某个节点失联了(PFail,即Possibly Fail),它会将这条信息向整个集群广播,其他节点就可以收到这点的失联信息。如果收到了某个节点失联的节点数量(PFail Count)已经达到了集群的大对数,就可以标记该失联节点为确定下线状态(Fail),然后向整个集群广播,强迫其他节点节点也接收该节点已经下线的事实,并立即对该失联节点进行主从切换。

集群配置参数

我们即将创建一个示例集群部署。在此之前,先了解一下Redis Cluster在redis.conf文件中引入的配置参数。有些会很明显,有些会在你继续阅读时更清楚。

  • cluster-enable<yes/no>: 如果设置为yes,则在特定Redis实例中启动Redis集群支持。否则,实例像往常一样作为独立实例启动。
  • cluster-config-file: 请注意,尽管有此选项的名称,但这不是用户可编辑的配置文件,而是每次发生更改时Redis集群节点自动保存集群配置(基本上是状态)的文件,为了能够在启动时重新读取。该文件列出了集群中其他节点、状态、持久变量等内容。由于某些消息接收,通常会将此文件重写并书最新到磁盘山。
  • cluster-node-time: 表示某个节点持续timeout的时间失联时,才可以认定该节点出现故障,需要进行主从切换。如果没有这个选项,网络抖动会导致主从频繁切换(数据的重新复制)。
  • cluster-slave-validity-factory: 如果设置为零,则不管master和slave之间链路保持断开连接的时间长短,slave将始终尝试对master进行故障切换。如果该值为正,则计算timeout值乘以此选项提供的因子作为最大断开时间,如果节点是从节点,则如果主链接断开连接的时间超过指定的时间,则不会尝试启动故障转移。例如,如果节点超时设置为5秒,并且有效性因子设置为10,则从主节点断开直到超过50秒的时间内从节点将不会尝试故障转移。请注意,如果没有slave能够对其进行故障转移,则任何不同于零的值都可能导致Redis集群在master发生故障后不可用。在这种情况下,只有当原始主节点重新加入集群时,集群才会返回可用状态。
  • cluster-require-full-converage<yes/no>: 如果设置为yes,则默认情况下,如果有任何节点未覆盖某个百分比的slots数量,则集群将处于不可用状态(即一个主节点没有从节点,当它发生故障时,则集群将处于不可用状态)。如果该选项为no,可以允许部分节点发生故障时,其他节点还可以继续提供对外访问。

集群搭建

我这边的系统是mac,使用docker compose搭建的集群。

Docker Compose 是一个轻量级的容器编排工具,它使用 yaml 来编排容器,可以在 n 个容器中做通信。

docker compose编写

version: "3"
services:
	# 节点1
  redis-cluster-6380:
  	# 使用的镜像
    image: redis:latest
    # 容器的名称
    container_name: redis-6380
    networks:
      cluster-net:
        ipv4_address: 172.16.238.10
    # 端口映射    
    ports:
      - "6380:6380"
      - "16380:16380"
    volumes:
      - ./conf/redis-6380.conf:/usr/local/etc/redis/redis.conf
      - ./log:/var/log/redis
      - ./data:/data/redis
    # 启动redis的时候指定配置文件  
    command: sh -c "redis-server /usr/local/etc/redis/redis.conf"
    environment:
      # 设置时区为上海,否则时间会有问题
      - TZ=Asia/Shanghai
  redis-cluster-6381:
    image: redis:latest
    container_name: redis-6381
    networks:
      cluster-net:
        ipv4_address: 172.16.238.11
    ports:
      - "6381:6381"
      - "16381:16381"
    volumes:
      - ./conf/redis-6381.conf:/usr/local/etc/redis/redis.conf
      - ./log:/var/log/redis
      - ./data:/data/redis
    command: sh -c "redis-server /usr/local/etc/redis/redis.conf"
    environment:
      - TZ=Asia/Shanghai
  redis-cluster-6382:
    image: redis:latest
    container_name: redis-6382
    networks:
      cluster-net:
        ipv4_address: 172.16.238.12
    ports:
      - "6382:6382"
      - "16382:16382"
    volumes:
      - ./conf/redis-6382.conf:/usr/local/etc/redis/redis.conf
      - ./log:/var/log/redis
      - ./data:/data/redis
    command: sh -c "redis-server /usr/local/etc/redis/redis.conf"
    environment:
      - TZ=Asia/Shanghai
  redis-cluster-6383:
    image: redis:latest
    container_name: redis-6383
    networks:
      cluster-net:
        ipv4_address: 172.16.238.13
    ports:
      - "6383:6383"
      - "16383:16383"
    volumes:
      - ./conf/redis-6383.conf:/usr/local/etc/redis/redis.conf
      - ./log:/var/log/redis
      - ./data:/data/redis
    command: sh -c "redis-server /usr/local/etc/redis/redis.conf"
    environment:
      - TZ=Asia/Shanghai
  redis-cluster-6384:
    image: redis:latest
    container_name: redis-6384
    networks:
      cluster-net:
        ipv4_address: 172.16.238.14
    ports:
      - "6384:6384"
      - "16384:16384"
    volumes:
      - ./conf/redis-6384.conf:/usr/local/etc/redis/redis.conf
      - ./log:/var/log/redis
      - ./data:/data/redis
    command: sh -c "redis-server /usr/local/etc/redis/redis.conf"
    environment:
      - TZ=Asia/Shanghai
  redis-cluster-6385:
    image: redis:latest
    container_name: redis-6385
    networks:
      cluster-net:
        ipv4_address: 172.16.238.15
    ports:
      - "6385:6385"
      - "16385:16385"
    volumes:
      - ./conf/redis-6385.conf:/usr/local/etc/redis/redis.conf
      - ./log:/var/log/redis
      - ./data:/data/redis
    command: sh -c "redis-server /usr/local/etc/redis/redis.conf"
    environment:
      - TZ=Asia/Shanghai
networks:
  # 创建集群网络,在容器之间通信
  cluster-net:
    ipam:
      config:
        - subnet: 172.16.238.0/24

Redis的集群需要容器的内部和外部通信,所以一般都设置容器的networkhost模式,但是如果你使用的是mac docker则不支持host模式。

如果你使用的是linux来部署Redis的集群,就不需要自定义network,可以直接设置network=host来完成容器的通信,上面docker-compose.yml的重点在于自己声明一个自定义的network-cluster-net来实现不同容器之间的通信。由于docker里面的ip是不固定的,我们没有办法获取docker容器里面的ip,所以在自定义network的时候,通过配置subnet=172.16.238.0/24cluster-netip范围固定在172.16.238.0/254之间,然后在每一个Redis的节点中指定该节点的ip地址在我们配置的范围之内,就可以实现容器之间的相互通信。

Redis配置文件的编写

################################## NETWORK #####################################

bind 0.0.0.0

port 6380

tcp-backlog 511

timeout 0

tcp-keepalive 300

################################# GENERAL #####################################
supervised no

pidfile /var/run/redis.pid

loglevel notice

# 日志文件地址
logfile /var/log/redis/redis-6380.log

################################ SNAPSHOTTING  ################################
save 900 1
save 300 10
save 60 10000

stop-writes-on-bgsave-error yes

rdbcompression yes

rdbchecksum yes

dbfilename dump-6380.rdb

# rdb文件和aof文件存放地址
dir /data/redis

############################## MEMORY MANAGEMENT ################################

# 淘汰策略,删除设置了过期时间最不经常访问的key
maxmemory-policy volatile-lru

############################# LAZY FREEING ####################################

lazyfree-lazy-eviction no
lazyfree-lazy-expire no
lazyfree-lazy-server-del no
replica-lazy-flush no

############################## APPEND ONLY MODE ###############################

# 开启aof持久化策略
appendonly yes
# aof文件名称
appendfilename "appendonly-6380.aof"

# 每秒写入
appendfsync everysec

no-appendfsync-on-rewrite no

auto-aof-rewrite-percentage 100
auto-aof-rewrite-min-size 64mb

aof-load-truncated yes

aof-use-rdb-preamble yes

################################ REDIS CLUSTER  ###############################

# 开启集群模式
cluster-enabled yes

# 集群配置文件名称
cluster-config-file nodes-6380.conf

# 节点失联超时时间
cluster-node-timeout 15000

########################## CLUSTER DOCKER/NAT support  ########################

# docker虚拟网卡的ip,这里使用的桥接方式,直接设置宿主机ip
cluster-announce-ip 192.168.1.107

# 节点映射端口
cluster-announce-port 6380

# 总线映射端口,通常为节点映射端口前加1
cluster-announce-bus-port 16380

配置文件就不做过多解释了,有兴趣的同学自行去官网查看。这份配置不建议放生成环境使用,我这边只是个人测试时使用。

上面配置文件一共6份,修改一下端口号、日志文件名和持久化文件名即可。我这边是提前创建了存放日志和持久化数据文件的文件夹,如下图所示。

Redis Cluster

集群启动

进入到docker-compose.yml文件所在的目录,执行下面命令。

docker-compose up -d

运行如下图所示。

Redis Cluster

可以观察到容器在启动的时候加载的network是我们自定义的network

接下来,配置集群。执行以下命令,输入yes就可以配置成功Redis集群。

docker exec -it redis-6380 redis-cli -p 6380 --cluster create 172.16.238.10:6380 172.16.238.11:6381 172.16.238.12:6382 172.16.238.13:6383 172.16.238.14:6384 172.16.238.15:6385 --cluster-replicas 1

选项--cluster replicas 1 表示我们希望为集群中的每个主节点创建一个从节点。

配置成功如下图所示。

Redis Cluster

上图中打印信息主要是帮你分配了槽,给主节点挂上了从节点。

最后打印的[OK] All 16384 slots covered,这表示集群中16384个槽都有至少一个主节点在处理,集群运作正常。

我们可以使用以下命令,查看集群状态。

docker exec -it redis-6380 redis-cli -p 6380 cluster info

如下图所示,表示集群正常运行中。

Redis Cluster

还可以用以下命令查看集群节点信息。

docker exec -it redis-6380 redis-cli -p 6380 cluster nodes

Redis Cluster

使用集群

手动触发故障转移

我们使用以下代码在集群中插入数据并在运行过程中,使用docker exec -it redis-6382 redis-cli -p 6382 debug segfault停掉一个主节点,观察写入数据是否丢失以及从节点替换主节点需要多少时间。

@Slf4j
public class ClusterTest {

    @Test
    public void test1() {
        try (JedisCluster cluster = RedisPool.getClusterConnection()) {
            for (int i = 0; i < 10000; i++) {
                String key = "leusurexi-" + i;
                try {
                    cluster.set(key, UUID.randomUUID().toString());
                } catch (Exception e) {
                    //e.printStackTrace();
                    log.error("{}写入失败", key);
                }
            }
        }
    }

}

JedisCluster对象的实例创建我就不贴代码了,大家自行Google。

Redis Cluster

Redis Cluster

上面两张图是第一次写入失败和最后一次写入失败的日志,由此可以看出大概用了20秒的时间完成了主从节点的切换。由于我们的配置文件写的是15秒,接下来我们把配置文件中cluster-node-timeout改为5秒,再看下具体情况。

Redis Cluster

Redis Cluster

这次只用了8秒,说明配置还是起作用的。生成环境下,看具体情况配置此参数。

然后我们再次用docker exec -it redis-6380 redis-cli -p 6380 cluster nodes命令查看集群节点信息。

Redis Cluster

可以看到端口号6382的节点状态已经变成了fail,而6383节点变成了主节点。

Redis Cluster

依次在三个主节点上执行dbsize命令,统计所有key的数量为9785,我们是循环了10000次。可以看出在主节点不可用,从节点切换为主节点的这段时间内的请求都会失败从而导致丢失数据。这种情况我们可以在catch代码块中加入待会再试之类的逻辑来处理。

接下来我们重新启动6382节点。再查看集群节点信息。

Redis Cluster

可以看到6382作为6383的从节点加入到了集群当中。

集群重新分片

现在我们准备尝试集群重新分片。重新分片基本上意味着散列槽从一组节点移动到另一组节点。

执行以下命令。

docker exec -it redis-6380 redis-cli -p 6380 --cluster reshard 192.168.1.107:6380

然后会询问你槽的范围,如下所示。

Redis Cluster

我们可以尝试将1000个散列槽重新分配,直接输入1000然后下一步。

Redis Cluster

接着让我们输入接收槽的节点ID,我们就输入6380节点的ID即可。

Redis Cluster

现在需要制定从哪些节点来移动key到目标节点我输入的是all,这样就会从其他master上获取一些槽。

然后会显示移动哪些槽的计划,这边直接输入yes即可,按照自动生成的计划执行。

Redis Cluster

接下来可以看到每个key移动的信息,如下图所示。

Redis Cluster

最后对比移动槽前的和移动槽之后的集群节点信息。

Redis Cluster

Redis Cluster

可以看到6380节点负责的槽位发生了变化,对比第一张图共计增加了1000个槽位,跟我们设置的一样。

注意:重新分片后,槽位上的key也会顺带着移过去。

总结

  • Redis Cluster不支持类似于mget这样一次获取多个key但是不是同一个slot的情况,在实际开发中,如果明确知道某类key会存在多键操作,可以在存储时通过上面提到的tag方式强制其位于同一个slot同一个节点。
  • Redis Cluster通过分片存储、主从数据复制以及合理的故障转移策略,提供了更强的性能、更好的扩展性以及可用性,满足了CAP定理的AP两个特性。对于一致性,集群模式配合客户端策略可以说实现了“弱一致性”。

参考

Redis Cluster

上一篇:【题解】CF601B Lipshitz Sequence


下一篇:项目中使用 husky 配合 lint-staged 进行git提交前代码检查