主从模式

image.png

主从同步

增量同步

redis 同步的是指令流,主节点会将那些对自己的状态产生修改性影响的指令记录在本地的内存 buffer 中,然后异步将 buffer 中的指令同步到从节点,从节点一边执行同步的指令流来达到和主节点一样的状态,一边向主节点反馈自己同步到哪里了 (偏移量,这是redis-2.8之后才有的特性)。从节点同步数据的时候不会影响主节点的正常工作,也不会影响自己对外提供读服务的功能,从节点会用旧的数据来提供服务,当同步完成后,需要删除旧数据集,加载新数据,这个时候才会暂停对外服务。
因为内存的 buffer 是有限的,所以 redis 主节点不能将所有的指令都记录在内存 buffer 中。redis 的复制内存 buffer 是一个定长的环形数组,如果数组内容满了,就会从头开始覆盖前面的内容。
image.png

快照同步

如果节点间网络通信不好,那么当从节点同步的速度不如主节点接收新写请求的速度时,buffer 中会丢失一部分指令,从节点中的数据将与主节点中的数据不一致,此时将会触发快照同步。
快照同步是一个非常耗费资源的操作,它首先需要在主节点上进行一次 bgsave 将当前内存的数据全部快照到RDB文件中,然后再将快照文件的内容全部传送到从节点。从节点将RDB文件接受完毕后,立即执行一次全量加载,加载之前先要将当前内存的数据清空。加载完毕后通知主节点继续进行增量同步。
在整个快照同步进行的过程中,主节点的复制buffer 还在不停的往前移动,如果快照同步的时间过长或者复制 buffer 太小,都会导致同步期间的增量指令在复制buffer 中被覆盖,这样就会导致快照同步完成后无法进行增量复制,然后会再次发起快照同步,如此极有可能会陷入快照同步的死循环。所以需要配置一个合适的复制 buffer 大小参数,避免快照复制的死循环
image.png

无盘复制

主节点在进行快照同步时,会进行大量的文件** IO 操作,特别是对于** SSD 磁盘存储时,快照会对系统的负载产生较大影响。特别是当系统正在进行AOF 的 fsync 操作时如果发生快照复制,fsync 将会被推迟执行,这就会严重影响主节点的服务效率
从 Redis 2.8.18 版开始支持无盘复制。所谓无盘复制是主节点会一边遍历内存,一遍将序列化的内容发送到从节点,而不是生成完整的 RDB 文件后才进行 IO 传输从节点还是跟之前一样,先将接收到的内容存储到磁盘文件中,再进行一次性加载。

从从同步

所有的Slave都和Master通信去同步数据也会加大Master节点的负担,实际上,除了主从同步,redis也可以从从同步,我们在这里统一描述为主从同步
image.png

Cluster模式

数据分区

Redis Cluster 则采用的是虚拟槽分区算法。其中提到了槽(Slot)的概念。这个槽是用来存放缓存信息的单位,在 Redis 中将存储空间分成了 16384 个槽,也就是说 Redis Cluster 槽的范围是 0 -16383(2^4 2^10)。
缓存信息通常是用 Key-Value 的方式来存放的,在存储信息的时候,集群会对 Key 进行 CRC16 校验并对 16384 取模(slot = CRC16(key)%16383)。得到的结果就是 Key-Value 所放入的槽,从而实现自动分割数据到不同的节点上。*然后再将这些槽分配到不同的缓存节点中保存。

image.png
此时 Redis Client 需要根据一个 Key 获取对应的 Value 的数据,首先通过 CRC16(key)%16383 计算出 Slot 的值,假设计算的结果是 5002。
将这个数据传送给 Redis Cluster,集群接受到以后会到一个对照表中查找这个 Slot=5002 属于那个缓存节点。
发现属于“缓存节点 2”,于是顺着红线的方向调用缓存节点 2 中存放的 Key-Value 的内容并且返回给 Redis Client。

分布式缓存节点之间的通信

image.png
假设 Redis Cluster 中存在“缓存节点 1”,此时由于业务扩展新增了“缓存节点 2”。
新加入的节点会通过 Gossip 协议向老节点,发出一个“Meet 消息”。收到消息以后“缓存节点 1”,会礼貌地回复一个“Pong 消息”
此后“缓存节点 2”会定期发送给“缓存节点 1” 一个“Ping 消息”,同样的“缓存节点 1”每次都会回复“Pong 消息”。
其实节点之间通讯的目的是为了维护节点之间的元数据信息。这个元数据就是每个节点包含哪些数据,是否出现故障。

维护集群的元数据一种集中式,一种叫做gossip。 集中式:好处在于,元数据的更新和读取,时效性非常好,一旦元数据出现了变更,立即就更新到集中式的存储中,其他节点读取的时候立即就可以感知到; 不好在于,所有的元数据的跟新压力全部集中在一个地方,可能会导致元数据的存储有压力

整个传输过程大致分为以下几点:
· Redis Cluster 的每个缓存节点都会开通一个独立的 TCP 通道,用于和其他节点通讯。
· 有一个节点定时任务,每隔一段时间会从系统中选出“发送节点”。这个“发送节点”按照一定频率,例如:每秒 5 次,随机向最久没有通讯的节点发起 Ping 消息。
· 接受到 Ping 消息的节点会使用 Pong 消息向“发送节点”进行回复。

从类型上来说其分为了四种,分别是:
· Meet 消息,用于通知新节点加入。就好像上面例子中提到的新节点上线会给老节点发送 Meet 消息,表示有“新成员”加入。
· Ping 消息,这个消息使用得最为频繁,该消息中封装了自身节点和其他节点的状态数据,有规律地发给其他节点
· Pong 消息,在接受到 Meet 和 Ping 消息以后,也将自己的数据状态发给对方。同时也可以对集群中所有的节点发起广播,告知大家的自身状态。
· Fail 消息,如果一个节点下线或者挂掉了,会向集群中广播这个消息。

请求分布式缓存的路由

上文提到了 Gossip 协议会将每个节点管理的槽信息发送给其他节点,其中用到了 unsigned char myslots[CLUSTER_SLOTS] 这样一个数组存放每个节点的槽信息。
这个数组的长度为 16384/8=2048 个字节,由于每个字节包含 8 个 bit 位(二进制位),所以共包含 16384 个 bit,也就是 16384 个二进制位。
每个节点用 bit 来标识自己是否拥有某个槽的数据。如图 4 所示,假设这个图表示节点 A 所管理槽的情况。
image.png
用二进制存放的优点是,判断的效率高,例如对于编号为 1 的槽,节点只要判断序列的第二位,时间复杂度为 O(1)
image.png如图所示,当收到发送节点的节点槽信息以后,接受节点会将这些信息保存到本地的 clusterState 的结构中,其中 Slots 的数组就是存放每个槽对应哪些节点信息。
image.png
如图所示,ClusterState 中保存的 Slots 数组中每个下标对应一个槽,每个槽信息中对应一个 clusterNode 也就是缓存的节点。
这些节点会对应一个实际存在的 Redis 缓存服务,包括 IP 和 Port 的信息。
Redis Cluster 的通讯机制实际上保证了每个节点都有其他节点和槽数据的对应关系。
Redis 的客户端无论访问集群中的哪个节点都可以路由到对应的节点上,因为每个节点都有一份 ClusterState,它记录了所有槽和节点的对应关系。
**

MOVED 重定向请求

image.png
Redis 客户端通过 CRC16(key)%16383 计算出 Slot 的值,发现需要找“缓存节点 1”读/写数据,但是由于缓存数据迁移或者其他原因导致这个对应的 Slot 的数据被迁移到了“缓存节点 2”上面。
那么这个时候 Redis 客户端就无法从“缓存节点 1”中获取数据了。
但是由于“缓存节点 1”中保存了所有集群中缓存节点的信息,因此它知道这个 Slot 的数据在“缓存节点 2”中保存,因此向 Redis 客户端发送了一个 MOVED 的重定向请求。
这个请求告诉其应该访问的“缓存节点 2”的地址。Redis 客户端拿到这个地址,继续访问“缓存节点 2”并且拿到数据。
上面的例子说明了,数据 Slot 从“缓存节点 1”已经迁移到“缓存节点 2”了,那么客户端可以直接找“缓存节点 2”要数据

ASK 重定向请求

image.png

MOVED和 ASK

  • 两者都是客户端重定向;
  • moved异常说明槽已经确定迁移;
  • ask 异常说明槽还在迁移中,客户端访问的键有可能在 source 节点,有可能在 target 节点;


缓存节点的扩展和收缩

image.png
新节点加入到集群的时候,作为孤儿节点是没有和其他节点进行通讯的。因此,其会采用 cluster meet 命令加入到集群中。
在集群中任意节点执行 cluster meet 命令让新节点加入进来。假设新节点是 192.168.1.1 5002,老节点是 192.168.1.1 5003,那么运行以下命令将新节点加入到集群中。
**_192.168.1.1 5003> cluster meet 192.168.1.1 5002_**
这个是由老节点发起的,有点老成员欢迎新成员加入的意思。新节点刚刚建立没有建立槽对应的数据,也就是说没有缓存任何数据。
如果这个节点是主节点,需要对其进行槽数据的扩容;如果这个节点是从节点,就需要同步主节点上的数据。总之就是要同步数据。
image.png如图所示,由客户端发起节点之间的槽数据迁移,数据从源节点往目标节点迁移。

故障发现和恢复

主观下线

当节点 1 向节点 2 例行发送 Ping 消息的时候,如果节点 2 正常工作就会返回 Pong 消息,同时会记录节点 1 的相关信息。
同时接受到 Pong 消息以后节点 1 也会更新最近一次与节点 2 通讯的时间。
如果此时两个节点由于某种原因断开连接,过一段时间以后节点 1 还会主动连接节点 2,如果一直通讯失败,节点 1 中就无法更新与节点 2 最后通讯时间了。
此时节点 1 的定时任务检测到与节点 2 最好通讯的时间超过了 cluster-node-timeout 的时候,就会更新本地节点状态,把节点 2 更新为主观下线。
这里的 cluster-node-timeout 是节点挂掉被发现的超时时间,如果超过这个时间还没有获得节点返回的 Pong 消息就认为该节点挂掉了。
这里的主观下线指的是,节点 1 主观的认为节点 2 没有返回 Pong 消息,因此认为节点 2 下线。
只是节点 1 的主观认为,有可能是节点 1 与节点 2 之间的网络断开了,但是其他的节点依旧可以和节点 2 进行通讯,因此主观下线并不能代表某个节点真的下线了

客观下线

由于 Redis Cluster 的节点不断地与集群内的节点进行通讯,下线信息也会通过 Gossip 消息传遍所有节点。
因此集群内的节点会不断收到下线报告,当半数以上持有槽的主节点标记了某个节点是主观下线时,便会触发客观下线的流程。
也就是说当集群内的半数以上的主节点,认为某个节点主观下线了,才会启动这个流程。
将主观下线的报告保存到本地的 ClusterNode 的结构中,并且针对主观下线报告的时效性进行检查,如果超过 cluster-node-timeout2 的时间,就忽略这个报告。
否则就记录报告内容,并且比较*被标记下线的主观节点的报告数量大于等于持有槽的主节点数量的时候,将其标记为客观下线。
同时向集群中广播一条 Fail 消息,通知所有的节点将故障节点标记为客观下线,这个消息指包含故障节点的 ID。
此后,群内所有的节点都会标记这个节点为客观下线,通知故障节点的从节点触发故障转移的流程,也就是故障的恢复。

恢复流程

  1. 资格检查每个节点都会检查与主节点断开的时间。如果这个时间超过了 cluster-node-timeout*cluster-slave-validity-factor(从节点有效因子,默认为 10),那么就没有故障转移的资格。也就是说这个从节点和主节点断开的太久了,很久没有同步主节点的数据了,不适合成为新的主节点,因为成为主节点以后其他的从节点回同步自己的数据。
  2. 触发选举,通过了上面资格的从节点都可以触发选举。但是触发选举是有先后顺序的,这里按照复制偏移量的大小来判断,偏移量越大越早触发。这个偏移量记录了执行命令的字节数。主服务器每次向从服务器传播 N 个字节时就会将自己的复制偏移量+N,从服务在接收到主服务器传送来的 N 个字节的命令时,就将自己的复制偏移量+N。复制偏移量越大说明从节点延迟越低,也就是该从节点和主节点沟通更加频繁,该从节点上面的数据也会更新一些,因此复制偏移量大的从节点会率先发起选举。
  3. 发起选举,首先每个主节点会去更新配置纪元(clusterNode.configEpoch),这个值是不断增加的整数。在节点进行 Ping/Pong 消息交互式也会更新这个值,它们都会将最大的值更新到自己的配置纪元中。这个值记录了每个节点的版本和整个集群的版本。每当发生重要事情的时候,例如:出现新节点,从节点精选。都会增加全局的配置纪元并且赋给相关的主节点,用来记录这个事件。
  4. 投票选举,参与投票的只有主节点,从节点没有投票权,超过半数的主节点通过某一个节点成为新的主节点时投票完成。这里每个候选的从节点会收到其他主节点投的票。在第2步领先的从节点通常此时会获得更多的票,因为它触发选举的时间更早一些。获得票的机会更大,也是由于它和原主节点延迟少,理论上数据会更加新一点。当满足投票条件的从节点被选出来以后,会触发替换主节点的操作。新的主节点别选出以后,删除原主节点负责的槽数据,把这些槽数据添加到自己节点上。


哨兵模式

哨兵职责

  • 监控(Monitoring): Sentinel 会不断地定期检查你的主服务器和从服务器是否运作正常。
  • 提醒(Notification): 当被监控的某个 Redis 服务器出现问题时, Sentinel 可以通过 API 向管理员或者其他应用程序发送通知。
  • 自动故障迁移(Automaticfailover): 当一个主服务器不能正常工作时, Sentinel 会开始一次自动故障迁移操作, 它会将失效主服务器的其中 一个从服务器升级为新的主服务器, 并让失效主服务器的其他从服务器改为复制新的主服务器; 当客 户端试图连接失效的主服务器时, 集群也会向客户端返回新主服务器的地址, 使得集群可以使用新主 服务器代替失效服务器。

    哨兵leader选举流程

    如果主节点被判定为客观下线之后,就要选取一个哨兵节点来完成后面的故障转移工作,选举出一个leader的流程如下:
  1. 每个在线的哨兵节点都可以成为领导者,当它(比如哨兵3)确认主节点下线时,会向其它哨兵发is-master-down-by-addr命令,征求判断并要求将自己设置为领导者,由领导者处理故障转移;
  2. 当其它哨兵收到此命令时,可以同意或者拒绝它成为领导者;
  3. 如果哨兵3发现自己在选举的票数大于等于num(sentinels)/2+1时,将成为领导者,如果没有超过,继续选举


从节点中选择新的主节点机制选举流程

sentinel状态数据结构中保存了主服务的所有从服务信息,leader sentinel按照如下的规则从从服务列表中挑选出新的主服务

  1. 过滤掉主观下线的节点
  2. 选择slave-priority最高的节点,如果有则返回没有就继续选择
  3. 选择出复制偏移量最大的系节点,因为复制便宜量越大则数据复制的越完整,如果由就返回了,没有就继续
  4. 选择run_id最小的节点

Sentinel的工作原理总结

  1. 每个Sentinel以每秒钟一次的频率向它所知的Master,Slave以及其他 Sentinel 实例发送一个 PING 命令
  2. 如果一个实例(instance)距离最后一次有效回复 PING 命令的时间超过 down-after-milliseconds 选项所指定的值, 则这个实例会被 Sentinel 标记为主观下线
  3. 如果一个Master被标记为主观下线,则正在监视这个Master的所有** Sentinel 要以每秒一次的频率确认**Master的确进入了主观下线状态。
  4. 当有足够数量的 Sentinel(大于等于配置文件指定的值)在指定的时间范围内确认Master的确进入了主观下线状态, 则Master会被标记为客观下线 。
  5. 在一般情况下, 每个 Sentinel 会以每** 10 秒一次的频率向它已知的所有Master,Slave发送 INFO 命令** 。
  6. 当Master被 Sentinel 标记为客观下线时,Sentinel 向下线的 Master 的所有 Slave 发送 INFO 命令的频率会从 10 秒一次改为每秒一次 。
  7. 若没有足够数量的 Sentinel 同意 Master 已经下线, Master 的客观下线状态就会被移除。 若 Master 重新向 Sentinel 的 PING 命令返回有效回复, Master 的主观下线状态就会被移除

Redis Cluster和哨兵模式得对比

  • 哨兵模式监控权交给了哨兵系统,集群模式中是工作节点自己做监控
  • 哨兵模式发起选举是选举一个leader哨兵节点来处理故障转移,集群模式是在从节点中选举一个新的主节点,来处理故障的转移


哈希槽和一致性hash的对比

当发生扩容时候,哈希槽采用灵活的可配置映射表,可以随意组织映射到新增server上面的slot数,比一致性hash的算法更灵活方便;同时也给开发人员手工配置更大的简洁性。
其次,在数据迁移时,一致性hash 需要算哪些key是落在新增服务节点的数据,然后迁移这部分数据,哈希槽则直接将一个slot对应的数据全部迁移,算法明确以及实现更简单。

Redis锁

SetNX

目前通常所说的setnx命令,并非单指redis的setnx key value这条命令。一般代指redis中对set命令加上nx参数进行使用。
SET key value [EX seconds|PX milliseconds] [NX|XX] [KEEPTTL]

因为redis版本在2.6.12之前,set是不支持nx参数的。 如果想要完成一个锁,那么需要两条命令:setnx Test uuid\expire Test 30 无法保证原子性。 官网(set命令页),也早早就说明了“SETNX, SETEX, PSETEX可能在未来的版本中,会弃用并永久删除”。

image.png
setnx大致原理,主要依托了它的key不存在才能set成功的特性,进程A拿到锁,在没有删除锁的Key时,进程B自然获取锁就失败了。
那么为什么要使用PX 30000
是怕
进程**A不讲道理啊,锁没等释放呢,万一崩了,直接原地把锁带走了,导致系统中谁也拿不到锁。
就算这样,还是不能保证万无一失。如果进程A又不讲道理,操作锁内资源超过笔者设置的超时时间,那么就会导致其他进程拿到锁,等进程A回来了,回手就是把其他进程的锁删了,如图:

image.png
所以在用setnx的时候,key虽然是主要作用,但是value也不能闲着,可以设置一个唯一的客户端**ID,或者用UUID这种随机数。当解锁的时候,先获取value判断是否是当前进程加的锁,再去删除。
伪代码如图所示:image.png
相反,这回的问题更明显了,在finally代码块中,
get和del并非原子操作,还是有进程安全问题。那么删除锁的正确姿势之一,就是可以使用lua脚本,通过redis的eval/evalsha命令来运行:
image.png通过
lua脚本能保证原子性。说的通俗一点:就算你在lua里写出花,执行也是一个命令(eval/evalsha**)去执行的,一条命令没执行完,其他客户端是看不到的。

RedLock

redis官方提出的一种分布式锁的算法。算法很易懂,起 5 个 master 节点,分布在不同的机房尽量保证可用性。为了获得锁,client 会进行如下操作:

  1. 得到当前的时间,微秒单位
  2. 尝试顺序地在 5 个实例上申请锁,当然需要使用相同的 key 和 random value,这里一个 client 需要合理设置与 master 节点沟通的 timeout 大小,避免长时间和一个 fail 了的节点浪费时间
  3. 当 client 在大于等于 3 个 master 上成功申请到锁的时候,且它会计算申请锁消耗了多少时间,这部分消耗的时间采用获得锁的当下时间减去第一步获得的时间戳得到,如果锁的持续时长(lock validity time)比流逝的时间多的话,那么锁就真正获取到了。
  4. 如果锁申请到了,那么锁真正的 lock validity time 应该是 origin(lock validity time) - 申请锁期间流逝的时间
  5. 如果 client 申请锁失败了,那么它就会在少部分申请成功锁的 master 节点上执行释放锁的操作,重置状态

此种方式比原先的单节点的方法更安全。它可以保证以下特性:

  1. 安全特性:互斥访问,即永远只有一个 client 能拿到锁
  2. 避免死锁:最终 client 都可能拿到锁,不会出现死锁的情况,即使原本锁住某资源的 client crash 了或者出现了网络分区
  3. 容错性:只要大部分 Redis 节点存活就可以正常提供服务