本文主要内容:
来源于《深入分布式缓存:从原理到实践》第八章 分布式Redis的读书笔记。
Redis作为数据存储系统,无论数据存储在内存中还是持久化到本地,作为单实例节点,在实际应用中总会面临如下挑战:
上述问题对于数据存储系统而言是通用的,基于分布式的解决方案如下:
水平拆分(sharding)为了解决数据量和访问量增加后对单节点造成的性能压力,通常引入水平拆分机制,将数据存储和对数据的访问分散到不同节点上分别处理。水平拆分后的每个节点存储和处理的数据原则上没有交集,使得节点间相互独立;但内部的拆分和多节点通常对外部服务透明,通过数据分布和路由请求的配合,可以做到数据存放和数据访问对水平拆分的适配。
数据分布分布式环境下,有多个Redis实例I[i] (i=0, …, N),同时业务数据的key全集为{k[0], k[1], …, k[M]}。数据分布指的是一种映射关系f,每个业务数据key都能通过这种映射确定唯一的实例I,即f(k)=i,其中k对应的业务数据存放于I[i]。
如何确定这个映射关系的算法?这其实主要取决于Redis的客户端。常用的是映射有3种:
❑ hash映射。为了解决业务数据key的值域不确定这个问题,引入hash运算,将不可控的业务值域key映射到可控的有限值域(hash值)上,且映射做到均匀,再将有限的均匀分布的hash值枚举地映射到Redis实例上。例如,crc16(key)%16384这个hash算法,将业务key映射到了0~16383这一万多个确定的有限整数集合上,再依据一定规则将这个整数集合的不同子集不相交地划分到不同Redis实例上,以此实现数据分布。
❑ 范围映射。和hash映射不同,范围映射通常选择key本身而不是key的某个函数运算值(如hash运算)作为数据分布的条件,且每个数据节点存放的key的值域是连续的一段范围。例如,当0≤key<100时,数据存放到实例1上;当100≤key<200时,数据存放到实例2上;以此类推。key的值域是业务层决定的,业务层需要清楚每个区间的范围和Redis实例数量,才能完整地描述数据分布。这使得业务域的信息(key的值域)和系统域的信息(实例数量)耦合,数据分布无法在纯系统层面实现,从系统层面看来,业务域的key值域不确定、不可控。
❑ hash和范围结合。典型的方式就是一致性hash,首先对key进行hash运算,得到值域有限的hash值,再对hash值做范围映射,确定该key对应的业务数据存放的具体实例。这种方式的优势是节点新增或退出时,涉及的数据迁移量小——变更的节点上涉及的数据只需和相邻节点发生迁移关系;缺点是节点不是成倍变更(数量变成原有的N倍或1/N)时,会造成数据分布的不均匀。
前述水平拆分讨论如何将数据划分到没有交集的各个数据节点上,即,不同节点间没有相同的数据。而在有的场景下,我们需要将相同的数据存放在多个不同的节点上。例如,当某个节点宕机时,其上的数据在其他节点上有副本,使得该数据对外服务可以继续进行,即,数据复制为后续所述的故障转移提供了基础。
再如,同一份数据在多个节点上存储后,写入节点可以和读取节点分离,提升性能。当一份数据落在了多个不同节点上时,如何保证节点间数据的一致性将是关键问题,在不同的存储系统架构下方案不同,有的采用客户端双写,有的采用存储层复制。Redis采用主备复制的方式保证一致性,即所有节点中,有一个节点为主节点(master)它对外提供写入服务,所有的数据变更由外界对master的写入触发,之后Redis内部异步地将数据从主节点复制到其他节点(slave)上。
Redis包含master和slave两种节点:master节点对外提供读写服务;slave节点作为master的数据备份,拥有master的全量数据,对外不提供写服务。
主备复制由slave主动触发,主要流程如图所示。
1)首先slave向master发起SYNC命令。这一步在slave启动后触发,master被动地将新进slave节点加入自己的主备复制集群。
2)master收到SYNC后,开启BGSAVE操作。BGSAVE是Redis的一种全量模式的持久化机制。
3)BGSAVE完成后,master将快照信息发送给slave。
4)发送期间,master收到的来自用户客户端的新的写命令,除了正常地响应之外,都再存入一份到backlog队列。
5)快照信息发送完成后,master继续发送backlog队列信息。
6)backlog发送完成后,后续的写操作同时发给slave,保持实时地异步复制。
在上图的slave侧,处理逻辑如下:
❑ 发送SYNC;
❑ 开始接收master的快照信息,此时,将slave现有数据清空,并将master快照写入自身内存;
❑ 接收backlog内容并执行它,即回放;
❑ 继续接收后续来自master的命令副本并继续回放,以保持数据和master一致。
注意:在slave没有收到master快照文件之前,会根据配置决定使用现有的数据响应客户端还是直接拒绝。
如果有多个slave节点并发发送SYNC命令给master,企图建立主备关系,只要第二个slave的SYNC命令发生在master完成BGSAVE之前,第二个slave将收到和第一个slave相同的快照和后续backlog;否则,第二个slave的SYNC将触发master的第二次BGSAVE。
每次当slave通过SYNC和master同步数据时,master都会dump全量数据并发发送。当一个已经和master完成了同步并持续保持了长时间的slave网络断开很短的时间再重新连上时,master不得不重新做一遍全量dump的传送。然而由于slave只断开了很短时间,重连之后master-slave的差异数据很少,全量dump的数据中绝大部分,slave都已经具有,再次发送这些数据会导致大量无效的开销。最好的方式是,master-slave只同步断开期间的少量数据。
Redis的PSYNC(Partial Sync)可以用于替代SYNC,做到master-slave基于断点续传的主备同步协议。master-slave两端通过维护一个offset记录当前已经同步过的命令,slave断开期间,master的客户端命令会保持在缓存中,在slave重连之后,告知master断开时的最新offset, master则将缓存中大于offset的数据发送给slave,而断开前已经同步过的数据,则不再重新同步,这样减少了数据传输开销。
当两台以上Redis实例形成了主备关系,它们组成的集群就具备了一定的高可用性:当master故障时,slave可以成为新的master,对外提供读写服务,这种运营机制称为failover。剩下的问题在于:谁去发现master的故障做failover的决策?
一种方式是,保持一个daemon进程,监控着所有的master-slave节点,如图所示:
一个Redis集群里有一个master和两个slave,这个daemon进程监视着这三个节点。这种方式的问题在于:daemon作为单点,它本身的可用性无法保证。因此需要引入多daemon,如图所示:
在图中,为了解决一个daemon的单点问题,我们引入了两个daemon进程,同时监视着三个Redis节点。但是,多个daemon的引入虽然解决了可用性问题,但带来了一致性问题:多个daemon之间,如何就某个master是否可用达成一致?比如,daemon1和master之间的网络不通,但master和其余节点均畅通,那么daemon1和daemon2观察到的master可用状态不同,那么如何决策此时master是否需要failover?
Redis的sentinel提供了一套多daemon间的交互机制,解决故障发现、failover决策协商机制等问题,如图所示。
多个daemon组成了一个集群,称为sentinel集群,其中的daemon也被称为sentinel节点。这些节点相互间通信、选举、协商,在master节点的故障发现、failover决策上表现出一致性。
sentinel节点间因为共同监视了同一个master节点从而相互也关联了起来,一个新加入的sentinel节点需要和有相同监视的master的其他sentinel节点相互感知,方式如下:所有需要相互感知的sentinel都向他们共同的master节点上订阅相同的channel:sentinel:hello,新加入的sentinel节点向这个channel发布一条消息,包含了自己信息,该channel的订阅者们就可以发现这个新的sentinel。随后新sentinel和已有的其他sentinel节点建立长连接。sentinel集群中所有节点两两连接,如图所示:
新的sentinel节点加入后,它向master节点发布自己加入这个信息,此时现有的订阅sentinel节点将会发现这条消息从而感知到了新sentinel节点的存在。
sentinel节点通过定期地向master发送心跳包判断其存活状态,称为PING。一旦发现master没有正确地响应,sentinel将此master置为“主观不可用态”,所谓主观,是因为“master不可用”这个判定尚未得到其他sentinel节点的确认。如图所示:
随后它将“主观不可用态”发送给其他所有的sentinel节点进行确认(即图"is-master-down-by-addr"这条交互),当确认的sentinel节点数>=quorum(可配置)时,则判定为该master为“客观不可用”,随后进入failover流程。
当一台master真正宕机后,可能多个sentinel节点同时发现了此问题并通过交互确认了相互的“主观不可用态”猜想,同时达到“客观不可用态”,同时打算发起failover。但是最终只能有一个sentinel节点作为failover的发起者,此时需要开始一个leader选举的过程,选择谁来发起failover。
Redis的sentinel机制采用类似Raft协议实现这个选举算法: