---
id: '20210719'
date: '2021/07/19 10:38:05'
title: 'redis三种集群（主从，哨兵，集群）模式全解析'
tags: ['redis', 'cluster']
categories:
  - '数据库'
  - 'redis'
---

redis 目前主流的有三种集群模式，本文将对这三种模式进行一个比较详细的讲解.

**所用到的所有相关代码位于：[github](https://github.com/FleyX/demo-project/tree/master/redis-cluster)**

**redis 版本基于 6.2.4**

## 主从模式

结构最简单的一种，和关系型数据的读写分离类似，一个主节点（master)，多个从节点(slaver)，写入请求全部到主节点上，读取会分散到各个从节点中,架构图如下：

![主从模式-架构图](http://qiniupic.fleyx.com/blog20210630225614.png)

其中比较重要的是主从复制的实现原理，大致可分为三个部分：**建立连接**,**数据同步**,**命令传播**

<!-- more -->

### 建立连接

建立连接过程比较简单，当从节点发现有主节点配置数据后,会经历如下流程：

1. 和主节点建立 tcp 连接
2. 发送 ping 命令，坚持主节点是否可用
3. 进行身份验证（如果配置了 masterauth 选项）
4. 发送从节点监听的 ip 端口信息给主节点,用于集群信息展示

经过以上 4 步，从节点和主节点就连接成功了，接下来进行数据同步。

### 数据同步（重点）

在建立连接完毕后就可以进行数据同步，从节点通过`psync`命令来同步主节点数据，同步有两种同步方式：全量同步和部分同步。那么哪些情况用全量同步哪些情况用部分同步呢？

- 全量同步
  - 从节点首次同步
  - 从节点已经同步了一部分数据，想要继续同步，但是主节点没有这部分的缓存数据，因此仍然会进行全量同步
- 部分同步
  - 从节点已经同步了一部分数据，想要继续同步，主节点有缓存这部分数据，进行部分同步

**介绍同步原理前，先了解三个概念：**

1. 复制偏移量（offset）

主节点和从节点都会维护一个复制偏移量，在主节点这边表示`主节点向从节点发送的字节数`,主节点每发送 N 数据，偏移量+N;在从节点表示`从节点收到的字节数`,从节点没收到 N 数据，偏移量+N.通过偏移量对比就能知道主从节点间的数据差异。（由于使用 tcp 发送，因此主节点的发送顺序一定是从节点的接收顺序，不会错乱）

2. 复制积压缓冲区

本区域是由主节点维护，固定长度的一个先进先出队列，当主节点开始有从节点时创建，用于记录主节点最近发送给从节点的数据（队列大小由 repl-backlog-size 决定，默认为 1MB）。注意无论有多少个从节点，主节点都只维护一个缓冲区。

从节点在请求同步时，会携带自身的 offset，当主节点 offset-从节点 offset < 缓冲区大小，只需将从节点缺少的数据从缓冲区取出来发送给从节点即可。如果>缓冲区大小，那就必须进行全量同步了。因此为了尽量减少因为网络中断导致的全量同步,可以适当增加缓冲区大小

3. 服务器运行 ID（runid）

每个节点在启动时都会生成一个 ID 用于标识自己（每次启动都会生成）,此 ID 由 40 位 16 进制数组成。主节点在进行同步时会将自身的 ID 发送给从节点。当从节点重连进行数据同步时，会携带该 ID 和偏移量，主节点根据 id 和偏移量来判断是进行全量同步还是部分同步

#### 同步过程

同步流程图如下：

![数据同步流程图](http://qiniupic.fleyx.com/blog/20210702114631.png)

1. 从节点接收 slaveof 指令，开始进行数据同步
2. 判断是否首次进行同步
3. 如果是首次同步直接进行全量同步
4. 如果非首次，向主节点发送 PSYNC 命令，以进行部分同步，主节点首先对比 RUNID 是否相同，然后判断主节点 OFFSET-从节点 OFFSET 是否小于复制积压缓冲区大小。如果小于进行部分同步;否则进行全量同步

### 命令传播

数据同步完成后，进入命令传播阶段，此阶段主节点将自身执行的写命令发送给从节点，从节点执行，从而保证主从节点数据一致。

在命令传播阶段主从节点之间会彼此进行心跳检测，如下图：

![主从交互](http://qiniupic.fleyx.com/blog/20210705172927.png)

#### 主节点向从节点发送`ping`命令

每隔 10s 主节点向从节点发送`ping`命令，用于判断从节点的连接状态，可通过参数`repl-ping-slave-period`设置发送间隔（默认 10s）

#### 从节点向主节点发送`replconf ack`命令

每隔 1s 从节点向主节点发送`replconf ack {offset}`命令上报自身的偏移量，本命令主要有两个作用

1. 检测同步状态：从节点发送了自身的偏移量，主节点比较偏移量就能够知道从节点的同步状态，如果丢了部分数据，可以通过复制积压缓冲区推送缺失的数据

2. 保证从节点的数量和延迟性：主节点执行`redis-cli info Replication | grep lag`,会打印下面的从节点信息，其中`lag`表示 replconf 的响应时间（单位 s）。可通过配置`min-slaves-to-write`(从节点数量最小值) `minslaves-max-lag`（replconf 超时时间）。如果节点数小于`min-slaves-to-write`且所有节点延迟大于`minslaves-max-lag`,主节点将会拒绝执行写命令。

```bash
slave0:ip=172.20.0.4,port=6379,state=online,offset=7168,lag=1
slave1:ip=172.20.0.3,port=6379,state=online,offset=7168,lag=1
```

### 部署

docker 部署见：[点击此处](https://github.com/FleyX/demo-project/tree/master/redis-cluster/master-slave)

### 问题

主从模式可以极大的提升读取性能，但是实际应用中有一些问题需要注意，比较没有什么东西是只有好处没有坏处的。

1. 数据延迟问题

主节点写入后再同步给从节点，这其中存在一个时间差，必然会导致一段时间（视网络情况）的主从节点数据不一致问题。因此应该尽量让主从节点部署在同一机房中，降低延迟。

2. 故障切换

单纯的主从模式，在主节点出现问题宕机后，需要指定新的主节点，还需要修改应用中的 redis 配置，比较麻烦。因此有了哨兵模式(后面会讲)，用于解决节点切换。

3. 复制超时（重点）

主从节点间通过网络进行交互，必然存在一个超时机制，避免掉线节点长期占用资源，那么主从节点的超时是怎么判断的呢？

主从复制超时判断的核心，在于 repl-timeout 参数，该参数规定了超时时间的阈值（默认 60s），对于主节点和从节点同时有效；主从节点触发超时的条件分别如下：

- 主节点：每秒 1 次调用复制定时函数 replicationCron()，在其中判断当前时间距离上次收到各个从节点 REPLCONF ACK 的时间，是否超过了 repl-timeout 值，如果超过了则释放相应从节点的连接。
- 从节点：从节点对超时的判断同样是在复制定时函数中判断，基本逻辑是：

  - 如果当前处于连接建立阶段，且距离上次收到主节点的信息的时间已超过 repl-timeout，则释放与主节点的连接；
  - 如果当前处于数据同步阶段，且收到主节点的 RDB 文件的时间超时，则停止数据同步，释放连接；
  - 如果当前处于命令传播阶段，且距离上次收到主节点的 PING 命令或数据的时间已超过 repl-timeout 值，则释放与主节点的连接。

**关于主从复制的更详细内容可参考[这篇文章](https://www.cnblogs.com/kismetv/p/9236731.html)**

## 哨兵模式

上一段中有提到主从模式有一个故障切换的缺点，主节点宕机后需要手动修改配置重启应用，很麻烦。

哨兵模式在 Redis2.8 中开始引入,核心功能是做主节点故障自动转移。redis 官方对于哨兵模式的描述如下：

- 监控：哨兵会不断监控各节点运作情况
- 自动故障转移：当主节点发生故障时，哨兵会选择一个从节点做为新的主节点，原来的主节点会变成从节点
- 配置提供：客户端初始化时获取主从节点地址
- 通知：哨兵将故障转移的结果发送给客户端

### 架构

哨兵模式架构图如下：

![哨兵模式架构图](http://qiniupic.fleyx.com/blog/20210708154446.png)

哨兵节点是特殊的 redis 节点，不会用于存储数据

**注意：每个哨兵节点都会监控所有节点**

**注意：哨兵并不是代理，哨兵只是将主节点信息提供给客户端，客户端实际还是直连 redis 节点，不通过哨兵节点转发**

### 原理

#### 哨兵节点支持的命令

1. 查询命令

- info sentinel：获取监控的所有主节点的基本信息
- sentinel masters：获取监控的所有主节点的详细信息
- sentinel master mymaster：获取监控的主节点 mymaster 的详细信息
- sentinel slaves mymaster：获取监控的主节点 mymaster 的从节点的详细信息
- sentinel sentinels mymaster：获取监控的主节点 mymaster 的哨兵节点的详细信息
- sentinel get-master-addr-by-name mymaster：获取监控的主节点 mymaster 的地址信息
- sentinel is-master-down-by-addr：哨兵节点之间可以通过该命令询问主节点是否下线，从而对是否客观下线做出判断

2. 管理命令

- sentinel monitor main2 192.168.92.128 6379 2： 配置主节点 main2 地址为 192.168.92.128：6379，至少需要 2 个哨兵节点同意才能判定主节点故障并进行故障转移
- sentinel remove main2：取消当前哨兵节点对主节点 main2 的监控
- sentinel failover main：该命令可以强制对 main 执行故障转移，即便当前的主节点运行完好

#### 转移原理

了解了如下几个概念，便能理解故障转移是如何实现的：

- 定时任务：每个哨兵节点维护 3 个定时任务。分别为：向主节点发送 info 命令获取最新的主从结构;通过发布-订阅模式获取其他哨兵节点的信息;向其它节点（包含哨兵节点）发送 ping 命令进行心跳检测
- 主观下线：在心跳检测任务中，如果某个节点超过一定时间未回复，并认为其主观下线。主观下线是一个哨兵节点自身认为这个节点下线，所以叫主观。
- 客观下线：某个哨兵节点对主节点（从节点没有客观下线,主观下线后就没有后续操作了）进行了主观下线操作后会通过`sentinel is-master-down-by-addr`询问其他哨兵节点该主节点的在线情况，如果一定数量（配置主节点数据的配置的）的哨兵都认为主节点下线，就会对该节点进行客观下线
- 领导者哨兵：客观下线后，各个哨兵会进行协商，选举出一个领导者哨兵进行故障转移的操作。协商算法为 Raft 算法，即先到先得：在议一轮选举中，A 哨兵节点向其他节点发送成为领导者的请求，其他节点如果未同意过 A 以外的其他哨兵，便会同意 A 的请求，最后获得同意最多的哨兵节点成为领导者。（因此哨兵节点数最好为奇数，避免出现选票一样的情况）
- 故障转移：选举出领导者哨兵后，该哨兵便会开始进行故障转移的过程：
  - 在从节点中选出新的主节点，首先过滤掉不健康的从节点；然后选择优先级最高的从节点(由 slave-priority 指定)；如果优先级无法区分，则选择复制偏移量最大的从节点；如果仍无法区分，则选择 runid 最小的从节点
  - 更新主从状态：先通过 slaveof no one 命令，让选出来的从节点解除从节点状态；并通过 slaveof 命令让其他从节点成为选出节点的从节点，选出的节点就变成主节点了
  - 将客观下线的主节点设置为新主节点的从节点

### 哨兵相关配置项

- `sentinel monitor {masterName} {masterIp} {masterPort} {quorum}` sentinel monitor 是哨兵最核心的配置，其中：masterName 指定了主节点名称，masterIp 和 masterPort 指定了主节点地址，quorum 是判断主节点客观下线的哨兵数量阈值：当判定主节点下线的哨兵数量达到 quorum 时，对主节点进行客观下线。建议取值为哨兵数量的一半加 1
- `sentinel down-after-milliseconds {masterName} {time}` 主观下线超时时常配置，默认为 30000（即 30s）
- `sentinel parallel-syncs {masterName} {number}`故障转移后，从节点的复制并发个数，假设此主节点对应 3 个从节点，然后此配置为 3,可以三个节点一起进行数据负责;配置为 1 那么从节点会一个一个进行数据复制。注意：本值设置的越大对主节点的网络和硬盘会造成较大压力，应该根据实际情况设置为合理的值

### 部署

哨兵节点配置文件如下：

```conf
bind 0.0.0.0
port 9003
# 配置主节点地址，至少需要2个哨兵节点同意才能判定主节点故障并进行故障转移
# 注意：这里配置的域名会被动态修改为ip
sentinel monitor mymaster masterDomain 6379 2
# 允许解析域名(如果上面一行要使用域名，要设置此项为yes)
sentinel resolve-hostnames yes
```

**需要注意的一点是上面的主节点域名在哨兵启动后会被修改为 ip 地址**

docker-compose 中编排好哨兵、主节点、从节点即可启动，docker-compose.yml 见[点击此处](https://github.com/FleyX/demo-project/blob/master/redis-cluster/redis-sentinel/docker-compose.yml)

启动命令如下：

```bash
# 注意，如果是用root用户拉取的代码，UID,GID都为0,
# 非root用户使用id -u,id -g命令获取UID和GID
UID=0 GID=0 docker-compose up -d
```

启动完毕后，哨兵节点的配置文件会增加从节点以及其他哨兵的数据，如下：

![哨兵节点配置文件修改](http://qiniupic.fleyx.com/blog/20210711215528.png)

哨兵模式解决了故障转移的问题，但本质仍然是主从架构，只对读做了负载均衡，并未提高写入并发能力，无法应对大量写的问题，大量写的情况就需要对写也做负载均衡，即下面的集群模式.

## 集群模式

为了解决哨兵模式，无法对写入进行拓展的问题，redis 从 3.0 引入分布式存储方案--集群模式。

集群由多个节点组成，分为主节点和从节点，主节点用于读写操作，从节点用于读。集群最大的优势是引入了数据分片机制，写操作可以分散到不同的主节点上，大幅提高整体性能。

### 搭建

全部文件，在[这里](https://github.com/FleyX/demo-project/tree/master/redis-cluster/redis-cluster)

使用 docker-compose 搭建，3 主 3 从的集群。（注意仅为搭建步骤演示，请勿用于生产环境）

1. 首先编写配置文件 node1.conf：

````conf
bind 0.0.0.0
port 11001
# 开启集群模式
cluster-enable yes
# 集群配置信息存放位置
cluster-config-file "redis-node1.conf"
``  `

将上面的文件复制成 6 份，作为 6 个节点的配置配置文件

2. 编写 docker-compose.yml 文件，启动 6 个节点

3. 启动完毕后，这 6 个节点还是独立存在的状态，需要执行命令将其关联起来

启动完毕后，选择一个节点使用 cli 登陆后，运行`cluster nodes`命令

```bash
root@fanxb-f1:/data# redis-cli -p 11001
127.0.0.1:11001> cluster nodes
c8cb00b3cb55ee7f6010509ec9e4ae568e2bf19b :11001@21001 myself,master - 0 0 0 connected
````

**注意前面的 id,并不是主从模式中的 runid,而是节点 id,不会每次启动时生成新值**

4. 将相互独立的节点关联起来，将 node1 和 ndoe23456 通过命令`cluster meet {ip} {port}`进行关联

```bash
127.0.0.1:11001> cluster meet 192.168.143.84 11002
OK
127.0.0.1:11001> cluster meet 192.168.143.84 11003
OK
127.0.0.1:11001> cluster meet 192.168.143.84 11004
OK
127.0.0.1:11001> cluster meet 192.168.143.84 11005
OK
127.0.0.1:11001> cluster meet 192.168.143.84 11006
OK
127.0.0.1:11001> cluster nodes
ba63167ebfd5f8cc9d02331275d15652e3c14aa1 192.168.143.84:11005@21005 master - 0 1626341118000 4 connected
04a5155f5727050cadcf5b9c9d6afaf58176f495 192.168.143.84:11006@21006 master - 0 1626341117255 5 connected
477e143230dec7fe849e74f41b9943cbf57becdf 192.168.143.84:11003@21003 master - 0 1626341117000 2 connected
c8cb00b3cb55ee7f6010509ec9e4ae568e2bf19b 192.168.143.84:11001@21001 myself,master - 0 1626341116000 1 connected
ddffbf8d0086939cd8a8ab4ad7e5be92a89c0493 192.168.143.84:11002@21002 master - 0 1626341119265 0 connected
584cea561bdc31daaa0352147cd6822a77826cf1 192.168.143.84:11004@21004 master - 0 1626341118261 3 connected
```

命令执行完毕后，再执行`cluster nodes`命令可以看到 6 个节点已经相互联通

5. 分配槽（后面讲 redis 分片原理）,这里我们指定 11001,11002,11003 为主节点，将 16384（编号从 0 到 16383）个数据槽分配到这三个节点上。在数据槽未分配完毕前，集群状态为下线状态（fail），可通过`cluster info`命令查看

```bash
root@fanxb-f1:/data# redis-cli -p 11001 cluster addslots {0..5461}
OK
root@fanxb-f1:/data# redis-cli -p 11002 cluster addslots {5462..10922}
OK
root@fanxb-f1:/data# redis-cli -p 11003 cluster addslots {10923..16383}
OK
```

执行完毕后，在使用 cluster 命令查看，可以发现集群处于在线模式

```bash
root@fanxb-f1:/data# redis-cli -p 11003 cluster info
cluster_state:ok
cluster_slots_assigned:16384
cluster_slots_ok:16384
```

6. 指定主从关系，将 11004-11006 分别指定成 11001-11003 的从节点

先通过 cluster nodes 获取节点 id 后，再执行指定从节点命令（下面的 id 是我实验时的 ip,不能照抄）

```bash
root@fanxb-f1:/data# redis-cli -p 11004 cluster replicate c8cb00b3cb55ee7f6010509ec9e4ae568e2bf19b
OK
root@fanxb-f1:/data# redis-cli -p 11005 cluster replicate ddffbf8d0086939cd8a8ab4ad7e5be92a89c0493
OK
root@fanxb-f1:/data# redis-cli -p 11006 cluster replicate 477e143230dec7fe849e74f41b9943cbf57becdf
OK
```

现在再执行`cluster nodes`命令，可以发现主从关系已经有了

```bash
c8cb00b3cb55ee7f6010509ec9e4ae568e2bf19b 192.168.143.84:11001@21001 master - 0 1626342161000 1 connected 0-5461
ddffbf8d0086939cd8a8ab4ad7e5be92a89c0493 192.168.143.84:11002@21002 master - 0 1626342160522 0 connected 5462-10922
ba63167ebfd5f8cc9d02331275d15652e3c14aa1 192.168.143.84:11005@21005 slave ddffbf8d0086939cd8a8ab4ad7e5be92a89c0493 0 1626342162000 0 connected
477e143230dec7fe849e74f41b9943cbf57becdf 192.168.143.84:11003@21003 master - 0 1626342160000 2 connected 10923-16383
584cea561bdc31daaa0352147cd6822a77826cf1 192.168.143.84:11004@21004 slave c8cb00b3cb55ee7f6010509ec9e4ae568e2bf19b 0 1626342162528 1 connected
04a5155f5727050cadcf5b9c9d6afaf58176f495 192.168.143.84:11006@21006 myself,slave 477e143230dec7fe849e74f41b9943cbf57becdf 0 1626342160000 2 connected
```

到此集群搭建成功。

### 原理

#### 分片原理

分片原理决定数据最终落在哪个主节点中，目前主流有以下两种分片方式：

1. 哈希取余

计算 key 的 hash 值，然后对主节点数目取余，就知道应该放在哪个节点中，但是确定很明显，在增加、删除节点时需要对所有的数据重新计算 key 重新分配

2. 一致性哈希

一致性 hash 引入了一个虚拟圆环，圆环有一个范围，比如 0-2^32-1，然后把真实的节点均分到环中，对于每个 key，先做 hash 然后对环长度取余，得到在环中的位置，然后从此位置顺时针许找第一个遇到的节点，就是数据真实落的节点。

![一致性hash示意图](http://qiniupic.fleyx.com/blog/20210718115447.png)

如上图所示，一致性哈希中，如果去掉了 cache 服务器 2,那么原本落到服务器 2 的数据就会全部落到服务器 3,对另外两个节点无影响。缺点也很明显，如果节点数比较少，删除一个节点会知道数据分布严重不均匀，比如上图中服务器 3 的数据量会是其他节点的两倍

3. 带虚拟节点的一致性哈希

在一致性哈希的基础上加入了虚拟节点，也就是虚拟圆环上的节点数永远不会发生变化，然后再将虚拟节点映射到真实的数据节点上，这样增加或者减少数据节点只需要修改虚拟节点到数据节点的映射即可.

假设虚拟节点数为 10000,数据节点 50

增加一个节点：数据节点变为 51,平均每个节点有 196 个,因此只需要从其余 50 个数据节点中转移 196 个虚拟节点到新增的数据节点中

删除一个节点：只需要将这个节点对应的 200 个虚拟节点分配到其他的 199 个数据节点既可

可以发现一致性 hash 的优点是将数据和实际的数据节点做了解耦，增加删除数据节点的数据迁移量很小，对系统整体的影响很小。

#### redis 分配原理

redis 集群使用的是带虚拟节点的一致性哈希，其中的虚拟节点被称为槽（slot），在 redis 集群中槽的数量固定为 16384,通过 crc16 计算 hash 值后，判断数据属于哪个槽，然后通过槽和数据节点的映射关系，得到数据真实存储的节点

#### 节点通信机制

1. 通信端口

集群中的每个节点都提供了两个 tcp 通信端口：

- 数据端口：配置文件中配置的端口（如 6379），用于为客户端提供服务以及数据迁移
- 集群端口：端口号是数据端口+10000（不可修改）,用于集群节点间的相互通信

2. Gossip 协议

集群节点间使用 Gossip 协议进行通信。该协议在节点数量有限的网络中，每个节点都“随机”（不是真随机，有一定的规则）的与部分节点通信，经过一段时间的通信，每个节点的状态会达到一致。该协议优点有负载低、去中心化、容错率高。缺点主要是收敛速度较慢。

#### 故障转移

在[哨兵](#哨兵模式)小节中，介绍了哨兵实现故障发现和故障转移的原理。虽然细节上有很大不同，但集群的实现与哨兵思路类似：通过定时任务发送 PING 消息检测其他节点状态；节点下线分为主观下线和客观下线；客观下线后选取从节点进行故障转移。

与哨兵一样，集群只实现了主节点的故障转移；从节点故障时只会被下线，不会进行故障转移。因此，使用集群时，应谨慎使用读写分离技术，因为从节点故障会导致读服务不可用，可用性变差。

主要知识点如下：

节点数量：在故障转移阶段，需要由主节点投票选出哪个从节点成为新的主节点；从节点选举胜出需要的票数为 N/2+1；其中 N 为主节点数量(包括故障主节点)，但故障主节点实际上不能投票。因此为了能够在故障发生时顺利选出从节点，集群中至少需要 3 个主节点(且部署在不同的物理机上)。

故障转移时间：从主节点故障发生到完成转移，所需要的时间主要消耗在主观下线识别、主观下线传播、选举延迟等几个环节；具体时间与参数 cluster-node-timeout 有关，一般来说：

`故障转移时间(毫秒) ≤ 1.5 * cluster-node-timeout + 1000`

cluster-node-timeout 的默认值为 15000ms(15s)，因此故障转移时间会在 20s 量级。

### 使用小技巧

1. 批量操作。由于数据分散到了不同的槽，因此对于 mget、mset 等批量操作只有操作的 key 都位于一个槽时才能进行。此种情况可以在客户端记录 key 与槽的关系，每次对特定槽执行批量操作。另外一种办法是使用`hash tag`（当一个 key 包含 {} 的时候，不对整个 key 做 hash，而仅对 {} 包括的字符串做 hash）,让不同的 key 能有相同的 hash 值，这样就能分在同一个槽中

2. keys/flushall 等操作：keys/flushall 等操作可以在任一节点执行，但是结果只针对当前节点，例如 keys 操作只返回当前节点的所有键。针对该问题，可以在客户端使用 cluster nodes 获取所有节点信息，并对其中的所有主节点执行 keys/flushall 等操作

3. 数据库：单机 Redis 节点可以支持 16 个数据库，集群模式下只支持一个，即 db0

写了好久，终于终于写完了。。。

