Redis复习笔记-分布式篇

Redis主从复制(replication)

配置

  1. 配置文件中添加:
replicaof [host ip] [port]
//添加在每个slave节点中

从节点启动后,会自动连接到master节点,开始同步数据。如果master节点更改了,比如原来的master节点宕机了,选举了新的master节点,这个配置项就会被重写。

  1. 在启动服务器时,通过参数直接指定master节点:
./redis-server --slaveof [host ip] [port]
  1. 在客户端直接执行slaveof [host ip] [port],使该Redis实例成为从节点。

从节点也可以是其他节点的主节点,形成级联复制的关系。可以通过info replication 查看集群状态。**从节点是只读的,不能执行写操作,执行写操作命令会报错。**数据在主节点写入后,slave节点会自动从主节点同步数据。

取消主从关系:

  1. 将配置文件中的replicaof 指令去除,此时从节点会变为主节点,不再复制数据。
  2. 直接运行指令slaveof no one断开连接,此时从节点会变为主节点,不再复制数据。

主从复制原理

Redis主从复制分为两类:

  • 全量复制:一个节点第一次连接到主节点,需要全部的数据
  • 增量复制:之前已经连接到master节点,但是中间网络断开,或者slave节点宕机了,缺失了一部分数据。

连接阶段:

  1. 从节点启动时,会在自己本地保存主节点的信息,包括主节点的host和ip
  2. 从节点内部有个定时任务replicationCron,每隔一秒钟检查是否有新的主节点需要连接和复制。如果发现有主节点需要连接,就跟主节点建立连接。如果连接成功,从节点就让一个专门处理复制工作的文件事件处理器负责后续的复制工作。为了让主节点感知到从节点的存活,从节点会定时向主节点发送ping请求。

建立连接以后,就可以同步数据了,这里也分成两个阶段。

数据同步阶段:

如果是新加入的从节点,那就需要全量复制,主节点通过bgsave命令在本地生成一份RDB快照,将RDB快照文件发给从节点(如果超时会重连,可以调大repl-timeout的值)。

如果从节点自己本来就有数据怎么办?

从节点首先需要清除自己的旧数据,然后使用RDB文件加载数据。

主节点生成RDB期间,接收到写命令怎么处理?

开始生成RDB文件时,主节点会把所有新的写命令缓存在内存中。在从节点保存了RDB之后,再将新的写命令复制给从节点。(与AOF重写rewrite期间接受到的命令的处理思路是一样的)

第一次全量同步完了,主从已经保持一致了,后面就是持续把接受到的命令发送给从节点。

命令传播阶段:

主节点持续把写命令,异步复制给从节点。一般情况下,我们不会使用Redis做读写分离,因为Redis的吞吐量已经够高了,做集群分片之后并发的问题更少,所以不需要考虑主从延迟的问题。只能通过优化网络来改善。

第二种情况是增量复制:

如果从节点有一段时间断开了与主节点的连接,是不是要把原来的数据全部清空,重新全量复制一遍?效率太低了。增量复制中从节点通过master_repl_offset记录了偏移量,以便使用增量复制。

为了降低主节点磁盘开销,Redis从6.0开始支持无盘复制,主节点生成的RDB文件不再保存到磁盘,而是直接通过网络发送给从节点。无盘复制适用于主节点所在机械磁盘性能较差但是网络宽带比较富裕的场景。


主从复制的不足

主从复制并没有解决高可用的问题。在一主一从或者一主多从的情况下,如果服务器挂了,对外提供的服务就不可用了,单点问题没有得到解决。如果每次都是手动把之前的从服务器切换成主服务器,然后再把剩余节点设置为它的从节点,这就比较费事费力,还会造成一定时间的服务不可用。


可用性保证之Sentinel

原理

如何实现高可用呢?通过运行监控服务器来保证服务的可用性。如果主节点超过一定时间没有给监控服务器发送心跳报文,就把主节点标记为下线,然后把某一个从节点变成主节点,应用每一次都是从这个监控服务器拿到主节点的地址。2.8版本后,提供了一个稳定版本的Sentinel,用来解决高可用的问题。

我们会启动奇数个数的Sentinel的服务,启动方式:

  • 通过Sentinel的脚本启动:
./redis-sentinel ../sentinel.conf
  • 使用redis-server的脚本加Sentinel参数启动:
./redis-server ../sentinel.conf --sentinel

Sentinel本质上只是一个运行在特殊模式之下的Redis。Sentinel通过info命令得到被监听Redis机器的主从节点等信息。为了保证监控服务器的可用性,我们会对Sentinel做集群的部署。Sentinel既监控了所有的服务,Sentinel之间也有互相监控。Sentinel本身没有主从之分,地位是平等的,只有Redis服务节点有主从之分。

Sentinel是如何直到其他Sentinel节点的存在的?

Sentinel是一个特殊状态的Redis节点,它也具有订阅发布功能。Sentinel上线时,给所有的Redis节点的名字为:_sentinel_:hello的channel发送消息。每个哨兵都订阅了所有Redis节点名字为_sentinel_:hello的channel,所以可以互相感知对方的存在,而进行监控。


功能实现

服务下线

Sentinel是如何知道主节点宕机了的?

Sentinel默认每秒一次向Redis服务节点发送ping命令,如果在指定的时间内没有收到有效回复,Sentinel会将该服务器标记为下线。(主观下线)。由参数:# sentinel conf控制。默认是30秒。但是,只有你发现主节点下线,并不代表主节点真的下线了,也有可能是自己的网络问题。所以这个时候第一个发现主节点下线的Sentinel节点会继续询问其他的Sentinel节点,确认这个节点是否下线。如果quorum数量的Sentinel节点都认为主节点下线,主节点在被真正确认下线(客观下线)。

quorum:确认客观下线的最少的哨兵数量,通过配置项进行设定

确定主节点下线之后,就需要重新选举主节点。Sentinel集群此时便开始故障转移,从从节点中选举一个节点作为主节点。

故障转移

由Sentinel完成。如果需要从redis集群中选举一个节点作为主节点,首先需要从Sentinel集群中选举一个Sentinel节点作为Leader。Sentinel使用与原生略有区别的Raft算法完成。

原生-Raft:

核心思想:先到先得,少数服从多数。Spring cloud 注册中心解决方案Consul也使用到了Raft协议。

文字描述:

  1. 分布式环境中节点有三个状态:Follower,Candidate(虚线外框),Leader(实现外框)
  2. 一开始所有的节点都是Follower状态,如果Follower连接不到Leader(Leader挂了),他就会成为Candidate。Candidate请求其他节点的投票,其他的节点会投给它。如果它得到了大多数节点的投票,他就成为了Leader。这个过程就叫做Leader Election。
  3. 现在所有的写操作需要在Leader节点上发生。Leader会记录操作日志。没有同步到其他Follower节点的日志,状态是uncommitted。等到超过半数的Follower同步了这条记录,日志状态就会变成committed。Leader会通知所有的Follower日志已经committed,这个时候所有的节点就达成了一致。这个过程叫做Log Replication。
  4. 在Raft协议中,选举的时候有两个超时时间。其中一个叫做election timeout(另一个叫做heartbeat timeout)。为了防止同一时间大量节点参与选举,每个节点在变成Candidate之前需要随机等待一段时间,时间范围是150ms~300ms之间。第一个变成Candidate的节点会先发起投票,他会先投给自己,然后请求其他节点的投票。
  5. 如果还没有收到投票结果,又到了超时时间,需要重置超时时间。只要有大部分节点投给了一个节点,他就会变成Leader。
  6. 成为了Leader之后,它会发送消息来让同步数据,发送消息的间隔是由heartbeat timeout来控制的。Followers会回复同步数据的消息。
  7. 只要Followers收到了同步数据的消息,代表Leader没挂,他们就会清除heartbeat timeout的计时。
  8. 但是一旦Follows在heartbeat timeout时间之内没有收到同步数据的消息,他就会认为Leader挂了,便开始让其他节点投票,成为新的Leader。
  9. 必须超过半数以上节点投票,保证只有一个Leader被选出来。
  10. 如果有两个Follower同时变成了Candidate,就会出现分割投票。但是因为他们的election timeout不同,在发起新的一轮选举的时候,有一个节点会优先收到更多的投票,所以只有会产生一个Leader。

Sentinel的Raft协议在此基础之上,略有不同:

  1. master客观下线触发选举,而不是过了election timeout时间开始选举。
  2. Leader并不会把自己成为了Leader的消息发送给其他Sentinel。其他Sentinel等待Leader从从节点选出主节点之后,检测到新的主节点正常工作之后,就会去掉主节点客观下线的标识,从而不需要进入故障转移流程。
  3. 如果一个Sentinel节点获得的选票数达到Leader最低票数(quorum和Sentinel节点数/2+1的最大值),则该Sentinel节点选举为Leader,否则重新进行选举。
  4. 被请求投票的Sentinel节点如果没有同意过其他Sentinel节点的选举请求,则同意该请求(选举票数+1),否则不同意。

Leader确定以后,开始对redis其余节点进行故障转移:

对于所有的从节点,按照以下顺序来进行选举主节点:

  1. 如果与哨兵节点断开的时间超过了某个阈值,就会直接失去选举权
  2. 比对优先级,选择优先级最高的从节点作为主节点,如果不存在则继续。优先级在配置文件中可以设置:slave_priority 100 数值越小优先级越高。
  3. 如果优先级相同,就看谁从主节点中复制的数据最多,也就是选择复制偏移量最大的从节点作为主节点,
  4. 选择runid(redis每次启动的时候生成随机的runid作为redis的标识)最小的从节点作为主节点。

至此,主节点选举完毕。

为什么Sentinel集群至少是3个?

如果Sentinel集群中只有2个节点,那么Leader最低票数至少为2,当该集群中有一个节点故障后,仅剩的一个节点便永远无法成为Leader。

确定了主节点之后,对将要成为主节点的从节点发送slaveof no one 让它成为独立节点,并对其他从节点发送slaveof [master ip] [master port],让他们成为这个主节点的从节点,故障转移完成。

哨兵机制的不足

主从切换的过程中会丢失数据,因为只有一个主节点。只能单点写,没有解决水平扩容的问题。如果数据量十分大,就需要对Redis进行数据分片。


Redis 数据分片

实现Redis数据分片,有三种解决方案:

1. 客户端 Sharding

redis修改主节点 redis指定主节点_Redis

Jedis客户端中,支持分片功能。RedisTemplate就是对Jedis的封装。

ShardedJedis:

Jedis有几种连接池,其中有一种支持分片。

redis修改主节点 redis指定主节点_redis修改主节点_02

这里通过这个连接池分别连接到两个Redis服务。插入一百条数据后,发现一台服务器上有44个key,另外一台为56个key。

ShardedJedis是如何做到的?

如果希望数据分布相对均匀,首先可以考虑哈希后取模,因为key不一定是整数,所以先计算哈希。例如:hash(key)%N,根据余数,决定映射到哪个节点。这种方式比较简单,属于静态的分片规则,但是一旦节点数量发生变化(新增或者减少),由于取模的N发生变化,数据就需要重新分布。为了解决这个问题,又使用了一致性哈希算法

ShardedJedis实际上就是使用一致性哈希算法。原理是:

把所有的哈希值空间组织成一个虚拟的圆环(哈希环),整个空间按照顺时针方向组织。因为是圆环,所以0和2^32-1是重叠的。

redis修改主节点 redis指定主节点_redis修改主节点_03

假如有四台机器要哈希环来实现映射(分布数据),我们就先根据机器的名称或者IP计算哈希值,然后分布到圆环中。对key计算后,得到它在哈希环中对应的位置。沿着哈希环顺时针找到第一个Node,就是数据存储的节点。

一致性哈希解决了动态增减节点时,所有数据都需要重新分布的问题,它只会影响到下一个相邻的节点,对其他节点没有影响。但是也存在一个缺点:

因为节点不一定是均匀分布的,特别是在节点数量比较少的情况下,所以数据不能得到均匀分布,为了解决这个问题,我们需要引入虚拟节点

redis修改主节点 redis指定主节点_redis修改主节点_04

那么,节点是怎么实现的?

redis修改主节点 redis指定主节点_数据_05

jedis实例被放到了一颗红黑树TreeMap()中,当存取键值对时,计算键的哈希值,然后从红黑树上摘下比该值大的第一个节点上的JedisShardInfo ,随后从resources取出Jedis。在jedis.getShard(“k”+i).getClient()获取到真正的客户端。

public R getShard(String key) {
    return resources.get(getShardInfo(key));
  }
  
 
public S getShardInfo(byte[] key) {
    // 获取比当前key的哈希值要大的红黑树的子集
    SortedMap<Long, S> tail = nodes.tailMap(algo.hash(key));
    if (tail.isEmpty()) {
      // 没有比它大的了,直接从nodes中取出
      return nodes.get(nodes.firstKey());
    }
    // 返回第一个比它大的JedisShardInfo
    return tail.get(tail.firstKey());
  }

使用ShardedJedis之类的客户端分片代码的优势是配置简单,不依赖于其他中间件,分区的逻辑自定义,比较灵活。但是居于客户端的方案,不能实现动态的服务增减,每个客户端需要自行维护分片策略,存在重复代码。


2. 代理 Proxy

redis修改主节点 redis指定主节点_redis_06

典型的代理分区的方案有Twitter开源的Twemproxy和国内的豌豆荚开源的Codis。

Twemproxy

redis修改主节点 redis指定主节点_Redis_07

**优点:**比较稳定,可用性高

缺点:

  1. 出现故障不能自动转移,架构复杂,需要借助其他组件(LVS/HAProxy + Keepalived)实现高可用
  2. 扩缩容需要修改配置,不能实现平滑地扩缩容(需要重新分布数据)。
Codis

是一个代理中间件,使用Go进行开发,跟数据库分库分表中间件的MyCat的工作层次是一样的,

redis修改主节点 redis指定主节点_Redis_08

客户端连接Codis和连接Redis没有区别。

redis修改主节点 redis指定主节点_Redis_09

**分片原理: **

Codis把所有的key分成了N个槽,每个槽对应一个分组,一个分组对应一个或一组Redis实例。Codis对key进行CRC32运算,得到一个32位的数字,然后模以N,得到余数,这个就是key对应的槽,槽后面就是Redis的实例,

如果需要解决单点问题,Codis也需要做集群部署,多个Codis节点需要运行一个Zookeeper或etcd/本地文件。

新增节点: 可以为节点指定特定的槽位,Codis也提供了自动均衡策略。

获取数据: 在Redis中的各个实例里获取到符合的key,然后再汇总到Codis中。


3. Redis Cluster

在Redis 3.0版本正式推出,用来解决分布式的需求,同时也可以实现高可用。跟Codis不一样,它是去中心化的,客户端可以连接到任意一个可用节点。

redis修改主节点 redis指定主节点_redis_10

可以看作是由多个Redis实例组成的数据集合。客户端不需要关注数据的子集到底存在哪个节点,只需要关注这个集合整体。

数据分布

使用虚拟槽来实现。Redis创建了16383个槽,每个节点负责一定区间的slot。

redis修改主节点 redis指定主节点_数据_11

对象分布到Redis节点上时,对key用CRC16算法计算再%16384,得到一个slot的值,数据落到负责这个slot的Redis节点上。

为什么slot是16384个?

CRC16算法产生的hash值有16bit,该算法可以产生2^16-=65536个值。换句话说,值是分布在0~65535之间。那作者在做mod运算的时候,为什么不mod65536,而选择mod16384?很幸运的是,这个问题,作者是给出了回答的!

The reason is:

  • Normal heartbeat packets carry the full configuration of a node, that can be replaced in an idempotent way with the old in order to update an old config. This means they contain the slots configuration for a node, in raw form, that uses 2k of space with16k slots, but would use a prohibitive 8k of space using 65k slots.
  • At the same time it is unlikely that Redis Cluster would scale to more than 1000 mater nodes because of other design tradeoffs.

So 16k was in the right range to ensure enough slots per master with a max of 1000 maters, but a small enough number to propagate the slot configuration as a raw bitmap easily. Note that in small clusters the bitmap would be hard to compress because when N is small the bitmap would have slots/N bits set that is a large percentage of bits set.

转换理解以下就是:

  1. 如果槽位为65536,发送心跳信息的消息头达8k,发送的心跳包过于庞大。如上所述,在消息头中,最占空间的是myslots[CLUSTER_SLOTS/8]。当槽位为65536时,这块的大小是:
    65536÷8÷1024=8kb 因为每秒钟,redis节点需要发送一定数量的ping消息作为心跳包,如果槽位为65536,这个ping消息的消息头太大了,浪费带宽。
  2. redis的集群主节点数量基本不可能超过1000个。
    如上所述,集群节点越多,心跳包的消息体内携带的数据越多。如果节点过1000个,也会导致网络拥堵。因此redis作者,不建议redis cluster节点数量超过1000个。
    那么,对于节点数在1000以内的redis cluster集群,16384个槽位够用了。没有必要拓展到65536个。
  3. 槽位越小,节点少的情况下,压缩比高Redis主节点的配置信息中,它所负责的哈希槽是通过一张bitmap的形式来保存的,在传输过程中,会对bitmap进行压缩,但是如果bitmap的填充率slots / N很高的话(N表示节点数),bitmap的压缩率就很低。
    如果节点数很少,而哈希槽数量很多的话,bitmap的压缩率就很低。

可以通过指令查看key属于哪个slot:cluster keyslot [key]

如何让相关的数据落到同一个节点上?

有些操作时不可以跨节点执行的。比如:multi key

解决方案:在key里加入{hash tag}即可,Redis在计算编号的时候会只获取{}之间的字符串进行槽号计算,这样由于上面两个不同的键,{}里面的字符串是相同的,因此他们可以被计算出相同的槽。

客户端连接到哪一台服务器?访问的数据不在当前节点上,怎么办?

那么我们就需要让客户端重定向。

客户端重定向

如果我操作指令:set redis 1返回(error) MOVED 13724 127.0.0.1:7293。则表示根据key计算出来的slot不归现在的端口管理,需要切换到7293端口去操作。这个时候,我们需要更换端口:redis-cli -p 7293操作,才会返回OK。这样客户端需要连接两次。

Jedis等客户端会在本地维护一份slot-node的映射关系,所以大部分时候都不需要重定向。

数据迁移

如果新增或下线了主节点,数据应该怎么迁移(重新分配)?

因为key-slot的关系永远不会变,所以当新增了节点的时候,把原来的slot分配给新的节点负责,并且把相关的数据迁移过来。

redis-cli --cluster add-node 127.0.0.1:7291 127.0.0.1:7297
//添加一个新节点(新增一个7297)

redis-cli --cluster reshard 127.0.0.1:7291
//新增的节点没有哈希槽,不能分布数据,在原来的任意一个节点执行,使得被分配的原节点重新分配slot

//输入需要分配的哈希槽的数量,和哈希槽的来源节点(可以输入all或者id)
高可用和主从切换原理

只有主节点可以写,如果一个主节点挂了,从节点怎么变成主节点?

当从节点发现自己的主节点变为FAIL状态时,便尝试进行Failover,以期成为新的主节点。由于挂掉的主节点可能会有多个从节点,从而存在多个从节点竞争成为主节点的过程:

  1. 从节点发现自己的主节点变为FAIL
  2. 将自己记录的集群currentEpoch+1,并广播FAILOVER_AUTH_REQUEST信息
  3. 其它节点收到该信息,只有主节点响应,判断请求者的合法性,并发送FAILOVER_AUTH_ACK,对每一个epoch只发送一次ack(相当于投票只能投一次)
  4. 尝试failover的从节点收集FAILOVER_AUTH_ACK
  5. 超过半数后变成新的主节点
  6. 广播ping通其他集群节点

currentEpoch: 可以当做记录集群状态变更的递增版本号.集群节点创建时,不管是 master 还是 slave,都置 currentEpoch 为 0。当前节点接收到来自其他节点的包时,如果发送者的 currentEpoch(消息头部会包含发送者的 currentEpoch)大于当前节点的currentEpoch,那么当前节点会更新 currentEpoch 为发送者的 currentEpoch。因此,集群中所有节点的 currentEpoch 最终会达成一致,相当于对集群状态的认知达成了一致 。

作用是: 当集群的状态发生改变,某个节点为了执行一些动作需要寻求其他节点的同意时,就会增加 currentEpoch 的值。当 slave A 发现其所属的 master 下线时,就会试图发起故障转移流程。首先就是增加 currentEpoch 的值,这个增加后的 currentEpoch 是所有集群节点中最大的。然后slave A 向所有节点发起拉票请求,请求其他 master 投票给自己,使自己能成为新的 master。其他节点收到包后,发现发送者的 currentEpoch 比自己的 currentEpoch 大,就会更新自己的 currentEpoch,并在尚未投票的情况下,投票给 slave A,表示同意使其成为新的 master。


补充知识点:

  • 集群模式redisTemplate为何支持pipeline操作: