拆解Redis Cluster,怎么实现“写安全”这个重要特性?

本文发在DBAplus公众号,https://mp.weixin.qq.com/s/Uyw7MjqMW9DR6EInFIo9Wg,在自己的博客也发一遍。

Redis是非常流行的缓存。在Redis升级到3.0版本后,升级到 集群 版本,被称之为 Redis cluster。在集群版本中,会将数据分成多份,被保存到多个server中,从而保证集群的水平扩展能力,加之每份数据保存多个副本,从而保证可用性。并且集群版本保证一定程度的Write Safety,本文详细介绍redis cluster的实现细节,从而分析Redis cluster的write safety的保证程度。

接口和架构

接口

Redis cluster的接口基本向前兼容,仍然是key-value类型。

架构

Redis Cluster包含server和client两个组件。一个Redis Cluster可以包含多个server,可以包含多个客户端。每个客户端可以连接任意的server,读取写入数据。保存在Redis cluster中的数据会被分成多份,分散地保存在多个server中,并且每一份数据也会保存多个副本。

实现

节点

在Redis cluster中,数据会被保存到多个Redis server中,每个Redis server都是一个独立的进程,具有独立的IP和Port,也被称之为一个 实例,或者叫做 节点Node)。Client通过这个IP和Port连接到这个node。

每个节点都有个 node id,node id是一个全局唯一的标识,它是在集群创建时随机生成的。一个节点的id始终都不会变化的,但是node的IP和port是可以变化的。

Node table

一个Redis cluster包含哪些节点,也就是这个集群包含哪些节点的id,也就是 集群成员关系,这个信息被保存在一个表结构中,被称之为 node table。 node table类似于:

id ip port
id1 xxx.xxx.xxx.xxx 3001
id2 xxx.xxx.xxx.xxx 3002

node table会在每个节点上都保存一份,Redis cluster通过gossip协议把node table复制到所有的节点。后面会继续讲述node table的复制。

集群成员关系变更

当添加一个节点或者删除一个节点时,只需要将命令发给集群中的任意一个节点,这个节点会修改本地的node table,并且这个修改会最终复制到所有的节点上去。添加节点的命令是CLUSTER MEET,删除节点的命令是CLUSTER FORGET。

举例来说明,打算搭建一个3个master节点的集群,当集群创建以前,所有3个节点的node table都只包含自己。给其中的一个节点A发送命令,CLUSTER MEET NodeB,节点A修改自己的Node table,将NodeB添加到自己的node table中,并且连接节点B,把自己的node table发送给节点B,节点B收到节点A发送过来的Node table,会更新自己的Node table,这时节点B就知道集群中还有节点A存在。这时,给节点A再发送CLUSTER MEET NodeC,节点A会把节点C添加到自己的node table,并且把自己的Node table复制给节点B,节点B把接收到的Node table更新自己的本地的node table,从而知道节点C的加入。同样节点A会把自己的node table发给节点C,节点C会更新自己本地的Node table,从而知道要加入的集群中已经存在节点A和节点B。

前面说过Redis cluster会把数据分成多份,也就是把数据进行 分片。Redis cluster中的每一份数据被称为 Slot)。Redis cluster将数据拆分成16384份,也就是说有16384个槽。

Redis Cluster采用 哈希Hash)机制来拆分数据。首先,数据的key通过CRC16算法计算出一个哈希值。这个哈希值再对16384取余,这个余数就是槽位,被称为 hash slot。具体的CRC16算法可以参看Redis官方文档^[1]^。所有余数相同的key都在一个slot中,也就是说,一个slot其实就是一批hash余数相同的key。

每个hash slot都会保存在Redis cluster一个节点中。具体哪个hash slot被保存在哪个实例中,就形成了类似于一个map的数据结构,被称之为 hash slot map。hash slot map类似于:
0 -> NodeA
1 -> NodeA
2 -> NodeB
...
16383 -> NodeN

与node table相同,hash slot map也会在每个节点上都会保存一份,Redis cluster通过gossip协议把hash slot map复制到所有节点。同样,后面还会讲述hash slot map的复制。

数据分片变更

要修改数据分片关系,可以连接任意一个节点,给这个节点发送CLUSTER ADDSLOTS, CLUSTER SETSLOT, CLUSTER DELSLOT命令,修改这个节点上的hash slot map,该节点会把这个修改复制到所有其他节点,其他节点会用接收到的hash slot map更新自己的hash slot map。

CLUSTER ADDSLOTS、CLUSTER DELSLOTS、CLUSTER SETSLOT命令的使用如下:
CLUSTER ADDSLOTS slot1 [slot2] ... [slotN]
CLUSTER DELSLOTS slot1 [slot2] ... [slotN]
CLUSTER SETSLOT slot NODE node

CLUSTER ADDSLOTS用来把多个slot分配給当前连接的节点。例如,连接到节点A执行:
CLUSTER ADDSLOTS 1 2 3
这个命令会把slot1、slot2、slot3分配给节点A。

CLUSTER DELSLOTS用来把多个slot从当前连接的节点删除。例如,连接到节点A执行:
CLUSTER DELSLOTS 1 2 3
这个命令会把slot1、slot2、slot3从节点A上删除。

CLUSTER SETSLOT用来把一个slot分配给指定的节点,可以不是当前连接的节点,另外这个命令还可以设定MIGRATING和IMPORTING两个状态,我们后面再讲。例如,连接到节点A执行:
CLUSTER SETSLOT 1 nodeB
这个命令会把slot1分配给节点B。

Slave

一个slot会被保存多个副本,既一个slot会保存在多个节点上,也就是slot会复制到多个节点上。Redis cluster的复制是以节点为单位的,一个节点上的所有slot会采用相同的复制。具体来说就是,其中一个节点会负责处理这个节点上所有slot的写操作,这个节点被称为 master,而其余的节点被称为 slave 节点。一个master可以有多个slave。在同一个节点上的所有slot的所有的写操作都会被从master节点异步复制到所有的slave节点。所以slave会具有与master相同的slot。

通过SLAVEOF命令来设置slave节点。SLAVEOF命令用来改变一个slave节点的复制设置。SLAVEOF命令有两种格式:
SLAVEOF NO ONE
SLAVEOF host port
具体来讲,SLAVEOF NO ONE命令会停止一个slave节点的复制,并且把这个slave节点变成master节点。SLAVEOF host port命令会停止一个slave节点的复制,丢弃数据集,开始从host和port指定的新master节点复制。

master和slave的关系会被记录在hash slot table中,相当于一个slot会映射到多个节点上,其中一个节点是master,其他记录的节点是slave。

加入了master/slave信息后的hash slot map类似于:
0 -> NodeA,NadeA1(slave)
1 -> NodeA,NadeA1(slave)
2 -> NodeB,NadeB1(slave)
...
16383 -> NodeN,NadeN1(slave)

作为hash slot map的一部分,master/slave信息也会通过gossip协议复制到集群中的每个节点。

Configuration

Node table和hash slot map这两个信息,被称之为 configuration,在其他的分布式系统中,也被称之为元数据。

前面我们讲过,hash slot map和node table在每个节点都会保存一份,并且对这两个信息的任何改动,都会通过gossip协议 传播propogate)到所有的节点。本文我们就不继续展开gossip协议了,对Redis cluster的gossip协议的实现感兴趣的同学可以参看redis的文档^[1]^。

在某些分布式系统中,元数据被存储在一个单独的架构组件中的。Redis cluster并没有这样一个元数据存储的组件,而是把元数据分散的存储在所有的节点上。

Hash slot map和node table在集群创建时会被创建出来,并且会随着后续的集群变更(比如failover和扩容、缩容等运维操作,后面会讲述)而跟随着变更。变动会在一个node上发起,通过gossip协议传播到所有其他的节点。这两个信息在所有节点都会保存,并且会最终达到一致。

集群创建

创建Redis cluster时,首先要以cluster模式运行多个redis server,redis server运行起来后,这些server的node id就已经生成了。但是这些redis server并没有形成集群,也就是server彼此之间并不知道相互的存在。接下来运行CLUSTER MEET命令,让这些节点形成一个集群。但是这时集群仍然没有处于运行状态,需要分配slot,通过CLUSTER SLOTADD命令把slot分配给具体的节点之后,集群就可以处理client的命令了。

客户端的读写操作

客户端要读取、写入数据时,虽然Client可以连接任意server,但是实际中,client需要根据实际需求连接到server读取、写入数据。client需要先根据key计算出hash slot,连接到负责这个hash slot的节点进行读写操作。这样的话,client就要需要知道hash slot -> node的映射关系,也就是需要知道hash slot map。前面讲过hash slot map被保存在server端的每个节点上。client可以从任意节点获取hash slot map,并且把它缓存到client本地,下次操作时根据本地缓存直接进行操作,但是需要处理缓存信息过期的问题,如果client发现hash slot map发生变化(即client读取写入数据时server端返回错误,接下来会详细讲述),会重新从server端获取新的hash slot map。通过hash slot map可以判断某个key应该存在在哪个节点上,client再连接这个节点进行读写操作。

MOVED Redirection

Hash slot map会发生变更,这些变更会复制到所有的节点,但是gossip保证的是最终复制到所有的节点,再加上client会缓存hash slot map,client可能会把某个key的请求发给错误的节点来处理。错误的节点收到请求后,发现这个key不应该自己来处理,会给客户端返回MOVED的错误,在错误消息中,会告诉客户端,哪个节点应该负责这个slot。Client收到MOVED消息后,会向消息中指定的节点再次发送请求。

Client可以将slot的节点信息更新到本地缓存的hash slot map中,但是更好的方法是,重新获取完整的hash slot map,替换本地的缓存。因为在大多数情况下,hash slot map中的变更不仅仅只修改一个slot。

虽然client按照MOVED消息中的节点信息重新发送请求,但是client仍然可能再次从新节点收到MOVED错误消息,因为上一个节点的hash slot map可能也不是最新的。但是因为hash slot map最终会在所有的节点上一致,所以client在几次收到MOVED错误后,最终会获取到最新的hash slot map。

Failover、currentEpoch、lastVoteEpoch

当master发生故障宕机后,Redis cluster会选出一个slave来接替这个master。如果有多个slave存在,那么每个slave都可能都会发现master发生了宕机,并且试图把自己变成为master,如果有多个slave成为master,那么这些新master都会更新本地hash slot map,把旧master负责的slot更新成自己,并且把自己对hash slot map的更新传播给其他的节点。这会导致hash slot map在节点间出现差异。从而导致,因为所连接的节点不同,client拿到不同的hash slot map,对于同一个slot,不同的client会连接不同的节点,最终导致节点上的数据出现差异。所以failover要保证,只有一个slave被选成新master。

Redis cluster采用了类似Raft算法的技术来防止多个slave被选成master。每个节点都会有叫做currentEpoch、lastVoteEpoch的两个值。在集群刚创建时,每个节点的currentEpoch都是0。当slave发现master宕机时,这个slave会增加currentEpoch(即currentEpoch++)。并且向所有的master发送FAILOVER_AUTH_REQUEST请求,请求中会携带自己currentEpoch,master收到FAILOVER_AUTH_REQUEST,如果请求中的currentEpoch比自己的currentEpoch和lastVoteEpoch都大,则记录请求中的currentEpoch值到自己的currentEpoch、lastVoteEpoch中,并且回复FAILOVER_AUTH_ACK给slave,回复中携带master的currentEpoch。所以可以看出,FAILOVER_AUTH_ACK中的Epoch一定与slave的currentEpoch相同。Slave从大多数的master收到FAILOVER_AUTH_ACK后,则成为master。

上面的过程保证只有一个slave被选出,我们来举例说明。五个master的集群,master节点分别是A、B、C、D、E,A节点有两个slave,分别是A1和A2。A节点发生宕机,A1增加自己的currentEpoch=5(4+1),A1给所有的master发送FAILOVER_AUTH_REQUEST,节点B、C、D收到FAILOVER_AUTH_REQUEST,把自己的currentEpoch和lastVoteEpoch更新成5,并且给A1回复FAILOVER_AUTH_ACK,A1赢得选举,成为新的master。但是与此同时,A2也发现A宕机,也试图选举成master。A2增加自己的currentEpoch=5(4+1),A2给所有的master发送FAILOVER_AUTH_REQUEST,但这时的B、C、D的lastVoteEpoch已经是5,所以B、C、D不会给A2回复,E还没有收到A1的请求,所以只有E会给A2回复,但是不能形成大多数,所以A2不能称为master。

上面讲述的过程已经可以保证只有一个master被选出,但是除此之外,Redis cluster还做了一个优化,那就是master回复了一个请求后不会在给这个master的其他的slave发送回复。

Configuration epoch

Failover完成后,新master会修改hash slot map,把相应的slot记录的节点改成自己,并且把这次对hash slot map的改动传播给其他节点。虽然currentEpoch和lastVoteEpoch能保证每次failover只能有一个节点被选成新的master,但是先后两次failover,可能选出的两个不同的master,但是他们对hash slot map的修改的传播却是异步,也就是后面一次failover的改动可能先于第一次failover的改动到达某个节点,从而导致节点间对hash slot map这个信息产生不一致。

Redis cluster通过configEpoch来解决这个问题。每个节点会保存一个configEpoch的值。相当于在node table中还会有一列数据叫configEpoch,类似于下面的表:

id ip port configEpoch
id1 xxx.xxx.xxx.xxx 3001 1
id2 xxx.xxx.xxx.xxx 3002 2

每次failover完成后,新选出的master会用currentEpoch覆盖configEpoch。Failover的机制保证两次failover的新master一定是具有不同的currentEpoch,并且后一次的failover的currentEpoch一定比前一次大。这样就可以保证,即便采用了gossip这样传播协议,仍然能够保证最后一次failover的hash slot map的变更会生效,也就是configEpoch更大的变更会生效,并且最终所有的节点上的hash slot map是一致的。

Slave节点的configEpoch就是其master的configEpoch。

由于gossip保证的hash slot map最终保持一致的,所以可能存在slave的hash slot map旧于master,failover不能基于旧的hash slot map基础上做变更,所以前面failover的过程中还需要补充一个规则要遵守:
在FAILOVER_AUTH_REQUEST中会携带slave节点的configEpoch,如果这个slave的configEpoch比这个slave负责的所有slot的master的configEpoch中任意一个要小,则master不会给slave回复FAILOVER_AUTH_ACK。

Resharding

在集群创建之后,我们还会有对Redis cluster做扩容、缩容、balancing这样的运维需求,这些需求本质上都可以用 Resharding 操作解决,resharding操作就是把slot在节点间重新的分布,把slot从一个节点转移到另外一个节点上。在Redis cluster中,扩容需求实质上就是加入一个新的节点,再把一些slot分配到这个新节点上。缩容需求实质上就是先把这个节点上的所有slot分配到其他节点上,再把这个节点从集群中移出。当节点间的流量不均衡时,我们有balancing这样的需求,balancing就是把流量比较大的节点上的一些slot分配都流量比较少的节点上。

Resharding操作可以是对整个hash slot map的调整,也就是可以包括对多个slot的 迁移migration),迁移就是把一个slot从一个节点迁移到另外一个节点。一个slot migration操作包括前面讲的hash slot map变更,另外还包括key的迁移操作。要把一个slot迁移到另外的节点上,首先把这个slot上的所有的key迁移到这个节点,当把所有key都迁移完后,再进行hash slot map变更,当hash slot map变更完成,这次slot migration结束。

Redis cluster使用CLUSTER SETSLOT来设置迁移。举例说明,将slot1从节点A迁移到节点B。分别对节点A和节点B执行下面的命令:
节点A上:CLUSTER SETSLOT 1 MIGRATING NODEB
节点B上:CLUSTER SETSLOT 1 IMPORTING NODEA
其中,MIGRATING表示数据要从这个节点迁出,而IMPORTING表示数据要往这个节点迁入。

执行完这两个命令后,节点A中的slot1不在创建新的key。一个叫做redis-trib的特殊的程序负责把所有的key从节点A迁移到节点B。redis-trib会执行下面的命令:
CLUSTER GETKEYSINSLOT slot count。

这个命令会返回count个key,对于每个返回的key,redis-trib执行下面的命令:
MIGRATE target_host target_port key target_database id timeout

这个命令会原子地把一个key从节点A迁移到节点B。具体来说,MIGRATE命令会连接目标节点,并发向目标节点发送这个key,一旦目标节点收到这个key,则从自己的数据库中删除这个key,在这个过程中,节点A和节点B都会加锁。

在把所有的key迁移完后,再分别在两个节点上执行下面的命令:
CLUSTER SETSLOT slot NODE nodeA

把所有的key迁移完一般需要一些时间,也就是说在开始迁移后和完成迁移前,在这个窗口期内,key的实际的分布,与hash slot map里记录的是不一致的,client按照hash slot map访问key,会出现错误。Redis cluster通过ASK redirection来解决这个问题。按照client端的hash slot map,slot1的key一定会发给节点A,节点A收到这个请求后,如果发现这个key已经迁移到节点B了,那么就会给client回复ASK redirection,client收到ASK redirection后,会向节点b先发送一个ASKING命令,之后在发送对这个key的请求。

Configuration的实际存储

Hash slot map和node table都是逻辑上的结构,他们在Redis cluster中的实际存储结构稍有不同^[2]^^[3]^^[4]^^[5]^。

在节点的内存中,用两个变量来存储这两个信息。第一个是myself变量。myself代表本节点,是一个ClusterNode类型的变量,这个变量中,包含本节点的configEpoch,还包括slaveof,如果是slave节点则在slaveof中记录着它的master节点,还包括一个bitmap,代表这个节点负责的所有的slot的槽位值。这个bitmap有2048个byte组成,一总是16384(2048*8)个bit,每个bit代表一个slot,bit置1,代表这个节点负责这个slot。

另外还有一个变量,叫做cluster,代表了所在集群的状态,它包含currentEpoch、lastVoteEpoch和slots数组,slots数组的index代表了slot,数组的每个成员都指向一个节点,是一个ClusterNode类型的变量,与myself变量的类型一样。

所有configuration的更改都会被保存到磁盘中,具体来讲是保存到一个名字叫node.conf的文件中,这个文件是Redis cluster负责写入的,不需要人工配置。node.conf按照节点维度进行保存。每一行对应一个节点。每行分别包含这些信息:id,ip:port,flag,slaveof,ping timestamp, pong timespamp,configEpoch,link status,slots。所有的节点结束后,会在文件的最后保存curruntEpoch和lastVoteEpoch两个变量。其中flag字段是枚举类型,会指明这个节点是不是自己,节点类型是master还是slave。如果是slave节点,则会在slaveof字段记录其master节点的id。如果是master节点,则在最后多一个slots字段,记录着这个节点负责着哪些slot。Flags字段还记录着其他非常重要的状态,本文就不继续展开了。同样,ping timestamp、pong timestmap、link staus三个字段本文也不继续展开了。

具体的node.conf文件类似下面的例子:
[root@10.112.178.141 data]# cat nodes-6384.conf
fb763117270d14205c41174605b15741co03a945 10.112.178.174:6383 slave 5e35bda1a44c8d781eb54e08be88a3bab42070f3 0 1596683852819 2 connected
3dc5890fb1591e3b20196f81eb5f2f99754253e8 10.112.178.141:6383 master - 0 1596683851915 1 connected 0-5461
f1967b687c9b2c27108cce08517e98e7a80d5e7e 10.112.178.171:6383 slave 3dc5890fb1591e3b20196f81eb5f2f99754253e8 0 1596683850813 1 connected
2bbab7353e973e991566df3bb52afb4857a7bf25 10.112.178.171:6384 slave 1f0a8cf1bfd0c915ef404482f3dc6bf5c7cf41f5 0 1596683848812 3 connected
5e35bda1a44c8d781eb54e08be88a3bab42070f3 10.112.178.142:6383 master - 0 1596683849813 2 connected 5462-10923
1f0a8cf1bfd0c915ef404482f3dc6bf5c7cf41f5 10.112.178.141:6384 myself,master - 0 0 3 connected 10924-16383

节点启动时会读取node.conf文件,把里面的信息加载到myself和cluster两个变量中。Slot信息会被转换成bitmap保存在myself变量中。并且slot信息还会逆向的转换成slot到节点的映射保存在cluster变量中。

Hash slot map变更或者node table变更,就是修改内存中的myself变量和cluater变量,并且每次变更都会把这两个变量序列化转化后保存到node.conf中。

查看configuration

Redis cluster提供了两个命令来查看configuration。

第一个是CLUSTER SLOT命令,用来展示hash slot维度的信息,CLUSTER SLOT命令的展示如下:
拆解Redis Cluster,怎么实现“写安全”这个重要特性?

第二个是CLUSTER NODE命令,用来展示node table维度的信息,CLUSTER NODE命令的展示如下:
$ redis-cli cluster nodes
d1861060fe6a534d42d8a19aeb36600e18785e04 127.0.0.1:6379 myself - 0 1318428930 1 connected 0-1364
3886e65cc906bfd9b1f7e7bde468726a052d1dae 127.0.0.1:6380 master - 1318428930 1318428931 2 connected 1365-2729
d289c575dcbc4bdd2931585fd4339089e461a27d 127.0.0.1:6381 master - 1318428931 1318428931 3 connected 2730-4095

CLUSTER NODE命令展示的结果与node.conf文件中的内容非常类似。每一行都代表一个节点,每一行都包含node id, address:port, slaveof,flags, last ping sent, last pong received, configuration epoch, link state, slots这些字段。

CLUSTER NODE、CLUSTER SLOT两个命令可以连接到任意节点上执行,这两个命令都是读取的这个节点的本地信息,根据gossip的特性,存在这两个命令展示的不是最新的configuration的可能性。

Conflict

虽然前面讲的failover过程通过大多数master投票的方式保证只有一个slave选中,并且产生唯一的configEpoch。但是Resharding的过程却没有经过大多数的master的投票。执行slot迁移时,仅仅是在集群中所有configEpoch中最大的那个configEpoch的基础上,再加一而得到的。并且由于Resharding一般包括多个slot的迁移,Redis cluster目前的做法是,在一次resharding过程中,所有的slot迁移使用的configEpoch都是第一个slot迁移时产生的那个configEpoch。

而failover和resharding都会修改hash slot map,如果在resharding的过程中发生了failover,这就有可能导致对hash slot map的修改产生冲突。另外,手动failover也是不经过master投票的,也就是执行CLUSTER FAILOVER命令(带TAKEOVER参数)。

产生冲突就是指针对同一个slot,slot被修改成映射到不同的节点上,并且这些修改具有相同的configEpoch。

为了解决这个问题,Redis cluster需要存在一个冲突解决的机制。如果一个master发现相同的configEpoch,则比较一下两个节点的id,id小的节点,把自己currentEpoch加一,作为自己的configEpoch。

Write safety

由于有冲突的存在,可能导致不同的节点上的hash slot map不一致,取决于连接的节点不同,一部分client可能会把某个slot的key写入到一个节点中,而另外一部分client会把同样slot的key写入到另外一个节点中。当冲突被解决后,其中一个节点上接受的写入会丢失。

另外,由于master和slave之间的数据复制是异步的,在failover时如果slave还没有收到最新的数据,就发生了failover,那么这部分写入就会丢失。Redis cluster在这方面做了一个优化,当一个slave发现master发生了宕机,它不会立即开始选举的过程,它会等待一个时间,这个时间计算公式如下:
DELAY = 500 milliseconds + random delay between 0 and 500 milliseconds + SLAVE_RANK * 1000 milliseconds

这个计算公式中,第一部分是一个固定500毫秒的时间,这是为了给master充分的时间,也发现节点宕机这个事实,第二个是随机等待一段时间,这是为了尽量避免多个slave同时发现master宕机,然后同时开始选举,导致master被瓜分,从而导致所有的选举都不成功。第三部分是slave的rank,rank主要取决于slave的复制进度,复制的数据越多,则rank越小,也就是越短的等待时间,越先开始选举,有更大的可能性被选成新的master。但这仅仅是个优化,不能完全防止丢数据的可能。

参考文献

1. Redis Cluster Specification, https://redis.io/topics/cluster-spec.
2. https://github.com/redis/redis/blob/unstable/src/cluster.c
3. https://github.com/redis/redis/blob/unstable/src/cluster.h
4. https://github.com/redis/redis/blob/unstable/src/server.c
5. https://github.com/redis/redis/blob/unstable/src/server.h    
上一篇:OSS 入门基础篇-自动刷新


下一篇:NoSQL 数据库不应该放弃 Consistency