Redis深入学习

数据结构底层

string

https://blog.csdn.net/pugongying_95/article/details/99718749

Redis使用自己的简单动态字符串(simple dynamic string, SDS)的抽象类型 。。Redis中,默认以SDS作为自己的字符串表示。只有在一些字符串不可能出现变化的地方使用C字符串。 SDS除了用来作为数据存储之外,SDS还被用作缓冲区(buffer),如AOF模块中的AOF缓冲区,以及客户端状态中的输入缓冲区 。

struct sdshdr {    
	int len;    
	int free;  
    //字节数组
	char buf[];
};
C字符串SDS
获取字符串长度的复杂度为O(N)获取字符串长度的复杂度为O(1)
API是不安全的,可能会造成缓冲区溢出API是安全的,不会造成缓冲区溢出
修改字符串长度N次必然需要执行N次内存重分配修改字符串长度N次最多需要执行N次内存重分配
只能保存文本数据可以保存文本或者二进制数据
可以使用所有库中的函数兼容c 可以使用一部分库的函数

杜绝缓冲区溢出 :
当使用SDS的API对字符串进行修改的时候,API内部第一步会检测字符串的大小是否满足。如果空间已经满足要求,那么就像C语言一样操作即可。如果不满足,则拓展buf的空间之后再进行操作。每次操作之后,len和free的值会做相应的修改。
buf空间扩容策略:修改之后总长度len<1MB: 总空间为2*len+1;修改之后总长度len>=1MB: 总空间为len+1MB+1。

减少修改字符串时带来的内存重分配次数:
字符串长度增加操作时,进行空间预分配
字符串长度减少操作时,惰性空间释放。当执行字符串长度缩短的操作的时候,SDS并不直接重新分配多出来的字节,而是修改len和free的值(len相应减小,free相应增大,buf的空间大小不变化),避免内存重分配。SDS也提供直接释放未使用空间的API,在需要的时候,也能真正的释放掉多余的空间。

list

https://zhuanlan.zhihu.com/p/102422311
https://blog.csdn.net/Fly_as_tadpole/article/details/88212436

Redis 3.2之前,Redis 列表list使用两种数据结构作为底层实现:

  • 压缩列表ziplist
  • 双向链表linkedlist
    因为双向链表占用的内存比压缩列表要多, 所以当创建新的列表键时, 列表会优先考虑使用压缩列表, 并且在有需要的时候, 才从压缩列表实现转换到双向链表实现。

当列表对象中元素的长度比较小或者数量比较少的时候,采用ziplist来存储,当列表对象中元素的长度比较大或者数量比较多的时候,则会转而使用双向列表linkedlist来存储。

两种存储方式的优缺点

  • 双向链表linkedlist便于在表的两端进行push和pop操作,在插入节点上复杂度很低,但是它的内存开销比较大。首先,它在每个节点上除了要保存数据之外,还要额外保存两个指针;其次,双向链表的各个节点是单独的内存块,地址不连续,节点多了容易产生内存碎片。
  • ziplist存储在一段连续的内存上,所以存储效率很高。但是,它不利于修改操作,插入和删除操作需要频繁的申请和释放内存。特别是当ziplist长度很长的时候,一次realloc可能会导致大批量的数据拷贝。

1、双向链表:
当链表enty节点数据超过512、或单个value 长度超过64,底层就会转化成linkedlist编码; linkedlist是标准的双向链表,Node节点包含prev和next指针,可以进行双向遍历; 还保存了 head 和 tail 两个指针,因此,对链表的表头和表尾进行插入的复杂度都为 (1) —— 这是高效实现 LPUSH 、 RPOP、 RPOPLPUSH 等命令的关键。

2、压缩列表:
将一系列数据与其编码信息存储在一块连续的内存区域,这块内存物理上是连续的,逻辑上被分为多个组成部分,其目的是在一定可控的时间复杂读条件下尽可能的减少不必要的内存开销,从而达到节省内存的效果。ziplist将数据按照一定规则编码规则在一块连续的内存区域,目的是节省内存,这种结构并不擅长做修改操作。一旦数据发生改动,就会引发内存realloc,可能导致内存拷贝。 list、map、zset底层都会用到压缩列表。

常态的压缩列表内存编码如上图所示,整个内存块区域内分为五个部分,下面分别介绍着五个部分:
img

  • zlbytes:存储一个无符号整数,固定四个字节长度,用于存储压缩列表所占用的字节,当重新分配内存的时候使用,不需要遍历整个列表来计算内存大小。

  • zltail:存储一个无符号整数,固定四个字节长度,代表指向列表尾部的偏移量,偏移量是指压缩列表的起始位置到指定列表节点的起始位置的距离。

  • zllen:压缩列表包含的节点个数,固定两个字节长度,源码中指出当节点个数大于2^16-2个数的时候,该值将无效,此时需要遍历列表来计算列表节点的个数。

  • entryX:列表节点区域,长度不定,由列表节点紧挨着组成。每个节点可以保存一个字节数组或者是一个整数值。每个列表节点由三部分组成:

    img

    pre length用于存储上一个节点的长度,因此压缩列表可以从尾部向头部遍 。encoding类型一共有两种,一种字节数组一种是整数 ,content为字节数组的encoding内容 。

  • zlend:一字节长度固定值为255,用于表示列表结束。

Redis 3.2+以后list使用quickList,quickList是ziplist和linkedlist二者的结合 。quickList是一个ziplist组成的双向链表。每个节点使用ziplist来保存数据。 quickList就是一个标准的双向链表的配置,有head 有tail; 每一个节点是一个quicklistNode,包含prev和next指针。 每一个quicklistNode 包含 一个ziplist,*zp 压缩链表里存储键值。 所以quicklist是对ziplist进行一次封装,使用小块的ziplist来既保证了少使用内存,也保证了性能。

使用压缩列表的意义:

Redis对于每种数据结构、无论是列表、哈希表还是有序集合,在决定是否应用压缩列表作为当前数据结构类型的底层编码的时候都会依赖**一个开关和一个阈值,开关用来决定我们是否要启用压缩列表编码,阈值总的来说通常指当前结构存储的key数量有没有达到一个数值(条件),或者是value值长度有没有达到一定的长度(条件)。**任何策略都有其应用场景,不同场景应用不同策略。
为什么当前结构存储的数据条目达到一定数值使用压缩列表就不好?压缩列表的新增、删除的操作平均时间复杂度为O(N),随着N的增大,时间必然会增加,他不像哈希表可以以O(1)的时间复杂度找到存取位置,然而在一定N内的时间复杂度我们可以容忍。然而压缩列表利用巧妙的编码技术除了存储内容尽可能的减少不必要的内存开销,将数据存储于连续的内存区域,

使用压缩列表的好处除了节约内存之外,还有减少内存碎片的作用。将很多小的数据块存储在一个比较大的内存区域,试想想,如果我们将要存储的数据都是很小的条目,我们为每一个数据条目都单独的申请内存,结果是这些条目将有可能分散在内存的每一个角落,最终导致碎片增加,这是一件令人头疼的事情。

map

https://zhuanlan.zhihu.com/p/193141635
https://www.jianshu.com/p/7f53f5e683cf

hash的底层存储有两种数据结构,一种是ziplist,另外一种是hashtable。hash对象只有同时满足以下条件,才会采用ziplist编码:1、hash对象保存的键和值字符串长度都小于64字节;2、hash对象保存的键值对数量小于512。ziplist存储的结构如下,当数据量比较小的时候,我们会将所有的key及value都当成一个元素,顺序的存入到ziplist中,构成有序。

img

采用hashtable如下结构:

img

两个hashtable:
dict 结构内部包含两个 hashtable,通常情况下只有一个 hashtable 是有值的。但是在 dict 扩缩容时,需要分配新的 hashtable,然后进行渐进式搬迁,这时候两个 hashtable 存储的分别是旧的 hashtable 和新的 hashtable。待搬迁结束后,旧的 hashtable 被删除内存被释放,新的 hashtable 取而代之。

hash算法:
当往字典中添加键值对时,需要根据键的大小计算出哈希值和在哈希表中的索引值 ,redis用的hash算法:Thomas Wang’s 32 bit Mix函数,对一个整数进行哈希 、MurmurHash2哈希算法对字符串进行哈希 、

扩缩容:
扩容:当 hash 表中元素的个数等于第一维数组的长度时,就会开始扩容,扩容的新数组是原数组大小的 2 倍。不过如果 Redis 正在做 bgsave,为了减少内存页的过多分离 (Copy On Write),Redis 尽量不去扩容 (dict_can_resize),但是如果 hash 表已经非常满了,元素的个数已经达到了第一维数组长度的 5 倍 (dict_force_resize_ratio),说明 hash 表已经过于拥挤了,这个时候就会强制扩容。

缩容:当 hash 表因为元素的逐渐删除变得越来越稀疏时,,Redis 会对 hash 表进行缩容来减少 hash 表的第一维数组空间占用。缩容的条件是元素个数低于数组长度的 10%。缩容不会考虑 Redis 是否正在做 bgsave。

哈希表扩容需要将ht[0]表中的所有键全部rehash到ht[1]中,但是rehash操作不是一次性、集中式完成的,而是分多次,渐进式,断续进行的,这样才不会对服务器性能造成影响。通过两种方式小步搬迁和定时任务搬迁实现增量渐进多次。

字典结构dict中的一个成员rehashidx,当rehashidx为-1时表示不进行rehash,当rehashidx值为0时,表示开始进行rehash。当rehash时进行完成时,将rehashidx置为-1,表示完成rehash。1、在rehash期间,每次对字典的添加、删除、查找、或更新操作时,都会判断是否正在进行rehash操作,如果是,则顺带进行单步rehash,并将rehashidx+1。2、但是有可能客户端闲下来了,没有了后续指令来触发这个搬迁,那么Redis还会在定时任务中对字典进行主动搬迁。

set

https://developer.aliyun.com/article/666399

Set底层用两种数据结构存储,,底层使用了intset和hashtable两种数据结构存储的,intset我们可以理解为数组,hashtable就是普通的哈希表(key为set的值,value为null)。 intset的底层结构,查询方式一般采用二分查找法,实际查询复杂度也就在log(n)。

typedef struct intset {
    // 编码类型
    uint32_t encoding;
    // 集合包含的元素数量
    uint32_t length;
    // 保存元素的数组
    int8_t contents[];
} intset;

intset内部其实是一个数组(int8_t coentents[]数组),而且存储数据的时候是有序的,因为在查找数据的时候是通过二分查找来实现的。 set的底层存储intset和hashtable是存在编码转换的,使用intset存储必须满足下面两个条件,否则使用hashtable,条件如下:

  • 结合对象保存的所有元素都是整数值
  • 集合对象保存的元素数量不超过512个

zset

https://zhuanlan.zhihu.com/p/53975333
https://cloud.tencent.com/developer/article/1710611

zset的应用场景有排行榜等。zset为有序(有限score排序,score相同则元素字典序),自动去重的集合数据类型,其底层实现为 字典(dict) + 跳表(skiplist),当数据比较少的时候用ziplist编码结构存储。同时满足以下两个条件采用ziplist存储:元素少和元素小

  • 有序集合保存的元素数量小于默认值128个
  • 有序集合保存的所有元素的长度小于默认值64字节

跳跃表是一种基于有序链表的扩展,简称跳表。跳表会**维护多个索引链表(关键节点链表)和原链表。**不断得提升新的关键节点形成新的有序链表,通过空间换时间。对有序链表进行关键点的提升,作为插入对比的索引。总体上跳表插入的时间复杂度是O(logN),空间复杂度是O(N) 。

2786935-277aa1881108c620.png

zset的dict+跳表的底层实现:

dict结构,主要key是其集合元素,而value就是对应分值,而zkiplist作为跳跃表,按照分值排序,方便定位成员

preview

并发控制

乐观锁:
redis乐观锁, 即CAS(check-and-set)机制, 客户端A在修改key之前先监控key, 如果key被其他客户端修改, 那么客户端A的修改就会失败, 返回 nil. 注意: 客户端A的修改要与事务一起使用,即watch和事务配合使用。

127.0.0.1:6379> set mykey 123
OK
127.0.0.1:6379> watch mykey
OK
127.0.0.1:6379> multi
OK
127.0.0.1:6379> set mykey 789
QUEUED
127.0.0.1:6379> exec
(nil)
127.0.0.1:6379> get mykey
"11111"
## 其他的客户端在修改 set mykey 11111

分布式锁(悲观锁):
客户端在读写redis之前必须先从redis获取锁, 只有获取到锁的客户端才能读写redis, 而其他没有获取到锁的客户端, 会不断地去尝试获取锁。

lua脚本:
https://felord.blog.csdn.net/article/details/109140542
Lua 脚本在 Redis 中是以原子方式执行的,在 Redis 服务器执行EVAL命令时,在命令执行完毕并向调用者返回结果之前,只会执行当前命令指定的 Lua 脚本包含的所有逻辑,其它客户端发送的命令将被阻塞,直到EVAL命令执行完毕为止。因此 LUA 脚本不宜编写一些过于复杂了逻辑,必须尽量保证 Lua 脚本的效率,否则会影响其它客户端。

redis过期key处理

https://blog.csdn.net/shuizimuzhonglingf/article/details/102782014
https://zhuanlan.zhihu.com/p/86531660

Redis使用懒惰删除+定期删除相结合的方式处理过期的key,结合内存淘汰机制。

懒惰删除:在客户端访问该key的时候,redis会对key的过期时间进行检查,如果过期了就立即删除。这种方式看似很完美,在访问的时候检查key的过期时间,不会占用太多的额外CPU资源。但是如果一个key已经过期了,如果长时间没有被访问,那么这个key就会一直存留在内存之中,严重消耗了内存资源。

定期删除:Redis会将所有设置了过期时间的key放入一个字典中,然后每隔一段时间从字典中随机一些key检查过期时间并删除已过期的key。Redis默认每秒进行10次过期扫描,为了保证扫描不会出现循环过度,导致线程卡死现象,还增加了扫描时间的上限,默认是 25 毫秒 。
1、从过期字典中随机20个key
2、删除这20个key中已过期的
3、如果超过25%的key过期,则重复第一步

Redis 在扫描过期键时,一般会循环扫描多次,如果请求进来,且正好服务器正在进行过期键扫描,那么需要等待 25 毫秒,如果客户端设置的超时时间小于 25 毫秒,那就会导致链接因为超时而关闭,就会造成异常,这些现象还不能从慢查询日志中查询到,因为慢查询只记录逻辑处理过程,不包括等待时间。 所以我们在设置过期时间时,一定要避免同时大批量键过期的现象,所以如果有这种情况,最好给过期时间加个随机范围,缓解大量键同时过期,造成客户端等待超时的现象 。

内存淘汰机制:当前已用内存超过maxmemory限定时,触发主动清理策略。redis 内存淘汰机制:

  • noeviction: 当内存不足以容纳新写入数据时,新写入操作会报错。
  • allkeys-lru:当内存不足以容纳新写入数据时,在键空间中,移除最近最少使用的 key(常用)。
  • allkeys-random:当内存不足以容纳新写入数据时,在键空间中,随机移除某个 key。
  • volatile-lru:当内存不足以容纳新写入数据时,在设置了过期时间的键空间中,移除最近最少使用的 key(这个一般不太合适)。
  • volatile-random:当内存不足以容纳新写入数据时,在设置了过期时间的键空间中,随机移除某个 key。
  • volatile-ttl:当内存不足以容纳新写入数据时,在设置了过期时间的键空间中,有更早过期时间的 key 优先移除。

持久化原理

redis rdb

https://blog.csdn.net/ctwctw/article/details/105147277

优点:由于rdb文件都是二进制文件,所以很小,在灾难恢复的时候会快些。他的效率(主进程处理命令的效率,而不是持久化的效率)相对于aof要高(bgsave而不是save),因为每来个请求他都不会处理任何事,只是bgsave的时候他会fork()子进程且可能copyonwrite,但copyonwrite只是一个寻址的过程,纳秒级别的。而aof每次都是写盘操作,毫秒级别。

缺点:数据可靠性比aof低,也就是会丢失的多。因为aof可以配置每秒都持久化或者每个写命令处理完就持久化一次这种高频率的操作,而rdb的话虽然也是靠配置进行bgsave,但是没有aof配置那么灵活,也没aof持久化快,因为rdb每次全量替换旧的rdb文件,aof每次只追加。

bgsave原理:fork() + copyonwrite

fork()用于创建一个子进程,注意是子进程,不是子线程。主进程每次收到bgsave命令需要fork()子进程之前都会判断是否存在子进程了,若存在也会忽略掉这次bgsave请求。若不存在我会fork()出子进程进行工作。 fork()出来的进程共享其父类的内存数据。仅仅是共享fork()出子进程的那一刻的内存数据,后期主进程修改数据对子进程不可见,同理,子进程修改的数据对主进程也不可见。比如:A进程fork()了一个子进程B,那么A进程就称之为主进程,这时候主进程子进程所指向的内存空间是同一个,所以他们的数据一致。但是A修改了内存上的一条数据,这时候B是看不到的,A新增一条数据,删除一条数据,B都是看不到的。而且子进程B出问题了,对我主进程A完全没影响,我依然可以对外提供服务,但是主进程挂了,子进程也必须跟随一起挂。这一点有点像守护线程的概念。

copyonwrite:

主进程fork()子进程之后,内核把主进程中所有的内存页的权限都设为read-only,然后子进程的地址空间指向主进程。这也就是共享了主进程的内存,当其中某个进程写内存时(这里肯定是主进程写,因为子进程只负责rdb文件持久化工作,不参与客户端的请求),CPU硬件检测到内存页是read-only的,于是触发页异常中断(page-fault),陷入内核的一个中断例程。中断例程中,内核就会把触发的异常的页复制一份(这里仅仅复制异常页,也就是所修改的那个数据页,而不是内存中的全部数据),于是主子进程各自持有独立的一份。

比如主进程收到了set k 1请求(之前k的值是2),然后这同时又有子进程在rdb持久化,那么主进程就会把k这个key的数据页拷贝一份,并且主进程中k这个指针指向新拷贝出来的数据页地址上,然后进行更改值为1的操作,这个主进程k元素地址引用的新拷贝出来的地址,而子进程引用的内存数据k还是修改之前的。

redis aof

https://blog.csdn.net/ctwctw/article/details/105173842

优点:持久化的速度快,因为每次都只是增量追加,rdb每次都全量持久化;数据相对更可靠,丢失少,因可以配置每秒持久化、每个命令执行完就持久化

缺点:灾难性恢复的时候过慢,因为aof每次都只追加原命令,导致aof文件过大,但是后面会rewrite,但是相对于rdb也是慢的。会对主进程对外提供请求的效率造成影响,接收请求、处理请求、写aof文件这三步是串行原子执行的。而非异步多线程执行的。

在这里插入图片描述

aof的频率高的话会对Redis带来性能影响,因为每次都是刷盘操作。跟mysql一样了。Redis每次都是先将命令放到缓冲区,然后根据具体策略(每秒/每条指令/缓冲区满)进行刷盘操作。如果配置的always,那么就是典型阻塞,如果是sec,每秒的话,那么会开一个同步线程去每秒进行刷盘操作,对主线程影响稍小。

其实Redis每次在写入AOF缓冲区之前,他都会调用flushAppendOnlyFile(),判断是否需要将AOF缓冲区的内容写入和同步到AOF文件中。这个决策是由配置文件的三个策略来控制的包括always总是刷盘、everysec启一个线程去每秒刷一次盘、no由内核自己去决策。

rewrite:

Redis4.0之前和Redis4.0的rewrite(重写)方式不一样,Redis4.0之前就是将aof文件中重复的命令给去掉。保留最新的命令。 4.0之前的做法效率很是低下,需要逐条命令对比。4.0开始的rewrite支持混合模式(也是就是rdb和aof一起用),直接将rdb持久化的方式来操作将二进制内容覆盖到aof文件中(rdb是二进制,所以很小),然后再有写入的话还是继续append追加到文件原始命令,等下次文件过大的时候再次rewrite(还是按照rdb持久化的方式将内容覆盖到aof中)。这种模式也是配置的,默认是开,也可以关闭。

触发:手动命令触发bgrewriteaof ;自动触发,当前AOF文件大小要大于server.aof_rewrite_min_size的值;当前AOF文件大小和最后一次重写后的大小之间的比率等于或者大于指定的增长百分比 。

rewrite原理:也是通过fork子进程去干重写的,如下

在这里插入图片描述

  • aof_rewrite_buf:rewrite(重写)缓冲区、aof_buf:写命令存放的缓冲区
  • 开始bgrewriteaof的时候,判断当前有没有bgsave/bgrewriteaof在执行,若有,则不执行,这个再rdb篇幅也有提到,以及下面很多fork()知识在rdb都有提到。彻底搞懂Redis持久化之RDB原理
  • 主进程fork()出子进程,在执行fork()这个方法的时候是阻塞的,子进程创建完毕后就不阻塞了
  • 主进程fork完子进程后,主进程能继续接收客户端的请求,所有写命令依然是写入AOF文件缓冲区并根据配置文件的策略同步到磁盘的。
  • 因为fork的子进程仅仅共享主进程fork()时的内存,后期主进程在更改内存数据,子进程是不可见的。因此Redis采取重写缓冲区(aof_rewite_buf)保存fork之后的客户端请求。防止新AOF文件生成期间丢失主进程执行的新命令所生成的数据。所以此时客户端的写请求不仅仅写入原来的aof_buf缓冲区,还写入了重写缓冲区。这就是我为什么用深蓝色的框给他两框到一起的原因。
  • 子进程通过内存快照的形式,开始生成新的aof文件。
  • 新aof文件生成完后,子进程向主进程发信号。
  • 主进程收到信号后,会把重写缓冲区(aof_rewite_buf)中的数据写入到新的AOF文件(主要是避免这部分数据丢失)
  • 使用新的AOF文件覆盖旧的AOF文件,且标记AOF重写完成。

混合持久化也是通过bgrewriteaof完成的,所以基本流程和上述一样。不同的是当开启混合模式时,fork出的子进程先将共享的内存副本全量以RDB的方式写入aof。这样提高了速度也极大的缩小了aof文件(毕竟都是二进制)。写完还是通知主进程,然后再将重写缓冲区的内容以AOF方式写入到文件,然后替换旧的aof文件。也就是说这种模式下的aof文件发生rewrite后前半部分是rdb格式(REDIS开头的二进制数据),后半部分是正常的aof追加的命令(重写缓冲区里的)。

redis epoll模型

https://www.cnblogs.com/kuotian/p/13199625.html
https://xie.infoq.cn/article/628ae27da9ccb37d2900e8ef4
https://blog.csdn.net/universsky2015/article/details/115258294

目前支持I/O多路复用的系统调用有 select,poll,epoll,I/O多路复用就是通过一种机制,一个进程可以监视多个描述符,一旦某个描述符就绪(一般是读就绪或者写就绪),能够通知程序进行相应的读写操作但select,poll,epoll本质上都是同步I/O,因为他们都需要在读写事件就绪后自己负责进行读写,也就是说这个读写过程是阻塞的。而异步I/O则无需自己负责进行读写,异步I/O的实现会负责把数据从内核拷贝到用户空间。

文件描述符(File descriptor)是计算机科学中的一个术语,是一个用于表述指向文件的引用的抽象化概念。文件描述符在形式上是一个非负整数。实际上,它是一个索引值,指向内核为每一个进程所维护的该进程打开文件的记录表。当程序打开一个现有文件或者创建一个新文件时,内核向进程返回一个文件描述符。在程序设计中,一些涉及底层的程序编写往往会围绕着文件描述符展开。

epoll操作过程:
epoll 的核心数据结构是:1个红黑树维护所有的fd和1个双向链表管理所有就绪的fd。还有3个核心API 如下。epoll对文件描述符的操作有两种模式:LT (level trigger)(默认)和ET (edge trigger)。LT模式是默认模式。

  1. int epoll_create(int size);

创建一个 epoll 文件描述符;创建 eventpoll数据结构,其中包含红黑树 cache 和双向链表。参数 size 并不是限制了 epoll 所能监听的文件描述符最大个数,只是对内核初始分配内部数据结构的一个建议。

  1. int epoll_ctl(int epfd, int op, int fd, struct epoll_event *event);
    函数是对指定描述符fd执行op操作。用于向内核注册新的描述符或者是改变某个文件描述符的状态。已注册的描述符在内核中会被维护在一棵红黑树上。

  2. int epoll_wait(int epfd, struct epoll_event * events, int maxevents, int timeout);
    等待epfd上的io事件,最多返回maxevents个事件。通过回调函数内核会将 I/O 准备好的描述符添加到rdlist双链表管理,进程调用 epoll_wait() 便可以得到事件完成的描述符。

epoll的优点:

  1. 监视的描述符数量不受限制,它所支持的FD上限是最大可以打开文件的数目,这个数字一般远大于2048,举个例子,在1GB内存的机器上大约是10万左 右,具体数目可以cat /proc/sys/fs/file-max察看,一般来说这个数目和系统内存关系很大。
  2. epoll是内核空间用一个红黑树维护所有的fd,epoll_wait 通过回调函数内核会将 I/O 准备好的描述符加入到一个双向链表中管理,只把就绪的fd用链表复制到用户空间。
  3. IO的效率不会随着监视fd的数量的增长而下降。epoll不同于select和poll轮询的方式,而是通过每个fd定义的回调函数来实现的。只有就绪的fd才会执行回调函数。

Redis中的io模型:

Redis 是跑在单线程中的,所有的操作都是按照顺序线性执行的,但是由于读写操作等待用户输入或输出都是阻塞的,所以 I/O 操作在一般情况下往往不能直接返回,这会导致某一文件的 I/O 阻塞导致整个进程无法对其它客户提供服务,而 I/O 多路复用就是为了解决这个问题而出现的。select,poll,epoll都是IO多路复用的机制。I/O多路复用就通过一种机制,可以监视多个描述符,一旦某个描述符就绪,能够通知程序进行相应的操作。redis的io模型主要是基于epoll实现的,不过它也提供了 select和kqueue的实现,默认采用epoll 。

每个客户端建立连接时,都需要redis服务端为其 创建 socket 套接字,建立连接。
然后该客户端的每个请求都要经历以下几步:
(1)等待请求数据数据从客户端发送过来
(2)将请求数据从内核复制到redis用户进程的缓冲区(buffer)
(3)对请求数据进行处理(对于 redis 而言,一般就是简单的 get/set)

由于操作简单+只涉及内存,所以第(3)步的处理很简单、很快,主要时间耗在(1)步,所以,如果采用普通 BIO阻塞IO模式,每个请求都要经历这几步,那么处理十万条数据,就要在(1)步花费大量的时间,这样的话,qps 一定很低。所以就采用了更高效的 IO 多路复用模式,即,将(1)步统一交给第三方(也就是操作系统,操作系统提供了 select、poll、epoll、kqueue、iocp等系统调用函数)。

应用

redis分布式锁

https://help.aliyun.com/document_detail/146758.html?spm=a2c4g.11186623.6.875.5e4d2655KDsa7P
https://zhuanlan.zhihu.com/p/150602212
https://blog.csdn.net/asd051377305/article/details/108384490
https://blog.csdn.net/jushisi/article/details/113095292

关注点:

加锁、解锁、互斥、可重入、续租、主从一致问题。确保锁的实现同时满足以下四个条件:

  1. 互斥性。在任意时刻,只有一个客户端能持有锁。
  2. 不会发生死锁。即使有一个客户端在持有锁的期间崩溃而没有主动解锁,也能保证后续其他客户端能加锁。
  3. 解铃还须系铃人。加锁和解锁必须是同一个客户端,客户端自己不能把别人加的锁给解了,即不能误解锁。
  4. 具有容错性。只要大多数Redis节点正常运行,客户端就能够获取和释放锁。

实现方式:

  • setnx + 超时时间2分钟
  • lua脚本 map hset 客户端id 加锁次数

1、setnx + 超时时间 时间戳 del

加锁:setnx mylock value 设置超时时间 pexpire mylock 1200000 毫秒

解锁:del mylock

锁互斥:setnx返回0,不断尝试去获取锁

续租:客户端占用这个锁成功获取这个锁时,启一个看门狗线程,每10s守护这个锁进行续租,返回pttl返回-2时,通过pexpire myLock 1200000 进行续租 。

可重入:setnx不能实现可重入重复获取锁。

主从一致问题:主挂了,数据异步复制到从还没有完成锁丢失,后续客户端请求到,导致脏数据。

缺点:

  1. setnx和expire分2步执行,非原子操作;若setnx执行成功,但expire执行失败,就可能出现死锁
  2. del命令存在误删除非当前线程持有的锁的可能场景
  3. 不支持阻塞等待、不可重入

2、lua脚本

img

img

加锁:使用lua脚本,可以把一大堆业务逻辑通过封装在lua脚本发送给redis,保证这段赋值业务逻辑执行的原子性。在这段脚本中,这里KEYS[1]代表的是你加锁的那个key,比如说:RLock lock = redisson.getLock(“myLock”);这里你自己设置了加锁的那个锁key就是“myLock”。 ARGV[1]代表的就是锁key的默认生存时间,默认30秒。ARGV[2]代表的是加锁的客户端的ID,类似于下面这样:8743c9c0-0795-4907-87fd-6c719a6b4586:1。 脚本的意思大概是:第一段if判断语句,就是用“exists myLock”命令判断一下,如果你要加锁的那个key不存在,就可以进行加锁。加锁就是用“hset myLock 8743c9c0-0795-4907-87fd-6c719a6b4586:1 1”命令。通过这个命令设置一个hash数据结构。

互斥:如果这个时候客户端B来尝试加锁,执行了同样的一段lua脚本。第一个if判断会执行“exists myLock”,发现myLock这个锁key已经存在。接着第二个if判断,判断myLock锁key的hash数据结构中,是否包含客户端B的ID,但明显没有,那么客户端B会获取到pttl myLock返回的一个数字,代表myLock这个锁key的剩余生存时间。此时客户端B会进入一个while循环,不听的尝试加锁。

续租:客户端A加锁的锁key默认生存时间只有30秒,如果超过了30秒,客户端A还想一直持有这把锁,怎么办?其实只要客户端A一旦加锁成功,就会启动一个watch dog看门狗,它是一个后台守护线程,会每隔10秒检查一下,如果客户端A还持有锁key,那么就会不断的延长锁key的生存时间。

可重入: 这个时候lua脚本是这样执行的:第一个if判断不成立,“exists myLock”会显示锁key已经存在了。第二个if判断会成立,因为myLock的hash数据结构中包含的那个ID,就是客户端A的ID,此时就会执行可重入加锁的逻辑,它会用“incrby myLock 8743c9c0-0795-4907-87fd-6c71a6b4586:1 1 ”这个命令对客户端A的加锁次数,累加1。

释放锁:释放逻辑是:每次对myLock数据结构中的那个加锁次数减1,如果加锁次数为0了,说明客户端已经不再持有锁了,此时就会用“del MyLock”命令,从redis里删除了这个key。然后另外的客户端B就可以尝试完成加锁了。

主从一致问题:上述解决不了。红锁解决。简单来说,**就是利用多个的主节点,在超过半数以上的主节点获取锁成功,才算成功;否则算失败,回滚–删除之前在所有节点上获取的锁。**使用N个完全独立、没有主从关系的Redis master节点以保证他们大多数情况下都不会同时宕机,N一般为奇数。一个客户端需要做如下操作来获取锁:

  1. 获取当前时间(单位是毫秒)。
  2. 轮流用相同的key和随机值在N个节点上请求锁,在这一步里,客户端在每个master上请求锁时,会有一个和总的锁释放时间相比小的多的超时时间。比如如果锁自动释放时间是10秒钟,那每个节点锁请求的超时时间可能是5-50毫秒的范围,这个可以防止一个客户端在某个宕掉的master节点上阻塞过长时间,如果一个master节点不可用了,我们应该尽快尝试下一个master节点。
  3. 客户端计算第二步中获取锁所花的时间,只有当客户端在大多数master节点上成功获取了锁((N/2) +1),而且总共消耗的时间不超过锁释放时间,这个锁就认为是获取成功了。
  4. 如果锁获取成功了,那现在锁自动释放时间就是最初的锁释放时间减去之前获取锁所消耗的时间。
  5. 如果锁获取失败了,不管是因为获取成功的锁不超过一半(N/2+1)还是因为总消耗时间超过了锁释放时间,客户端都会到每个master节点上释放锁,即便是那些他认为没有获取成功的锁。

redis用于异步队列

https://cloud.tencent.com/developer/article/1406259
https://blog.csdn.net/qq_36236890/article/details/81174504

队列中的常见问题解决:消息丢失、消息重复、消息的顺序性、队列满了处理

1、消息重复:

保证消费者的接口的冥等性即可。

2、消息丢失:

1)消费者丢数据

2)redis挂了,重启了,rdb的list消息丢失。

3)生产者丢数据

3、消息的顺序性:

一个队列,一个消费者,一个生产者

4、队列塞满了处理

写个临时的程序在晚上去消费队列中的消息。

5、队列中的消息塞满了,并设置了过期时间导致过期失效,消息丢失

重跑业务生成消息塞到队列中,再消费。

秒杀系统设计

https://blog.csdn.net/qq_35190492/article/details/103105780

注意点:高并发 几十万的qps、超卖、恶意请求、熔断降级限流

设计:

  1. 微服务架构,单独的秒杀服务,对应单独的秒杀库;

  2. 前端对请求的url加密,服务端对通过解密的url的请求才放行;

  3. redis搞个集群,单机redis能抗住7-8万的qps,搞个20-30十个;

  4. Nginx作为代理服务器,Tomcat只能顶几百的并发,一台服务几百,那就多搞点,在秒杀的时候多租点流量机;

  5. Nginx拦截请求,一般单个用户请求次数太夸张,不像人为的请求在网关那一层就得拦截掉了 ;

    img

  6. 前端限流:一般秒杀都是点击一下或者两下然后几秒之后才可以继续点击,这也是保护服务器的一种手段;

  7. 后端限流:秒杀的时候肯定是涉及到后续的订单生成和支付等操作,但是都只是成功的幸运儿才会走到那一步,那一旦100个产品卖光了,return了一个false,前端直接秒杀结束,然后你后端也关闭后续无效请求的介入了。

  8. 库存预热:秒杀的本质,就是对库存的抢夺,每个秒杀的用户来你都去数据库查询库存校验库存,然后扣减库存,撇开性能因数,数据库顶不住。Redis能抗住,提前把商品的库存加载到Redis中去,让整个流程都在Redis里面去做,然后等秒杀介绍了,再异步的去修改库存就好了。 分布式锁控制修改库存;redis事务+watch控制;Lua脚本是类似Redis事务,有一定的原子性,不会被其他命令插队,可以完成一些Redis事务性的操作。

  9. 补救措施:限流->服务降级->熔断->隔离

示意图:img

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值