1 介绍

在前面的文章中,已经介绍了Redis的几种高可用技术:持久化、主从复制和哨兵,但这些方案仍有不足,其中最主要的问题是存储能力受单机限制,以及无法实现写操作的负载均衡。Redis单机内存大小受限问题,在介绍持久化和主从复制时都有提及;例如,如果单机内存太大,bgsavebgrewriteaof的 fork 操作可能导致主进程阻塞,主从环境下主机切换时可能导致从节点长时间无法提供服务,全量复制阶段主节点的复制缓冲区可能溢出。

因此Redis 3.0开始引入的分布式存储方案 - 集群(Redis Cluster)。集群由多个节点(Node)组成,Redis的数据分布在这些节点中。集群中的节点分为主节点和从节点:只有主节点负责读写请求和集群信息的维护;从节点只进行主节点数据和状态信息的复制。集群的作用,可以归纳为两点:

  1. 数据分区:数据分区(或称数据分片)是集群最核心的功能。集群将数据分散到多个节点,一方面突破了Redis单机内存大小的限制,存储容量大大增加;另一方面每个主节点都可以对外提供读服务和写服务,大大提高了集群的响应能力。
  2. 高可用:集群支持主从复制和主节点的自动故障转移(与哨兵类似);当任一节点发生故障时,集群仍然可以对外提供服务。

2 集群搭建

集群的搭建可以分为四步:1 启动节点:将节点以集群模式启动,此时节点是独立的,并没有建立联系;2 节点握手:让独立的节点连成一个网络;3 分配槽:将16384个槽分配给主节点;4 指定主从关系:为从节点指定主节点。

2.1 启动节点

集群节点的启动仍然是使用redis-server命令,但需要使用集群模式启动。

  1. ## 1 准备目录存放数据
  2. mkdir 6371 6372 6373 6374 6375 6376
  3. cp redis.conf /usr/local/redis-cluster/6371/
  4. cp redis.conf /usr/local/redis-cluster/6372/
  5. ...
  6. ## 2 修改配置文件,以6371为例,其他将6371换成对应端口号既可
  7. port 6371
  8. daemonize yes # 后台形式启动
  9. protected-mode no # 关闭保护模式
  10. dir /usr/local/redis-cluster/6371/ # 指定数据存放的目录,必须要指定不同的目录位置
  11. appendonly yes # aof模式
  12. # bind 127.0.0.1
  13. # 集群信息
  14. cluster-enabled yes # 启动集群模式
  15. cluster-node-timeout 5000 # 节点超时时间
  16. cluster-config-file nodes-6371.conf # 集群节点信息文件,这里最好和port对应上
  17. # 配置密码,可选
  18. requirepass pass # 设置redis访问密码
  19. materauth pass # 设置集群节点访问密码,跟上面一致
  20. ## 3 启动Redis服务
  21. redis-server /usr/local/redis-cluster/6371/redis.conf
  22. redis-server /usr/local/redis-cluster/6372/redis.conf
  23. ...

2.2 节点握手

节点启动以后是相互独立的,并不知道其他节点存在;需要进行节点握手,将独立的节点组成一个网络。节点握手使用cluster meet {ip} {port}命令实现,例如在6371节点中执行cluster meet 192.168.x.x 6372,可以完成6371节点和6372节点的握手;注意ip使用的是局域网ip而不是localhost或127.0.0.1,是为了其他机器上的节点或客户端也可以访问。此时再使用cluster nodes查看。

2.3 分配槽

在Redis集群中,借助槽实现数据分区,集群有16384个槽,槽是数据管理和迁移的基本单位。当数据库中的16384个槽都分配了节点时,集群处于上线状态(ok);如果有任意一个槽没有分配节点,则集群处于下线状态(fail)。分配槽使用cluster addslots命令,执行下面的命令将槽(编号0-16383)全部分配完毕。

  1. redis-cli -p 6371 cluster addslots {0..5461}
  2. redis-cli -p 6372 cluster addslots {5462..10922}
  3. redis-cli -p 6373 cluster addslots {10923..16383}

2.4 指定主从关系

集群中指定主从关系不再使用replicaof命令,而是使用cluster replicate命令;参数使用节点id。通过cluster nodes获得几个主节点的节点id后,执行下面的命令为每个从节点指定主节点。

  1. redis-cli -p 6374 cluster replicate 节点id
  2. redis-cli -p 6375 cluster replicate 节点id
  3. redis-cli -p 6376 cluster replicate 节点id

2.5 快速创建集群

  1. ## 1 查看redis集群的命令帮助
  2. redis-cli --cluster help
  3. create:创建一个集群环境
  4. call:可以执行redis命令
  5. add-node:将一个节点添加到集群里,第一个参数为新节点的ip:port,第二个参数为集群中任意一个已经存在的节点的ip:port
  6. del-node:移除一个节点
  7. reshard:重新分片
  8. check:检查集群状态
  9. ## 2 创建集群,1表示每个小集群有1分副本
  10. redis-cli -a pass --cluster create --cluster-replicas 1 ip:port ip:port ip:port ...
  11. ## 3 验证
  12. redis-cli -a pass -c -h 192.168.x.x -p 6379
  13. 查看集群信息:cluster info
  14. 查看节点列表:cluster nodes

3 Java客户端

3.1 Jedis

  1. public class JedisClusterTest {
  2. public static void main(String[] args) throws IOException {
  3. JedisPoolConfig config = new JedisPoolConfig();
  4. config.setMaxTotal(20);
  5. config.setMaxIdle(10);
  6. config.setMinIdle(5);
  7. Set<String> jedisClusterNodes = new HashSet<>();
  8. jedisClusterNodes.add(new HostAndPort("127.0.0.1", 6379));
  9. jedisClusterNodes.add(new HostAndPort("127.0.0.1", 6380));
  10. jedisClusterNodes.add(new HostAndPort("127.0.0.1", 16379));
  11. JedisCluster jedisCluster = null;
  12. try {
  13. jedisCluster = new JedisCluster(jedisClusterNodes, 6000, 5000, 10, "pass", config);
  14. System.out.println(jedisCluster.set("cluster", "123"));
  15. } catch (Exception e) {
  16. e.printStackTrace();
  17. } finally {
  18. if (jedisCluster != null)
  19. jedisCluster.close();
  20. }
  21. }
  22. }

3.2 Spring Boot

  1. spring:
  2. redis:
  3. database: 0
  4. timeout: 3000
  5. password: pass
  6. cluster: # 集群模式
  7. nodes: 192.168.0.60:6379,192.168.0.60:6380,...
  8. lettuce:
  9. pool:
  10. max-idle: 50
  11. min-idle: 10
  12. max-active: 100
  13. max-wait: 1000

4 集群设计方案

设计集群方案时,至少要考虑以下因素:

  1. 高可用要求:根据故障转移的原理,至少需要3个主节点才能完成故障转移,且3个主节点不应在同一台物理机上;每个主节点至少需要1个从节点,且主从节点不应在一台物理机上;因此高可用集群至少包含6个节点。
  2. 数据量和访问量:估算应用需要的数据量和总访问量(考虑业务发展,留有冗余),结合每个主节点的容量和能承受的访问量(可以通过benchmark得到较准确估计),计算需要的主节点数量。
  3. 节点数量限制:Redis官方给出的节点数量限制为1000,主要是考虑节点间通信带来的消耗。在实际应用中应尽量避免大集群;如果节点数量不足以满足应用对Redis数据量和访问量的要求,可以考虑:(1)业务分割,大集群分为多个小集群;(2)减少不必要的数据;(3)调整数据过期策略等。
  4. 适度冗余:Redis可以在不影响集群服务的情况下增加节点,因此节点数量适当冗余即可,不用太大。

5 集群的基本原理

集群最核心的功能是数据分区,因此首先介绍数据的分区规则;然后介绍集群实现的细节:通信机制和数据结构;最后以cluster meet(节点握手)、cluster addslots(槽分配)为例,说明节点是如何利用上述数据结构和通信机制实现集群命令的。

5.1 数据分区方案

数据分区有顺序分区哈希分区等,其中哈希分区由于其天然的随机性,使用广泛;集群的分区方案便是哈希分区的一种。
哈希分区的基本思路是:对数据的特征值(如key)进行哈希,然后根据哈希值决定数据落在哪个节点。常见的哈希分区包括:哈希取余分区、一致性哈希分区、带虚拟节点的一致性哈希分区等。
衡量数据分区方法好坏的标准有很多,其中比较重要的两个因素是:(1) 数据分布是否均匀,(2) 增加或删减节点对数据分布的影响。由于哈希的随机性,哈希分区基本可以保证数据分布均匀;因此在比较哈希分区方案时,重点要看增减节点对数据分布的影响。详细看Redis分区章节。

5.2 节点通信机制

集群要作为一个整体工作,离不开节点之间的通信。

5.2.1 两个端口

在哨兵系统中,节点分为数据节点和哨兵节点:前者存储数据,后者实现额外的控制功能。在集群中,没有数据节点与非数据节点之分:所有的节点都存储数据,也都参与集群状态的维护。为此,集群中的每个节点,都提供了两个TCP端口:

  • 普通端口:即我们指定的端口(6379等)。普通端口主要用于为客户端提供服务(与单机节点类似);但在节点间数据迁移时也会使用。
  • 集群端口:端口号是普通端口+10000(10000是固定值,无法改变),如6379节点的集群端口为16379。集群端口只用于节点之间的通信,如搭建集群、增减节点、故障转移等操作时节点间的通信;不要使用客户端连接集群接口。为了保证集群可以正常工作,在配置防火墙时,要同时开启普通端口和集群端口。

5.2.2 Gossip协议

节点间通信,按照通信协议可以分为几种类型:单对单、广播、Gossip协议等。重点是广播和Gossip的对比。
广播协议:是指向集群内所有节点发送消息;优点是集群的收敛速度快(集群收敛是指集群内所有节点获得的集群信息是一致的),缺点是每条消息都要发送给所有节点,CPU、带宽等消耗较大。
Gossip协议:Gossip协议的特点是在节点数量有限的网络中,每个节点都“随机”的与部分节点通信(并不是真正的随机,而是根据特定的规则选择通信的节点),经过一番杂乱无章的通信,每个节点的状态很快会达到一致。Gossip协议的优点有负载(比广播)低、去中心化、容错性高(因为通信有冗余)等;缺点主要是集群的收敛速度慢。

5.2.3 消息类型

集群中的节点采用固定频率(每秒10次)的定时任务进行通信相关的工作:判断是否需要发送消息及消息类型、确定接收节点、发送消息等。如果集群状态发生了变化,如增减节点、槽状态变更,通过节点间的通信,所有节点会很快得知整个集群的状态,使集群收敛。
节点间发送的消息主要分为5种:meet消息、ping消息、pong消息、fail消息、publish消息。不同的消息类型,通信协议、发送的频率和时机、接收节点的选择等是不同的。

  • MEET消息:在节点握手阶段,当节点收到客户端的CLUSTER MEET命令时,会向新加入的节点发送MEET消息,请求新节点加入到当前集群;新节点收到MEET消息后会回复一个PONG消息。
  • PING消息:集群里每个节点每秒钟会选择部分节点发送PING消息,接收者收到消息后会回复一个PONG消息。PING消息的内容是自身节点和部分其他节点的状态信息;作用是彼此交换信息,以及检测节点是否在线。PING消息使用Gossip协议发送,接收节点的选择兼顾了收敛速度和带宽成本,具体规则如下:(1)随机找5个节点,在其中选择最久没有通信的1个节点(2)扫描节点列表,选择最近一次收到PONG消息时间大于cluster_node_timeout/2的所有节点,防止这些节点长时间未更新。
  • PONG消息:PONG消息封装了自身状态数据。可以分为两种:第一种是在接到MEET/PING消息后回复的PONG消息;第二种是指节点向集群广播PONG消息,这样其他节点可以获知该节点的最新信息,例如故障恢复后新的主节点会广播PONG消息。
  • FAIL消息:当一个主节点判断另一个主节点进入FAIL状态时,会向集群广播这一FAIL消息;接收节点会将这一FAIL消息保存起来,便于后续的判断。
  • PUBLISH消息:节点收到PUBLISH命令后,会先执行该命令,然后向集群广播这一消息,接收节点也会执行该PUBLISH命令。

5.3 数据结构

节点需要专门的数据结构来存储集群的状态。所谓集群的状态,是一个比较大的概念,包括:集群是否处于上线状态、集群中有哪些节点、节点是否可达、节点的主从状态、槽的分布……
节点为了存储集群状态而提供的数据结构中,最关键的是clusterNode和clusterState结构:前者记录了一个节点的状态,后者记录了集群作为一个整体的状态。

5.3.1 clusterNode

clusterNode结构保存了一个节点的当前状态,包括创建时间、节点id、ip和端口号等。每个节点都会用一个ClusterNode结构记录自己的状态,并为集群内所有其他节点都创建一个clusterNode结构来记录节点状态。下面列举了clusterNode的部分字段,除此之外,clusterNode还包含节点连接、主从复制、故障发现和转移需要的信息等。

  1. typedef struct clusterNode {
  2. // 节点创建时间
  3. mstime_t ctime;
  4. // 节点id
  5. char name[REDIS_CLUSTER_NAMELEN];
  6. // 节点的ip和端口号
  7. char ip[REDIS_IP_STR_LEN];
  8. int port;
  9. // 节点标识:整型,每个bit都代表了不同状态,如节点的主从状态、是否在线、是否在握手等
  10. int flags;
  11. // 配置纪元:故障转移时起作用,类似于哨兵的配置纪元
  12. uint64_t configEpoch;
  13. // 槽在该节点中的分布:占用16384/8个字节,16384个比特;每个比特对应一个槽:
  14. // 比特值为1,则该比特对应的槽在节点中;比特值为0,则该比特对应的槽不在节点中
  15. unsigned char slots[16384/8];
  16. // 节点中槽的数量
  17. int numslots;
  18. …………
  19. } clusterNode;

5.3.2 clusterState

clusterState结构保存了在当前节点视角下,集群所处的状态。下面列举了clusterState的部分字段,除此之外,clusterState还包括故障转移、槽迁移等需要的信息。

  1. typedef struct clusterState {
  2. // 自身节点
  3. clusterNode *myself;
  4. // 配置纪元
  5. uint64_t currentEpoch;
  6. // 集群状态:在线还是下线
  7. int state;
  8. // 集群中至少包含一个槽的节点数量
  9. int size;
  10. // 哈希表,节点名称->clusterNode节点指针
  11. dict *nodes;
  12. // 槽分布信息:数组的每个元素都是一个指向clusterNode结构的指针;如果槽还没有分配给任何节点,则为NULL
  13. clusterNode *slots[16384];
  14. …………
  15. } clusterState;

5.4 集群命令的实现

5.4.1 cluster meet

假设要向A节点发送cluster meet命令,将B节点加入到A所在的集群,则A节点收到命令后,执行的操作如下:

  1. A为B创建一个clusterNode结构,并将其添加到clusterState的nodes字典中
  2. A向B发送MEET消息
  3. B收到MEET消息后,会为A创建一个clusterNode结构,并将其添加到clusterState的nodes字典中
  4. B回复A一个PONG消息
  5. A收到B的PONG消息后,便知道B已经成功接收自己的MEET消息
  6. 然后,A向B返回一个PING消息
  7. B收到A的PING消息后,便知道A已经成功接收自己的PONG消息,握手完成
  8. 之后,A通过Gossip协议将B的信息广播给集群内其他节点,其他节点也会与B握手;一段时间后,集群收敛,B成为集群内的一个普通节点

通过上述过程可以发现,集群中两个节点的握手过程与TCP类似,都是三次握手:A向B发送MEET;B向A发送PONG;A向B发送PING。

5.4.2 cluster addslots

集群中槽的分配信息,存储在clusterNode的slots数组和clusterState的slots数组中,两个数组的结构前面已做介绍;二者的区别在于:前者存储的是该节点中分配了哪些槽,后者存储的是集群中所有槽分别分布在哪个节点。

cluster addslots命令接收一个槽或多个槽作为参数,例如在A节点上执行cluster addslots {0..10}命令,是将编号为0-10的槽分配给A节点,具体执行过程如下:

  1. 遍历输入槽,检查它们是否都没有分配,如果有一个槽已分配,命令执行失败;方法是检查输入槽在clusterState.slots[]中对应的值是否为NULL。
  2. 遍历输入槽,将其分配给节点A;方法是修改clusterNode.slots[]中对应的比特为1,以及clusterState.slots[]中对应的指针指向A节点
  3. A节点执行完成后,通过节点通信机制通知其他节点,所有节点都会知道0-10的槽分配给了A节点

5 客户端访问集群

在集群中,数据分布在不同的节点中,客户端通过某节点访问数据时,数据可能不在该节点中;下面介绍集群是如何处理这个问题的。

5.1 redis-cli

当节点收到redis-cli发来的命令(如set/get)时,过程如下:

  1. 计算key属于哪个槽:CRC16(key) & 16383
  2. 判断key所在的槽是否在当前节点,假设key位于第i个槽,clusterState.slots[i]则指向了槽所在的节点。
  3. 如果clusterState.slots[i]==clusterState.myself,说明槽在当前节点,可以直接在当前节点执行命令。
  4. 否则,说明槽不在当前节点,则查询槽所在节点的地址(clusterState.slots[i].ip/port),并将其包装到MOVED错误中返回给redis-cli。redis-cli收到MOVED错误后,根据返回的ip和port重新发送请求。

5.2 Smart客户端

redis-cli这一类客户端称为Dummy客户端,因为它们在执行命令前不知道数据在哪个节点,需要借助MOVED错误重新定向。与Dummy客户端相对应的是Smart客户端。Smart客户端以JedisCluster为例。

  1. JedisCluster初始化时,在内部维护slot->node的缓存,方法是连接任一节点,执行cluster slots命令。
  2. 此外,JedisCluster为每个节点创建连接池(即JedisPool)。
  3. 当执行命令时,JedisCluster根据key->slot->node选择需要连接的节点,发送命令。如果成功,则命令执行完毕。如果执行失败,则会随机选择其他节点进行重试,并在出现MOVED错误时,使用cluster slots重新同步slot->node的映射关系。

注意事项如下:

  1. JedisCluster中已经包含所有节点的连接池,因此JedisCluster要使用单例。
  2. 客户端维护了slot->node映射关系以及为每个节点创建了连接池,当节点数量较多时,应注意客户端内存资源和连接资源的消耗。
  3. Jedis较新版本针对JedisCluster做了一些性能方面的优化,如cluster slots缓存更新和锁阻塞等方面的优化,应尽量使用2.8.2及以上版本的Jedis。

6 集群伸缩

6.1 增加redis实例

  1. 首先安装2.1的方法增加两个节点6377,6378。
  2. 然后使用add-node命令新增一个主节点6377(master),前面的ip:port为新增节点,后面的ip:port为已知节点。

    1. # 新增加点
    2. redis-cli -a pass --cluster add-node 192.168.x.x:6377 192.168.x.x:6371
  3. 使用redis-cli命令为6378分配hash槽,找到集群中的任意一个主节点,对其进行重新分片工作。

    1. # 分配槽位
    2. redis-cli -a pass --cluster reshard 192.168.x.x:6371
    3. How many slots do you want to move (from 1 to 16384)? 600
    4. What is the receiving node ID? 节点id
    5. Source node 1: all
    6. Do you want to proceed with the proposed reshard plan (yes/no)? yes

注意:记得每操作完一步使用 cluster nodes 查看集群状态

6.2 配置从节点

  1. 添加从节点6378到集群中去并查看集群状态。

    1. # 添加到集群
    2. redis-cli -a pass --cluster add-node 192.168.x.x:6378 192.168.x.x:6371
  2. 我们需要执行replicate命令来指定当前节点(从节点)的主节点id为哪个,首先需要连接新加的6378节点的客户端,然后使用集群命令进行操作,把当前的6378(slave)节点指定到一个主节点下(这里使用之前创建的6377主节点)。 ```scala

    连接6378服务

    redis-cli -a pass -c -h 192.168.x.x -p 6378

指定为6377从节点

cluster replicate 6377节点id

  1. <a name="YLOLb"></a>
  2. #### 6.3 删除节点
  3. <a name="MH4DZ"></a>
  4. ##### 6.3.1 删除从节点
  5. 用del-node删除从节点6378,指定删除节点ip和端口。
  6. ```scala
  7. # 删除节点
  8. redis-cli -a pass --cluster del-node 192.168.x.x:6378 6378节点id

6.3.2 删除主节点

最后,我们尝试删除之前加入的主节点6377,这个步骤相对比较麻烦一些,因为主节点的里面是有分配了hash槽的,所以我们这里必须先把6377里的hash槽放入到其他的可用主节点中去,然后再进行移除节点操作,不然会出现数据丢失问题(目前只能把master的数据迁移到一个节点上,暂时做不了平均分配功能),执行命令如下:

  1. # 分配6377槽位
  2. redis-cli -a pass --cluster reshard 192.168.0.61:6377
  3. How many slots do you want to move (from 1 to 16384)? 600
  4. What is the receiving node ID? 6371节点id
  5. Source node 1: 6377节点id
  6. Source node 2: done
  7. Do you want to proceed with the proposed reshard plan (yes/no)? Yes
  8. # 删除节点
  9. redis-cli -a pass --cluster del-node 192.168.x.x:6377 6377节点id