为什么需要集群?
1、性能
Redis本身的QPS已经很高了,但是如果在一些并发量非常高的情况下,性能还是会受到影响。这个时候我们希望有更多的Redis服务来完成工作。
2、扩展
第二个是出于存储的考虑。因为Redis所有的数据都放在内存中,如果数据量大,很容易受到硬件的限制。升级硬件收效和成本比太低,所以我们需要有一种横向扩展的方法。
3、可用性
第三个是可用性和安全的问题。如果只有一个Redis服务,一旦服务宕机,那么所有的客户端都无法访问,会对业务造成很大的影响。另一个,如果硬件发生故障,而单机的数据无法恢复的话,带来的影响也是灾难性的。
可用性、数据安全、性能都可以通过搭建多个Reids服务实现。其中有一个是主节点(master),可以有多个从节点(slave)。主从之间通过数据同步,存储完全相同的数据。如果主节点发生故障,则把某个从节点改成主节点,访问新的主节点。
Redis主从复制(replication)
主从复制配置
例如一主多从,100是主节点,在每个slave节点的redis.conf配置文件增加一行
slaveof 192.168.1.100 6379
//在主从切换的时候,这个配置会被重写成:
#GeneratedbyCONFIGREWRITE
replicaof 192.168.1.100 6379
或者在启动服务时通过参数指定master节点:
./redis-server --slaveof 192.168.1.100 6379
或在客户端直接执行slaveofxxxx,使该Redis实例成为从节点。
启动后,查看集群状态:
redis>info replication
从节点不能写入数据(只读),只能从master节点同步数据。get成功,set失败。
127.0.0.1:6379>set zhangsan 100
(error)READONLY You can't write against a read only replica.
主节点写入后,slave会自动从master同步数据。
redis>slaveof no one
此时从节点会变成自己的主节点,不再复制数据。
主从复制原理
1. 连接阶段
1、slavenode启动时(执行slaveof命令),会在自己本地保存masternode的信息,包括masternode的host和ip。
2、slavenode内部有个定时任务replicationCron(源码replication.c),每隔1秒钟检查是否有新的masternode要连接和复制,如果发现,就跟masternode建立socket网络连接,如果连接成功,从节点为该socket建立一个专门处理复制工作的文件事件处理器,负责后续的复制工作,如接收RDB文件、接收命令传播等。
当从节点变成了主节点的一个客户端之后,会给主节点发送ping请求。
2.数据同步阶段
3、masternode第一次执行全量复制,通过bgsave命令在本地生成一份RDB快照,将RDB快照文件发给slavenode(如果超时会重连,可以调大repl-timeout的值)。slavenode首先清除自己的旧数据,然后用RDB文件加载数据。
生成RDB期间,master接收到的命令怎么处理?
开始生成RDB文件时,master会把所有新的写命令缓存在内存中。在slave node保存了RDB之后,再将新的写命令复制给slave node。
3.命令传播阶段
4、masternode持续将写命令,异步复制给slave node
延迟是不可避免的,只能通过优化网络。
repl-disable-tcp-nodelay no
当设置为yes时,TCP会对包进行合并从而减少带宽,但是发送的频率会降低,从节点数据延迟增加,一致性变差;具体发送频率与Linux内核的配置有关,默认配置为40ms。当设置为no时,TCP会立马将主节点的数据发送给从节点,带宽增加但延迟变小。
一般来说,只有当应用对Redis数据不一致的容忍度较高,且主从节点之间网络状况不好时,才会设置为yes;多数情况使用默认值no。
如果从节点有一段时间断开了与主节点的连接是不是要重新全量复制一遍?如果可以增量复制,怎么知道上次复制到哪里?
通过master_repl_offset记录的偏移量
redis>info replication
主从复制的不足
主从模式解决了数据备份和性能(通过读写分离)的问题,但是还是存在一些不足:
- RDB文件过大的情况下,同步非常耗时。
- 在一主一从或者一主多从的情况下,如果主服务器挂了,对外提供的服务就不可用了,单点问题没有得到解决。如果每次都是手动把之前的从服务器切换成主服务器,这个比较费时费力,还会造成一定时间的服务不可用。
可用性保证之Sentinel(哨兵模式)
Sentinel原理
如何实现主从的自动切换?我们的思路:
创建一台监控服务器来监控所有Redis服务节点的状态,比如,master节点超过一定时间没有给监控服务器发送心跳报文,就把master标记为下线,然后把某一个slave变成master。应用每一次都是从这个监控服务器拿到master的地址。
问题是:如果监控服务器本身出问题了怎么办?那我们就拿不到master的地址了,应用也没有办法访问。
那我们再创建一个监控服务器,来监控监控服务器......似乎陷入死循环了,这个问题怎么解决?这个问题先放着。
Redis的Sentinel就是这种思路:通过运行监控服务器来保证服务的可用性。
官网:https://redis.io/topics/sentinel
从Redis2.8版本起,提供了一个稳定版本的Sentinel(哨兵),用来解决高可用的问题。它是一个特殊状态的redis实例。
我们会启动一个或者多个Sentinel的服务(通过src/redis-sentinel),它本质上只是一个运行在特殊模式之下的Redis,Sentinel通过info命令得到被监听Redis机器的master,slave等信息。
为了保证监控服务器的可用性,我们会对Sentinel做集群的部署。Sentinel既监控所有的Redis服务,Sentinel之间也相互监控。
注意:Sentinel本身没有主从之分,只有Redis服务节点有主从之分。
概念梳理:master,slave(redisgroup),sentinel,sentinel集合
服务下线
Sentinel默认以每秒钟1次的频率向Redis服务节点发送PING命令。如果在down-after-milliseconds内都没有收到有效回复,Sentinel会将该服务器标记为下线(主观下线)。
#sentinel.conf
sentinel down-after-milliseconds <master-name> <milliseconds>
这个时候Sentinel节点会继续询问其他的Sentinel节点,确认这个节点是否下线,如果多数Sentinel节点都认为master下线,master才真正确认被下线(客观下线),这个时候就需要重新选举master。
故障转移
如果master被标记为下线,就会开始故障转移流程。
既然有这么多的Sentinel节点,由谁来做故障转移的事情呢?
故障转移流程的第一步就是在Sentinel集群选择一个Leader,由Leader完成故障转移流程。Sentinle通过Raft算法,实现Sentinel选举。
Ratf算法
在分布式存储系统中,通常通过维护多个副本来提高系统的可用性,那么多个节点之间必须要面对数据一致性的问题。Raft的目的就是通过复制的方式,使所有节点达成一致,但是这么多节点,以哪个节点的数据为准呢?所以必须选出一个Leader。
大体上有两个步骤:领导选举,数据复制。
Raft是一个共识算法(consensusalgorithm)。比如比特币之类的加密货币,就需要共识算法。SpringCloud的注册中心解决方案Consul也用到了Raft协议。
Raft的核心思想:先到先得,少数服从多数。
Raft算法演示:http://thesecretlivesofdata.com/raft/
总结:
Sentinle的Raft算法和Raft论文略有不同。
1、master客观下线触发选举,而不是过了electiontimeout时间开始选举。
2、Leader并不会把自己成为Leader的消息发给其他Sentinel。其他Sentinel等待Leader从slave选出master后,检测到新的master正常工作后,就会去掉客观下线的标识,从而不需要进入故障转移流程。
怎么让一个原来的slave节点成为主节点?
- 选出SentinelLeader之后,由SentinelLeader向某个节点发送slaveofnoone命令,让它成为独立节点。
- 然后向其他节点发送slaveofx.x.x.xxxxx(本机服务),让它们成为这个节点的子节点,故障转移完成。
这么多从节点,选谁成为主节点?
关于从节点选举,一共有四个因素影响选举的结果,分别是断开连接时长、优先级排序、复制数量、进程id。
如果与哨兵连接断开的比较久,超过了某个阈值,就直接失去了选举权。如果拥有选举权,那就看谁的优先级高,这个在配置文件里可以设置(replica-priority100),数值越小优先级越高。
如果优先级相同,就看谁从master中复制的数据最多(复制偏移量最大),选最多的那个,如果复制数量也相同,就选择进程id最小的那个。
Sentinel的功能总结
监控:Sentinel会不断检查主服务器和从服务器是否正常运行。
通知:如果某一个被监控的实例出现问题,Sentinel可以通过API发出通知。
自动故障转移(failover):如果主服务器发生故障,Sentinel可以启动故障转移过程。把某台服务器升级为主服务器,并发出通知。
配置管理:客户端连接到Sentinel,获取当前的Redis主服务器的地址。
哨兵机制的不足
主从切换的过程中会丢失数据,因为只有一个master。
只能单点写,没有解决水平扩容的问题。
如果数据量非常大,这个时候我们需要多个master-slave的group,把数据分布到不同的group中。
问题来了,数据怎么分片?分片之后,怎么实现路由?
Redis 分布式方案
如果要实现Redis数据的分片,我们有三种方案。
第一种是在客户端实现相关的逻辑,例如用取模或者一致性哈希对key进行分片,查询和修改都先判断key的路由。
第二种是把做分片处理的逻辑抽取出来,运行一个独立的代理服务,客户端连接到这个代理服务,代理服务做请求的转发。
第三种就是基于服务端实现。
客户端 Sharding
Jedis客户端提供了RedisSharding的方案,并且支持连接池。
使用ShardedJedis之类的客户端分片代码的优势是配置简单,不依赖于其他中间件,分区的逻辑可以自定义,比较灵活。但是基于客户端的方案,不能实现动态的服务增减,每个客户端需要自行维护分片策略,存在重复代码。
第二种思路就是把分片的代码抽取出来,做成一个公共服务,所有的客户端都连接到这个代理层。由代理层来实现请求和转发。
代理 Proxy
典型的代理分区方案有Twitter开源的Twemproxy和国内的豌豆荚开源的Codis。
Redis Cluster(服务端)
RedisCluster是在Redis3.0的版本正式推出的,用来解决分布式的需求,同时也可以实现高可用。跟Codis不一样,它是去中心化的,客户端可以连接到任意一个可用节点。
数据分片有几个关键的问题需要解决:
1、数据怎么相对均匀地分片
2、客户端怎么访问到相应的节点和数据
3、重新分片的过程,怎么保证正常服务
架构
RedisCluster可以看成是由多个Redis实例组成的数据集合。客户端不需要关注数据的子集到底存储在哪个节点,只需要关注这个集合整体。
以3主3从为例,节点之间两两交互,共享数据分片、节点状态等信息。
数据分布
如果是希望数据分布相对均匀的话,我们首先可以考虑哈希后取模。
哈希后取模
例如,hash(key)%N,根据余数,决定映射到那一个节点。这种方式比较简单,属于静态的分片规则。但是一旦节点数量变化,新增或者减少,由于取模的N发生变化,数据需要重新分布。
为了解决这个问题,我们又有了一致性哈希算法。
一致性哈希
一致性哈希的原理:把所有的哈希值空间组织成一个虚拟的圆环(哈希环),整个空间按顺时针方向组织。因为是环形空间,0和2^32-1是重叠的。
假设我们有四台机器要哈希环来实现映射(分布数据),我们先根据机器的名称或者IP计算哈希值,然后分布到哈希环中(红色圆圈)。
现在有4条数据或者4个访问请求,对key计算后,得到哈希环中的位置(绿色圆圈)。沿哈希环顺时针找到的第一个Node,就是数据存储的节点。
谷歌的MurmurHash就是一致性哈希算法。在分布式系统中,负载均衡、分库分表等场景中都有应用。
一致性哈希解决了动态增减节点时,所有数据都需要重新分布的问题,它只会影响到下一个相邻的节点,对其他节点没有影响。
但是这样的一致性哈希算法有一个缺点,因为节点不一定是均匀地分布的,特别是在节点数比较少的情况下,所以数据不能得到均匀分布。解决这个问题的办法是引入虚拟节点(VirtualNode)。
Redis虚拟槽分区
Redis既没有用哈希取模,也没有用一致性哈希,而是用虚拟槽来实现的。
Redis创建了16384个槽(slot),每个节点负责一定区间的slot。比如Node1负责0-5460,Node2负责5461-10922,Node3负责10923-16383。
Redis的每个master节点维护一个16384位(2048bytes=2KB)的位序列,比如:序列的第0位是1,就代表第一个slot是它负责;序列的第1位是0,代表第二个slot不归它负责。
对象分布到Redis节点上时,对key用CRC16算法计算再%16384,得到一个slot的值,数据落到负责这个slot的Redis节点上。
查看key属于哪个slot:
redis>cluster keyslot zhangsan
注意:key与slot的关系是永远不会变的,会变的只有slot和Redis节点的关系。
怎么让相关的数据落到同一个节点上?
比如有些multikey操作是不能跨节点的,如果要让某些数据分布到一个节点上,例如用户2673的基本信息和金融信息,怎么办?
在key里面加入{hashtag}即可。Redis在计算槽编号的时候只会获取{}之间的字符串进行槽编号计算,这样由于上面两个不同的键,{}里面的字符串是相同的,因此他们可以被计算出相同的槽。
user{2673}base=...
user{2673}fin=...
127.0.0.1:7293>set a{qs}a 1
OK
127.0.0.1:7293>set a{qs}b 1
OK
127.0.0.1:7293>set a{qs}c 1
OK
127.0.0.1:7293>set a{qs}d 1
OK
127.0.0.1:7293>set a{qs}e 1
OK