多机 Redis 的实现


十二月的第一周,学习多机 Redis 的实现。

主要学习三个方面:复制、哨兵、集群,基本还是以《Redis 设计与实现》为来源学习,图片也来源于该书。


多个 Redis 协同运行,最基础的内容是复制。

复制是指主从复制:一个主服务器,一个从服务器,从服务器的数据是主服务器复制过来的。使用 Redis 的 SLAVEOF 命令,就可以使一个服务器变成另一个服务器的从属服务器,不断从主服务器复制数据,例如:

1
127.0.0.1:12345> SLAVEOF 127.0.0.1 6379

Redis 的复制有两部分:刚开始复制的全量复制(sync),以及在此之后不断的增量复制(command propagate)。

当从服务器发送 SLAVEOF 命令给主服务器后,首先会复制主服务器的全部数据,然后主服务每修改一条数据,都会将修改命令传播给从服务器,使从服务器不断地跟主服务器保持一致。

刚开始的全量复制,由从服务器发送 SYNC 命令发起,主服务器收到命令之后执行 BGSAVE 命令,在后台生成 RDB 文件,并发送给从服务器。这种方式有个问题,如果把 RDB 文件传送给从服务器中途断了,需要重来一遍,为此又有了 PSYNC 命令。

感觉这部分没什么意思,抄个复制的全流程,过了:

  1. 从服务器设置主服务器的地址和端口
  2. 建立 socket 连接
  3. 从服务器 PING 主服务器
  4. 身份验证(主从要不然都验证,要不然都不验证)
  5. 从服务器发送端口信息,让主服务器可以发送信息
  6. 全量复制
  7. 增量复制(命令传播)

哨兵(Sentinel)是 Redis 的高可用解决方案之一,在 Redis 复制的基础上实现了自动化的故障恢复,可以参考博文《深入剖析Redis系列(二) - Redis哨兵模式与高可用集群》。

Redis 哨兵的最小配置是一主一从,执行期间会不断检查主服务器和从服务器是否正常运行,如果主服务器挂了,能够自动将一个从服务器升级为新的主服务器,并让其他从服务器指向它。

Redis哨兵

哨兵实例

Redis 的哨兵系统中可以有一个或多个哨兵实例,每一个哨兵本质上是一个特殊的 Redis 服务器。

启动一个哨兵,最开始会创建一个 Redis 服务器,然后将代码替换成哨兵的专用代码,并根据配置文件初始化哨兵。

哨兵的配置文件,将设置哨兵监控的主服务器(可多个),哨兵初始化完成之后,将与主服务器建立连接,开始监控。

哨兵监控

在哨兵的配置文件中,只设置了主服务器的地址信息,没有设置从服务器的,例如(在 sentinel.conf 文件中配置):

1
sentinel monitor master1 127.0.0.1 6379 2

哨兵通过配置,首先监控主服务器,并获取到每个主服务器的从服务器,然后监控这些从服务器。

哨兵跟每个服务器之间都将建立两个连接:命令连接、订阅连接。

  • 命令连接是哨兵专门用来向主服务器发送命令、接收命令回复的连接,例如哨兵向服务器发送 INFOPING 命令。
  • 订阅连接是哨兵专门用于订阅主服务器的 __sentinel__:hello 频道的连接,例如当一个哨兵向服务器的 __sentinel__:hello 频道发送了一条消息,所有订阅了该频道的哨兵,都将收到这条消息。

正常情况下,哨兵与服务器建立两种连接之后,会不停向服务器发送三种命令:

  • INFO 命令,十秒一次,用于获知服务器的信息
  • PING 命令,一秒一次,用于心跳检测服务器是否存活
  • PUBLISH __sentinel_:hello ... 命令,两秒一次,用于告知服务器和其他哨兵自己的存在(哨兵之间只建立命令连接,不建立订阅连接,哨兵之间通过 __sentinel__:hello 频道互相知道存在)

监控服务器故障恢复

哨兵每秒 PING 一次服务器,以此为心跳监控服务器是否存活,如果一段时间 PING 不通服务器,会认为服务器出了故障(服务器下线)。

哨兵眼中有两种服务器下线,一种是主观下线,一种是客观下线:

  • 主观下线:连续一段时间都 PING 不通服务器(超时时间在配置文件中配置),将服务器标记为主观下线
  • 客观下线:如果认定主服务器主观下线,为了确认它是否真的下线,会跟其他监视该服务器的哨兵通信,看其他哨兵是否也认为这个主服务器主观下线。如果有一定数量的哨兵(该数量也在配置文件中配置)都认定该主服务器主观下线,那么就可以认定这个主服务器真的下线了,即客观下线

当一个主服务器被判断为客观下线后,会着手选一个新的主服务器:

  1. 从所有监视原来主服务器的哨兵中,选举出一个领头哨兵。

    选举方式采用 Raft 协议,这是一个分布式协议,有个做得特别好的科普 Raft 的网站分享一下:《Raft Understandable Distributed Consensus》。

  2. 由领头哨兵从所有从服务器中,挑选出来一个从服务器(挑选算法有点麻烦,略),并将其转换为主服务器(发送 SLAVEOF no one 命令)。

  3. 向其他从服务器发送 SLAVEOF 命令,让它们复制新的主服务器。


Redis 哨兵实现了自动故障恢复,但无法实现读写的负载均衡。如果有多台 Redis 服务器,如何让 key-value 均衡地存储进多台服务器里,兼顾高可用和高性能,是 Redis 集群(Redis Cluster)关注的问题。

一个 Redis 集群由多个 Redis 服务器组成,每个 Redis 服务器被称为一个节点(Cluster Node),也就是多个节点构成一个集群。集群内部采用哈希槽的方式存放数据,例如整个集群预设 16384 个槽位,由 A、B、C 三个节点分占:

  • 节点 A 占用第 0 到第 5000 个槽位
  • 节点 B 占用第 5001 到第 10000 个槽位
  • 节点 C 占用第 10001 到第 16383 个槽位

每个 key 通过 CRC16 校验后对 16384 取模,根据数值决定存放在哪个槽位中。例如 “msg” 经过 CRC16 校验后取模算出来 6257,最终会存储在第 6257 个槽位里,也就是在节点 B 中。

每个节点的数据结构

有三个类:

  • clusterNode:节点类
  • clusterLink:连接节点类
  • clusterState:集群状态类

启动一个 Redis 服务器,如果配置开启集群模式(在 conf 文件中配置 cluster-enabled yes),那么这个 Redis 服务器在启动时就会成为一个集群节点(尽管此时只有它自己一个)。

这个节点在创建时,会实例化一个 clusterNode 对象,也就是它自己:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
struct clusterNode {
//创建节点的时间
mstime_t ctime;

//节点的名字,由40 个十六进制字符组成
//例如68eef66df23420a5862208ef5b1a7005b806f2ff
char name[REDIS_CLUSTER_NAMELEN];

//节点标识
//使用各种不同的标识值记录节点的角色(比如主节点或者从节点),
//以及节点目前所处的状态(比如在线或者下线)。
int flags;

//节点当前的配置纪元,用于实现故障转移
uint64_t configEpoch;

//节点的IP 地址
char ip[REDIS_IP_STR_LEN];

//节点的端口号
int port;

//保存连接节点所需的有关信息
clusterLink *link;

// ...
};

它还会实例化一个 clusterLink 对象,用来保存和其他节点连接时的所需信息:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
typedef struct clusterLink {
//连接的创建时间
mstime_t ctime;

// TCP 套接字描述符
int fd;

//输出缓冲区,保存着等待发送给其他节点的消息(message )。
sds sndbuf;

//输入缓冲区,保存着从其他节点接收到的消息。
sds rcvbuf;

//与这个连接相关联的节点,如果没有的话就为NULL
struct clusterNode *node;

} clusterLink;

该节点还会实例化一个 clusterState 对象,用于记录集群的整体信息:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
typedef struct clusterState {

//指向当前节点的指针
clusterNode *myself;

//集群当前的配置纪元,用于实现故障转移
uint64_t currentEpoch;

//集群当前的状态:是在线还是下线
int state;

//集群中至少处理着一个槽的节点的数量
int size;

//集群节点名单(包括myself 节点)
//字典的键为节点的名字,字典的值为节点对应的clusterNode 结构
dict *nodes;

// ...
} clusterState;

Redis 集群是分布式而非中心式的,并没有一个统一的中心管理器,而是在每个节点中,都存储着集群信息和其他节点信息。

如果节点 A 和节点 B 合并入同一个集群,节点 A 内部会创建一个 clusterNode 对象用来记录节点 B,同样节点 B 也会创建一个 clusterNode 对象用来记录节点 A,两个节点也都会各自更新 clusterState 对象(更新集群信息)。

通过任意一个节点,都可以获知整个集群的工作状况,以及其他联系到节点。

哈希槽

如何把数据分布到集群中的多个节点,做到读写的负载均衡,是一个分布式系统的常见问题。数据分布有很多方案,例如顺序分布、哈希分布、一致性哈希、虚拟槽分区等,Redis 集群采用虚拟槽分区,也就是哈希槽。

哈希槽(槽 slot)中的槽位,是一个虚拟的概念,比如划分了 10000 个槽位,4 个节点各自分得 1000、2000、3000、4000 个槽位,之后通过计算哈希值在哪个槽里,就能知道数据应该存储在哪个节点里,这个过程不是真的需要槽位,槽只是个虚拟概念。


Redis 集群共设置了 16384 个槽位,不管有多少个节点,都是分这 16384 个槽。(16384 即 2^14,是 Redis 作者觉得合适的大小,跟数据传输和压缩比有关,具体原因自行谷歌)

当这 16384 个槽位没有被分完时,集群处于下线状态,只有当所有的槽都被分完,集群才能上线。

通过向节点发送 CLUSTER ADDSLOTS 命令,可以将一个或多个槽位指派给节点负责,例如:

1
CLUSTER ADDSLOTS 0 1 2 3 4 ... 5000

节点处理好后,还会同步给集群内的其他节点,每个节点都会知道,哪个槽位由哪个节点负责。

  • 在 clusterNode 结构中,会存储本节点负责处理哪些槽(以位数组 bit array 的数据结构存储,省空间)。
  • 在 clusterState 结构中,会存储每个槽位由哪个节点负责(长度为 16384 的节点数组,clusterNode *slots[16384])。

也就是说,每个节点同时存储了【自己负责的槽位】和【所有槽位由谁负责】。当查询 key 时可以直接查到负责的节点,当需要获取本节点负责哪些槽位时(例如槽重新分片)也可以直接返回。


计算 key 存储在哪个槽里,哈希算法是首先计算 key 的 CRC-16 校验和,然后再用该校验和对 16384 取模,计算出一个介于 0 至 16383 之间的数值,也就是槽位。这个哈希算法一行代码就可以实现:

1
CRC16(key) & 16384

当读写一条 Redis 数据时,可以向任何一个节点发出命令,接到命令的节点将首先计算出槽位 i,再读取 clusterState 中存储的 slots[i],得知该槽位由哪个节点负责。如果是自己负责,直接处理,如果是别的节点负责,返回 MOVED 错误,让客户端进行节点转向,转到正确的节点上重新执行命令(MOVED 错误是暗中进行的)。


哈希槽还可以由一个节点转给另一个节点,这被称为重新分片。这个过程是由 Redis 集群管理软件 redis-trib 来负责的,大致逻辑是找到每个槽的负责节点,一个槽一个槽地迁移。没意思,略了。

这周就写到这里吧。