redis(24):集群

即使使用哨兵,此时的 Redis 集群的每个数据库依然存有集群中的所有数据,从而导致 集群的总数据存储量受限于可用存储内存最小的数据库节点,形成木桶效应。由于Redis中的所有数据都是基于内存存储,这一问题就尤为突出了,尤其是当使用 Redis 做持久化存储服 务使用时。


1. 概述

对 Redis 进行水平扩容,在旧版 Redis 中通常使用客户端分片来解决这个问题,即启动 多个 Redis 数据库节点,由客户端决定每个键交由哪个数据库节点存储,下次客户端读取该 键时直接到该节点读取。这样可以实现将整个数据分布存储在N个数据库节点中,每个节点 只存放总数据量的 1/N。但对于需要扩容的场景来说,在客户端分片后,如果想增加更多的 节点,就需要对数据进行手工迁移,同时在迁移的过程中为了保证数据的一致性,还需要将 集群暂时下线,相对比较复杂。

考虑到Redis实例非常轻量的特点,可以采用预分片技术(presharding)来在一定程度上避免此问题,具体来说是在节点部署初期,就提前考虑日后的存储规模,建立足够多的实例 (如128个节点),初期时数据很少,所以每个节点存储的数据也非常少,但由于节点轻量 的特性,数据之外的内存开销并不大,这使得只需要很少的服务器即可运行这些实例。日后存储规模扩大后,所要做的不过是将某些实例迁移到其他服务器上,而不需要对所有数据进 行重新分片并进行集群下线和数据迁移了。
无论如何,客户端分片终归是有非常多的缺点,比如维护成本高,增加、移除节点较繁 琐等。Redis 3.0版的一大特性就是支持集群(Cluster)功能。集群的特点在于拥有和单机实例同样的性能,同时在网络分区后能够提供一定 的可访问性以及对主数据库故障恢复的支持。另外集群支持几乎所有的单机实例支持的命 令,对于涉及多键的命令(如MGET),如果每个键都位于同一个节点中,则可以正常支 持,否则会提示错误。除此之外集群还有一个限制是只能使用默认的0号数据库,如果执行 SELECT切换数据库则会提示错误。
哨兵与集群是两个独立的功能,但从特性来看哨兵可以视为集群的子集,当不需要数据 分片或者已经在客户端进行分片的场景下哨兵就足够使用了,但如果需要进行水平扩容,则集群是一个非常好的选择。


2. 插槽的分配

新的节点加入集群后有两种选择,要么使用 CLUSTER REPLICATE命令复制每个主数据 库来以从数据库的形式运行,要么向集群申请分配插槽(slot)来以主数据库的形式运行。

在一个集群中,所有的键会被分配给16384个插槽,而每个主数据库会负责处理其中的 一部分插槽。现在再回过头来看8.3.1节创建集群时的输出:

M: d4f906940d68714db787a60837f57fa496de5d12 127.0.0.1:6380 slots:0-5460 (5461 slots) master 
M: b547d05c9d0e188993befec4ae5ccb430343fb4b 127.0.0.1:6381 slots:5461-10922 (5462 slots) master 
M: 887fe91bf218f203194403807e0aee941e985286 127.0.0.1:6382 slots:10923-16383 (5461 slots) master 

上面的每一行表示一个主数据库的信息,其中可以看到6380负责处理0~5460这5461个 插槽,6381负责处理5461~10922这5462个插槽,6382则负责处理10923~16383这5461个插 槽。虽然redis-trib.rb初始化集群时分配给每个节点的插槽都是连续的,但是实际上Redis并没 有此限制,可以将任意的几个插槽分配给任意的节点负责。
在介绍如何将插槽分配给指定的节点前,先来介绍键与插槽的对应关系。Redis 将每个 键的键名的有效部分使用CRC16算法计算出散列值,然后取对16384的余数。这样使得每个键 都可以分配到16384个插槽中,进而分配的指定的一个节点中处理。CRC16的具体实现参见附 录C。这里键名的有效部分是指:

  1. 如果键名包含{符号,且在{符号后面存在}符号,并且{和}之间有至少一个字符, 则有效部分是指{和}之间的内容;
  2. 如果不满足上一条规则,那么整个键名为有效部分。

例如,键hello.world的有效部分为"hello.world",键{user102}:last.name的有效部分 为"user102"。如本节引言所说,如果命令涉及多个键(如MGET),只有当所有键都位于同
一个节点时 Redis 才能正常支持。利用键的分配规则,可以将所有相关的键的有效部分设置 成同样的值使得相关键都能分配到同一个节点以支持多键操作。比如,{user102}:first.name 和{user102}:last.name 会被分配到同一个节点,所以可以使用 MGET {user102}:first.name {user102}:last.name来同时获取两个键的值。
介绍完键与插槽的对应关系后,接下来再来介绍如何将插槽分配给指定节点。插槽的分 配分为如下几种情况。

  1. 插槽之前没有被分配过,现在想分配给指定节点。
  2. 插槽之前被分配过,现在想移动到指定节点。

其中第一种情况使用 CLUSTER ADD SLOT S命令来实现,redis-trib.rb 也是通过该命令 在创建集群时为新节点分配插槽的。CLUSTER ADDSLOTS命令的用法为:

CLUSTER ADDSLOTS slot1 [slot2] ... [slotN]

如想将 100 和 101 两个插槽分配给某个节点,只需要在该节点执行:

CLUSTER ADDSLOTS 100 101

即可。如果指定插槽已经分配过了,则会提示:

(error) ERR Slot 100 is already busy

可以通过命令 CLUSTER SLOTS来查看插槽的分配情况,如:

redis 6380> CLUSTER SLOTS 
1) 1) (integer) 5461 
	2) (integer) 10922 
	3) 1) "127.0.0.1" 
	2) (integer) 6381 
	4) 1) "127.0.0.1" 
	2) (integer) 6384 
2) 1) (integer) 0 
	2) (integer) 5460 
	3) 1) "127.0.0.1" 2) (integer) 6380 
	4) 1) "127.0.0.1" 
	2) (integer) 6383 
3) 1) (integer) 10923 
	2) (integer) 16383 
	3) 1) "127.0.0.1" 
	2) (integer) 6382 
	4) 1) "127.0.0.1" 
	2) (integer) 6385 

其中返回结果的格式很容易理解,一共3条记录,每条记录的前两个值表示插槽的开始 号码和结束号码,后面的值则为负责该插槽的节点,包括主数据库和所有的从数据库,主数 据库始终在第一位。
对于情况2,处理起来就相对复杂一些,不过redis-trib.rb提供了比较方便的方式来对插槽 进行迁移。我们首先使用redis-trib.rb将一个插槽从6380迁移到6381,然后再介绍如何不使用 redis-trib.rb来完成迁移。
首先执行如下命令:

$ /path/to/redis-trib.rb reshard 127.0.0.1:6380 

其中reshard表示告诉redis-trib.rb要重新分片,127.0.0.1:6380是集群中的任意一个节点的地址和端口,redis-trib.rb会自动获取集群信息。接下来,redis-trib.rb将会询问具体如何进行 重新分片,首先会询问想要迁移多少个插槽:

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

我们只需要迁移一个,所以输入1后回车。接下来redis-trib.rb会询问要把插槽迁移到哪个 节点:

What is the receiving node ID? 

可以通过 CLUSTER NODES命令获取6381的运行ID,这里是 b547d05c9d0e188993befec 4ae5ccb430343fb4b,输入并回车。接着最后一步是询问从哪个节点移出插槽:

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:all 

我们输入6380对应的运行ID按回车然后输入done再按回车确认即可。
接下来输入 y es来确认重新分片方案,重新分片即告成功。使用 CLUSTER SLOTS命令 获取当前插槽的分配情况如下:

redis 6380> CLUSTER SLOTS 
1) 1) (integer) 1 
	2) (integer) 5460 
	3) 1) "127.0.0.1" 
	2) (integer) 6380 
	4) 1) "127.0.0.1" 
	2) (integer) 6383 
2) 1) (integer) 10923 
	2) (integer) 16383 
	3) 1) "127.0.0.1" 
	2) (integer) 6382 
	4) 1) "127.0.0.1" 
	2) (integer) 6385 
3) 1) (integer) 0 
	2) (integer) 0 
	3) 1) "127.0.0.1" 
	2) (integer) 6381 
	4) 1) "127.0.0.1" 
	2) (integer) 6384 
4) 1) (integer) 5461 
	2) (integer) 10922 
	3) 1) "127.0.0.1" 
	2) (integer) 6381 
	4) 1) "127.0.0.1" 
	2) (integer) 6384

可以看到现在比之前多了一条记录,第0号插槽已经由6381负责,此时重新分片成功。 那么redis-trib.rb实现重新分片的原理是什么,我们如何不借助redis-trib.rb手工进行重新 分片呢?使用如下命令即可:

CLUSTER SETSLOT 插槽号 NODE 新节点的运行 ID 

如想要把0号插槽迁移回6380:

redis 6381> CLUSTER SETSLOT 0 NODE d4f906940d68714db787a60837f57fa496de5d12 
OK

此时重新使用 CLUSTER SLOTS 查看插槽的分配情况,可以看到已经恢复如初了。然而 这样迁移插槽的前提是插槽中并没有任何键,因为使用 CLUSTER SETSLOT命令迁移插槽时 并不会连同相应的键一起迁移,这就造成了客户端在指定节点无法找到未迁移的键,造成这 些键对客户端来说“丢失了”(8.3.4节会介绍客户端如果找到对应键的负责节点)。为此需要 手工获取插槽中存在哪些键,然后将每个键迁移到新的节点中才行。
手工获取某个插槽存在哪些键的方法是:

CLUSTER GETKEYSINSLOT 插槽号要返回的键的数量 

之后对每个键,使用MIGRATE命令将其迁移到目标节点: MIGRATE 目标节点地址目标节点端口键名数据库号码超时时间 [COPY] [REPLACE] 其中COPY选项表示不将键从当前数据库中删除,而是复制一份副本。REPLACE表示如 果目标节点存在同名键,则覆盖。因为集群模式只能使用0号数据库,所以数据库号码始终 为0。如要把键abc从当前节点(如6381)迁移到6380:

redis 6381> MIGRATE 127.0.0.1 6380 abc 0 15999 REPLACE 

至此,我们已经知道如果将插槽委派给其他节点,并同时将当前节点中插槽下所有的键 迁移到目标节点中。然而还有最后一个问题是如果要迁移的数据量比较大,整个过程会花费 较长时间,那么究竟在什么时候执行 CLUSTER SETSLOT命令来完成插槽的交接呢?如果在 键迁移未完成时执行,那么客户端就会尝试在新的节点读取键值,此时还没有迁移完成,自 然有可能读不到键值,从而造成相关键的临时“丢失”。相反,如果在键迁移完成后再执行, 那么在迁移时客户端会在旧的节点读取键值,然后有些键已经迁移到新的节点上了,同样也 会造成键的临时“丢失”。那么redis-trib.rb工具是如何解决这个问题的呢?Redis提供了如下两 个命令用来实现在集群不下线的情况下迁移数据:

CLUSTER SETSLOT 插槽号 MIGRATING 新节点的运行 ID 
CLUSTER SETSLOT 插槽号 IMPORTING 原节点的运行 ID 

进行迁移时,假设要把0号插槽从A迁移到B,此时redis-trib.rb会依次执行如下操作。
3. 在B执行 CLUSTER SETSLOT 0 IMPORTING A。
4. 在A执行 CLUSTER SETSLOT 0 MIGRATING B。
5. 执行 CLUSTER GETKEYSINSLOT 0获取0号插槽的键列表。
6. 对第3步获取的每个键执行MIGRATE命令,将其从A迁移到B。
7. 执行 CLUSTER SETSLOT 0 NODE B来完成迁移。

从上面的步骤来看 redis-trib.rb多了 1和 2两个步骤,这两个步骤就是为了解决迁移过程 中键的临时“丢失”问题。首先执行完前两步后,当客户端向 A请求插槽 0 中的键时,如果键 存在(即尚未被迁移),则正常处理,如果不存在,则返回一个 ASK跳转请求,告诉客户端 这个键在 B里,如图 8-6所示。客户端接收到 ASK跳转请求后,首先向 B发送 ASKING命 令,然后再重新发送之前的命令。相反,当客户端向 B请求插槽 0 中的键时,如果前面执行 了 ASKING 命令,则返回键值内容,否则返回 MOVED跳转请求(会在8.3.4节介绍),如图 8-7所示。这样一来客户端只有能够处理ASK跳转,则可以在数据库迁移时自动从正确的节点 获取到相应的键值,避免了键在迁移过程中临时“丢失”的问题。

在这里插入图片描述

在这里插入图片描述


3. 获取与插槽对应的节点

前面介绍了插槽的分配方式,对于指定的键,可以根据前文所述的算法来计算其属于 哪个插槽,但是如何获取某一个键由哪个节点负责呢?

实际上,当客户端向集群中的任意一个节点发送命令后,该节点会判断相应的键是否在 当前节点中,如果键在该节点中,则会像单机实例一样正常处理该命令;如果键不在该节点 中,就会返回一个 MOVE 重定向请求,告诉客户端这个键目前由哪个节点负责,然后客户 端再将同样的请求向目标节点重新发送一次以获得结果。
一些语言的 Redis 库支持代理 MOVE请求,所以对于开发者而言命令重定向的过程是透 明的,使用集群与使用单机实例并没有什么不同。然而也有些语言的 Redis 库并不支持集 群,这时就需要在客户端编码处理了。
还是以上面的集群配置为例,键foo实际应该由6382节点负责,如果尝试在6380节点执行 与键foo相关的命令,就会有如下输出:

redis 6380> SET foo bar 
(error) MOVED 12182 127.0.0.1:6382

返回的是一个MOVE重定向请求,12182表示foo所属的插槽号,127.0.0.1:6382则是负责 该插槽的节点地址和端口,客户端收到重定向请求后,应该将命令重新向 6382节点发送一 次:

redis 6382> SET foo bar 
OK

Redis命令行客户端提供了集群模式来支持自动重定向,使用-c参数来启用:

$ redis-cli -c -p 6380 
reds 6380> SET foo bar 
-> Redirected to slot [12182] located at 127.0.0.1:6382 
OK

可见加入了-c参数后,如果当前节点并不负责要处理的键,Redis命令行客户端会进行自 动命令重定向。而这一过程正是每个支持集群的客户端应该实现的。
然而相比单机实例,集群的命令重定向也增加了命令的请求次数,原先只需要执行一次 的命令现在有可能需要依次发向两个节点,算上往返时延,可以说请求重定向对性能的还是 有些影响的。
为了解决这一问题,当发现新的重定向请求时,客户端应该在重新向正确节点发送命令 的同时,缓存插槽的路由信息,即记录下当前插槽是由哪个节点负责的。这样每次发起命令 时,客户端首先计算相关键是属于哪个插槽的,然后根据缓存的路由判断插槽由哪个节点负 责。考虑到插槽总数相对较少(16384个),缓存所有插槽的路由信息后,每次命令将均只 发向正确的节点,从而达到和单机实例同样的性能。


4. 故障恢复

在一个集群中,每个节点都会定期向其他节点发送 PING 命令,并通过有没有收到回复 来判断目标节点是否已经下线了。具体来说,集群中的每个节点每隔1秒钟就会随机选择5个 节点,然后选择其中最久没有响应的节点发送PING命令。

如果一定时间内目标节点没有响应回复,则发起 PING 命令的节点会认为目标节点疑似 下线(PFAIL)。疑似下线可以与哨兵的主观下线类比,两者都表示某一节点从自身的角度
认为目标节点是下线的状态。与哨兵的模式类似,如果要使在整个集群中的所有节点都认为 某一节点已经下线,需要一定数量的节点都认为该节点疑似下线才可以,这一过程具体为:

  1. 一旦节点A认为节点B是疑似下线状态,就会在集群中传播该消息,所有其他节点 收到消息后都会记录下这一信息;
  2. 当集群中的某一节点C收集到半数以上的节点认为B是疑似下线的状态时,就会将B 标记为下线(FAIL),并且向集群中的其他节点传播该消息,从而使得B在整个集群中下 线。

在集群中,当一个主数据库下线时,就会出现一部分插槽无法写入的问题。这时如果该 主数据库拥有至少一个从数据库,集群就进行故障恢复操作来将其中一个从数据库转变成主 数据库来保证集群的完整。选择哪个从数据库来作为主数据库的过程与在哨兵中选择领头哨 兵的过程一样,都是基于Raft算法,过程如下。

  1. 发现其复制的主数据库下线的从数据库(下面称作A)向每个集群中的节点发送请 求,要求对方选自己成为主数据库。
  2. 如果收到请求的节点没有选过其他人,则会同意将A设置成主数据库。
  3. 如果A发现有超过集群中节点总数一半的节点同意选自己成为主数据库,则A则成 功成为主数据库。
  4. 当有多个从数据库节点同时参选主数据库,则会出现没有任何节点当选的可能。 此时每个参选节点将等待一个随机时间重新发起参选请求,进行下一轮选举,直到选举成 功。

当某个从数据库当选为主数据库后,会通过命令 SLAVEOF ON ONE将自己转换成主数 据库,并将旧的主数据库的插槽转换给自己负责。
如果一个至少负责一个插槽的主数据库下线且没有相应的从数据库可以进行故障恢复, 则整个集群默认会进入下线状态无法继续工作。如果想在这种情况下使集群仍能正常工作, 可以修改配置cluster-require-full-coverage为no(默认为yes):

cluster-require-full-coverage no
相关推荐
©️2020 CSDN 皮肤主题: 酷酷鲨 设计师:CSDN官方博客 返回首页