Redis之集群
阅读原文时间:2022年06月23日阅读:1

  Redis Cluster是 Redis的分布式解决方案,在3.0版本正式推出,有效地解决了Redis分布式方面的需求。当遇到单机内存、并发、流量等瓶颈时,可以采用Cluster架构方案达到负载均衡的目的。之前,Redis分布式方案一般有两种:

□ 客户端分区方案,优点是分区逻辑可控,缺点是需要自己处理数据路由、高可用、故障转移等问题。

□ 代理方案,优点是简化客户端分布式逻辑和升级维护便利,缺点是加重架构部署复杂度和性能损耗。

  现在官方为我们提供了专有的集群方案:Redis Cluster, 它非常优雅地解决了 Redis集群方面的问题,因此理解应用好Redis Cluster将极大地解放我们使用分布式Redis的工作量,同时它也是学习分布式存储的绝佳案例。

  本章将从数据分布、搭建集群、节点通信、集群伸缩、请求路由、故障转移、集群运维几个方面介绍Redis Cluster。

1.1 数据分布理论

  分布式数据库首先要解决把整个数据集按照分区规则映射到多个节点的问题,即把数据集划分到多个节点上,每个节点负责整体数据的一个子集。如图10-1所示。需要重点关注的是数据分区规则。常见的分区规则有哈希分区和顺序分区两种,表10-1对这两种分区规则进行了对比。

表10-1哈希分区和顺序分区对比

分区方式

特点

代表产品

哈希分区

离散度好

数据分布业务无关

无法顺序访问

Redis Cluster

Cassandra

Dynamo

顺序分区

离散度易倾斜

数据分布业务相关

可顺序访问

Bigtable

HBase

Hypertable

由于Redis Cluster采用哈希分区规则,这里我们重点讨论哈希分区,常见的哈希分区规则有几种,下面分别介绍。

1.节点取余分区

  使用特定的数据,如 Redis的键或用户ID,再根据节点数量N使用公式:hash (key) %N计算出哈希值,用来决定数据映射到哪一个节点上。这种方案存在一个问题:当节点数量变化时,如扩容或收缩节点,数据节点映射关系需要重新计算,会导致数据的重新迁移。这种方式的突出优点是简单性,常用于数据库的分库分表规则,一般采用预分区的方式,提前根据数据量规划好分区数,比如划分为512或 1024张表,保证可支撑未来一段时间的数据量,再根据负载情况将表迁移到其他数据库中。扩容时通常采用翻倍扩容,避免数据映射全部被打乱导致全量迁移的情况,如图10-2所示。

2.一致性哈希分区

一致性哈希分区(Distributed Hash Table) 实现思路是为系统中每个节点分配一个token,范围一般在0~232,这些token构成一个哈希环。数据读写执行节点查找操作时,先根据 key计算hash值,然后顺时针找到第一个大于等于该哈希值的token节点,如图 10-3所示。

  这种方式相比节点取余最大的好处在于加入和删除节点只影响哈希环中相邻的节点,对其他节点无影响。但一致性哈希分区存在几个问题:

□ 加减节点会造成哈希环中部分数据无法命中,需要手动处理或者忽略这部分数据,因此一致性哈希常用于缓存场景。

□ 当使用少量节点时,节点变化将大范围影响哈希环中数据映射,因此这种方式不适合少量数据节点的分布式方案。

□ 普通的一致性哈希分区在增减节点时需要增加一倍或减去一半节点才能保证数据和负载的均衡。

正因为一致性哈希分区的这些缺点 ,一些分布式系统采用虚拟槽对一致性哈希进行改进,比如Dynamo系统。

3.虚拟槽分区

  虚拟槽分区巧妙地使用了哈希空间,使用分散度良好的哈希函数把所有数据映射到一个固定范围的整数集合中,整数定义为槽(slot)。这个范围一般远远大于节点数,比如RedisCluster槽范围是0 ~ 16383。槽是集群内数据管理和迁移的基本单位。采用大范围槽的主要目的是为了方便数据拆分和集群扩展。每个节点会负责一定数量的槽,如图10-4所示。

  当前集群有5个节点,每个节点平均大约负责3276个槽。由于采用高质量的哈希算法,每个槽所映射的数据通常比较均匀,将数据平均划分到5个节点进行数据分区。Redis Cluster就是采用虚拟槽分区,下面就介绍Redis数据分区方法。

1.2 Redis 数据分区

Redis Cluser采用虚拟槽分区,所有的键根据哈希函数映射到0~16383整数槽内,计算公式:slot=CRC16(key)&16383。每一个节点负责维护一部分槽以及槽所映射的键值数据,如图10-5所示。

Redis虚拟槽分区的特点:

□ 解耦数据和节点之间的关系,简化了节点扩容和收缩难度。

□ 节点自身维护槽的映射关系,不需要客户端或者代理服务维护槽分区元数据。

□ 支持节点、槽、键之间的映射查询,用于数据路由、在线伸缩等场景。

数据分区是分布式存储的核心,理解和灵活运用数据分区规则对于掌握Redis Cluster非常有帮助。

1.3 集群功能限制

Redis 集群相对单机在功能上存在一些限制,需要开发人员提前了解,在使用时做好规避。限制如下:

1) key批量操作支持有限。如mset、mget,目前只支持具有相同slot值的key执行批量操作。对于映射为不同slot值的key由于执行mget、mget等操作可能存在于多个节点上因此不被支持。

2) key事务操作支持有限。同理只支持多key在同一节点上的事务操作,当多个key分布在不同的节点上时无法使用事务功能。

3) key作为数据分区的最小粒度,因此不能将一个大的键值对象如hash、list等映射到不同的节点。

4) 不支持多数据库空间。单机下的 Redis 可以支持16个数据库,集群模式下只能使用一个数据库空间,即db0。

5) 复制结构只支持一层,从节点只能复制主节点,不支持嵌套树状复制结构。

介绍完Redis集群分区规则之后,下面我们开始搭建 Redis 集群。搭建集群工作需要以下三个步骤:

1) 准备节点。

2) 节点握手。

3) 分配槽。

2.1 准备节点

  Redis集群一般由多个节点组成,节点数量至少为6个才能保证组成完整高可用的集群。每个节点需要开启配置cluster-enabled yes,让Redis运行在集群模式下。建议为集群内所有节点统一目录,一般划分三个目录:conf、data、log ,分别存放配置、数据和日志相关文件。把6个节点配置统一放在conf目录下,集群相关配置如下:

# 节点端口
port 6379

开启集群模式

cluster-enabled yes

节点超时时间,单位毫秒

cluster-node-timeout 15000

集群内部配置文件

cluster-config-file "nodes-6379.conf"

  其他配置和单机模式一致即可,配置文件命名规则:redis-{port}.conf,准备好配置后启动所有节点,命令如下:

redis-server conf/redis-6379.conf
redis-server conf/redis-6380.conf
redis-server conf/redis-6381.conf
redis-server conf/redis-6382.conf
redis-server conf/redis-6383.conf
redis-server conf/redis-6384.conf

  检查节点日志是否正确,日志内容如下:

cat log/redis-6379.log
* No cluster configuration found, I'm cfb28ef1deee4e0fa78da86abe5d24566744411e
* Server started, Redis version 3.0.7
* The server is now ready to accept connections on port 6379

  6379节点启动成功,第一次启动时如果没有集群配置文件,它会自动创建一份,文件名称采用cluster-config-file 参数项控制,建议采用node-{port}.conf格式定义,通过使用端口号区分不同节点,防止同一机器下多个节点彼此覆盖,造成集群信息异常。如果启动时存在集群配置文件,节点会使用配置文件内容初始化集群信息。启动过程如图10-6所示。

  集群模式的Redis除了原有的配置文件之外又加了一份集群配置文件。当集群内节点信息发生变化,如添加节点、节点下线、故障转移等。节点会自动保存集群状态到配置文件中。需要注意的是,Redis自动维护集群配置文件,不要手动修改,防止节点重启时产生集群信息错乱。如节点6379首次启动后生成集群配置如下:

#cat data/nodes-6379.conf
cfb28ef1deee4e0fa78da86abe5d24566744411e 127.0.0.1:6379 myself,master - 0 0 0 connected
vars currentEpoch 0 lastVoteEpoch 0

  文件内容记录了集群初始状态,这里最重要的是节点ID, 它是一个40位16进制字符串,用于唯一标识集群内一个节点,之后很多集群操作都要借助于节点ID来完成。需要注意是,节点ID不同于运行ID。节点ID在集群初始化时只创建一次,节点重启时会加载集群配置文件进行重用,而 Redis的运行ID每次重启都会变化。在节点6380执行cluster nodes命令获取集群节点状态:

127.0.0.1:6380>cluster nodes
8e41673d59c9568aa9d29fbl74ce733345b3e8fl 127.0.0.1:6380 myself,master - 0 0 0 connected

每个节点目前只能识别出自己的节点信息。我们启动6个节点,但每个节点彼此并不知道对方的存在,下面通过节点握手让6个节点彼此建立联系从而组成一个集群。

2.2 节点握手

节点握手是指一批运行在集群模式下的节点通过Gossip协议彼此通信,达到感知对方的过程。节点握手是集群彼此通信的第一步,由客户端发起命令:cluster meet {ip}{port},如图10-7所示。

  图中执行的命令是:cluster meet 127.0.0.1 6380让节点6379和6380节点进行握手通信。cluster meet命令是一个异步命令,执行之后立刻返同。内部发起与目标节点进行握手通信,如图10-8所示。

1) 节点6379本地创建6380节点信息对象,并发送meet消息。

2) 节点6380接受到meet消息后,保存6379节点信息并回复pong消息。

3) 之后节点6379和 6380彼此定期通过ping/pong消息进行正常的节点通信。

  这里的meet、ping、pong消息是Gossip协议通信的载体,之后的节点通信部分做进一步介绍,它的主要作用是节点彼此交换状态数据信息。6379和 6380节点通过meet命令彼此建立通信之后,集群结构如图10-9所示。

  对节点6379和 6380分别执行cluster nodes命令,可以看到它们彼此已经感知到对方的存在。

127.0.0.1:6379> cluster nodes
cfb28ef1deee4e0fa78da86abe5d24566744411e 127.0.0.1:6379 myself, master - 0 0 0 connected
8e41673d59c9568aa9d29fb174ce733345b3e8f1 127.0.0.1:6380 master - 0 1468073534265 1 connected

127.0.0.1:6380 > cluster nodes
cfb28efldeee4e0fa78da86abe5d24566744411e 127.0.0.1:6379 master - 0 1468073571641 0 connected
8e41673d59c9568aa9d29fb174ce733345b3e8f1 127.0.0.1:6380 myself, master - 0 0 1 connected

  下面分别执行meet命令让其他节点加入到集群中:

127.0.0.1:6379>cluster meet 127.0.0.1 6381
127.0.0.1:6379>cluster meet 127.0.0.1 6382
127.0.0.1:6379>cluster meet 127.0.0.1 6383
127.0.0.1:6379>cluster meet 127.0.0.1 6384

  我们只需要在集群内任意节点上执行cluster meet命令加入新节点,握手状态会通过消息在集群内传播,这样其他节点会自动发现新节点并发起握手流程。最后执行cluster nodes命令确认6个节点都彼此感知并组成集群:

127.0.0.1:6379> cluster nodes
4fa7eac4080f0b667ffeab9b87841da49b84a6e4 127.0.0.1:6384 master - 0 1468073975551 5 connected
cfb28ef1deee4e0fa78da86abe5d24566744411e 127.0.0.1:6379 myself,master - 0 0 0 connected
be9485a6a729fc98c5151374bc30277e89a461d8 127.0.0.1:6383 master - 0 1468073978579 4 connected
40622f9e7adc8ebd77fca0de9edfe691cb8a74fb 127.0.0.1:6382 master - 0 1468073980598 3 connected
8e41673d59c9568aa9d29fbl74ce733345b3e8f1 127.0.0.1:6380 master - 0 1468073974541 1 connected
40b8d09d44294d2e23c7c768efc8fcd153446746 127.0.0.1:6381 master - 0 1468073979589 2 connected

  节点建立握手之后集群还不能正常工作,这时集群处于下线状态,所有的数据读写都被禁止。通过如下命令可以看到:

127.0.0.1:6379> set hello redis
(error) CLUSTERDOWN The cluster is down

通过cluster info命令可以获取集群当前状态:

127.0.0.1:6379> cluster info
cluster_state: fail
cluster_slots_assigned:0
cluster_slots_ok:0
cluster_slots_pfail:0
cluster_slots_fail:0
cluster_known_nodes:6
cluster_size:0

  从输出内容可以看到,被分配的槽(cluster slots_assigned)是0,由于目前所有的槽没有分配到节点,因此集群无法完成槽到节点的映射。只有当16384个槽全部分配给节点后,集群才进入在线状态。

2.3 分配槽

Redis集群把所有的数据映射到16384个槽中。每个key会映射为一个固定的槽,只有当节点分配了槽,才能响应和这些槽关联的键命令。通过cluster addslots命令为节点分配槽。这里利用bash特性批量设置槽(slots),命令如下:

redis-cli -h 127.0.0.1 -p 6379 cluster addslots {0 .. 5461}
redis-cli -h 127.0.0.1 -p 6380 cluster addslots {5 4 6 2… 10922}
redis-cli -h 127.0.0.1 -p 6381 cluster addslots {10923…16383}

  把16384个slot平均分配给6379、6380、6381三个节点。执行cluster info查看集群状态,如下所示:

127.0.0.1:6379> cluster info
cluster_state:ok
cluster_slots_assigned:16384
cluster_slots_ok:16384
cluster_slots_pfail:0
cluster_slots_fail:0
cluster_known_nodes:6
cluster_size:3
cluster_current_epoch :5
cluster_my_epoch:0
cluster_stats_messages_sent: 4874
cluster_stats_messages_received:4726

  当前集群状态是OK, 集群进入在线状态。所有的槽都已经分配给节点,执行cluster nodes命令可以看到节点和槽的分配关系:

127.0.0.1:6379> cluster nodes
4fa7eac4080f0b667ffeab9b87841da49b84a6e4 127.0.0.1:6384 master - 0 1468076240123 5 connected
cfb28ef1deee4e0fa78da86abe5d24566744411e 127.0.0.1:6379 myselfpaster - 0 0 0 connected 0-5461
be9485a6a729fc98c5151374bc30277e89a461d8 127.0.0.1:6383 master - 0 1468076239622 4 connected
40622f9e7adc8ebd77fca0de9edfe691cb8a74fb 127.0.0.1:6382 master - 0 1468076240628 3 connected
8e41673d59c9568aa9d29fb174ce733345b3e8f1 127.0.0.1:6380 master - 0 1468076237606 1 connected 5462-10922
40b8d09d44294d2e23c7c768efc8fcd153446746 127.0.0.1:6381 master - 0 1468076238612 2 connected 10923-16383

  目前还有三个节点没有使用,作为一个完整的集群,每个负责处理槽的节点应该具有从节点,保证当它出现故障时可以自动进行故障转移。集群模式下,Reids节点角色分为主节点和从节点。首次启动的节点和被分配槽的节点都是主节点,从节点负责复制主节点槽信息和相关的数据。使用cluster replicate {nodeId}命令让一个节点成为从节点。其中命令执行必须在对应的从节点上执行,nodeld是要复制主节点的节点ID,命令如下:

127.0.0.1:6382>cluster replicate Cfb28ef1deee4e0fa78da86abe5d24566744411e
OK
127.0.0.1:6383>cluster replicate 8e41673d59c9568aa9d29fbl74ce733345b3e8f1
OK
127.0.0.1:6384>cluster replicate 40b8d09d44294d2e23c7c768efc8fcd153446746
OK

  Redis集群模式下的主从复制使用了之前介绍的Redis复制流程,依然支持全量和部分复制。复制(replication) 完成后,整个集群的结构如图10-11所示。

  通过cluster nodes命令查看集群状态和复制关系,如下所示:

127.0.0.1:6379> cluster nodes
4fa7eac4080f0b667ffeab9b87841da49b84a6e4 127.0.0.1:6384 slave 40b8d09d44294d2e23c7c768efc8fcd153446746 0 1468076865939 5 connected
cfb28ef1deee4e0fa78da86abe5d24566744411e 127.0.0.1:6379 myself,master - 0 0 0 connected 0-5461
be9485a6a729fc98c5151374bc30277e89a461d8 127.0.0.1:6383 slave 8e41673d59c9568aa9d29fb174ce733345b3e8f1 0 1468076868966 4 connected
40622f9e7adc8ebd77fca0de9edfe691cb8a74fb 127.0.0.1:6382 slave cfb28ef1deee4e0fa78da86abe5d24566744411e 0 1468076869976 3 connected
8e41673d59c9568aa9d29fb174ce733345b3e8f1 127.0.0.1:6380 master - 0 1468076870987 1 connected 5462-10922
40b8d09d44294d2e23c7c768efc8fcd153446746 127.0.0.1:6381 master - 0 1468076867957 2 connected 10923-16383

  目前为止,我们依照Redis协议手动建立一个集群。它由6个节点构成,3 个主节点负责处理槽和相关数据,3个从节点负责故障转移。手动搭建集群便于理解集群建立的流程和细节,不过读者也从中发现集群搭建需要很多步骤,当集群节点众多时,必然会加大搭建集群的复杂度和运维成本。因此Redis官方提供了 redis-trib.rb工具方便我们快速搭建集群。

2.4 用redis-trib.rb搭建集群

  redis-trib.rb是采用Ruby实现的Redis集群管理工具。内部通过Cluster相关命令帮我们简化集群创建、检查、槽迁移和均衡等常见运维操作,使用之前需要安装Ruby依赖环境。下面介绍搭建集群的详细步骤。

1.Ruby环境准备

安装Ruby:

--下载 ruby
wget https://cache.ruby-lang.Org/pub/ruby/2.3/ruby-2.3.1.tar.gz
--安装 ruby
tar xvf ruby-2.3.1.tar.gz
./ configure -prefix=/usr/local/ruby
make
make install
cd /usr/local/ruby
sudo cp bin/ruby /usr/local/bin
sudo cp bin/gem /usr/local/bin

安装 rubygem redis依赖:

wget http://rubygems.org/downloads/redis-3.3.0.gem
gem install -l redis-3.3.0.gem
gem list --check redis gem

安装 redis-trib.rb:

sudo cp /{redis_home}/src/redis-trib.rb /usr/local/bin

安装完Ruby环境后,执行redis-trib.rb命令确认环境是否正确,输出如下:

# redis-trib.rb
Usage: redis-trib
create hostl:portl … hostN:portN

从 redis-trib.rb的提示信息可以看出,它提供了集群创建、检查、修复、均衡等命令行工具。这里我们关注集群创建命令,使用redis-trib.rb create命令可快速搭建集群。

2.准备节点

  首先我们跟之前内容一样准备好节点配置并启动:

redis-server conf/redis-6481.conf
redis-server conf/redis-6482.conf
redis-server conf/redis-6483.conf
redis-server conf/redis-6484.conf
redis-server conf/redis-6485.conf
redis-server conf/redis-6486.conf

3.创建集群

  启动好6个节点之后,使用redis-trib.rb create命令完成节点握手和槽分配过程,命令如下:

redis-trib.rb create --replicas 1 127.0.0.1:6481 127.0.0.1:6482 127.0.0.1:6483
127.0.0.1:6484 127.0.0.1:6485 127.0.0.1:6486

  --replicas参数指定集群中每个主节点配备几个从节点,这里设置为1。我们出于测试目的使用本地IP地址127.0.0.1,如果部署节点使用不同的IP地址,redis-trib.rb会尽可能保证主从节点不分配在同一机器下,因此会重新排序节点列表顺序。节点列表顺序用于确定主从角色,先主节点之后是从节点。创建过程中首先会给出主从节点角色分配的计划,如下所示。

>>> Creating cluster

Performing hash slots allocation on 6 nodes…
Using 3 masters:
127.0.0.1:6481
127.0.0.1:6482
127.0.0.1:6483
Adding replica 127.0.0.1:6484 to 127.0.0.1:6481
Adding replica 127.0.0.1:6485 to 127.0.0.1:6482
Adding replica 127.0.0.1:6486 to 127.0.0.1:6483
M: 869de192169c4607bb886944588bc358d6045afa 127.0.0.1:6481
slots:0-5460 (5461 slots) master
M: 6f9f24923eb37f1e4dcelc88430f6fc23ad4a47b 127.0.0.1:6482
slots:5461-10922 (5462 slots) master
M: 6228a1adb6c26139b0adbe81828f43a4ec196271 127.0.0.1:6483
slots:10923-16383 (5461 slots) master
S: 22451ea81fac73fe7a91cf051cd50b2bf308c3f3 127.0.0.1:6484
replicates 869de192169c4607bb886944588bc358d6045afa
S: 89158df8e62958848134d632e75d1a8d2518f07b 127.0.0.1:6485
replicates 6f9f24923eb37f1e4dcelc88430f6fc23ad4a47b
S: bcb394c48d50941f235cd6988a40e469530137af 127.0.0.1:6486
replicates 6228a1adb6c26139b0adbe81828f43a4ec196271
Can I set the above configuration? (type 'yes' to accept):

  当我们同意这份计划之后输入yes, redis-trib.rb开始执行节点握手和槽分配操作,输出如下:

>>> Nodes configuration updated

Assign a different config epoch to each node
Sending CLUSTER MEET messages to join the cluster
Waiting for the cluster to join..
Performing Cluster Check (using node 127.0.0.1:6481)
… 忽 略 …
[OK] All nodes agree about slots configuration.
Check for open slots…
Check slots coverage…
[OK] All 16384 slots covered.

  最后的输出报告说明:16384个槽全部被分配,集群创建成功。这里需要注意给redis-trib.rb 的节点地址必须是不包含任何槽/数据的节点,否则会拒绝创建集群。

4.集群完整性检查

  集群完整性指所有的槽都分配到存活的主节点上,只要16384个槽中有一个没有分配给节点则表示集群不完整。可以使用redis-trib.rb check命令检测之前创建的两个集群是否成功,check命令只需要给出集群中任意一个节点地址就可以完成整个集群的检査工作,命令如下:

redis-trib.rb check 127.0.0.1:6379
redis-trib.rb check 127.0.0.1:6481

  当最后输出如下信息,提示集群所有的槽都已分配到节点:

[OK] All nodes agree about slots configuration.

Check for open slots…
Check slots coverage…
[OK] All 16384 slots covered.

3.1 通信流程

  在分布式存储中需要提供维护节点元数据信息的机制,所谓元数据是指:节点负责哪些数据,是否出现故障等状态信息。常见的元数据维护方式分为:集中式和P2P方式。Redis集群采用P2P的Gossip (流言)协议,Gossip协议工作原理就是节点彼此不断通信交换信息,一段时间后所有的节点都会知道集群完整的信息,这种方式类似流言传播,如图10-12所示。

  通信过程说明:

1) 集群中的每个节点都会单独开辟一个TCP通道,用于节点之间彼此通信,通信端口号在基础端口上加10000。

2) 每个节点在固定周期内通过特定规则选择几个节点发送Ping消息。

3) 接收到Ping消息的节点用pong消息作为响应。

  集群中每个节点通过一定规则挑选要通信的节点,每个节点可能知道全部节点,也可能仅知道部分节点,只要这些节点彼此可以正常通信,最终它们会达到一致的状态。当节点出故障、新节点加入、主从角色变化、槽信息变更等事件发生时,通过不断的ping/pong消息通信,经过一段时间后所有的节点都会知道整个集群全部节点的最新状态,从而达到集群状态同步的目的。

3.2 Gossip 消息

  Gossip协议的主要职责就是信息交换。信息交换的载体就是节点彼此发送的Gossip消息,了解这些消息有助于我们理解集群如何完成信息交换。

  常用的Gossip消息可分为:ping消息、pong消息、meet消息、fail消息等,它们的通信模式如图10-13所示。

□ meet消息:用于通知新节点加入。消息发送者通知接收者加入到当前集群,meet消息通信正常完成后,接收节点会加人到集群中并进行周期性的Ping、pong消息交换。

□ ping消息:集群内交换最频繁的消息,集群内每个节点每秒向多个其他节点发送ping消息,用于检测节点是否在线和交换彼此状态信息。ping消息发送封装了自身节点和部分其他节点的状态数据。

□ pong消息:当接收到ping、meet消息时,作为响应消息回复给发送方确认消息正常通信。pong消息内部封装了自身状态数据。节点也可以向集群内广播自身的pong消息来通知整个集群对自身状态进行更新。

□ fail消息:当节点判定集群内另一个节点下线时,会向集群内广播一个fail消息,其他节点接收到fail消息之后把对应节点更新为下线状态。具体细节将在后面10.6节 “故障转移”中说明。

  所有的消息格式划分为:消息头和消息体。消息头包含发送节点自身状态数据,接收节点根据消息头就可以获取到发送节点的相关数据,结构如下:

typedef struct {
char sig [4] ; /* 信号标示 */
uint32_t totlen ; /* 消息总长度 */
uint16_t ver; /* 协议版本 */
uint16_t type; /*消息类型,用于区分meet,ping,pong等消息 */
uint16_t count; / * 消息体包含的节点数量,仅用于 meet,ping,ping 消息类型 */
uint64_t currentEpoch; / * 当前发送节点的配置纪元 */
uint64_ t configEpoch; / * 主节点 / 从节点的主节点配置纪元 */
uint64_t offset ; /*复制偏移量 */
char sender [CLUSTER_NAMELEN] ; /* 发送节点的 nodeId */
unsigned char myslots [CLUSTER_SL0TS/8] ; /* 发送节点负责的槽信息 */
char slaveof[CLUSTER_NAMELEN]; / * 如果发送节点是从节点,记录对应主节点的 nodeld */
uint16_t port; /* 端口号 * /
uintl6_t flags; / * 发送节点标识,区分主从角色,是否下线等 */
unsigned char state ; / * 发送节点所处的集群状态 */
unsigned char mflags [3] ; / * 消 息 标 识 */
union clusterMsgData data /* 消息正文 */;
} clusterMsg;

  集群内所有的消息都采用相同的消息头结构clusterMsg,它包含了发送节点关键信息,如节点id、槽映射、节点标识(主从角色,是否下线)等。消息体在Redis内部采用clusterMsgData结构声明,结构如下:

union clusterMsgData {
/* ping,meet,pong 消息体 */
struct {
/* gossip 消息结构数组 */
clusterMsgDataGossip gossip [1];
} ping
/* FAIL消息体 */
struct {
clusterMsgDataFail about;
} fail ;
// …
};

  消息体clusterMsgData定义发送消息的数据,其中ping、meet、pong都采用 clusterMsgDataGossip 数组作为消息体数据,实际消息类型使用消息头的type属性区分。每个消息体包含该节点的多个clusterMsgDataGossip 结构数据,用于信息交换,结构如下:

typedef struct {
char nodename [CLUSTER_NAMELEN] ; / * 节点的 nodeId */
uint32_t ping_sent; / * 最后一次向该节点发送 ping 消息时间 */
uint32_t pong_received; /* 最后一次接收该节点 pong 消息时间 */
char ip[NET_IP_STR_LEN]; /* IP */
uint16_t port; /* port */
uintl6_t flags; /* 该节点标识, */
} clusterMsgDataGossip;

  当接收到Ping、meet消息时,接收节点会解析消息内容并根据自身的识别情况做出相应处理,对应流程如图10-14 所示。

  接收节点收到ping/meet消息时,执行解析消息头和消息体流程:

□ 解析消息头过程:消息头包含了发送节点的信息,如果发送节点是新节点且消息是meet类型,则加入到本地节点列表;如果是已知节点,则尝试更新发送节点的状态,如槽映射关系、主从角色等状态。

□ 解析消息体过程:如果消息体的clusterMsgDataGossip 数组包含的节点是新节 点,则尝试发起与新节点的meet握手流程;如果是已知节点,则根据cluster MsgDataGossip 中的flags字段判断该节点是否下线,用于故障转移。

  消息处理完后回复pong消息,内容同样包含消息头和消息体,发送节点接收到回复的pong消息后,采用类似的流程解析处理消息并更新与接收节点最后通信时间,完成一次消息通信。

3.3 节点选择

  虽然Gossip协议的信息交换机制具有天然的分布式特性,但它是有成本的。由于内部需要频繁地进行节点信息交换,而ping/pong消息会携带当前节点和部分其他节点的状态数据,势必会加重带宽和计算的负担。Redis集群内节点通信采用固定频率(定时任务每秒执行10次)。因此节点每次选择需要通信的节点列表变得非常重要。通信节点选择过多虽然可以做到信息及时交换但成本过高。节点选择过少会降低集群内所有节点彼此信息交换频率,从而影响故障判定、新节点发现等需求的速度。因此Redis集群的Gossip 协议需要兼顾信息交换实时性和成本开销,通信节点选择的规则如图10-15所示。

  根据通信节点选择的流程可以看出消息交换的成本主要体现在单位时间选择发送消息的节点数量和每个消息携带的数据量。

1.选择发送消息的节点数量

  集群内每个节点维护定时任务默认每秒执行10次,每秒会随机选取5个节点找出最久没有通信的节点发送Ping消息,用于保证Gossip信息交换的随机性。每100毫秒都会扫描本地节点列表,如果发现节点最近一次接受Pong消息的时间大于cluster_node_timeout/ 2,则立刻发送ping消息,防止该节点信息太长时间未更新。根据以上规则得出每个节点每秒需要发送ping消息的数量=1 + 10 * num(node.pong_received > cluster_node_tim eout/2),因此 cluster_node_timeout 参数对消息发送的节点数量影响非常大。当我们的带宽资源紧张时,可以适当调大这个参数,如从默认15秒改为30秒来降低带宽占用率。过度调大cluster_node_timeout会影响消息交换的频率从而影响故障转移、槽信息更新、新节点发现的速度。因此需要根据业务容忍度和资源消耗进行平衡。同时整个集群消息总交换量也跟节点数成正比。

2.消息数据量

  每个ping消息的数据量体现在消息头和消息体中,其中消息头主要占用空间的字段是myslots[CLUSTER_SLOTS/8],占用2KB,这块空间占用相对固定。消息体会携带一定数量的其他节点信息用于信息交换。具体数量见以下伪代码:

def get_wanted():
int total_size = size (cluster.nodes)
# 默认包含节点总量的1/10
int wanted = floor (total_size/10) ;
if wanted < 3: # 至少携带3个其他节点信息 wanted = 3 ; if wanted > total_size -2 :
# 最多包含 total_ size - 2 个
wanted = total_ size - 2;
return wanted;

  根据伪代码可以看出消息体携带数据量跟集群的节点数息息相关,更大的集群每次消息通信的成本也就更高,因此对于Redis集群来说并不是大而全的集群更好,对于集群规模控制的建议见之后10.7节“集群运维”。

4.1 伸缩原理

  Redis集群提供了灵活的节点扩容和收缩方案。在不影响集群对外服务的情况下,可以为集群添加节点进行扩容也可以下线部分节点进行缩容,如图10-16所示。

  从图10-16看出,Redis集群可以实现对节点的灵活上下线控制。其中原理可抽象为槽和对应数据在不同节点之间灵活移动。首先来看我们之前搭建的集群槽和数据与节点的对应关系,如图10-17所示。

  三个主节点分别维护自己负责的槽和对应的数据,如果希望加入1个节点实现集群扩容时,需要通过相关命令把一部分槽和数据迁移给新节点,如图10-18 所示。

  图中每个节点把一部分槽和数据迁移到新的节点6385,每个节点负责的槽和数据相比之前变少了从而达到了集群扩容的目的。这里我们故意忽略了槽和数据在节点之间迁移的细节,目的是想让读者重点关注在上层槽和节点分配上来,理解集群的水平伸缩的上层原理:集群伸缩=槽和数据在节点之间的移动,下面将介绍集群扩容和收缩的细节。

4.2 扩容集群

  扩容是分布式存储最常见的需求,Redis集群扩容操作可分为如下步骤:

1) 准备新节点。

2) 加入集群。

3) 迁移槽和数据。

1.准备新节点

  需要提前准备好新节点并运行在集群模式下,新节点建议跟集群内的节点配置保持一致,便于管理统一。准备好配置后启动两个节点命令如下:

redis-server conf/redis-6385.conf
redis-server conf/redis-6386.conf

启动后的新节点作为孤儿节点运行,并没有其他节点与之通信,集群结构如图10-19所示。

2.加入集群

  新节点依然采用cluster meet命令加入到现有集群中。在集群内任意节点执行clu ster meet命令让6385和 6386节点加人进来,命令如下:

127.0.0.1:6379> cluster meet 127.0.0.1 6385
127.0.0.1:6379> cluster meet 127.0.0.1 6386

  新节点加入后集群结构如图10-20所示。

  集群内新旧节点经过一段时间的ping/pong消息通信之后,所有节点会发现新节点并将它们的状态保存到本地。例如我们在6380节点上执行cluster nodes命令可以看到新节点信息,如下所示:

127.0.0.1:6380 > cluster ndoes
1a205dd8b2819a00dd1e8b6be40a8e2abe77b756 127.0.0.1:6385 master - 0 1469347800759 7 connected
475528b1bcf8e74d227104a6cf1bf70f00c24aae 127.0.0.1:6386 master - 0 14693477987438 connected

  新节点刚开始都是主节点状态,但是由于没有负责的槽,所以不能接受任何读写操作。对于新节点的后续操作我们一般有两种选择:

□ 为它迁移槽和数据实现扩容。

口 作为其他主节点的从节点负责故障转移。

  redis-trib.rb工具也实现了为现有集群添加新节点的命令,还实现了直接添加为从节点的支持,命令如下:

redis-trib.rb add-node new_host:new_port existing_host:existing_port --slave --master-id

  内部同样采用cluster meet命令实现加人集群功能。对于之前的加入集群操作,我们可以采用如下命令实现新节点加入:

redis-trib.rb add-node 127.0.0.1:6385 127.0.0.1:6379
redis-trib.rb add-node 127.0.0.1:6386 127.0.0.1:6379

  提示: 正式环境建议使用redis-trib.rb add-node命令加入新节点,该命令内部会执行新节点状态检查,如果新节点已经加入其他集群或者包含数据,则放弃集群加入操作并打印如下信息:\

[ERR] Node 127.0.0.1:6385 is not empty. Either the node already knows other
nodes (check with CLUSTER NODES) or contains some key in database 0.

  如果我们手动执行cluster meet命令加入已经存在于其他集群的节点,会造成被加入节点的集群合并到现有集群的情况,从而造成数据丢失和错乱,后果非常严重,线上谨慎操作。

3.迁移槽和数据

  加入集群后需要为新节点迁移槽和相关数据,槽在迁移过程中集群可以正常提供读写服务,迁移过程是集群扩容最核心的环节,下面详细讲解。

(1) 槽迁移计划

槽是Redis集群管理数据的基本单位,首先需要为新节点制定槽的迁移计划,确定原有节点的哪些槽需要迁移到新节点。迁移计划需要确保每个节点负责相似数量的槽,从而保证各节点的数据均匀。例如,在集群中加入6385节点,如图10-21所示。加入6385节点后,原有节点负责的槽数量从6380变为4096个。

槽迁移计划确定后开始逐个把槽内数据从源节点迁移到目标节点,如图10-22所示。

(2) 迁移数据

数据迁移过程是逐个槽进行的,每个槽数据迁移的流程如图10-23所示。

流程说明:

1) 对目标节点发送 cluster set slot {slot} importing {sourceNodeId}命令,让目标节点准备导入槽的数据。

2) 对源节点发送 cluster setslot {slot} migrating {targetNodeId}命令,让源节点准备迁出槽的数据。

3) 源节点循环执行 cluster getkeysinslot {slot} {count}命令,获取 count个属于槽{slot}的键。

4) 在源节点上执行migrate {targetlp} {targetPort} "" 0 {timeout} keys {keys…}  命令,把获取的键通过流水线(pipeline) 机制批量迁移到目标节点,批量迁移版本的migrate命令在Redis 3.0.6以上版本提供,之前的migrate命令只能单个键迁移。对于大量key的场景,批量键迁移将极大降低节点之间网络IO次数。

5) 重复执行步骤3)和步骤4)直到槽下所有的键值数据迁移到目标节点。

6) 向集群内所有主节点发送 cluster setslot {slot} node {targetNodeld}命令,通知槽分配给目标节点。为了保证槽节点映射变更及时传播,需要遍历发送给所有主节点更新被迁移的槽指向新节点。

根据以上流程,我们手动使用命令把源节点6379负责的槽4096迁移到目标节点6385中,流程如下:

1) 目标节点准备导入槽4096数据:

127.0. 0.1:6385>cluster setslot 4096 importing cfb28ef1deee4e0fa78da86abe5d24566744411e
OK

确认槽4096导入状态开启:

127.0.0.1:6385>cluster nodes
1a205dd8b2819a00dd1e8b6be40a8e2abe77b756 127.0.0.1:6385 myself,master - 0 0 7 connected
[4096-<-cfb28ef1deee4e0fa78da86abe5d24566744411e]

2) 源节点准备导出槽4096数据:

127.0.0.1:6379>cluster setslot 4096 migrating 1a205dd8b2819a00dd1e8b6be40a8e2abe77b756
OK

确认槽4096导出状态开启:

127.0.0.1:6379>cluster nodes
cfb28ef1deee4e0fa78da86abe5d24566744411e 127.0.0.1:6379 myself,master - 0 0 0 connected
0-5461 [4096->-1a205dd8b2819a00dd1e8b6be40a8e2abe77b756]

3) 批量获取槽4096对应的键,这里我们获取到3个处于该槽的键:

127.0.0.1:6379>cluster nodes
cfb28ef1deee4e0fa78da86abe5d24566744411e 127.0.0.1:6379 myself,master - 0 0 0 connected
0-5461 [4096->-1a205dd8b2819a00dd1e8b6be40a8e2abe77b756]

确认这三个键是否存在于源节点:

127.0.0.1:6379>mget key:test:5028 key:test:68253 key:test:79212
1) "value:5028"
2) "value:68253"
3) "value:79212"

批量迁移这3个键,migrate命令保证了每个键迁移过程的原子性:

127.0.0.1:6379>migrate 127.0.0.1 6385 "" 0 5000 keys key:test:5028 key:test:68253 key:test:79212

出于演示目的,我们继续查询这三个键,发现已经不在源节点中, Redis返回ASK转向错误, ASK转向负责引导客户端找到数据所在的节点,细节将在后面 10.5节“请求路由”中说明。

127.0.0.1:6379> mget key:test:5028 key:test:68253 key:test:79212
(error) ASK 4096 127.0.0.1:6385

通知所有主节点槽4096指派给目标节点6385:

127.0.0.1:6379>cluster setslot 4096 node 1a205dd8b2819a00dd1e8b6be40a8e2abe77b756
127.0.0.1:6380>cluster setslot 4096 node 1a205dd8b2819a00dd1e8b6be40a8e2abe77b756
127.0.0.1:6381>cluster setslot 4096 node 1a205dd8b2819a00dd1e8b6be40a8e2abe77b756
127.0.0.1:6385>cluster setslot 4096 node 1a205dd8b2819a00dd1e8b6be40a8e2abe77b756

确认源节点6379不再负责槽4096改为目标节点6385负责:

127.0.0.1:6379 > cluster nodes
cfb28ef1deee4e0fa78da86abe5d24566744411e 127.0.0.1:6379 myself ,master - 0 0 0 connected 0-4095 4097-5461
1a205dd8b2819a00dd1e8b6be40a8e2abe77b756 127.0.0.1:6385 master - 0 1469718011079 7 connected 4096

手动执行命令演示槽迁移过程,是为了让读者更好地理解迁移流程,实际操作时肯定涉及大量槽并且每个槽对应非常多的键。因此redis-trib提供了槽重分片功能,命令如下:

redis-trib.rb reshard host:port --from --to --slots --yes --timeout --pipeline

参数说明:

□ host:port:必传参数,集群内任意节点地址,用来获取整个集群信息。

□ --from:制定源节点的id,如果有多个源节点,使用逗号分隔,如果是all源节点变为集群内所有主节点,在迁移过程中提示用户输入。

□ --to: 需要迁移的目标节点的id, 目标节点只能填写一个,在迁移过程中提示用户输入。

□ --slots:需要迁移槽的总数量,在迁移过程中提示用户输入。

□ --yes:当打印出reshard执行计划时,是否需要用户输入yes确认后再执行reshard。

□ --timeout:控制每次migrate操作的超时时间,默认为60 000毫秒。

□ --pipeline:控制每次批量迁移键的数量,默认为10。

reshard命令简化了数据迁移的工作量,其内部针对每个槽的数据迁移同样使用之前的流程。我们已经为新节点6395迁移了一个槽4096,剩下的槽数据迁移使用redis-trib.rb 完成,命令如下:

#redis-trib.rb reshard 127.0.0.1:6379

Performing Cluster Check (using node 127.0.0.1:6379)
M: Cfb28ef1deee4e0fa78da86abe5d24566744411e 127.0.0.1:6379
slots: 0-4095,4097-5461 (5461 slots) master
1 additional replica(s)
M: 40b8d09d44294d2e23c7c768efc8fcd153446746 127.0.0.1:6381
slots: 10923-16383 (5461 slots) master
1 additionalreplica(s )
M: 8e41673d59c9568aa9d29fb174ce733345b3e8f1 127.0.0.1:6380
slots : 5462-10922 (5461 slots ) master
1 additional replica(s)
M: 1a205dd8b2819a00dd1e8b6be40a8e2abe77b756 127.0.0.1:6385
slots:4096 (1 slots) master
0 additional replica(s)
// …
[OK] All nodes agree about slots configuration.
Check for open slots …
Check slots coverage. . .
[OK] All 16384 slots covered.

打印出集群每个节点信息后,reshard命令需要确认迁移的槽数量,这里我们输入4096个:

How many slots do you want to move (from 1 to 16384)?4096

输入6385的节点ID作为目标节点,目标节点只能指定一个:

What is the receiving node ID? 1a205dd8b2819a00dd1e8b6be40a8e2abe77b756

之后输入源节点的ID, 这里分别输入节点6379、6380、6381三个节点ID最后用done表示结束:

Please enter all the source node IDs.
Type 'all' to use all the nodes as source nodes for the hash slots .
Type 'done' once you entered all the source nodes IDs.
Source node #1:cfb28ef1deee4e0fa78da86abe5d24566744411e
Source node #2:8e41673d59c9568aa9d29fb174ce733345b3e8f1
Source node #3:40b8d09d44294d2e23c7c768efcBfcd153446746
Source node #4:done

数据迁移之前会打印出所有的槽从源节点到目标节点的计划,确认计划无误后输入ye s执行迁移工作:

Moving slot 0 from cfb28ef1deee4e0fa78da86abe5d24566744411e
Moving slot 1365 from cfb28ef1deee4e0fa78da86abe5d24566744411e
Moving slot 5462 from 8e41673d59c9568aa9d29fb174ce733345b3e8f1
Moving slot 6826 from-8e41673d59c9568aa9d29fb174ce733345b3e8f1
Moving slot 10923 from 40b8d09d44294d2e23c7c768efc8fcd153446746
Moving slot 12287 from 40b8d09d44294d2e23c7c768efc8fcd153446746
Do you want to proceed with the proposed reshard plan (yes/no)? yes

redis-trib工具会打印出每个槽迁移的进度,如下:

Moving slot 0 from 127.0.0.1:6379 to 127.0.0.1:6385 . . . .
Moving slot 1365 from 127.0.0.1:6379 to 127.0.0.1:6385 ..
Moving slot 5462 from 127.0.0.1:6380 to 127.0.0.1:6385: . . . .
Moving slot 6826 from 127.0.0.1:6380 to 127.0.0.1:6385 ..
Moving slot 10923 from 127.0.0.1:6381 to 127.0.0.1:6385 ..
Moving slot 10923 from 127.0.0.1:6381 to 127.0.0.1:6385 ..

当所有的槽迁移完成后,reshard命令自动退出,执行cluster nodes命令检查节点和槽映射的变化,如下所示:

127.0.0.1:6379>cluster nodes
40622f9e7adc8ebd77fca0de9edfe691cb8a74fb 127.0.0.1:6382 slave cfb28efIdeee4e0fa
78da86abe5d24566744411e 0 1469779084518 3 connected
40b8d09d44294d2e23c7c768efc8fcd153446746 127.0.0.1:6381 master - 0
1469779085528 2 connected 12288-16383
4fa7eac4080f0b667ffeab9b87841da49b84a6e4 127.0.0.1:6384 slave 40b8d09d44294d2e2
3c7c768efc8fcd153446746 0 1469779087544 5 connected
be9485a6a729fc98c5151374bc30277e89a461d8 127.0.0.1:6383 slave 8e41673d59c9568aa
9d29fb174ce733345b3e8f1 0 1469779088552 4 connected
Cfb28ef1deee4e0fa78da86abe5d24566744411e 127.0.0.1:6379 myself,master - 0 0 0
connected 1366-4095 4097-5461
475528b1bcf8e74d227104a6cf1bf70f00c24aae 127.0.0.1:6386 master - 0
1469779086536 8 connected
8e41673d59c9568aa9d29fb174ce733345b3e8f1 127.0.0.1:6380 master - 0
1469779085528 1 connected 6827-10922
1a205dd8b2819a00dd1e8b6be40a8e2abe77b756 127.0.0.1: 6385 master - 0
1469779083513 9 connected 0-1365 4096 5462-6826 10923-12287

节点6385负责的槽变为: 0-1365 4096 5462-6826 10923-l2287。由于槽用于hash运算本身顺序没有意义,因此无须强制要求节点负责槽的顺序性。迁移之后建议使用redis-trib.rb rebalance命令检查节点之间槽的均衡性。命令如下:

# redis-trib.rb rebalance 127.0.0.1:6380

Performing Cluster Check (using node 127.0.0.1:6380)
[OK] All nodes agree about slots configuration.
Check for open slots …
Check slots coverage …
[OK] All 16384 slots covered.
*** No rebalancing needed! All nodes are within the 2.0% threshold.

可以看出迁移之后所有主节点负责的槽数量差异在2% 以内,因此集群节点数据相对均匀,无需调整。

(3) 添加从节点

扩容之初我们把6385、6386节点加人到集群,节点6385迁移了部分槽和数据作为主节点,但相比其他主节点目前还没有从节点,因此该节点不具备故障转移的能力。这时需要把 节点6386作为6385的从节点,从而保证整个集群的高可用。使用cluster replicate {masterNodeId}命令为主节点添加对应从节点,注意在集群模式下slaveof添加从节点操作不再支持。如下所示:

# redis-trib.rb rebalance 127.0.0.1:6380

Performing Cluster Check (using node 127.0.0.1:6380)
[OK] All nodes agree about slots configuration.
Check for open slots …
Check slots coverage …
[OK] All 16384 slots covered.
*** No rebalancing needed! All nodes are within the 2.0% threshold.

从节点内部除了对主节点发起全量复制之外,还需要更新本地节点的集群相关状态,查看节点6386状态确认已经变成6385节点的从节点:

127.0. 0.1:6386>cluster nodes
475528b1bcf8e74d227104a6cf1bf70f00c24aae 127.0.0.1:6386 myself,slave 1a205dd8b2
819a00dd1e8b6be40a8e2abe77b756 0 0 8 connected
1a205dd8b2819a00dd1e8b6be40a8e2abe77b756 127.0.0.1:6385 master - 0 1469779083513 9
connected 0-1365 4096 5462-6826 10923-12287

到此整个集群扩容完成,集群关系结构如图 10-24 所示。

4.3 收缩集群

  收缩集群意味着缩减规模,需要从现有集群中安全下线部分节点。安全下线节点流程如图10-25所示。

  流程说明:

1) 首先需要确定下线节点是否有负责的槽,如果是,需要把槽迁移到其他节点,保证节点下线后整个集群槽节点映射的完整性。

2) 当下线节点不再负责槽或者本身是从节点时,就可以通知集群内其他节点忘记下线节点,当所有的节点忘记该节点后可以正常关闭。

1.下线迁移槽

  下线节点需要把自己负责的槽迁移到其他节点,原理与之前节点扩容的迁移槽过程一致。例如我们把6381和6384节点下线,节点信息如下:

127.0.0.1:6381> cluster nodes
40b8d09d44294d2e23c7c768efc8fcd153446746 127.0.0.1:6381 myself,master - 0 0 2 connected
12288-16383
4fa7eac4080f0b667ffeab9b87841da49b84a6e4 127.0.0.1:6384 slave 40b8d09d44294d2e2
3c7c768efc8fcd153446746 0 1469894180780 5 connected

  6381是主节点,负责槽(12288-16383),6384是它的从节点,如图10-26所示。下线6381之前需要把负责的槽迁移到其他节点。

  收缩正好和扩容迁移方向相反,6381变为源节点,其他主节点变为目标节点,源节点需要把自身负责的4096个槽均匀地迁移到其他主节点上。这里直接使用redis-trib.rb reshard命令完成槽迁移。由于每次执行reshard命令只能有一个目标节点,因此需要执行3次 reshard命令,分别迁移1365、1365、1366个槽,如下所示:

# redis-trib.rb reshard 127.0.0.1:6381

Performing Cluster Check (using node 127.0.0.1:6381)
[OK] All 16384 slots covered.
How many slots do you want to move (from 1 to 16384)?1365
What is the receiving node ID? cfb28ef1deee4e0fa78da86abe5d24566744411e /*输入6379节点id作为目标节点*/
Please enter all the source node IDs.
Type 'all' to use all the nodes as source nodes for the hash slots.
Type 'done' once you entered all the source nodes IDs.
Source node #1:40b8d09d44294d2e23c7c768efc8fcd153446746 /* 源节点6381 id*/
Source node #2:done /* 输入 done 确认 * /
Do you want to proceed with the proposed reshard plan (yes/no)? yes

  槽迁移完成后,6379 节点接管了1365个槽12288 ~ 13652, 如下所示:

127.0.0.1:6379> cluster nodes
cfb28ef1deee4e0fa78da86abe5d24566744411e 127.0.0.1:6379 myself,master - 0 0 10 connected
1366-4095 4097-5461 12288-13652
40b8d09d44294d2e23c7c768efc8fcd153446746 127.0.0.1:6381 master - 0 1469895725227 2
connected 13653-16383

  继续把1365个槽迁移到节点6380:

# redis-trib.rb reshard 127.0.0.1:6381

Performing Cluster Check (using node 127.0.0.1:6381)
How many slots do you want to move (from 1 to 16384)? 1365
What is the receiving node ID? 8e41673d59c9568aa9d29fb174ce733345b3e8f1 /*6380 节点id作为目标节点 -*/
Please enter all the source node IDs.
Type 'all' to use all the nodes as source nodes for the hash slots.
Type ' done' once you entered all the source nodes IDs . .
Source node #1 :40b8d09d44294d2e23c7c768efc8fcd153446746
Source node #2:done
Do you want to proceed with the proposed reshard plan (yes/no)?yes

  完成后,6380节点接管了1365个槽13653 ~ 15017,如下所示:

127.0.0.1:6379> cluster nodes
40b8d09d44294d2e23c7c768efc8fcd153446746 127.0.0.1:6381 master - 0 1469896123295 2
connected 15018-16383
8e41673d59c9568aa9d29fb174ce733345b3e8f1 127.0.0.1:6380 master - 0 1469896125311 11
connected 6827-10922 13653-15017

  把最后的1366个槽迁移到节点6385中,如下所示:

# redis-trib.rb reshard 127.0.0.1:6381
How many slots do you want to move (from 1 to 16384)? 1366
What is the receiving node ID? 1a205dd8b2819a00dd1e8b6be40a8e2abe77b756 /*6385节点id作为目标节点.*/
Please enter all the source node IDs.
Type all ' to use all the nodes as source nodes for the hash slots.
Type 'done' once you entered all the source nodes IDs.
Source node #1:40b8d09d44294d2e23c7c768efc8fcd153446746
Source node #2:done
Do you want to proceed with the proposed reshard plan (yes/no)? yes

  到目前为止,节点6381所有的槽全部迁出完成,6381不再负责任何槽。状态如下所示:

127.0.0.1:6379> cluster nodes
40b8d09d44294d2e23c7c768efc8fcd153446746 127.0.0.1:6381 master - 0 1469896444768 2
connected
8e41673d59c9568aa9d29fb174ce733345b3e8f1 127.0.0.1:6380 master - 0 1469896443760 11
connected 6827-10922 13653-15017
1a205dd8b2819a00dd1e8b6be40a8e2abe77b756 127.0.0.1:6385 master - 0 1469896445777 12
connected 0-1365 4096 5462-6826 10923-12287 15018-16383
cfb28ef1deee4e0fa78da86abe5d24566744411e 127.0.0.1:6379 myself,master - 0 0 10 connected
1366-4095 4097-5461 12288-13652
be9485a6a729fc98c5151374bc30277e89a46148 127.0.0.1:6383 slave 8e41673d59c9568aa9d29fb174ce733345b3e8f1 0 1469896444264 11 connected

  下线节点槽迁出完成后,剩下的步骤需要让集群忘记该节点。

2.忘记节点

  由于集群内的节点不停地通过Gossip消息彼此交换节点状态,因此需要通过一种健壮的机制让集群内所有节点忘记下线的节点。也就是说让其他节点不再与要下线节点进行Gossip消息交换。Redis提供了 cluster forget {downNodeId}命令实现该功能,如图10-27所示。

  当节点接收到cluster forget {downNodeld}命令后,会把nodeld指定的节点加入到禁用列表中,在禁用列表内的节点不再发送Gossip消息。禁用列表有效期是60秒,超过60秒节点会再次参与消息交换。也就是说当第一次forget命令发出后,我们有60秒的时间让集群内的所有节点忘记下线节点。

  线上操作不建议直接使用cluster forget命令下线节点,需要跟大量节点命令交互,实际操作起来过于繁琐并且容易遗漏forget节点。建议使用redis-trib.rb del-node {host:port} {downNodeid}命令,内部实现的伪代码如下:

def delnode_cluster_cmd(downNode):
# 下线节点不允许包含 slots
if downNode.slots.length!= 0
exit 1
end

向集群内节点发送 cluster forget

for n in nodes:
if n .id == downNode.id:
# 不能对自己做 forget操作
continue;
# 如果下线节点有从节点则把从节点指向其他主节点
if n. replicate && n.replicate.nodeld == downNode.id:
# 指向拥有最少从节点的主节点
master = get_master_with_least _replicas{} ;
n.cluster( "replicate" , master.nodeid);
# 发送忘记节点命令
n.cluster('forget' , downNode.id)

节点关闭

downNode.shutdown() ;

  从伪代码看出del-node命令帮我们实现了安全下线的后续操作。当下线主节点具有从节点时需要把该从节点指向到其他主节点,因此对于主从节点都下线的情况,建议先下线从节点再下线主节点,防止不必要的全量复制。对于6381和6384节点下线操作,命令如下:

redis-trib.rb del-node 127.0.0.1:6379 4fa7eac4080f0b667ffeab9b87841da49b84a6e4 #从节点6384 id
redis-trib.rb del-node 127.0.0.1:6379 40b8d09d44294d2e23c7c768efc8fcdl53446746 #主节点6381 id

  节点下线后确认节点状态:

127.0.0.1:6379> cluster nodes
cfb28ef1deee4e0fa78da86abe5d24566744411e 127.0.0.1:6379 myself, master - 0 0 10 connected 1366-4095 4097-5461 12288-13652
be9485a6a729fc98c5151374bc30277e89a461d8 127.0.0.1:6383 slave 8e41673d59c9568aa9d29fb174ce733345b3e8f1 0 1470048035624 11 connected
475528b1bcf8e74d227104a6cf1bf70f00c24aae 127.0.0.1:6386 slave 1a205dd8b2819a00dd1e8b6be40a8e2abe77b756 0 1470048032604 12 connected
40622f9e7adc8ebd77fca0de9edfe691cb8a74fb 127.0.0.1:6382 slave cfb28ef1deee4e0fa78da86abe5d24566744411e 0 1470048035120 10 connected
8e41673d59c9568aa9d29fb174ce733345b3e8f1 127.0.0.1:6380 master - 0 1470048034617 11 connected 6827-10922 13653-15017
1a205dd8b2819a00dd1e8b6be40a8e2abe77b756 127.0.0.1:6385 master - 0 1470048033614 12 connected 0-1365 4096 5462-6826 10923-12287 15018-16383

  集群节点状态中已经不包含6384和 6381节点,到目前为止,我们完成了节点的安全下线,新的集群结构如图10-28所示。

  本节介绍了 Redis集群伸缩的原理和操作方式,它是Redis集群化之后最重要的功能,熟练掌握集群伸缩技巧后,可以针对线上的数据规模和并发量做到从容应对。

  目前我们已经搭建好Redis集群并且理解了通信和伸缩细节,但还没有使用客户端去操作集群。Redis集群对客户端通信协议做了比较大的修改,为了追求性能最大化,并没有采用代理的方式而是采用客户端直连节点的方式。因此对于希望从单机切换到集群环境的应用需要修改客户端代码。本节我们关注集群请求路由的细节,以及客户端如何高效地操作集群。

5.1 请求重定向

  在集群模式下,Redis接收任何键相关命令时首先计算键对应的槽,再根据槽找出所对应的节点,如果节点是自身,则处理键命令;否则回复MOVED重定向错误,通知客户端请求正确的节点。这个过程称为 moved 重定向,如图10-29所示。例如,在之前搭建的集群上执行如下命令:

127.0.0.1:6379> set key:test:1 value-1
OK

  执行set命令成功,因为键key:test:1对应槽5191正好位于6379节点负责的槽范围内,可以借助cluster keyslot {key}命令返回key所对应的槽,如下所示:

127.0.0.1:6379> cluster keyslot key:test:1
(integer) 5191
127.0.0.1:6379> cluster nodes
cfb28ef1deee4e0fa78da86abe5d24566744411e 127.0.0.1:6379 myself,master - 0 0 10 connected
1366-4095 4097-5461 12288-13652

  再执行以下命令,由于键对应槽是9252,不属于6379节点,则回复MOVED {slot} {ip} {port}格式重定向信息:

127.0.0.1:6379> set key:test:2 value-2
(error) MOVED 9252 127.0.0.1:6380
127.0.0.1:6379> cluster keyslot key:test:2
(integer) 9252

  重定向信息包含了键所对应的槽以及负责该槽的节点地址,根据这些信息客户端就可以向正确的节点发起请求。在6380节点上成功执行之前的命令:

127.0.0.1:6380> set key:test:2 value-2
OK

  使用 redis-cli命令时,可以加入-c参数支持自动重定向,简化手动发起重定向操作,如下所示:

#redis-cli -p 6379 -c
127.0.0.1:6379> set key:test:2 value-2
-> Redirected to slot [9252] located at 127.0.0.1:6380
OK

  redis-cli 自动帮我们连接到正确的节点执行命令,这个过程是在 redis-cli 内部维护,实质上是client端接到 MOVED 信息之后再次发起请求,并不在 Redis 节点中完成请求转发,如图10-30所示。

  节点对于不属于它的键命令只回复重定向响应,并不负责转发。熟悉 Cassandra 的用户希望在这里做好区分,不要混淆。正因为集群模式下把解析发起重定向的过程放到客户端完成,所以集群客户端协议相对于单机有了很大的变化。

  键命令执行步骤主要分两步:计算槽,查找槽所对应的节点。下面分别介绍。

1.计算槽

  Redis首先需要计算键所对应的槽。根据键的有效部分使用CRC16函数计算出散列值,再取对16383的余数,使每个键都可以映射到0 ~ 16383槽范围内。

2.槽节点查找

  Redis计算得到键对应的槽后,需要查找槽所对应的节点。集群内通过消息交换每个节点都会知道所有节点的槽信息,内部保存在clusterState结构中。

  具体伪代码这里不做展示,具体实现见如下:

  节点对于判定键命令是执行还是moved重定向,都是借助slots[ CLUSTER_SLOTS ]数组实现。根据 MOVED重定向机制,客户端可以随机连接集群内任一Redis获取键所在节点,这种客户端又叫Dummy(傀儡)客户端,它优点是代码实现简单,对客户端协议影响较小,只需要根据重定向信息再次发送请求即可。但是它的弊端很明显,每次执行键命令前都要到Redis上进行重定向才能找到要执行命令的节点,额外增加了IO开销,这不是Redis集群高效的使用方式。正因为如此通常集群客户端都采用另一种实现:Smart (智能> 客户端。

5.2 Smart客户端

1.smart客户端原理

  大多数开发语言的Redis客户端都采用Smart客户端支持集群协议,客户端如何选择见:http://redis.io/clients, 从中找出符合自己要求的客户端类库。Smart客户端通过在内部维护slot→node的映射关系,本地就可实现键到节点的查找,从而保证IO效率的最大化,而MOVED重定向负责协助Smart客户端更新slot→node映射。我们以Java的Jedis为例,说明Smart客户端操作集群的流程。

1) 首先在JedisCluster初始化时会选择一个运行节点,初始化槽和节点映射关系,使用cluster slots 命令完成,如下所示:

127.0.0.1:6379 > cluster slots
1) 1) (integer) 0 // 开始槽范围
2) (integer) 1365 // 结束槽范围
3) 1) "127.0.0.1" //主节点 ip
2) (integer) 6385 // 主节点地址
4) 1) "127.0.0.1” // 从节点 ip
2) (integer) 6386 / / 从节点端口
2) 1) (integer) 5462
2) (integer) 6826
3) 1) "127.0.0.1"
2) (integer) 6385
4) 1) "127.0.0.1 "
2) (integer) 6386

2) JedisCluster 解析cluster slots结果缓存在本地,并为每个节点创建唯一的JedisPool 连接池。映射关系在JedisCluster InfoCache类中。

3) JedisCluster执行键命令的过程有些复杂,但是理解这个过程对于开发人员分析定位问题非常有帮助,具体代码就不做展示。

键命令执行流程:

1) 计算slot并根据slots缓存获取目标节点连接,发送命令。

2) 如果出现连接错误,使用随机连接重新执行键命令,每次命令重试对redi­rections 参数减1。

3) 捕获到MOVED重定向错误,使用cluster slots命令更新slots缓存(renewSlotCache 方法)。

4) 重复执行1)~ 3)步,直到命令执行成功,或者当redirections<=0时抛出JedisClusterMaxRedirectionsException 异常。

整个流程如图10-31所示。

从命令执行流程中发现,客户端需要结合异常和重试机制时刻保证跟Redis集群的slots同步,因此Smart客户端相比单机客户端有了很大的变化和实现难度。了解命令执行流程后,下面我们对Smart客户端成本和可能存在的问题进行分析:

1) 客户端内部维护slots缓存表,并且针对每个节点维护连接池,当集群规模非常大时,客户端会维护非常多的连接并消耗更多的内存。

2) 使用Jedis操作集群时最常见的错误是:

throw new JedisClusterMaxRedirectionsException("Too many Cluster redirections?");

这经常会引起开发人员的疑惑,它隐藏了内部错误细节,原因是节点宕机或请求超时都会抛出JedisConnectionException,导致触发了随机重试,当重试次数耗尽抛出这个错误。

3) 当出现 JedisConnectionException 时,Jedis认为可能是集群节点故障需要随机重试来更新slots 缓存,因此了解哪些异常将抛出JedisConnectionException 变得非常重要,有如下几种情况会抛出 JedisConnectionException:

□ Jedis连接节点发生 socket 错误时抛出。

□ 所有命令/Lua脚本读写超时抛出。

□ JedisPool连接池获取可用Jedis对象超时抛出。

前两点都可能是节点故障需要通过JedisConnectionException 来更新 slots缓存,但是第三点没有必要,因Jedis2.8.1版本之后对于连接池的超时抛出 JedisException, 从而避免触发随机重试机制。

4) Redis集群支持自动故障转移,但是从故障发现到完成转移需要一定的时间,节点宕机期间所有指向这个节点的命令都会触发随机重试,每次收到 MOVED 重定向后会调用JedisClusterlnfoCache类的renewSlotCache方法。

思路为: 获得写锁后再执行cluster slots 命令初始化缓存,由于集群所有的键命令都会执getSlotPool方法计算槽对应节点,它内部要求读锁。 ReentrantReadWriteLock是读锁共享且读写锁互斥,从而导致所有的请求都会造成阻塞。对于并发量高的场景将极大地影响集群吞吐。这个现象称为cluster slots 风暴,有如下现象:

□ 重试机制导致IO通信放大问题。比如默认重试5次的情况,当抛出JedisC lusterMaxRedirectionsException异常时,内部最少需要9次IO通信:5次发送命令+2次ping命令保证随机节点正常+2次cluster slots命令初始化slots缓存。导致异常判定时间变长。

□ 个别节点操作异常导致频繁的更新slots缓存,多次调用cluster slots 命令,高并发时将过度消耗Redis节点资源,如果集群slot<->node映射庞大则cluster slots返回信息越多,问题越严重。

□ 频繁触发更新本地slots 缓存操作,内部使用了写锁,阻塞对集群所有的键命令调用。

针对以上问题在 Jedis 2.8.2 版本做了改进:

□ 当接收到JedisConnectionException时不再轻易初始化slots缓存,大幅降低内部IO次数。只有当重试次数到最后1次或者出现MovedDataException时才更新 slots操作,降低了 cluster slots命令调用次数。

□ 当更新slots缓存时,不再使用ping命令检测节点活跃度,并且使用redis covering变量保证同一时刻只有一个线程更新slots缓存,其他线程忽略,优化了写锁阻塞和cluster slots调用次数。

综上所述,Jedis2.8.2之后的版本,当出现JedisConnectionException时,命令发送次数变为5次:4次重试命令+1次cluster slots命令,同时避免了 clusterslots不必要的并发调用。

这里我们用大量篇幅介绍了Smart客户端Jedis与集群交互的细节,主要原因是针对于高并发的场景,这里是绝对的热点。集群协议通过Smart客户端全面高效的支持需要一个过程,因此用户在选择Smart客户端时要重点审核集群交互代码,防止线上踩坑。必要时可以自行优化修改客户端源码。

具体每个语言的客户端可以去对应官网及redis官网查找。

5.3 ASK重定向

1.客户端ASK重定向流程

  Redis集群支持在线迁移槽(slot)和数据来完成水平伸缩,当slot对应的数据从源节点到目标节点迁移过程中,客户端需要做到智能识别,保证键命令可正常执行。例如当一个slot 数据从源节点迁移到目标节点时,期间可能出现一部分数据在源节点,而另一部分在目标节点,如图10-32所示。

  当出现上述情况时,客户端键命令执行流程将发生变化,如下所示:

1) 客户端根据本地slots缓存发送命令到源节点,如果存在键对象则直接执行并返回结果给客户端。

2) 如果键对象不存在,则可能存在于目标节点,这时源节点会回复ASK重定向异常。格式如下:(error) ASK {slot} {targetIP} : {targetPort}。

3) 客户端从ASK重定向异常提取出目标节点信息,发送asking命令到目标节点打开客户端连接标识,再执行键命令。如果存在则执行,不存在则返回不存在信息。

ASK重定向整体流程如图10-33 所示。

  ASK与MOVED虽然都是对客户端的重定向控制,但是有着本质区别。ASK重定向说明集群正在进行slot 数据迁移,客户端无法知道什么时候迁移完成,因此只能是临时性的重定向,客户端不会更新slots缓存。但是MOVED重定向说明键对应的槽已经明确指定到新的节点,因此需要更新slots缓存。

2.节点内部处理

  为了支持ASK重定向,源节点和目标节点在内部的clusterState结构中维护当前正在迁移的槽信息,用于识别槽迁移情况,结构如下:

typedef struct clusterState {
clusterNode *myself; /* 自身节点 /
clusterNode * slots [CLUSTER_SLOTS] ; /* 槽和节点映射数组 */
clusterNode *migrating_slots_to[CLUSTER_SLOTS] ; /* 正在迁出的槽节点数组 */
clusterNode *importing_slots_from[CLUSTER_SLOTS] ; /* 正在迁入的槽节点数组 */
} clusterState ;

  节点每次接收到键命令时,都会根据clusterState内的迁移属性进行命令处理,如下所示:

□ 如果键所在的槽由当前节点负责,但键不存在则查找migrating_slots_to数组查看槽是否正在迁出,如果是返回ASK重定向。

□ 如果客户端发送asking命令打开了CLIENT_ASKING标识,则该客户端下次发送键命令时查找importing_slots_from数组获取clusterNode,如果指向自身则执行命令。

  需要注意的是,asking命令是一次性命令,每次执行完后客户端标识都会修改回原状态,因此每次客户端接收到ASK重定向后都需要发送asking命令。

□ 批量操作。ASK重定向对单键命令支持得很完善,但是,在开发中我们经常使用批量操作,如mget或 pipeline。当槽处于迁移状态时,批量操作会受到影响。

  综上所处,使用 smart 客户端批量操作集群时,需要评估mget/mset、Pipeline等方式在slot迁移场景下的容错性,防止集群迁移造成大量错误和数据丢失的情况。

  集群环境下对于使用批量操作的场景,建议优先使用Pipeline方式,在客户端实现对ASK重定向的正确处理,这样既可以受益于批量操作的IO优化,又可以兼容slot迁移场景。

  Redis集群自身实现了高可用。高可用首先需要解决集群部分失败的场景:当集群内少量节点出现故障时通过自动故障转移保证集群可以正常对外提供服务。本节介绍故障转移的细节,分析故障发现和替换故障节点的过程。

6.1 故障发现

  当集群内某个节点出现问题时,需要通过一种健壮的方式保证识别出节点是否发生了故障。Redis集群内节点通过ping/pong消息实现节点通信,消息不但可以传播节点槽信息,还可以传播其他状态如:主从状态、节点故障等。因此故障发现也是通过消息传播机制实现的,主要环节包括:主观下线(pfail)和客观下线(fail)。

□ 主观下线:指某个节点认为另一个节点不可用,即下线状态,这个状态并不是最终的故障判定,只能代表一个节点的意见,可能存在误判情况。

□ 客观下线:指标记一个节点真正的下线,集群内多个节点都认为该节点不可用,从而达成共识的结果。如果是持有槽的主节点故障,需要为该节点进行故障转移。

1.主观下线

  集群中每个节点都会定期向其他节点发送ping消息,接收节点回复pong消息作为响应。如果在cluster-node-timeout时间内通信一直失败,则发送节点会认为接收节点存在故障,把接收节点标记为主观下线(pfail) 状态。流程如图10-34所示。

流程说明:

1) 节点a 发送ping消息给节点b, 如果通信正常将接收到pong消息,节点a更新最近一次与节点b 的通信时间。

2) 如果节点a与节点b通信出现问题则断开连接,下次会进行重连。如果一直通信失败,则节点a 记录的与节点b 最后通信时间将无法更新。

3) 节点a内的定时任务检测到与节点b最后通信时间超高cluster-node-timeout时,更新本地对节点b的状态为主观下线(pfail)。

  主观下线简单来讲就是,当cluster-note-timeout时间内某节点无法与另一个节点顺利完成Ping消息通信时,则将该节点标记为主观下线状态。每个节点内的cluster State结构都需要保存其他节点信息,用于从自身视角判断其他节点的状态。结构关键属性如下:

typedef struct clusterState {
clusterNode *myself; /* 自身节点 /
dict *nodes;/* 当前集群内所有节点的字典集合,key为节点 ID, value为对应节点ClusterNode 结构 */
} clusterState;
字典nodes属性中的clusterNode 结构保存了节点的状态,关键属性如下:
typedef struct clusterNode {
int flags; /* 当前节点状态,如:主从角色,是否下线等 */
mstime_t ping_sent; / *最后一次与该节点发送 ping消息的时间*/
mstime_t pong_received; /* 最后一次接收到该节点 pong消息的时间 */
} clusterNode;

  其中最重要的属性是flags, 用于标示该节点对应状态,取值范围如下:

CLUSTER_NODE_MASTER 1 /* 当前为主节点 */
CLUSTER_NODE_SLAVE 2 /* 当前为从节点 */
CLUSTER_N0DE_PFAIL 4 /* 主观下线状态 */
CLUSTER_NODE_FAIL 8 /* 客观下线状态 */
CLUSTER_NODE_MYSELF 16 /* 表示自身节点 */
CLUSTER_N0DE_HANDSHAKE 32 / * 握手状态,未与其他节点进行消息通信 */
CLUSTER_N0DE_N0ADDR 64 /* 无地址节点,用于第一次meet 通信未完成或者通信失败 */
CLUSTER_NODE_MEET 128 /* 需要接受 m eet 消息的节点状态 */
CLUSTER_NODE_MIGRATE_TO 256 / * 该节点被选中为新的主节点状态 */

  使用以上结构,主观下线判断伪代码如下:

// 定时任务,默认每秒执行10次
def clusterCron( ) :
// . . . 忽略其他代码
for(node in server.cluster.nodes):
//忽略自身节点比较
if (node.flags == CLUSTER_NODE_MYSELF):
continue;
//系统当前时间
long now = mstime();
// 自身节点最后一次与该节点PING通信的时间差
long delay = now - node.ping_sent;
// 如果通信时间差超过 cluster_node_timeout,将该节点标记为 PFAIL ( 主观下线)
if (delay > server.cluster_node_timeout):
node, flags = CLUSTER_NODE_PFAIL;

  Redis集群对于节点最终是否故障判断非常严谨,只有一个节点认为主观下线并不能准确判断是否故障。例如图10-35的场景。节点6379与 6385通信中断,导致6379判断6385为主观下线状态,但是6380与 6385节点之间通信正常,这种情况不能判定节点6385发生故障。因此对于一个健壮的故障发现机制,需要集群内大多数节点都判断6385故障时,才能认为6385确实发生故障,然后为6385节点进行故障转移。而这种多个节点协作完成故障发现的过程叫做客观下线。

2.客观下线

  当某个节点判断另一个节点主观下线后,相应的节点状态会跟随消息在集群内传播。ping/pong消息的消息体会携带集群1/10的其他节点状态数据,当接受节点发现消息体中含有主观下线的节点状态时,会在本地找到故障节点的ClusterNode结构,保存到下线报告链表中。结构如下:

struct ClusterNode {/* 认为是主观下线的 ClusterNode 结构 */
list *fail_reports; / * 记录了所有其他节点对该节点的下线报告*/

};

  通过Gossip消息传播,集群内节点不断收集到故障节点的下线报告。当半数以上持有槽的主节点都标记某个节点是主观下线时。触发客观下线流程。这里有两个问题:

1) 为什么必须是负责槽的主节点参与故障发现决策?因为集群模式下只有处理槽的主节点才负责读写请求和集群槽等关键信息维护,而从节点只进行主节点数据和状态信息的复制。

2) 为什么半数以上处理槽的主节点?必须半数以上是为了应对网络分区等原因造成的集群分割情况,被分割的小集群因为无法完成从主观下线到客观下线这一关键过程,从而防止小集群完成故障转移之后继续对外提供服务。假设节点a标记节点b 为主观下线 ,一 段时间后节点a通过消息把节点b的状态发送到其他节点,当节点c接受到消息并解析出消息体含有节点b的pfail状态时,会触发客观下线流程,如图10-36所示。

流程说明:

1) 当消息体内含有其他节点的Pfail状态会判断发送节点的状态,如果发送节点是主节点则对报告的pfail状态处理,从节点则忽略。

2) 找到pfail对应的节点结构,更新ClusterNode内部下线报告链表。

3) 根据更新后的下线报告链表告尝试进行客观下线。

  这里针对维护下线报告和尝试客观下线逻辑进行详细说明。

(1) 维护下线报告链表

每个节点ClusterNode结构中都会存在一个下线链表结构,保存了其他主节点针对当前节点的下线报告,结构如下:

typedef struct clusterNodeFailReport {
struct ClusterNode *node; / * 报告该节点为主观下线的节点*/
mstime_t time; / *最近收到下线报告的时间 */
} clusterNodeFailReport;

下线报告中保存了报告故障的节点结构和最近收到下线报告的时间,当接收到fail状态时,会维护对应节点的下线上报链表,伪代码如下:

def clusterNodeAddFailureReport(clusterNode failNode, clusterNode senderNode):
//获取故障节点的下线报告链表
list report_list=failNode_fail_reports;
//查找发送节点的下线报告是否存在
for(clusterNodeFailReport report : report_list):
//存在发送节点的下线报告上报
if(senderNode == report.node):
//更新下线报告时间
report.time = now();
return 0;
//如果下线报告不存在,插入新的下线报告
report_list.add(new clusterNodeFailReport(senderNode,now ( } ) ) ;
return 1;

每个下线报告都存在有效期,每次在尝试触发客观下线时,都会检测下线报告是否过期,对于过期的下线报告将被删除。如果在cluster-node-time* 2 的时间内该下线报告没有得到更新则过期并删除,伪代码如下:

def clusterNodeCleanupFailureReports(clusterNode node) :
list report_list = node.fail_reports;
long maxtime = server.cluster_node_timeout * 2;
long now = now();
for (clusterNodeFailReport report:report_list) :
// 如果最后上报过期时间大于 cluster_node_timeout * 2 则删除
if(now - report.time > maxtime):
report_list.del(report) ;

下线报告的有效期限是server.cluster_node_timeout * 2, 主要是针对故障误报的情况。例如节点A 在上一小时报告节点B 主观下线,但是之后又恢复正常。现在又有其他节点上报节点B 主观下线,根据实际情况之前的属于误报不能被使用。

提示: 如果在cluster-node-time * 2 时间内无法收集到一半以上槽节点的下线报告,那么之前的下线报告将会过期,也就是说主观下线上报的速度追赶不上下线报告过期的速度,那么故障节点将永远无法被标记为客观下线从而导致故障转移失败。因此不建议将cluster-node-time设置得过小。

(2) 尝试客观下线

集群中的节点每次接收到其他节点的pfail状态,都会尝试触发客观下线,流程如图 10-37所示。

流程说明:

1) 首先统计有效的下线报告数量,如果小于集群内持有槽的主节点总数的一半则退出。

2) 当下线报告大于槽主节点数量一半时,标记对应故障节点为客观下线状态。

3) 向集群广播一条fail消息,通知所有的节点将故障节点标记为客观下线,fail消息的消息体只包含故障节点的ID。

使用伪代码分析客观下线的流程,如下所示:

def markNodeAsFailinglfNeeded(clusterNode failNode) {
//获取集群持有槽的节点数量
int slotNodeSize = getSlotNodeSize( ) ;
//主观下线节点数必须超过槽节点数量的一半
int needed_quorum = (slotNodeSize / 2) + 1;
// 统计 failNode 节点有效的下线报告数量(不包括当前节点)
int failures = clusterNodeFailureReportsCount(failNode);
// 如果当前节点是主节点,将当前节点计累加到 failures
if (nodelsMaster(myself) ) :
failures++ ;
//下线报告数量不足槽节点的一半退出
if (failures < needed_quorum):
return;
// 将改节点标记为客观下线状态(fail)
failNode.flags = REDIS_N0DE_FAIL;
//更新客观下线的时间
failNode.fail_time = mstime();
// 如果当前节点为主节点,向集群广播对应节点的 fail 消息
if (nodelsMaster(myself))
clusterSendFail(failNode);

广播fail消息是客观下线的最后一步,它承担着非常重要的职责:

□ 通知集群内所有的节点标记故障节点为客观下线状态并立刻生效。

□ 通知故障节点的从节点触发故障转移流程。

需要理解的是,尽管存在广播fail消息机制,但是集群所有节点知道故障节点进入客观下线状态是不确定的。比如当出现网络分区时有可能集群被分割为一大一小两个独立集群中。大的集群持有半数槽节点可以完成客观下线并广播fail消息,但是小集群无法接收到fail消息,如图10-38所示。但是当网络恢复后,只要故障节点变为客观下线,最终总会通过Gossip消息传播至集群的所有节点。

提示: 分区会导致分割后的小集群无法收到大集群的fail消息,因此如果故障节点所有的从节点都在小集群内将导致无法完成后续故障转移,因此部署主从结构时需要根据自身机房/机架拓扑结构,降低主从被分区的可能性。

6.2 故障恢复

  故障节点变为客观下线后,如果下线节点是持有槽的主节点则需要在它的从节点中选出一个替换它,从而保证集群的高可用。下线主节点的所有从节点承担故障恢复的义务,当从节点通过内部定时任务发现自身复制的主节点进入客观下线时,将会触发故障恢复流程,如图10-39所示。

1.资格检查

  每个从节点都要检查最后与主节点断线时间,判断是否有资格替换故障的主节点。如果从节点与主节点断线时间超过 cluster-node-time * cluster-slave-validity-factor,则当前从节点不具备故障转移资格。参数cluster-slave-validity-factor用于从节点的有效因子,默认为10。

2.准备选举时间

当从节点符合故障转移资格后,更新触发故障选举的时间,只有到达该时间后才能执行后续流程。故障选举时间相关字段如下:

struct clusterState {
mstime_t failover_auth_time; / * 记录之前或者下次将要执行故障选举时间 */
int failover_auth_rank ; /* 记录当前从节点排名 */
}

  这里之所以采用延迟触发机制,主要是通过对多个从节点使用不同的延迟选举时间来支持优先级问题。复制偏移量越大说明从节点延迟越低,那么它应该具有更高的优先级来替换故障主节点。优先级计算伪代码如下:

def clusterGetSlaveRank():
int rank = 0;
//获取从节点的主节点
ClusteRNode master = myself.slaveof;
//获取当前从节点复制偏移量
long myoffset = replicationGetSlaveOffset( ) ;
//跟其他从节点复制偏移量对比
for (int j = 0 ; j < m aster.slaves.length; j++ ) : //rank 表示当前从节点在所有从节点的复制偏移量排名,为 0 表示偏移量最大 . if (master.slaves [j] != myself && master.slaves[j].repl_offset > myoffset):
rank++;
return rank;
}

使用之上的优先级排名,更新选举触发时间,伪代码如下:

def updateFailoverTime( ) :
// 默认触发选举时间:发现客观下线后一秒内执行。
server.cluster.failover_auth_time = now() + 500 + random() % 500;
//获取当前从节点排名
int rank = clusterGetSlaveRank( ) ;
long added_delay = rank * 1000;
// 使用 added_delay 时间累加到 failover_auth_time中
server.cluster.failover_auth_time += added_delay;
//更新当前从节点排名
server.cluster.failover_auth_rank = rank;

  所有的从节点中复制偏移量最大的将提前触发故障选举流程,如图10-40所示。

  主节点b进入客观下线后,它的三个从节点根据自身复制偏移量设置延迟选举时间,如复制偏移量最大的节点slaveb-1延迟1秒执行,保证复制延迟低的从节点优先发起选举。

3.发起选举

当从节点定时任务检测到达故障选举时间(fail over_auth_time) 到达后,发起选举流程如下:

(1) 更新配置纪元

配置纪元是一个只增不减的整数,每个主节点自身维护一个配置纪元(clusterNode.configEpoch) 标示当前主节点的版本,所有主节点的配置纪元都不相等,从节点会复制主节点的配置纪元。整个集群又维护一个全局的配置纪元(clusterState. currentEpoch),用于记录集群内所有主节点配置纪元的最大版本。执行cluster info命令可以查看配置纪元信息:

127.0.0.1:6379> cluster info
cluster_current_epoch: 15 //整个集群最大配置纪元
cluster_my_epoch: 13 //当前主节点配置纪元

配置纪元会跟随ping/pong消息在集群内传播,当发送方与接收方都是主节点且配置纪元相等时代表出现了冲突,nodeId更大的一方会递增全局配置纪元并赋值给当前节点来区分冲突,伪代码如下:

def clusterHandleConfigEpochCollision (clusterNode sender ) :
if (sender.configEpoch != myself .configEpoch || !nodelsMaster(sender) || !nodeIsMaster (myself) ) :
return;
// 发送节点的 nodeld 小于自身节点 nodeld 时忽略
if (sender.nodeld <= myself.nodeld ):
return
//更新全局和自身配置纪元
server.cluster.currentEpoch++;
myself.configEpoch = server.cluster.currentEpoch;

配置纪元的主要作用:

□ 标示集群内每个主节点的不同版本和当前集群最大的版本。

□ 每次集群发生重要事件时,这里的重要事件指出现新的主节点(新加入的或者由从节点转换而来),从节点竞争选举。都会递增集群全局的配置纪元并赋值给相关主节点,用于记录这一关键事件。

□ 主节点具有更大的配置纪元代表了更新的集群状态,因此当节点间进行ping/pong消息交换时,如出现slots等关键信息不一致时,以配置纪元更大的一方为准,防止过时的消息状态污染集群。

配置纪元的应用场景有:

□ 新节点加入。

□ 槽节点映射冲突检测。

□ 从节点投票选举冲突检测

之前在通过cluster setslot 命令修改槽节点映射时,需要确保执行请求的主节点本地配置纪元(configEpoch) 是最大值,否则修改后的槽信息在消息传播中不会被拥有更高的配置纪元的节点采纳。由于Gossip通信机制无法准确知道当前最大的配置纪元在哪个节点,因此在槽迁移任务最后的cluster setslot {slot} node{nodeId} 命令需要在全部主节点中执行一遍。

从节点每次发起投票时都会自增集群的全局配置纪元,并单独保存在clusterState.failover_auth_epoch变量中用于标识本次从节点发起选举的版本。

(2) 广播选举消息

在集群内广播选举消息( failover_auth_request),并记录已发送过消息的状态,保证该从节点在一个配置纪元内只能发起一次选举。消息内容如同ping消息只是将type类型变为 FAILOVER_AUTH_REQUEST。

4.选举投票

  只有持有槽的主节点才会处理故障选举消息(FAILOVER_AUTH__REQUEST),因为每个持有槽的节点在一个配置纪元内都有唯一的一张选票,当接到第一个请求投票的从节点消息时回复FAILOVER_AUTH_ACK消息作为投票,之后相同配置纪元内其他从节点的选举消息将忽略。

  投票过程其实是一个领导者选举的过程,如集群内有N个持有槽的主节点代表有N张选票。由于在每个配置纪元内持有槽的主节点只能投票给一个从节点,因此只能有一个从节点获得N2+1的选票,保证能够找出唯一的从节点。

  Redis集群没有直接使用从节点进行领导者选举,主要因为从节点数必须大于等于3 个才能保证凑够N/2 + 1个节点,将导致从节点资源浪费。使用集群内所有持有槽的主节点进行领导者选举,即使只有一个从节点也可以完成选举过程。

  当从节点收集到N/2+1个持有槽的主节点投票时,从节点可以执行替换主节点操作,例如集群内有5 个持有槽的主节点,主节点b 故障后还有4 个,当其中一个从节点收集到3 张投票时代表获得了足够的选票可以进行替换主节点操作,如图10-41所示。

提示: 主节点也算在投票数内,假设集群内节点规模是3主3从,其中有2个主节点部署在一台机器上,当这台机器宕机时,由于从节点无法收集到3/2+1个主节点选票将导致故障转移失败。这个问题也适用于故障发现环节。因此部署集群时所有主节点最少需要部署在3 台物理机上才能避免单点问题。

投票作废:每个配置纪元代表了一次选举周期,如果在开始投票之后的cluster-node-timeout*2 时间内从节点没有获取足够数量的投票,则本次选举作废。从节点对配置纪元自增并发起下一轮投票,直到选举成功为止。

5.替换主节点

  当从节点收集到足够的选票之后,触发替换主节点操作:

1) 当前从节点取消复制变为主节点。

2) 执行clusterDelSlot操作撤销故障主节点负责的槽,并执行clusterAddSlot把这些槽委派给自己。

3) 向集群广播自己的pong消息,通知集群内所有的节点当前从节点变为主节点并接管了故障主节点的槽信息。

6.3 故障转移时间

  在介绍完故障发现和恢复的流程后,这时我们可以估算出故障转移时间:

1) 主观下线(pfail)识别时间=cluster-node-timeout。

2) 主观下线状态消息传播时间<=cluster-node-timeout/2。消息通信机制对超过cluster-node-timeout/2 未通信节点会发起ping消息,消息体在选择包含哪些节点时会优先选取下线状态节点,所以通常这段时间内能够收集到半数以上主节点的pfail报告从而完成故障发现。

3) 从节点转移时间<= 1000毫秒。由于存在延迟发起选举机制,偏移量最大的从节点会最多延迟1秒发起选举。通常第一次选举就会成功,所以从节点执行转移时间在1秒以内。

  根据以上分析可以预估出故障转移时间,如下:

failover-time ( 毫秒)≤ cluster-node-timeout + cluster-node-timeout/2 + 1000

  因此,故障转移时间跟cluster-node-timeout参数息息相关,默认15秒。配置时可以根据业务容忍度做出适当调整,但不是越小越好,下一节的带宽消耗部分会进一步说明。

6.4 故障转移演练

  到目前为止介绍了故障转移的主要细节,下面通过之前搭建的集群模拟主节点故障场景,对故障转移行为进行分析。使用kill -9强制关闭主节点6385进程,如图10-42所示。

  确认集群状态:

127.0.0.1:6379> cluster nodes
1a205dd8b2819a00dd1e8b6be40a8e2abe77b756 127.0.0.1:6385 master - 0 1471877563600 16
connected 0-1365 5462-6826 10923-12287 15018-16383
40622f9e7adc8ebd77fca0de9edfe691cb8a74fb 127.0.0.1:6382 slave cfb28ef1deee4e0fa78da
86abe5d24566744411e 0 1471877564608 13 connected
8e41673d59c9568aa9d29fb174ce733345b3e8f1 127.0.0.1:6380 master - 0 1471877567129 11
connected 6827-10922 13653-15017
475528b1bcf8e74d227104a6cf1bf70f00c24aae 127.0.0.1:6386 slave 1a205dd8b2819a00dd1e8
b6be40a8e2abe77b756 0 1471877569145 16 connected
cfb28ef1deee4e0fa78da86abe5d24566744411e 127.0.0.1:6379 my self, master - 0 0 13
connected 1366-5461 12288-13652
be9485a6a729fc98c5151374bc30277e89a461d8 127.0.0.1:6383 slave 8e41673d59c9568aa9
d29fb174ce733345b3e8f1 0 1471877568136 11 connected

强制关闭6385进程:

# ps -ef | grep redis-server | grep 6385
501 1362 1 0 10:50 0:11.65 redis-server *:6385 [cluster]

kill -9 1362

日志分析如下:

□ 从节点6386与主节点6385复制中断,日志如下:

==> redis-6386.log <==

Connection with master lost .

Caching the disconnected master state.

Connecting to MASTER 127.0.0.1:6385

MASTER <-> SLAVE sync started

Error condition on socket for SYNC: Connection refused

□ 6379和 6380两个主节点都标记6385为主观下线,超过半数因此标记为客观下线状态,打印如下日志:

==> redis-6380.log <==

Marking node 1a205dd8b2819a00dd1e8b6be40a8e2abe77b756 as failing (quorum reached).

==> redis-6379.log <==

Marking node 1a205dd8b2819a00dd1e8b6be40a8e2abe77b756 as failing (quorum reached).

□ 从节点识别正在复制的主节点进人客观下线后准备选举时间,日志打印了选举延迟964毫秒之后执行,并打印当前从节点复制偏移量。

==> redis-6386.log <==

Start of election delayed for 964 milliseconds (rank #0, offset 1822).

□ 延迟选举时间到达后,从节点更新配置纪元并发起故障选举。

==> redis-6386.log <==
1364:S 22 Aug 23:12:25.064 # Starting a failover election for epoch 17.

□ 6379和6380主节点为从节点6386投票,日志如下:

==> redis-6380.log <==

Failover auth granted to 475528b1bcf8e74d227104a6cf1bf70f00c24aae for epoch 17

==> redis-6379.log <==

Failover auth granted to 475528b1bcf8e74d227104a6cf1bf70f00c24aae for epoch 17

□ 从节点获取2 个主节点投票之后,超过半数执行替换主节点操作,从而完成故障转移:

==> redis-6386.log <==

Failover election won: I'm the new master.

configEpoch set to 17 after successful failover

成功完成故障转移之后,我们对已经出现故障节点6385进行恢复,观察节点状态是否正确:

1) 重新启动故障节点6385。

#redis-server conf/redis-6385.conf

2) 6385节点启动后发现自己负责的槽指派给另一个节点,则以现有集群配置为准,变为新主节点6386的从节点,关键日志如下:

# I have keys for slot 4096, but the slot is assigned to another node. Setting it to importing state.

Configuration change detected.Reconfiguring myself as a replica of 475528b1bcf8e74d227104a6cf1bf70f00c24aae

3) 集群内其他节点接收到6385发来的ping消息,清空客观下线状态:

==> redis-6379.log <== * Clear FAIL state for node 1a205dd8b2819a00dd1e8b6be40a8e2abe77b756:master without slots is reachable again. ==> redis-6380.log <== * Clear FAIL state for node 1a205dd8b2819a00dd1e8b6be40a8e2abe77b756:master without slots is reachable again. ==> redis-6382.log <== * Clear FAIL state for node 1a205dd8b2819a00dd1e8b6be40a8e2abe77b756:master without slots is reachable again. ==> redis-6383.log <== * Clear FAIL state for node 1a205dd8b2819a00dd1e8b6be40a8e2abe77b756:master without slots is reachable again. ==> redis-6386.log <==
* Clear FAIL state for node 1a205dd8b2819a00dd1e8b6be40a8e2abe77b756: master without slots is reachable again.

4) 6385节点变为从节点,对主节点6386发起复制流程:

==> redis-6385.log <== * MASTER <-> SLAVE sync: Flushing old data
* MASTER <-> SLAVE sync: Loading DB in memory
* MASTER <-> SLAVE sync: Finished with success

5) 最终集群状态如图10-43所示。

  Redis集群由于自身的分布式特性,相比单机场景在开发和运维方面存在一些差异。本节我们关注于常见的问题进行分析定位。

7.1 集群完整性

为了保证集群完整性,默认情况下当集群16384个槽任何一个没有指派到节点时整个集群不可用。执行任何键命令返回(error) CLUSTERDOWN Hash slot not served错误。这是对集群完整性的一种保护措施,保证所有的槽都指派给在线的节点。但是当持有槽的主节点下线时,从故障发现到自动完成转移期间整个集群是不可用状态,对于大多数业务无法容忍这种情况,因此建议将参数cluster-require-full-coverage配置为no, 当主节点故障时只影响它负责槽的相关命令执行,不会影响其他主节点的可用性。

7.2 带宽消耗

  集群内Gossip消息通信本身会消耗带宽,官方建议集群最大规模在1000以内,也是出于对消息通信成本的考虑,因此单集群不适合部署超大规模的节点。在之前节点通信小节介绍到,集群内所有节点通过ping/pong消息彼此交换信息,节点间消息通信对带宽的消耗体现在以下几个方面:

□ 消息发送频率:跟cluster-node-timeout密切相关,当节点发现与其他节点最后通信时间超过cluster-node-timeout/2 时会直接发送ping消息。

□ 消息数据量:每个消息主要的数据占用包含:slots槽数组(2KB空间)和整个集群1/10的状态数据(10个节点状态数据约1KB)。

□ 节点部署的机器规模:机器带宽的上线是固定的,因此相同规模的集群分布的机器越多每台机器划分的节点越均匀,则集群内整体的可用带宽越高。

  例如,一个总节点数为200的Redis集群,部署在20台物理机上每台划分10个节点,cluster-node-timeout采用默认15秒,这时ping/pong消息占用带宽达到25Mb。如果把 cluster-node-timeout设为20,对带宽的消耗降低到15Mb以下。

集群带宽消耗主要分为:读写命令消耗+ Gossip消息消耗。因此搭建Redis集群时需要根据业务数据规模和消息通信成本做出合理规划:

1) 在满足业务需要的情况下尽量避免大集群。同一个系统可以针对不同业务场景拆分使用多套集群。这样每个集群既满足伸缩性和故障转移要求,还可以规避大规模集群的弊端。如本书作者维护的一个推荐系统,根据数据特征使用了 5 个 Redis集群,每个集群节点规模控制在100以内。

2) 适度提高cluster-node-timeout降低消息发送频率,同时cluster-node-timeout还影响故障转移的速度 ,因此需要根据自身业务场景兼顾二者的平衡。

3) 如果条件允许集群尽量均勻部署在更多机器上。避免集中部署,如集群有60个节点,集中部署在3 台机器上每台部署20个节点,这时机器带宽消耗将非常严重。

7.3 Pub/Sub广播问题

  Redis在 2.0版本提供了 Pub/Sub (发布/订阅)功能,用于针对频道实现消息的发布和订阅。但是在集群模式下内部实现对所有的publish命令都会向所有的节点进行广播,造成每条publish数据都会在集群内所有节点传播一次,加重带宽负担,如图10-44所示:通过命令演示Pub/Sub广播问题,如下所示:

1) 对集群所有主从节点执行subscribe命令订阅 cluster_pub_spread 频道,用于验证集群是否广播消息:

127.0.0.1:6379> subscribe cluster_pub_spread
127.0.0.1:6380> subscribe cluster_pub_spread
127.0.0.1:6382> subscribe cluster_pub_spread
127.0.0.1:6383> subscribe cluster_pub_spread
127.0.0.1:6385> subscribe cluster_pub_spread
127.0.0.1:6386> subscribe cluster_pub_spread

2) 在6379节点上发布频道为cluster_pub_spread的消息:

127.0.0.1:6379> publish cluster_pub_spread message_body_1

3) 集群内所有的节点订阅客户端全部收到了消息:

127.0.0.1:6380> subscribe cluster_pub_spread
1) "message"
2) "cluster_pub_spread"
3) "message_body_1
127.0.0.1:6382> subscribe cluster_pub_spread
1) "message"
2) "cluster_pub_spread"
3) "message_body_1

针对集群模式下publish广播问题,需要引起开发人员注意,当频繁应用Pub/Sub功能时应该避免在大量节点的集群内使用,否则会严重消耗集群内网络带宽。针对这种情况建议使用sentinel结构专门用于Pub/Sub功能,从而规避这一问题。

7.4 集群倾斜

  集群倾斜指不同节点之间数据量和请求量出现明显差异,这种情况将加大负载均衡和开发运维的难度。因此需要理解哪些原因会造成集群倾斜,从而避免这一问题。

1.数据倾斜

  数据倾斜主要分为以下几种:  .

□ 节点和槽分配严重不均。

□ 不同槽对应键数量差异过大。

口 集合对象包含大量元素。

□  内存相关配置不一致。

1) 节点和槽分配严重不均。针对每个节点分配的槽不均的情况,可以使用 redis-trib.rb info {host:ip} 进行定位,命令如下:

#redis-trib.rb info 127.0.0.1:6379
127.0.0.1:6379 (cfb28ef1 …) -> 33348 keys | 5461 slots | 1 slaves.
127.0.0.1:6380 (8e41673d…) -> 33391 keys | 5461 slots | 1 slaves.
127.0.0.1:6386 (475528b1…) -> 33263 keys | 5462 slots | 1 slaves.
[OK] 100002 keys in 3 masters.
6.10 keys per slot on average.

以上信息列举出每个节点负责的槽和键总量以及每个槽平均键数量。当节点对应槽数量不均匀时,可以使用redis-trib.rb rebalance命令进行平衡:

#redis-trib.rb rebalance 127.0.0.1:6379
[OK] All 16384 slots covered.
*** No rebalancing needed! All nodes are within the 2.0% threshold.

2) 不同槽对应键数量差异过大。键通过CRC16哈希函数映射到槽上,正常情况下槽内键数量会相对均匀。但当大量使用hash_tag时,会产生不同的键映射到同一个槽的情况。特别是选择作为hash_tag的数据离散度较差时,将加速槽内键数量倾斜情况。通过命令:cluster countkeysinslot {slot}可以获取槽对应的键数量,识别出哪些槽映射了过多的键。再通过命令cluster getkey sin slot {slot} {count}循环迭代出槽下所有的键。从而发现过度使用hash_tag的键。

3) 集合对象包含大量元素。对于大集合对象的识别可以使用redis-cli --bigkeys命令识别。找出大集合之后可以根据业务场景进行拆分。同时集群槽数据迁移是对键执行 migrate 操作完成,过大的键集合如几百兆,容易造成migrate命令超时导致数据迁移失败。

4) 内存相关配置不一致。内存相关配置指hash-max-ziplist-value、set-max-intset-entries等压缩数据结构配置。当集群大量使用 hash、set等数据结构时,如果内存压缩数据结构配置不一致,极端情况下会相差数倍的内存,从而造成节点内存量倾斜。

2.请求倾斜

  集群内特定节点请求量/流量过大将导致节点之间负载不均,影响集群均衡和运维成本。常出现在热点键场景,当键命令消耗较低时如小对象的get、set、incr等,即使请求量差异较大一般也不会产生负载严重不均。但是当热点键对应高算法复杂度的命令或者是大对象操作如hgetall、smembers等,会导致对应节点负载过高的情况。避免方式如下:

1) 合理设计键,热点大集合对象做拆分或使用hmget替代hgetall避免整体读取。

2) 不要使用热键作为hash_tag,避免映射到同一槽。

3) 对于一致性要求不高的场景,客户端可使用本地缓存减少热键调用。

7.5 集群读写分离

1.只读连接

  集群模式下从节点不接受任何读写请求,发送过来的键命令会重定向到负责槽的主节点上 (其中包括它的主节点)。当需要使用从节点分担主节点读压力时,可以使用readonly命令打开客户端连接只读状态。之前的复制配置 slave-read-only 在集群模式下无效。当开启只读状态时,从节点接收读命令处理流程变为:如果对应的槽属于自己正在复制的主节点则直接执行读命令,否则返回重定向信息。命令如下:

//默认连接状态为普通客户端:flags=N
127.0.0.1:6382> client list
id=3 addr=127.0.0.1:56499 fd=6 name= age=130 idle=0 flags=N db=0 sub=0 psub=0 multi=-1
qbuf=0 qbuf-free=32768 obl=0 oll=0 omem=0 events=r cmd=client
//命令重定向到主节点
127.0. 0.1:6382> get key:test:3130
(error) MOVED 12944 127.0.0.1:6379
//打开当前连接只读状态
127.0. 0.1:6382> readonly
OK
//客户端状态变为只读:flags=r
127.0. 0.1:6382> client list
id=3 addr=127.0.0.1:56499 fd=6 name= age=154 idle=0 flags=r db=0 sub=0 psub=0 multi=-1
qbuf=0 qbuf-free=32768 obl=0 oll=0 omem=0 events=r cmd=client
//从节点响应读命令
127.0. 0.1:6382> get key:test:3130
"value:3130"

readonly 命令是连接级别生效,因此每次新建连接时都需要执行readonly开启只读状态。执行readwrite命令可以关闭连接只读状态。

2.读写分离

  集群模式下的读写分离,同样会遇到:复制延迟,读取过期数据,从节点故障等问题,具体细节见复制运维小节。针对从节点故障问题,客户端需要维护可用节点列表,集群提供了cluster slaves {nodeld} 命令,返回 nodeld 对应主节点下所有从节点信息,数据格式同 cluster nodes, 命令如下:

// 返回6379节点下所有从节点
127.0.0.1:6382> cluster slaves cfb28ef1deee4e0fa78da86abe5d24566744411e
1) "40622f9e7adc8ebd77fca0de9edfe691cb8a74fb 127.0.0.1:6382 myself,slave cfb28ef1deee4e0fa78da86abe5d24566744411e 0 0 3 connected"
2) "2e7cf7539d076a1217a408bb897727e5349bcfcf 127.0.0.1:6384 slave,fail cfb28ef1deee4e0fa78da86abe5d24566744411e 1473047627396 1473047622557 13 disconnected"

  解析以上从节点列表信息,排除fail状态节点,这样客户端对从节点的故障判定可以委托给集群处理,简化维护可用从节点列表难度。

提示:集群模式下读写分离涉及对客户端修改如下:

1) 维护每个主节点可用从节点列表。

2) 针对读命令维护请求节点路由。

3) 从节点新建连接开启readonly状态。

  集群模式下读写分离成本比较高,可以直接扩展主节点数量提髙集群性能,一般不建议集群模式下做读写分离。

  集群读写分离有时用于特殊业务场景如:

1) 利用复制的最终一致性使用多个从节点做跨机房部署降低读命令网络延迟。

2) 主节点故障转移时间过长,业务端把读请求路由给从节点保证读操作可用。

  以上场景也可以在不同机房独立部署Redis集群解决,通过客户端多写来维护,读命令直接请求到最近机房的Redis集群,或者当一个集群节点故障时客户端转向另一个集群。

7.6 手动故障转移

  Redis集群提供了手动故障转移功能:指定从节点发起转移流程,主从节点角色进行切换,从节点变为新的主节点对外提供服务,旧的主节点变为它的从节点,如图10-45所示。

  在从节点上执行 cluster failover 命令发起转移流程,默认情况下转移期间客户端请求会有短暂的阻塞,但不会丢失数据,流程如下:

1) 从节点通知主节点停止处理所有客户端请求。

2) 主节点发送对应从节点延迟复制的数据。

3) 从节点接收处理复制延迟的数据,直到主从复制偏移量一致为止,保证复制数据不丢失。

4) 从节点立刻发起投票选举(这里不需要延迟触发选举)。选举成功后断开复制变为新的主节点,之后向集群广播主节点pong消息。

5) 旧主节点接受到消息后更新自身配置变为从节点,解除所有客户端请求阻塞,这些请求会被重定向到新主节点上执行。

6) 旧主节点变为从节点后,向新的主节点发起全量复制流程

  主从节点转移后,新的从节点由于之前没有缓存主节点信息无法使用部分复制功能,所以会发起全量复制,当节点包含大量数据时会严重消耗CPU和网络资源,线上不要频繁操作。Redis4.0的Rsync2将有效改善这一问题。

  手动故障转移的应用场景主要如下:

1) 主节点迁移: 运维Redis集群过程中经常遇到调整节点部署的问题,如节点所在的老机器替换到新机器等。由于从节点默认不响应请求可以安全下线关闭,但直接下线主节点会导致故障自动转移期间主节点无法对外提供服务,影响线上业务的稳定性。这时可以使用手动故障转移,把要下线的主节点安全的替换为从节点后,再做下线操作操作,如图10-46所示。

2) 强制故障转移。当自动故障转移失败时,只要故障的主节点有存活的从节点就可以通过手动转移故障强制让从节点替换故障的主节点,保证集群的可用性。自动故障转移失败的场景有:

□ 主节点和它的所有从节点同时故障。这个问题需要通过调整节点机器部署拓扑做规避,保证主从节点不在同一机器/机架上。除非机房内大面积故障,否则两台机器/机架同时故障概率很低。

□ 所有从节点与主节点复制断线时间超过cluster-slave-validity-factor * cluster-node-timeout + repl-ping-slave-period, 导致从节点被判定为没有故障转移资格,手动故障转移从节点不做中断超时检査。

□ 由于网络不稳定等问题,故障发现或故障选举时间无法在cluster-node-timeout* 2 内完成,流程会不断重试,最终从节点复制中断时间超时,失去故障转移资格无法完成转移。

□ 集群内超过一半以上的主节点同时故障。

  根据以上情况,cluster failover命令提供了两个参数force/takeover提供支持:

□ cluster failover force---用于当主节点宕机且无法自动完成故障转移情况。从节点接到cluster failover force请求时,从节点直接发起选举,不再跟主节点确认复制偏移量(从节点复制延迟的数据会丢失),当从节点选举成功后替换为新的主节点并广播集群配置。

□ cluster failover takeover---用于集群内超过一半以上主节点故障的场景,因为从节点无法收到半数以上主节点投票,所以无法完成选举过程。可以执行 cluster failover takeover 强制转移,接到命令的从节点不再进行选举流程而是直接更新本地配置纪元并替换主节点。takeover故障转移由于没有通过领导者选举发起故障转移,会导致配置纪元存在冲突的可能。当冲突发生时,集群会以 nodeid 字典序更大的一方配置为准。因此要小心集群分区后,手动执行takeover导致的集群冲突问题。如图10-47所示。

  图中Redis 集群分别部署在2个同城机房,机房A部署节点:master-1、master-2、master-3、slave-4。机房B部署节点:slave-1、slave-2、slave-3、master-4。

□ 当机房之间出现网络中断时,机房A内的节点持有半数以上主节点可以完成故障转移,会将slave-4转换为master-4。

□ 如果客户端应用都部署在机房B,运维人员为了快速恢复对机房B的 Redis 访问,对slave-1, slave-2,slave-3 分别执行cluster failover takeover 强制故障转移,让机房B的节点可以快速恢复服务。

□ 当机房专线恢复后,Redis 集群会拥有两套持有相同槽信息的主节点。这时集群会使用配置纪元更大的主节点槽信息,配置纪元相等时使用 nodeId 更大的一方,因此最终会以哪个主节点为准是不确定的。如果集群以机房 A 的主节点槽信息为准,则这段时间内对机房 B 的写入数据将会丢失。

  综上所述,在集群可以自动完成故障转移的情况下,不要使用cluster failover takeover 强制干扰集群选举机制,该操作主要用于半数以上主节点故障时采取的强制措施,请慎用。

  手动故障转移时,在满足当前需求的情况下建议优先级:cluster failver > cluster failover force > cluster failover takeover。

7.7 数据迁移

  应用Redis集群时,常需要把单机Redis数据迁移到集群环境。redis-trib.rb工具提供了导入功能,用于数据从单机向集群环境迁移的场景,命令如下:

redis-trib.rb import host:port --from --copy --replace

  redis-trib.rb import命令内部采用批量scan和 migrate的方式迁移数据。这种迁移方式存在以下缺点:

1) 迁移只能从单机节点向集群环境导人数据。

2) 不支持在线迁移数据,迁移数据时应用方必须停写,无法平滑迁移数据。

3) 迁移过程中途如果出现超时等错误,不支持断点续传只能重新全量导人。

4) 使用单线程进行数据迁移,大数据量迁移速度过慢。

  正因为这些问题,社区开源了很多迁移工具,这里推荐一款唯品会开发的redis-m igrate-tool,该工具可满足大多数Redis迁移需求,特点如下:

□ 支持单机、Twemproxy、Redis Cluster、RDB/AOF等多种类型的数据迁移。

□ 工具模拟成从节点基于复制流迁移数据,从而支持在线迁移数据,业务方不需要停写。

口 采用多线程加速数据迁移过程且提供数据校验和査看迁移状态等功能。

  更多细节见  GitHub:https://github.com/vipshop/redis-migrate-tool。

  1. redis集群数据分区规则采用虚拟槽方式,所有的键映射到16384个槽中,每个节点负责一部分槽和相关数据,实现数据和请求的负载均衡。

  2. 集群划分三个步骤:准备节点,节点握手,分配槽。可以使用redis-trib.rb create命令快速搭建集群。

  3. 集群内部节点通信采用Gossip协议彼此发送消息,消息类型分为: ping消息、pong消息、meet消息、fail消息等。节点定期不断发送和接受ping/pong消息来维护更新集群的状态。消息内容包括节点自身数据和部分其他节点的状态数据。

  4. 集群伸缩通过在节点之间移动槽和相关数据实现。扩容时根据槽迁移计划把槽从源节点迁移到目标节点,源节点负责的槽相比之前变少从而达到集群扩容的目的,收缩时如果下线的节点有负责的槽需要迁移到其他节点,再通过cluster forget命令让集群内其他节点忘记被下线节点。

  5. 使用Smart客户端操作集群达到通信效率最大化,客户端内部负责计算维护键—槽—节点的映射,用于快速定位键命令到目标节点。集群协议通过Smart客户端全面高效的,支持需要一个过程,用户在选择Smart客户端时建议review下集群交互代码如:异常判定和重试逻辑,更新槽的并发控制等。节点接收到键命令时会判断相关的槽是否由自身节点负责,如果不是则返回重定向信息。重定向分为 MOVED 和ASK,ASK 说明集群正在进行槽数据迁移,客户端只在本次请求中做临时重定向,不会更新本地槽缓存。 MOVED 重定向说明槽已经明确分派到另一个节点,客户端需要更新槽节点缓存。

  6. 集群自动故障转移过程分为故障发现和故障恢复。节点下线分为主观下线和客观下线,当超过半数主节点认为故障节点为主观下线时标记它为客观下线状态。从节点负责对客观下线的主节点触发故障恢复流程,保证集群的可用性。

  7. 开发和运维集群过程中常见问题包括:超大规模集群带宽消耗,pub/sub广播问题,集群节点倾斜问题,手动故障转移,在线迁移数据等