Redis模式
单点模式
单节点模式是最简单的Redis模式,它采用单个 Redis 节点部署架构,没有备用节点实时同步数据,不提供数据持久化和备份策略,适用于数据可靠性要求不高的纯缓存业务场景。
主从复制模式
主从模式就是在N个Redis服务器中,一台Redis服务器作为主节点,其余的作为从节点,主节点的数据复制到其他的Redis服务器,且复制只能由主节点到从节点。Redis主从复制支持主从同步和从从同步
主从模式的一个作用是备份数据,这样当一个节点损坏(指不可恢复的硬件损坏)时,数据因为有备份,可以方便恢复;另一个作用是负载均衡,所有客户端都访问一个节点会影响Redis工作效率,有了主从以后,查询操作就可以通过查询从节点来完成。
默认配置下,master节点可以进行读和写,slave节点只能进行读操作。如果slave节点挂了不会影响其他slave节点的读和master节点的读和写重新启动后会将数据从master节点同步过来;master节点挂了以后,不影响slave节点的读,Redis将不再提供写服务,master节点启动后Redis将重新对外提供写服务。
需要说明的是:master节点挂了以后,slave不会竞选成为master,这是主从模式的一个缺点。
演示
建立复制
分别在两个cmd下创建两个不同Redis实例
redis-server --port 6379
redis-server --port 6380
此时两个Redis节点默认为主节点。
我们再分别新建两个cmd并连接本地的redis服务,并在6380客户端中执行slaveof 命令
redis-cli -p 6379
redis-cli -p 6380
# 成为本地 6379 端口实例的从节点
SLAVEOF 127.0.0.1 6379
观察复制效果
我们在从节点6380中查询一个不存在的key
127.0.0.1:6380> GET mkey
(nil)
然后在主节点6379中添加这个key
127.0.0.1:6379> set mkey mval
OK
此时再从 从节点中查询,即可发现该结构
127.0.0.1:6380> get mkey
"mval"
我们可以在从节点执行slaveof no one来断开复制,此时从节点不会删除已有数据,只是不再接受主节点新的数据变化。
复制过程
- 从节点执行 slaveof 命令,此时从节点只是保存了 slaveof 命令中主节点的信息,并没有立即发起复制;
- 从节点内部的定时任务发现有主节点的信息,开始使用 socket 连接主节点;
- 连接建立成功后,发送 ping 命令,希望得到 pong 命令响应,否则会进行重连;
- 如果主节点设置了权限,那么就需要进行权限验证;如果验证失败,复制终止;
- 权限验证通过后,进行数据同步,这是耗时最长的操作,主节点将把所有的数据全部发送给从节点;
- 当主节点把当前的数据同步给从节点后,便完成了复制的建立流程。接下来,主节点就会持续的把写命令发送给从节点,保证主从数据一致性。
Sentinel 哨兵模式
该模式分为哨兵和数据两部分,其中哨兵部分由一个或多个哨兵节点组成,哨兵节点是特殊的 Redis 节点,不存储数据;主节点和从节点都是数据节点;
在主从复制的基础上,哨兵实现了 自动化的故障恢复 功能,它的功能是:
- 哨兵会不断地检查主节点和从节点是否运作正常。
- 当 主节点 不能正常工作时,哨兵会开始 自动故障转移操作,它会将失效主节点的其中一个 从节点升级为新的主节点,并让其他从节点改为复制新的主节点。
- 客户端在初始化时,通过连接哨兵来获得当前 Redis 服务的主节点地址。
- 哨兵可以将故障转移的结果发送给客户端。
演示
创建主从节点
在Redis的安装目录下分别新建*redis-master.conf/redis-slave1.conf/redis-slave2.conf*作为1个主节点和2个从节点,并分别写上如下内容:
#redis-master.conf
port 6379
daemonize yes
logfile "6379.log"
dbfilename "dump-6379.rdb"
#redis-slave1.conf
port 6380
daemonize yes
logfile "6380.log"
dbfilename "dump-6380.rdb"
slaveof 127.0.0.1 6379
#redis-slave2.conf
port 6381
daemonize yes
logfile "6381.log"
dbfilename "dump-6381.rdb"
slaveof 127.0.0.1 6379
然后在3个cmd下分别执行如下语句来启动不同的Redis实例
redis-server E:\Redis-x64-4.0.14.2\redis-master.conf
redis-server E:\Redis-x64-4.0.14.2\redis-slave1.conf
redis-server E:\Redis-x64-4.0.14.2\redis-slave2.conf
然后在一个新cmd下执行*redis-cli*默认连接到端口为 6379 的主节点,然后执行 info Replication 检查一下主从状态是否正常:
创建哨兵节点
同样的方式我们创建3个哨兵节点,并创建其配置文件:
# redis-sentinel-1.conf
port 26379
daemonize yes
logfile "26379.log"
# 该哨兵节点监控主节点 127.0.0.1:6379,主节点名为mymaster
# 数字2表示至少需要 2 个哨兵节点同意,才能判定主节点故障并进行故障转移。
sentinel monitor mymaster 127.0.0.1 6379 2
# redis-sentinel-2.conf
port 26380
daemonize yes
logfile "26380.log"
sentinel monitor mymaster 127.0.0.1 6379 2
# redis-sentinel-3.conf
port 26381
daemonize yes
logfile "26381.log"
sentinel monitor mymaster 127.0.0.1 6379 2
执行如下命令启动哨兵节点
redis-server E:\Redis-x64-4.0.14.2\redis-sentinel-1.conf --sentinel
redis-server E:\Redis-x64-4.0.14.2\redis-sentinel-2.conf --sentinel
redis-server E:\Redis-x64-4.0.14.2\redis-sentinel-3.conf --sentinel
连接端口为 26379 的 Redis 节点,并执行 info Sentinel 命令来查看是否已经在监视主节点:
演示故障转移
通过命令*netstat -aon|findstr 6379*查询端口对应进程ID(我得到的id为7672),然后杀掉主节点6379
然后我们再去哨兵节点那里查看信息,刚杀掉主节点时可能主节点尚未切换,发现故障并转移需要一段时间:
此时slave仍为2表明哨兵节点认为新的主节点仍然有两个从节点,这时这是因为哨兵在将 6381 切换成主节点的同时,将 6379 节点置为其从节点。虽然 6379 从节点已经挂掉,但是由于 哨兵并不会对从节点进行客观下线,因此认为该从节点一直存在。当 6379 节点重新启动后,会自动变成 6381 节点的从节点。
如何挑选新主节点
- 在失效主节点属下的从节点里,那些被标记为被标记为主观下线、已断线、或最后一次回复 PING 命令的时间大于五秒钟的从节点都会被淘汰。
- 在失效主节点属下的从节点里,与失效主节点连接断开的时长超过 down-after 选项指定的时长十倍的从节点会被 淘汰。
- 经过上述两部淘汰剩余的从节点中,选出复制偏移量最大 的那个 从节点 作为新的主节点;如果复制偏移量不可用,或者从节点的复制偏移量相同,那么 带有最小运行 ID 的那个从节点成为新的主节点。
集群模式
Redis 集群是一个提供在多个Redis间节点间共享数据的程序集,客户端任意直连到集群中的任意一台,即可对其他Redis节点进行读写操作。
集群的优点
- 集群将数据分散到多个节点,一方面 突破了 Redis 单机内存大小的限制,存储容量大大增加;另一方面 每个主节点都可以对外提供读服务和写服务.
- 集群支持主从复制和主节点的 自动故障转移 ,当任一节点发生故障时,集群仍然可以对外提供服务。
基本原理
Redis 集群中内置了 16384 个哈希槽,当客户端连接到 Redis 集群之后,会同时得到一份关于这个 集群的配置信息,当客户端具体对某一个 key 值进行操作时,会计算出它的一个 Hash 值,然后把结果对 16384 求余数,这样每个 key 都会对应一个编号在 0-16383 之间的哈希槽,Redis 会根据节点数量 大致均等 的将哈希槽映射到不同的节点。如果查询的key不归属于某个Redis节点,那么它会使用特殊的MOVED 命令来进行一个跳转,告诉客户端去连接这个节点以获取数据
GET x
-MOVED 3999 127.0.0.1:6381
如上所示,符号-表示这是一个错误信息,在收到 MOVED 指令后,就立即纠正本地的 槽位映射表,那么下一次再访问 key 时就能够到正确的地方去获取了;3999表示key对应的槽位编号,后面跟着的是目标Redis节点地址。
数据分区的方案
前面我们介绍的是哈希取余的分区思路,但这个方案存在一个问题:当新增或删减节点时,节点数量发生变化,系统中所有的数据都需要 重新计算映射关系,引发大规模数据迁移。
一致性哈希分区
该算法将整个空间组成一个虚拟的圆环,根据每一个数据的key值算出其hash值,确定它在圆环上的位置,然后从此位置顺时针行走,找到的第一个节点就是其所属节点。
与哈希取余方案相比,该方案将增减节点的影响限制在相邻节点,以上图为例,若在node1和node2之间增加node5,则则只有 node2 中的一部分数据会迁移到 node5;如果去掉 node2,则原 node2 中的数据只会迁移到 node4 中,只有 node4 会受影响。
该方案存在一个问题:当节点数量较少时,增加或删减节点,对单个节点的影响可能很大,造成数据的严重不平衡。比如上图中去掉node2,node4中的数据由总数据的1/4左右变为1/2左右,与其他节点相比负载过高。
带虚拟节点的一致性哈希分区
这是Redis集群使用的方案。该方案在 一致性哈希分区的基础上,引入了 **虚拟节点(也成为槽) **的概念,每个实际节点包含一定数量的槽,每个槽包含哈希值在一定范围内的数据。引入槽以后,查询数据值的过程变为:
-
对数据的特征值(一般是key)计算哈希值,使用的算法是CRC16
-
根据哈希值,计算数据属于哪个槽。
-
根据槽与节点的映射关系,计算数据属于哪个节点。
此时槽是数据管理和迁移的基本单位。槽解耦了数据和实际节点之间的关系,减小了增加或删除节点对系统的影响。
此处我们仍以上图为例,系统中有4个实际节点,假设为其分配16个槽(0-15);槽0-3位于node1,4-7位于node2,以此类推。如果此时删除node2,只需要将槽4-7重新分配即可,例如槽4-5分配给node1,槽6分配给node3,槽7分配给node4;可以看出删除node2后,数据在其他节点的分布仍然较为均衡。
在Redis集群中,槽的数量为16384。
节点通信机制
两个端口
在 集群 中,没有数据节点与非数据节点之分:所有的节点都存储数据,也都参与集群状态的维护。为此,集群中的每个节点,都提供了两个 TCP 端口:
- 普通端口:主要为客户端提供服务;在节点间数据迁移也会使用
- 集群端口: 端口号是普通端口 + 10000 。它只用于节点之间的通信,如搭建集群、增减节点、故障转移等操作时节点间的通信。不要使用客户端连接集群接口
Gossip 协议
节点间通信按照通信协议可以分为几种类型:单对单、广播、Gossip 协议等。此处只说明广播和 Gossip 的对比:
- 广播是指向集群内所有节点发送消息。这种做法的优点是集群内所有节点获得的集群信息是一致的,缺点是每条消息都要发送给所有节点,CPU、带宽等消耗较大。
- Gossip 协议:在节点数量有限的网络中,每个节点都根据特定的规则与部分节点通信 。
数据结构
节点需要专门的数据结构来存储集群的状态,此处介绍两个:clusterNode 和 clusterState 结构:前者记录了一个节点的状态,后者记录了集群作为一个整体的状态。
clusterNode 结构
typedef struct clusterNode {
//节点创建时间
mstime_t ctime;
//节点id
char name[REDIS_CLUSTER_NAMELEN];
//节点的ip和端口号
char ip[REDIS_IP_STR_LEN];
int port;
//节点标识:整型,每个bit都代表了不同状态,如节点的主从状态、是否在线、是否在握手等
int flags;
//配置纪元:故障转移时起作用,类似于哨兵的配置纪元
uint64_t configEpoch;
//槽在该节点中的分布:占用16384/8个字节,16384个比特;每个比特对应一个槽:比特值为1,则该比特对应的槽在节点中;比特值为0,则该比特对应的槽不在节点中
unsigned char slots[16384/8];
//节点中槽的数量
int numslots;
…………
} clusterNode;
clusterState 结构
clusterState 结构保存了在当前节点视角下,集群所处的状态。主要字段包括:
typedef struct clusterState {
//自身节点
clusterNode *myself;
//配置纪元
uint64_t currentEpoch;
//集群状态:在线还是下线
int state;
//集群中至少包含一个槽的节点数量
int size;
//哈希表,节点名称->clusterNode节点指针
dict *nodes;
//槽分布信息:数组的每个元素都是一个指向clusterNode结构的指针;如果槽还没有分配给任何节点,则为NULL
clusterNode *slots[16384];
…………
}clusterState;
分布式锁
什么是分布式系统
什么是集中式系统
在学习分布式之前,先了解一下与之相对应的集中式系统是什么:即一个主机带多个终端。终端没有数据处理能力,仅负责数据的录入和输出。而运算、存储等全部在主机上进行。集中式系统主要流行于上个世纪。
(1)集中式系统优点
集中式系统的最大的特点就是部署结构非常简单,底层一般采用从IBM、HP等厂商购买到的昂贵的大型主机。无需考虑如何对服务进行多节点的部署,无需考虑各节点之间的分布式协作问题。
(2)集中式系统缺点
由于采用单机部署。很可能带来系统大而复杂、难于维护;发生单点故障而导致整个系统或者网络的瘫痪。
什么是分布式系统
简单来说就是一群独立计算机集合共同对外提供服务,但是对于系统的用户来说,就像是一台计算机在提供服务一样。分布式意味着可以采用更多的普通计算机(相对于昂贵的大型机)组成分布式集群对外提供服务。计算机越多,CPU、内存、存储资源等也就越多,能够处理的并发访问量也就越大。
各个主机之间通信和协调主要通过网络进行,所以,分布式系统中的计算机在空间上几乎没有任何限制,这些计算机可能被放在不同的机柜上,也可能被部署在不同的机房中,还可能在不同的城市中,对于大型的网站甚至可能分布在不同的国家和地区
为什么需要分布式锁
(1)使用分布式锁可以避免不同节点重复相同的工作,比如用户付了钱之后有可能不同节点会发出多封短信,这些工作会浪费资源。
(2)分布式锁可以避免破坏正确性的发生,比如多个节点机器对同一个订单操作不同的流程有可能会导致该笔订单最后状态出现错误,造成损失。
分布式锁的特点
(1)分布式锁需要保证在不同节点的不同线程的互斥。
(2)同一个节点上的同一个线程如果获取了锁之后那么也可以再次获取这个锁。
(3)锁超时:和本地锁一样支持锁超时,防止死锁。
(4)支持阻塞和非阻塞:和ReentrantLock一样支持lock和trylock以及tryLock(long timeOut)。
常见的分布式锁
一般实现分布式锁有以下几个方式:
- MySql
- Redis
- ZooKeeper
Redis的分布式锁
由于Redis是单线程,命令是以串行的方式执行,并且它还提供了像setNx(set if not exist)这样的指令,即不存在则更新, 它只允许被一个客户端占有。因此对于某资源加锁我们可以:
setNx resourceName value
Redission
Redission也是Redis的客户端,相比于Jedis功能简单,它简单使用阻塞的I/O和redis交互,并通过Netty支持非阻塞I/O。
Redis 分布式锁的问题
锁超时
不过此处存在一个问题:加锁了之后如果获取锁的机器宕机了,那么这个锁就不会得到释放。因此我们需要额外设置一个超时时间,但这也可能会导致机器A的逻辑执行时间超出超时时间限制,使得其逻辑操作还未执行完就被其他机器B得到锁,这时机器去A释放的锁就不是自己的锁,而是其他机器B占有的锁。
因此在某个机器占有锁时,需要将锁的value值设置为一个随机数,等该机器释放锁时,先匹配随机数是否一致:若一致则代表该锁占有者还是自己,就可以执行释放锁操作;否则代表锁已经被别人占有,不能执行释放锁操作。
但由于查询和释放锁的操作非原子性,可能会出现一种情况:查询key时对应的value值一致,但就在释放锁的前一时刻,锁由于过期被释放了,这时释放的锁仍然是有问题的,因此我们需要保证这两步骤是原子性的,引入Jedis,使用Lua脚本将查询锁和释放锁的两部分逻辑写成脚本,这样Redis执行Lua脚本时,其他机器的所有命令都必须等到Lua脚本执行结束才能执行。
单点/多点问题
若Redis采用单机部署模式,那么如果Redis发生故障,则会导致整个服务不可用;而如果采用哨兵模式部署,加锁时只对一个节点加锁,那么当master节点故障,则在主从切换的过程中可能出现锁丢失的问题。针对以上问题,Redis作者提出了RedLock算法。
RedLock算法
假设Redis的部署模式为5个Redis主节点,该算法的大致流程如下:
- 获取当前时间戳;
- 轮流尝试在每个主节点上通过相同的key和唯一性的value获取锁,并设置过期时间,时间设置的较短,一般就几十毫秒;
- 客户端使用当前时间减去开始获取锁时间来得到获取锁使用的时间,当且仅当从半数以上的Redis节点(比如此处是3个节点)取到锁,并且使用时间小于锁失效时间,才能算作成功获取锁。
- 此外如果取到了锁,则这个key的真正有效时间等于有效时间减去获取锁所使用的时间
- 若因某些原因(有在半数以上实例取到锁或者取锁时间已经超过了有效时间)没有成功获取锁,客户端应在所有的Redis实例上进行解锁,无论某个Redis实例是否加锁成功。