文章目录
epoll
BIO
线程和内核长链接,没有读到数据一直挂起等待,有数据读到返回
NIO
然后随着内核的发展,进化成可以不阻塞了
于是Nio出现了,用户线程定时轮询fd,轮询一次,哪个fd有数据读哪个
因此也有新的问题产生,那就是如果有数据的fd很少或者一致没有数据,就造成空轮询浪费cpu资源
再然后,出现了select,select的作用在于monitor multiple fd waiting until one or more fd become ready,内核监控网卡数据,有数据就返回有数据的fd,用户线程再去读数据
select传参:
select虽然解决了空轮询的问题,但是数据频繁的从网卡拷贝到内核,再拷贝到用户空间
epoll
接下来发展到使用共享空间的epoll
用户需要监控哪些fd,添加到红黑树中,红黑树相当于epoll用缓存记录要监控的fd,监控到IO事件发生的fd,epoll将其放到链表,
epoll create:
epoll_create()创建一个epoll实例。其中nfd为epoll句柄,参数max_size标识这个监听的数目最大有多大,从Linux 2.6.8开始,max_size参数将被忽略,但必须大于零。epoll_create()返回引用新epoll实例的文件描述符。该文件描述符用于随后的所有对epoll的调用接口。每创建一个epoll句柄,会占用一个fd,因此当不再需要时,应使用close关闭epoll_create()返回的文件描述符,否则可能导致fd被耗尽。当所有文件描述符引用已关闭的epoll实例,内核将销毁该实例并释放关联的资源以供重用。
epoll ctl:
、
epoll wait():
epoll wait监控fd的event发生,
epoll的工作方式
epoll的两种工作方式:1.水平触发(LT)2.边缘触发(ET)
LT模式:若就绪的事件一次没有处理完要做的事件,就会一直去处理。即就会将没有处理完的事件继续放回到就绪队列之中(即那个内核中的链表),一直进行处理。
ET模式:就绪的事件只能处理一次,若没有处理完会在下次的其它事件就绪时再进行处理。而若以后再也没有就绪的事件,那么剩余的那部分数据也会随之而丢失。
由此可见:ET模式的效率比LT模式的效率要高很多。只是如果使用ET模式,就要保证每次进行数据处理时,要将其处理完,不能造成数据丢失,这样对编写代码的人要求就比较高。
注意:ET模式只支持非阻塞的读写:为了保证数据的完整性。
查看redis的系统调用:
1 epoll_create 初始化了1024个fd,返回epoll fd为5
2 调用epoll_ctl ADD了文件描述符6,7,3,对应socket fd 6,7,3
3 调用epoll_wait epoll fd=5 的文件 返回0没有数据,一直监控
mmap:
1)进程调用mmap()时,只是在进程空间内新增了一块相应大小的缓冲区,并设置了相应的访问标识,但并没有建立进程空间到物理页面的映射。因此,第一次访问该空间时,会引发一个缺页异常。
2)一个共享内存区域可以看作是特殊文件系统shm中的一个文件,shm的安装点在交换区上。
3)mmap()系统调用使得进程之间通过映射同一个普通文件实现共享内存。普通文件被映射到进程地址空间后,进程可以向访问普通内存一样对文件进行访问,不必再调用read(),write()等操作。
4)最终被映射文件的内容的长度不会超过文件本身的初始大小,即映射不能改变文件的大小。文件被映射部分而不是整个文件决定了进程能够访问的空间大小,另外,如果指定文件的偏移部分,一定要注意为页面大小的整数倍。
AIO
目前只有windows实现了AIO,linux由于内核的先知还不能实现AIO
redis支持的类型:
redis文件描述符:
Redis Pipe
而redis给我们提供了另一种高效的导入方法。redis pipe,
golang go_redis库实现:
```go
package main
import (
"fmt"
"github.com/go-redis/redis"
)
func main() {
/* client := redis.NewClusterClient(&redis.ClusterOptions{
Addrs: []string{"127.0.0.1:6379"},
ReadOnly: true,
RouteRandomly: true,
})
*/
client := redis.NewClient(&redis.Options{
Addr: "127.0.0.1:6379",
Password: "",
})
pipe := client.Pipeline()
pipe.SAdd("USER_UID_LIST_TEST", "123")
pipe.SAdd("USER_UID_LIST_TEST", "456")
cmders, err := pipe.Exec()
if err != nil {
fmt.Println("err", err)
}
for _, cmder := range cmders {
//cmd := cmder.(*redis.StringStringMapCmd)
cmd := cmder.(*redis.IntCmd)
res, err := cmd.Result()
if err != nil {
fmt.Println("err", err)
}
fmt.Println("res", res)
}
}
存储结构
全局hash表
列表+二维数组
set(key ,value) -计算ket的hash,与列表长度取余,结果就是要插入的位置
get(key) -计算ket的hash,与列表长度取余,key一致取出值返回,从链表遍历
对应的底层结构:
STRING
HASH
LIST(栈,队列,阻塞队列,数组)
redis list 实现消息流:(先进后出-栈)
存:LPUSH [“CHARLIE”-1000123020] 127809876
取: LRANGE [“CHARLIE”-1000123020]
linkedlist
LTRIM KEY 2 -2 删除key【2- (-2)】区间外的元素
SET
SINTERSTORE
SUNIONSTORE
SDIFF(注意传入集合的顺序决定取哪个集合的差集,如下:)
ZSET
交并差+权重(score=score*权重)+聚合(sum max min)
聚合:aggregate 取两个集合中的sum,max ,min
bitmap
场景一:
统计在某一年从1.1-12.31用户在线情况
分析:在线状态只有两个值-在线 不在线 分别极左1和0,用二进制就可以表示,用二进制的位表示天
场景二:
火车票售卖系统
车站为一个bitmap,有人标记1 无人标记0
作为为一个bitmap,售出标记1,空闲标记0
求某站某座位是否有人,取或运算
双端链表-list
redis中双端链表的结构
包含两个指针(prev,next)
四个方法(dup(),free(),match(),len())
创建一个链表:
1 分配内存
2初始化属性
3方法置空
insert元素(四个指针-我自己的前后指针,前面元素的后指针,后面元素的前指针)
删除链表:(两个指针-前面元素的后指针,后面元素的前指针)
缺点:
1访问元素慢,时间复杂度O(N)
2需要额外的内存开销来存储小值,每向linked list插入1个元素,Redis 会创建大约 40 字节的元数据以及内存分配导致的额外开销,假如存储 10 个字节的数据,将产生超过 40 个字节的元数据,最终需要的内存是实际内容的4倍
3遍历列表时缓存效率不高
优点
1 头/尾访问时间复杂度O(1)
2 append或prepend时间复杂度O(1)
3 从任意一端删除时间复杂度O(1)
任何内部删除只是将前一个/下一个指针连接在一起
任何内部插入只会在兄弟节点中创建新的上一个/下一个指针
跳表skiplist-zsrt
调表与红黑树对比:
1调表插入简单,不需要旋转等等操作
2调表实现范围查找效率更高
3 层高相对低 内存消耗少
redis跳跃表结构:
redis跳跃表node结构:
1 redisobject对象
2 用于排序的score
3 前后指针forward、backward
4 level跳跃表的层
5 每一层中两个node的跨度span
创建skiplist
1 分配空间
2 初始化level为1,加点数为0
3初始化头领节点,设置对应的空值
1 头领节点不存储任何数据
2 头领节点的层数等于max_allow_level
3 遍历某一个node时,先从header的最高层开始查找,若最高层的backward为null,则向下一层查找
插入节点:
1 要插入一个node,需要知道每一层要插入那个节点的后面
2确认前置节点,从最高层开始,查找中把每一层前置节点的信息保存到update[]中
1 该节点的后置节点不为空
2 该节点是最后一个score小于要插入节点的节点,
32插入
1 第一层插入7 的后面
2 第二层插入到6 的后面
4 更新每一层前置节点的span(间隔)值
链表迭代器:
压缩列表ziplist-hash,list,zset
类似于utf-8编码,用1字节的高位表示数据类型
这样实现了从前向后遍历,要想从后往前遍历,还需要知道前向元素的长度,但是不知道那个元素是最后一个元素
这就需要知道起始位置和最后一个位置的偏移量
从后往前遍历:
1通过偏移量ztail找到最后一个元素的位置
2通过previous_entry_length确定前一个元素的长度,往前遍历
创建压缩列表
添加元素:
缺点就是插入大量数据是 操作复杂度增加
优点
1 顺序内存读写
2 不需要内部指针,空间优势
3 高效的整数存储和高效的偏移记录
4 头/尾访问时间复杂度O(1)
缺点
1 插入列表需要将所有后续元素向下移动,连续内存块会使插入到列表的中间或从列表的中间删除,变得不友好
2 从列表中删除需要将所有后续元素向上移动,
3 插入如果需要重新分配内存(内存块必须扩容)这可能导致整个列表被复制到一个新的内存位置
SDS动态字符串-string
SDS的优势
传统的字符串在使用上 有以下的问题
预分配策略
懒释放
二进制安全
redis的SDS是二进制安全的,上例字符串的两个特殊字符“”\0“”在C语言中会被当做字符串的标志,会被计算成len只有5
hash字典-hash
结构
redis实现hash字典
hmap
bucket
bmap
可以看到redis对value的定义为三种
1 int64
2 uint64
3 对象
创建
插入元素
插入key-value
扩容
redis hash扩容条件和规则
条件:
1 hash_table_user >=hash_table_size 已用>=总容量
&&
( dict_can_resize=1
||
容积率超过5 hash_table_user /hash_table_size>5)
*链表的查找时间复杂度为O(N),若链表长度超过5,会增加查找的时间复杂度
rehash
如图:
扩容前hashtable1的容量为4,已经存储了4个对象
扩容为原来的两倍后,挂载到hashtable1
1 容为原来的两倍后,挂载到hashtable1
2 然后hashtable0的元素迁移到hashtable1,
3 交换hashtable0和hashtable1的指针,释放hashtable1的指针
整数集合-set
初始化 默认是int16编码
查找元素-二分查找法
当插入一个int32位的数之后,会自动升级成int32位编码
删除这个int32位数字之后,并不会再降会int16编码
删除元素
redis objct
前面学习了双向链表,跳表,压缩列表,动态字符串,hash字典,整数集合 这些基础类型
redis在这些基础的数据结构上,封装了string,list,set,zset,hash五中redis对象类型
这样做的好处是:
1 节省内存
2 灵活性,encoding的转换升级,在封装的对象里面完成即可,
3 方便管理内存,内存申请 扩容 共享 回收等等
string对象(int,embstr,raw)
字符串存储类型选择:
1 可转换成整型–REDIS_ENCODING_INT 也就是用整型来村存储
2 长度小于等于32字节的非整型-REDIS_ENCODING_EMBSTR 对于不能转换成整型的小于32字节的元素,采用动态字符串
3 长度大于32字节,采用简单动态字符串存储
(Redis的embstr编码方式和raw编码方式分界线为39字节(3.2之前版本)或44字节,如果一个字符串值的长度小于等于44字节,则按照embstr进行编码,否则按raw进行编码
Embstr(Embedded String),是一种保存短字符串的特殊编码方式。与raw不同的是,raw会调用内存分配函数两次,创建redisobject结构和sdshdr结构,而embstr代码会调用一次内存分配函数,分配一块连续的空间,包括redisobject和sdshdr两种结构。
Embstr 具有以下优点:
embstr 编码将创建字符串对象所需的内存分配和释放次数从两次减少到一次
由于embstr 编码字符串对象的所有数据都存储在一个连续的内存中,这些编码字符串对象比原始编码对象字符串可以更好地利用缓存(CPU 缓存/缓存行)。
Embstr的缺点:
如果字符串长度增加,需要重新分配内存,SDS需要重新分配空间,所以embstr编码的字符串对象实际上是只读的,redis并没有为embstr编码的字符串对象写任何相应的修饰符。当我们对 embstr 编码的字符串对象执行任何修改命令(如 append)时,先将对象的编码从 embstr 转换为 raw,然后执行修改命令
list对象(ziplist,linklist)
ziplist的encoding用8位表示,前两位11表示int,其他表示字符串,后6位表示长度,
2
6
2^6
26=64,单个元素的长度只能是64?
list对象选择类型的时候,会优先选择压缩列表,当元素长度大于64,元素个数大于512的时候选择链表
set对象(intset,hashtable)
hash对象(ziplist,hashtable)
1 key,value长度小于64,elem个数小于512的受使用压缩列表
2 大于上述情况采用hashtable
zset对象(ziplist,skiplist-dict)
zset类型的选择:
1 元素长度小于64字节,elem个数小于128,采用ziplist
2 元素大于64字节,elem个数大于128个,采用字典与skipliat组合的方式实现
redis DB
redis启动,会启动redisserver服务,初始化redisdb ,redisdb包含:
1 数据库键空间
2 对应的过期时间
插入string:
插入list元素
插入hsah
设置过期时间
惰性删除,定期删除
Redis服务器实际使用的是惰性删除和定期删除两种策略:通过配合使用这两种删除策略,服务器可以很好地在合理使用CPU时间和避免浪费内存空间之间取得平衡。
惰性删除策略的实现
过期键的惰性删除策略由db.c/expireIfNeeded函数实现,所有读写数据库的Redis命令在执行之前都会调用expireIfNeeded函数对输入键进行检查:
· 如果输入键已经过期,那么expireIfNeeded函数将输入键从数据库中删除。
· 如果输入键未过期,那么expireIfNeeded函数不做动作。
定期删除策略的实现
过期键的定期删除策略由redis.c/activeExpireCycle函数实现,每当Redis的服务器周期性操作redis.c/serverCron函数执行时,activeExpireCycle函数就会被调用,它在规定的时间内,分多次遍历服务器中的各个数据库,从数据库的expires字典中随机检查一部分键的过期时间,并删除其中的过期键。
说明:
· 定时删除占用太多CPU时间,影响服务器的响应时间和吞吐量。
· 惰性删除浪费太多内存,有内存泄漏的危险。
定期删除策略是前两种策略的一种整合和折中:
· 定期删除策略每隔一段时间执行一次删除过期键操作,并通过限制删除操作执行的时长和频率来减少删除操作对CPU时间的影响。
主从复制
主从复制共有三种模式:全量复制、基于长连接的命令传播、增量复制。
第一次全量复制,主从服务器间的第一次同步的过程可分为三个阶段:
第一阶段是建立链接、协商同步;
第二阶段是主服务器同步数据给从服务器;
第三阶段是主服务器发送新写操作命令给从服务器。
这里有一个问题,就是主从复制风暴,当同一时间master想slave发送全量RDB,压力会很大,所以同步策略可以改为树形同步或者增加master节点
命令传播
主从服务器在完成第一次同步后,双方之间就会维护一个 TCP 连接。
出现网络抖动,恢复后就从offset继续同步
主要有三个步骤:
1 从服务器在恢复网络后,会发送 psync 命令给主服务器,此时的 psync 命令里的 offset 参数不是 -1;
2 主服务器收到该命令后,然后用 CONTINUE 响应命令告诉从服务器接下来采用增量复制的方式同步数据;
3 然后主服务将主从服务器断线期间,所执行的写命令发送给从服务器,然后从服务器执行这些命令。
那么关键的问题来了,主服务器怎么知道要将哪些增量数据发送给从服务器呢?
答案藏在这两个东西里:
¥ repl_backlog_buffer,是一个「环形」缓冲区,用于主从服务器断连后,从中找到差异的数据;
¥ replication offset,标记上面那个缓冲区的同步进度,主从服务器都有各自的偏移量,主服务器使用 master_repl_offset 来记录自己「写」到的位置,从服务器使用 slave_repl_offset 来记录自己「读」到的位置。
网络断开后,当从服务器重新连上主服务器时,从服务器会通过 psync 命令将自己的复制偏移量 slave_repl_offset 发送给主服务器,主服务器根据自己的 master_repl_offset 和 slave_repl_offset 之间的差距,然后来决定对从服务器执行哪种同步操作:
如果判断出从服务器要读取的数据还在 repl_backlog_buffer 缓冲区里,那么主服务器将采用增量同步的方式;
相反,如果判断出从服务器要读取的数据已经不存在
repl_backlog_buffer 缓冲区里,那么主服务器将采用全量同步的方式。
当主服务器在 repl_backlog_buffer 中找到主从服务器差异(增量)的数据后,就会将增量的数据写入到 replication buffer 缓冲区,这个缓冲区我们前面也提到过,它是缓存将要传播给从服务器的命令
而 repl_backlog_buffer的大小是固定的,如果数据多则会被覆盖,所以如何决定它的大小?
总结
主从复制共有三种模式:全量复制、基于长连接的命令传播、增量复制。
主从服务器第一次同步的时候,就是采用全量复制,此时主服务器会两个耗时的地方,分别是生成 RDB 文件和传输 RDB 文件。为了避免过多的从服务器和主服务器进行全量复制,可以把一部分从服务器升级为「经理角色」,让它也有自己的从服务器,通过这样可以分摊主服务器的压力。
第一次同步完成后,主从服务器都会维护着一个长连接,主服务器在接收到写操作命令后,就会通过这个连接将写命令传播给从服务器,来保证主从服务器的数据一致性。
如果遇到网络断开,增量复制就可以上场了,不过这个还跟 repl_backlog_size 这个大小有关系。
如果它配置的过小,主从服务器网络恢复时,可能发生「从服务器」想读的数据已经被覆盖了,那么这时就会导致主服务器采用全量复制的方式。所以为了避免这种情况的频繁发生,要调大这个参数的值,以降低主从服务器断开后全量同步的概率。
redis持久化
RDB
配置自动更新的两个条件:
1 自上次持久化后有多少次修改
2 最后一次持久化时间
string:
AOF
AOF持久化机制
每当有一个修改数据库的命令被执行时,服务器就将命令写入到 appendonly.aof 文件中,该文件存储了服务器执行过的所有修改命令,因此,只要服务器重新执行一次 .aof 文件,就可以实现还原数据的目的,这个过程被形象地称之为“命令重演”。
1) 写入机制
Redis 在收到客户端修改命令后,先进行相应的校验,如果没问题,就立即将该命令存追加到 .aof 文件中,也就是先存到磁盘中,然后服务器再执行命令。这样就算遇到了突发的宕机情况情况,也只需将存储到 .aof 文件中的命令,进行一次“命令重演”就可以恢复到宕机前的状态。
在上述执行过程中,有一个很重要的环节就是命令的写入,这是一个 IO 操作。Redis 为了提升写入效率,它不会将内容直接写入到磁盘中,而是将其放到一个内存缓存区(buffer)中,等到缓存区被填满时才真正将缓存区中的内容写入到磁盘里。
AOF的写入是支持COW的,即写入过程中的修改也会执行到aof文件
2) 重写机制
前面提到AOF的缺点时,说过AOF属于日志追加的形式来存储Redis的写指令,这会导致大量冗余的指令存储,从而使得AOF日志文件非常庞大,比如同一个key被写了10000次,最后却被删除了,这种情况不仅占内存,也会导致恢复的时候非常缓慢,因此Redis提供重写机制来解决这个问题。Redis的AOF持久化机制执行重写后,保存的只是恢复数据的最小指令集,我们如果想手动触发可以使用如下指令:
手动执行BGREWRITEAOF命令
AOF重写流程如下:
1 bgrewriteaof触发重写,判断是否存在bgsave或者bgrewriteaof正在执行,存在则等待其执行结束再执行。
2 主进程fork子进程,防止主进程阻塞无法提供服务,类似RDB。
3 子进程遍历Redis内存快照中数据写入临时AOF文件,同时主进程会将重写期间的指令写入aof_buf和aof_rewrite_buf两个重写缓冲区,前者是为了写回旧的AOF文件,后者是为了后续刷新到临时AOF文件中,防止快照内存遍历时新的写入操作丢失。
4 子进程结束临时AOF文件写入后,通知主进程。
5 主进程会将上面3中的aof_rewirte_buf缓冲区中的数据写入到子进程生成的临时AOF文件中。
6 主进程使用临时AOF文件替换旧AOF文件,完成整个重写过程。
通过上述操作后,服务器会生成一个新的 aof 文件,该文件具有以下特点:
1新的 aof 文件记录的数据库数据和原 aof 文件记录的数据库数据完全一致;
2新的 aof 文件会使用尽可能少的命令来记录数据库数据,因此新的 aof 文件的体积会小很多;
3AOF 重写期间,服务器不会被阻塞,它可以正常处理客户端发送的命令。
3)自动触发AOF重写
Redis 为自动触发 AOF 重写功能,提供了相应的配置策略。如下所示:修改 Redis 配置文件,让服务器自动执行 BGREWRITEAOF 命令。
默认配置项
auto-aof-rewrite-percentage 100
该配置项表示:触发重写所需要的 aof 文件体积百分比,只有当 aof 文件的增量大于 100% 时才进行重写,也就是大一倍。比如,第一次重写时文件大小为 64M,那么第二次触发重写的体积为 128M,第三次重写为 256M,以此类推。如果将百分比值设置为 0 就表示关闭 AOF 自动重写功能。
auto-aof-rewrite-min-size 64mb
表示触发AOF重写的最小文件体积,大于或等于64MB自动触发。
上述配置策略说明如下:
-
Always:服务器每写入一个命令,就调用一次 fsync 函数,将缓冲区里面的命令写入到硬盘。这种模式下,服务器出现故障,也不会丢失任何已经成功执行的命令数据,但是其执行速度较慢;
-
Everysec(默认):服务器每一秒调用一次 fsync> 函数,将缓冲区里面的命令写入到硬盘。这种模式下,服务器出现故障,最多只丢失一秒钟内的执行的命令数据,通常都使用它作为 AOF 配置策略;
-
No:服务器不主动调用 fsync函数,由操作系统决定何时将缓冲区里面的命令写入到硬盘。这种模式下,服务器遭遇意外停机时,丢失命令的数量是不确定的,所以这种策略,不确定性较大,不安全。
关联知识点2:mysql持久化
cat aof文件:
aof的rewrite会判断多条相关命令是否可以压缩为一条,并将其压缩 如:
多条incr命令,只需要最后将其incr成正确的值即可
混合持久化
RDB和AOF各自有优缺点
在恢复数据的时候,显然aof方式更安全,但是恢复速度慢,混合持久化,将二者的有点结合,避免了其缺点,使备份和恢复的效率都有所提高
1 首先要开启AOF
2 aof在rewrite的时候,将之前的数据以RDB快照的二进制方式加上增量数据重写入新的aof文件,rewrite过程中文件不叫appendonly.aof,写完修改文件名,替换原来的aof文件
也就是说aof重写后的文件内容是这样:
cat查看:
redis哨兵
如果 Master 节点崩溃了,在上面的情况下不会将 Slave 节点转换为 Master 节点,因此 Master 节点崩溃之后整个 Redis 集群就不能再执行写入操作。
为了解决这个问题,提高系统的可用性,Redis 提供了 Sentinel(哨兵)来实现 Slave 节点到 Master 节点的转换
“哨兵” 节点本质上也是一个 Redis 节点,但是和 Master 节点和 Slave 节点不同,“哨兵” 节点只是监视 Master 节点和 Slave 节点,并不执行相关的业务操作
哨兵” 的主要有以下几个作用:
1 监控 Redis 节点运行状态
2 通知:当被监控的 Redis 节点出现问题时,Sentinel 可以通过向 API 或者管理员以及其它应用发送通知
3自动故障转移:当一个主服务器不能正常工作时,Sentinel 会开始一次自动故障迁移,它会在失效的 Redis 集群中寻找一个有效的节点,并将它升级为新的 Master 节点,并见原来失效的 Master 节点降级为 Slave 节点。当客户端试图访问已经失效的 Master 节点时,Redis 集群也会想客户端返回新的 Master 节点的地址,使得 Redis 集群可以使用新的 Master 节点代替失效的 Master 节点
哨兵集群
由于使用单个的 “哨兵” 来监视 Redis 集群的节点也不是完全可靠的,因为 “哨兵” 节点也有可能会出现故障,因此,一般情况下会使用多个 “哨兵” 节点来监视整个 Redis 集群 ,由于选举投票的需要,最少需要三个哨兵节点
由于存在多个 哨兵 节点,因此在 Redis Sentinel 中,对于 Redis 节点的下线也有区分:
主观下线(Subjectively Down,即 SDOWN):指单个 Sentinel 节点对集群中的节点作出下线判断
客观下线(Objectively Down,即 ODOWN):指多个 Sentinel 节点对集群中的节点作出 “SDOWN” 判断,并且通过 SENTINEL is-master-down-by-addr 命令互相交流之后,作出 Redis 节点下线的判断
一个 Sentinel 节点可以通过向另一个 Sentinel 节点发送 SENTINEL is-master-down-by-addr 命令来询问对方是否认为给定的节点已经下线
哨兵选举master:
对于节点的检测,主要通过以下三种方式来进行检测:
-
每个 Sentinel 会每隔 10s 向主节点中发送 INFO 指令,通过该指令可以获得整个 Redis 节点的拓扑图。在这个时候,如果有新的节点加入或者有节点退出当前的集群,那么 Sentinel 就能够感知到拓扑图结构的变化。
-
每个 Sentinel 节点每隔 2s 会向指定的 Channel 发布自己对 Master 节点是否正常的判断以及当前 Sentinel 节点的信息,通过订阅这个 Channel,可以获得其它 Sentinel 节点的信息以及对 Master 节点的存活状态的判断
-
每个 Sentinel 节点每隔 1s 就会向所有节点(包括 Sentinel 节点、Master 节点以及 Slave 节点)发送 PING 指令来检测节点的存活状态
主节点的选举流程:
1 当一个 Sentinel 节点判断 Master 节点不可用时,首先进行 “SDOWN”(主观下线),此时,这个 Sentinel 通过 SENTINEL is-masterdown-by-addr 指令获取其它哨兵节点对于当前 Master 节点的判断情况,如果当前哨兵节点对于当前 Master 节点的下线判断数量超过了在配置文件中定义的票数,那么该 Master 节点就被判定为 “ODOWN”(主观下线)
2 Sentinel 节点列表中也会存在一个 Leader Sentinel,该 Sentinel 会从原主节点的从节点中选出一个新的主节点,
具体步骤如下所示:
1 )首先,过滤i掉所有的 ODOWN 节点
2)选择 slave-priority 优先级 最大的节点,如果存在则选择这个节点为新的主节点,如果没有则继续下面的流程
3)选出复制偏移量最大的节点,如果有则返回;如果没有则继续执行下面的流程
4)选择 run_id (服务运行 id) 最小的节点
3 当选择出新的主节点之后,Leader Sentinel 节点会通过 SLAVEOF NO ONE 命令让选择出来的节点成为主节点,然后通过 SLAVEOF 命令让其他节点成为该节点的从节点
作为分布式锁的应用
setnx
SETNX key value
1 SETNX全程Set If Not Exists,表示只有不存在的时候才设置键值对。
2 只有在键key不存在的情况下才能将key的值设置为value
3 若key已经存在则SETNX命令不做任何动作
4 SETNX设置成功则返回1表示当前进程已经获得锁
5 SETNX设置失败则返回0表示其他进程已经获得锁,当前进程不能进入临界区,可以自旋尝试SETNX以获得锁。
利用Redis的SetNX可以实现锁的效果
问题1
若当前进程获得锁以后,断开了与Redis的连接(可能是进程挂掉,或者是网络中断等等),如果没有有效的释放锁的机制,那么其他进程会处于一直等待的状态,即出现死锁。
解决:设置过期时间
问题2
由于redis的连个命令是串行的,获得锁还没来得及设置过期时间,线程挂点,导致死锁
解决:
set lock true NX px 60000
加锁和设置过期时间一起执行,并保证原子性
问题3
进程A获得锁,执行业务,业务还没有完成就过期了,进程B获得锁执行业务,进程A执行完业务后释放掉了锁
解决:缓存设置随机数,防止比人释放锁
$rs = $redis->set($key, $random, ["nx", "ex"=>$ttl]);
总结:
redlock
RedLock特性:
1 安全特性:互斥访问,即永远只有一个 client 能拿到锁
2 避免死锁:最终 client 都可能拿到锁,不会出现死锁的情况,即使原本锁住某资源的 client crash 了或者出现了网络分区
3 容错性:只要大部分 Redis 节点存活就可以正常提供服务
Redlock 算法
假如有 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 节点上执行释放锁的操作,重置状态
时钟漂移:
这个算法成立的一个条件是:即使集群中没有同步时钟,各个进程的时间流逝速度也要大体一致,并且误差与锁存活时间相比是比较小的。实际应用中的计算机也能满足这个条件:各个计算机中间有几毫秒的时钟漂移(clock drift)。
失败重试机制:
如果一个Client无法获得锁,它将在一个随机延时后开始重试。使用随机延时的目的是为了与其他申请同一个锁的Client错开申请时间,减少脑裂(split brain)发生的可能性。
问题1
Redis实例的配置不进行任何持久化或者还未持久化,集群中5个实例 M1,M2,M3,M4,M5,client A获得了M1,M2,M3实例的锁。此时M1宕机并重启。由于没有进行持久化,M1重启后不存在任何KEY,client B获得M4,M5和重启后的M1中的锁。此时client A 和Client B 同时获得锁
解决:每一条指令进行一次持久化,但是会影响性能
问题2
A获得锁,master发生主从切换,slave中还未同步锁,导致A的锁丢失,此时B获得锁
解决:主从切换后在一个TTL时间段内不能加锁
问题3
业务还没有完成,或者超时,锁被释放,另一个线程获得锁
解决:当一个Client在工作计算到一半时发现锁的剩余有效期不足。可以向Redis实例发送续约锁的Lua脚本。如果Client在一定的期限内(耗间与申请锁的耗时接近)成功的续约了半数以上的实例,那么续约锁成功。
为了提高系统的可用性,每个Client申请锁续约的次数需要有一个最大限制,避免其不断续约造成该key长时间不可用。
Redisson
实现原理:
步骤:
1 如果该客户端面对的是一个redis cluster集群,他首先会根据hash节点选择一台机器。
2 发送lua脚本到redis服务器上,脚本如下:(lua的作用:保证这段复杂业务逻辑执行的原子性。)
锁互斥机制
client1获得锁,如果客户端2来尝试加锁,执行了同样的一段lua脚本,会咋样呢?
很简单,第一个if判断会执行“exists myLock”,发现myLock这个锁key已经存在了。
接着第二个if判断,判断一下,myLock锁key的hash数据结构中,是否包含客户端2的ID,但是明显不是的,因为那里包含的是客户端1的ID。
所以,客户端2会获取到pttl myLock返回的一个数字,这个数字代表了myLock这个锁key的剩余生存时间。比如还剩15000毫秒的生存时间。
此时客户端2会进入一个while循环,不停的尝试加锁。
Watchdog自动延时机制
只要客户端1一旦加锁成功,就会启动一个watch dog看门狗,他是一个后台线程,会每隔10秒检查一下,如果客户端1还持有锁key,那么就会不断的延长锁key的生存时间。
可重入锁机制
第一个if判断 肯定不成立,“exists myLock”会显示锁key已经存在了。
第二个if判断 会成立,因为myLock的hash数据结构中包含的那个ID,就是客户端1的那个ID,也就是“8743c9c0-0795-4907-87fd-6c719a6b4586:1”
此时就会执行可重入加锁的逻辑,他会用:incrby myLock 8743c9c0-0795-4907-87fd-6c71a6b4586:1 1
通过这个命令,对客户端1的加锁次数,累加1。数据结构会变成:myLock :{“8743c9c0-0795-4907-87fd-6c719a6b4586:1”:2 }
释放锁
总结:
1 互斥性
任意时刻,只能有一个客户端获取锁,不能同时有两个客户端获取到锁。
2 同一性
锁只能被持有该锁的客户端删除,不能由其它客户端删除。
3 可重入性
持有某个锁的客户端可继续对该锁加锁,实现锁的续租
4 容错性
锁失效后(超过生命周期)自动释放锁(key失效),其他客户端可以继续获得该锁,防止死锁
redis的启动
initserver第一步
初始化全局对象redisserver
initserver第二步:
创建共享对象
redis在初始化的时候创建一些共享对象,运行后可直接使用,包括
1 服务器应答语 比如“ok”“error”“”“no such key”
2 常用字符 “:”,“+”等
3 常用命令 “DEL”“LPUSH”等
4 基础类型
initserver第三步:
初始化DB,初始化数据结构
Redis在分布式架构的应用
利用Redis的单线程,可以控制分布式场景下的并发安全,在进入临界区前线程通过redis拿到锁,操作资源后释放锁,