Redis --- 原理

数据结构

动态字符串SDS

redis中保存的Key是字符串,value往往是字符串或者字符串的集合,可见字符串时redis中最常见的一种数据结构
不过redis并没有直接使用c中的字符串,因为c的字符串存在很多问题:
1.获取字符串长度需要通过运算
如:c中存储字符串底层是字符数组,"abc" ["a","b","c","\0"],"\0" 是字符串的结束标识,当得到字符串长度时需要整个字符数组的长度-1
2.非二进制安全
如果字符数组中有"\0",不允许字符数组中有自定义的"\0",即为关键字
3.不可修改

redis构建了一种新的字符串结构,称为简单动态字符串,简称SDS,其中一个sds类型的,其结构体为:
struct __attribute__ ((__packed__)) sdshdr8{
	uint8_t len; # buf已保存的字符串字节数,不包含结束标识
    uint8_t alloc;# buf申请的总的字节数,不包含结束标识
    unsigned char flags;# 不同SDS的头类型,用来控制SDS的头大小
    char buf[];  # 字符数组存储空间
} sdshdr8

例如name在空间存储的样子(连续空间):
    len:4 alloc:4 flags:1 n a m e \0
在读取name时,会根据len的长度读取     

# SDS之所以叫做动态字符串,是因为它具备动态扩容的能力,例如一个内容为"hi"的SDS,第一次创建时:
len:2 alloc:2 flags:1 h i \0
# 加入给这个SDS追加一段字符串为",jack"这里会先申请新内存空间
1.如果新字符串小于1M,则新空间为扩展后字符串长度的2倍+1;
2.如果新字符串大于1M.则新空间为扩展后字符串长度+1M+1,称为内存预分配,用来避免多次申请内存导致的性能问题
# 重新分配内存后,追加为先拷贝源数据,再加入新数据
len:7 alloc:13 flags:1 h i , j a c k \0   


# SDS的优点:
1.获取字符串长度的时间复杂度为O(1)
2.支持动态扩容
3.减少内存分配次数
4.二进制安全

IntSet

intset是redis中set集合的一种实现方式,基于整数数组来实现,并且具备长度可变,有序等特征,在c的基础上做了优化
typedef struct inset {
    uint32_t encoding;  # 编码方式,支持存放16,32,64位整数
    uint32_t length;  # 元素个数
    int8_t contents[]; # 整数数组的起始地址
} intset

其中encoding包含三种模式,表示存储的整数大小不同,2字节整数,4字节整数,8字节整数

为了方便查找,redis会将intset中所有的整数按照升序一次保存在contents数组中,

# 统一编码
当数组创建时,其中的每个元素所占的字节数是固定的,当通过下标来查找某个元素时,只需要知道起始地址+下标*字节数 就能找到对应的元素所在位置

# inset升级
例如现在需要往数组插入一个50000,那么2字节存不下这个数字,intset会重新申请空间,编码方式也会升级成对应的,例如50000,会升级为INTSET_ENC_INT32,即数组内的每个元素占4字节,并按照重新的编码方式及元素个数扩容数组,先将intset的头信息改成正确的,当拷贝数据进新数组时,因为是从小到大,如果正序拷贝的话,后续元素始终会覆盖之前的元素,所以这里采用倒序将数组中的元素拷贝到扩容后的正确位置,最后将新加的元素放图数组末尾,
底层会采用二分查找来查找需要插入元素的位置

Dict

reids是一个键值型的数据库,我们可以根据键实现快速的增删改查,而键与值的映射关系正是通过Dict来实现的

Dict由三部分组成,分别是:哈希表,哈希节点,字典
# 哈希表的实现
typedef struct dictht{
    dictEntry **table; # 数组中保存的是指向entry的指针
    unsigned long size;# 哈希表的大小,总等于2^n
    unsigned long sizemask;# 哈希表大小的掩码,总等于size-1
    unsigned long used; # entry个数
}dictht
# 哈希节点的实现
typedef struct dictEntry{
    void *key # 键
    union {
        void *val;
        uint64_t u64;
        int64_t s64;
        double d;
    } v; # 值
    struct dictEntry *next # 下一个entry的指针,解决hash冲突
}dictEntry


当向Dict添加键值对是,Redis首先根据key计算出hash值,然后利用h&sizemask来计算元素应该存储到数组中的哪个索引位置(一般来说是对 % size来计算元素存储的位置,这里是因为有掩码存在可以&运算,结果一样)

当出现hash冲突时,会将新的k-v保存到原来的位置,把他的指向存储原来的k-v地址,往链表的对首添加元素比较方便

# 字典的实现
typedef struct dict {
    dictType *type;   # dict类型,内置不同的hash函数
    void *privdata; # 是由数据,在做特殊hash运算时用
    dictht ht[2]; # 一个dict包含两个hash表,一个是当前数据,另一个一般是空,rehash时使用
    long rehashidx; # rehash的进度,-1表示未进行
    int16_t pauserehash; # rehash是否暂停,1时暂停,0则继续
}dict;

redis的dict中的 HashTable就是数组结合单项链表实现,当集合元素较多时,必然导致哈希冲突增多,链表过长,则查询效率会大大降低

# Dict扩容
redis的dict在每次新增键值对时都会检查负载因子(LoadFactor = used/size),满足一下两种情况时会触发哈希表扩容:
1.哈希表的LoadFactor >=1,并且服务器没有执行BGSAVE或者BGREWRITEAOF等后台进程
2.哈希表的LoadFactor>5
扩容的大小为used+1,底层会对扩容大小做判断,实际上找的是第一个大于等于 used +1 的第一个2^n,如used+1=6,第一个2^n为8

# 收缩
redis的dict除了扩容,每次删除元素时也会对负载因子做检查,当LoadFactor<0.1时,会做哈希表的收缩,同样是2^n

# rehash
不管是扩容还是收缩,弊病会创建新的哈希表,导致哈希表的siz和sizemask变化,而key的查询与sizemask有关,因此必须对哈希表中的每一个key重新计算索引,插入新的哈希表,这个过程成为rehash,过程:
1.计算新hash的realeSize,值去局域当前要做的是扩容还是收缩
    1.如果是扩容,则新size为第一个大于等于dict.ht[0].used +1 的 2^n
   2.如果是收缩,则新size为第一个大于等于dict.ht[0]的used的2^n(不得小于4)
2.按照新的realeSize申请内存空间,创建dictht,并赋值给dict.ht[1]
3.设置dict.rehashidx=0,标识开始rehash
4.将dict.ht[0]中的每一个dictEntry都rehash到dict.ht[1]中
5.将dict.ht[1]复制给dict.ht[0],给dict.ht[1]初始化为空哈希表,释放原来的dict.ht[0]的内存

# 渐进式rehash
因为在每次新增或删除时,都会做rehash,如果dict中存储了上百万个元素,因为执行rehash的过程太久导致redis的主线程阻塞,因此dict在做rehash时分多次,渐进式的完成,因此成为渐进式rehash,流程如下:
1.计算新hash的realeSize,值去局域当前要做的是扩容还是收缩
    1.如果是扩容,则新size为第一个大于等于dict.ht[0].used +1 的 2^n
   2.如果是收缩,则新size为第一个大于等于dict.ht[0]的used的2^n(不得小于4)
2.按照新的realeSize申请内存空间,创建dictht,并赋值给dict.ht[1]
3.设置dict.rehashidx=0,标识开始rehash
4.每次执行新增,查询,修改,删除操作时,都检查一下dict.rehashidx是否大于-1,如果是则将dict.ht[0].table[rehashidx]的entry链表rehash到dict.ht[1],并且将rehashidx++,直至dict.ht[0]的所有数据都rehash到dict.ht[1] #这里会不同,简单来说就是每操作一次dict都会rehash一次(下标上的一个单链表的数据)
5.将dict.ht[1]复制给dict.ht[0],给dict.ht[1]初始化为空哈希表,释放原来的dict.ht[0]的内存
6.将rehashidx赋值为-1,表示rehash结束
7.在rehash过程中,新增操作,则直接写入ht[1]中,查询,修改,删除则会在两个数组中一次查找并执行,这样可以确保ht[0]的数据只减不正,随着rehash最终为空

# 缺点:
内存浪费比较严重,大量指针(一个指针8字节)

Ziplist

1677578963744

# 压缩列表
是一种特殊的"双端链表",由一系列特殊编码的连续内存块组成,可以在任意一段进行压入/弹出操作,并且该操作的时间复杂度为O(1),既然是连续的,就不用通过指针来寻找元素,则省去了存储指针的空间

头信息               head节点    元素    tail节点     结束标识:0xff(1字节)
zlbytes:总字节数(以4字节来表示)
zltail:tail节点偏移量(以4字节来表示)
zllen:entry节点个数(以2字节来表示)

1677578989926

entry结构

1677579808438

1677581851107

ziplist中的Entry并不像普通链表那样记录前后节点的指针,因为记录两个指针要占用16个字节,浪费内存,而是采用下面的结构:

previous_entry_length encoding content
# previous_entry_length:前一节点的长度,占1或5字节
如果前一节点的长度小于254字节,则用1字节保存超过则用5字节保存,第一个字节为0xfe,后是个字节才是真实长度数据
# encoding:编码属性,记录content的数据类型(字符串还是整数)以及长度,占1或2或5字节
# content:负责保存节点数据,可以是字符串或整数

ziplist中所有存储长度的数字均采用小端字节序,即地位字节在前,高位字节在后;例如0x1234,采用小段字节序存储为:0x3412
    
    
# ziplistentry中的encoding编码分为字符串和整数两种:
字符串:如果encoding字节码是以"00","01","10"开头,则证明content是字符串,"00"开头则表示长度存储为1字节,如:00001000,001000表示content长度为001000个长度,换算成十进制为10
        
例如:保存"ab"he "bc"
    首先保存ab,ab是第一个元素,previous_entry_length部分为00000000 ,encoding为00000010,content(ascii码)为01100001 |01100010,用16进制字节数更少,表示0x00|0x02|0x61|0x62
    bc为,0x04|0x02|0x62|0x63
    
    两个entry的字节加起来为8字节,头和尾加起来是11字节,总共为19字节
    ziplist是用小端字节序来表示
    
整数:如果encoding字节码是以"11"开头,则证明content是整数,且encoding固定值只占用1字节,上图是规则
    

  

连锁更新问题

ziplist的每个Entry都包含previous_entry_length来记录上一个节点的大小,长度是1或5个字节
1.如果前一个节点的长度小于254字节,采用1字节来保存着长度值
2.如果大于或等于则采用5个字节来保存这个长度值,第一个字节为0xfe,后是个字节才是正事长度

假如由1000个连续的entry,每个intry的字节数刚好是252个字节,此时有一个254字节的数据要存储到ziplist中,导致这个元素后面的所有节点全部都需要扩容,这种连续多次空间扩展操作就是连锁更新问题,新增删除都可能导致这种情况的发生,从而影响性能,因为这种情况发生的概率很低,所以redis现在暂时还未对此现象做出解决
# 解决方法,redis引进了listpack数据结构,但暂未重构ziplist代码
相关博客链接:https://blog.csdn.net/weixin_45735834/article/details/126559632

QuickList

1677585322160

1677585456144

# ziplist虽然节省内存,但是申请内存必须是连续空间,如果内存占用较多,申请内存效率会很低
为了缓解这个问题,我们必须限制ZipList的长度和entry大小
# 但是要存储大量数据,超过了ZipList最佳的上限,怎么办
可以创建多个Ziplist来分片存储数据
# 数据拆分后比较分散,不方便管理和查找,这多个ZipList如何建立联系?
redis3.2版本后引入了新的数据结构QuickList,她是一个双端链表,只不过链表中的每个节点都是一个ZipList

#为了避免QuickList中的每个ZipList中entry过多,Redis提供了一个配置项:list-max-ziplist-size来限制,默认值为-2
如果值为正:则代表ziplist的允许entry个数的最大值
如果值为负:则代表ziplist的最大内存大小,分5中情况:
        -1:每个ziplist内存不超过4kb
        -2:每个ziplist内存不超过8kb
        -3:每个ziplist内存不超过16kb
        -4:每个ziplist内存不超过32kb
        -5:每个ziplist内存不超过64kb
# 除了控制Ziplist的大小,QuickList还可以对节点的Ziplist做压缩,进一步节省内存,通过配置项list-compress-depth来控制,因为链表一般都是从首尾访问较多,所以首位是不压缩的,这个参数是控制首位不压缩的节点个数,默认为0:
		0:特殊值,表示不压缩
         1:表示首位各有1个节点不压缩,中间节点压缩
         2:2个不压缩,中间节点压缩
         ......以此类推

SkipList

1677585826035

1677585935194

跳表,首先还是链表,但是与传统链表相比有几个差异:
1.元素按照score(分数)升序排列存储,如果score一样则根据ele字典排序
2.节点可能包含多个指针,指针跨度不同,即1和5,跨度为5
3.最多允许层数为32,存储元素个数为2^32
4.增删改查效率与红黑树基本一致,实现却更简单

RedisObject

1677586487272

1677586674881

1677586750785

redis中的任意数据类型的键和值都会被封装成一个redisobject,也叫做redis对象
头部就要占用16个字节,如果用stirng类型存储每个元素,则每个string的头部信息都要占用16个字节,如果用list存储,10个元素,头部信息只有一个对象头

五种数据类型的底层编码

string

# RAW编码
基本编码方式为RAW,基于简单动态字符串(SDS)实现,存储上限为512mb,redis object是两个独立的内存空间

# EMBSTR编码
如果存储的SDS长度小于44字节,则会采用EMBSTR编码,此时object head与SDS时一段连续空间,申请内存时只需要调用一次内存分配函数,效率更高
# 为什么是小于44字节,
object头信息是16字节 + 小于44字节的SDS的头尾信息为4字节 + 44字节的数据 = 64字节
因为redis的底层内存分配算法是JMLOCK,在分配内存时为2^n来分配内存的,64bytes正好符合内存分片,不会产生内存碎片

# INT编码
如果存储的字符串是整数值,并且大小在无符号整数的最大值(0-255)范围内,则会采用INT编码:直接将数据保存在Redis Object的ptr指针位置(刚好8字节),不再需要SDS了

list

1677590249812

# LinkedList
普通链表,可以从双端访问,内存占用较高,内存碎片较多
# ZipList
压缩列表,可以从双端访问,内存占用低,存储上限低
# QuickList
LinkedList+ZipList,可以从双端访问,内存占用较低,包含多个ZipList,存储上限高


在3.2版本之前,Redis采用ZipList和LinkedList来实现List,当元素数量小于512并且元素大小小于64字节是采用Ziplist编码,超过则采用LinkedList编码
3.2版本之后,Redis统一采用QuickList来实现List

set

1677657350650

1677657475912

set是Redis中的单列集合,满足以下特点:
不保证有序性
保证元素唯一(可以判断元素是否存在)
求交集,并集,差集

# 可以看出 Set对查询元素的效率要求非常高,
Hashtable,也就是Redis中的Dict,不过Dict是双列集合(可以存储键值对),key用来存储值,value统一为null,但是这样会造成很多内存碎片
当set中的所有数据都是整数,并且元素数量不超过set-max-inset-entries时,set会采用intset编码,以节省内存,默认值是512

sortedset

1677658594999

1677658691053

其中每一个元素都需要指定一个score值和member值
可以根据score值排序
member必须唯一(member是key,score是值)
可以根据member查询分数

SkipList:可以排序,并且可以同时存储score和ele值(member),但是因为SkipList是根据score排序,如果想要根据member差socre值,只能一个一个遍历

HT(dict):可以键值存储,并且可以根据key找value

在redis中,是结合两个结构一起实现的,数据存了两份,分别利用两个结构的优点进行查询,但是这样会大量占用内存

# 当元素数量不多是,HT和SkipList的优势不明显,而且更耗内存,因此zset还会采用ZipList结构来节省内存,不过需要同时满足两个条件
1.元素数量小于zset_max_ziplist_entries,默认值128
2.每个元素都小于zset_max_ziplist_value字节,默认值64

如果其中一个条件不满足则使用HT+SkipList来存储


# ziplist本身没有排序功能,而且没有键值对的概念,因此需要由zset通过编码实现:
Ziplist因为是连续内存,因此score和element是紧挨在一起的两个entry element在前,socre灾后

score越小越接近对首,score越大越接近队尾,按照score值升序怕排列

hash

1677659843919

1677659992367

1677660175393

redis中的hash结构与Redis中的Zset类似
1.都是键值存储
2.都需要根据键获取值
3.键必须唯一
区别如下:
zeset的键是member,值是score,hash的键和值都是任意值
zset要根据score排序,hash则无需排序

# 因此,Hash底层采用的编码与Zset基本一致,只需要把排序有关的SkipList去掉即可:
hash结构默认采用ZipList编码,用以节省内存,Ziplist中 相邻的两个entry分别保存field和value
当数据量较大时,Hash结构会转为HT编码,也就是Dict,触发条件有两个:
    ZipList中的元素数量超过了hash-max-ziplist-entries(默认512)
    ziplist中的任意entry大小超过了hash-max-ziplist-value(默认64字节)

网络模型

(参考资料:<UNIX网络编程>)

Linux用户空间和内核空间

在linux中,其系统内核就是linux,所有应用都需要通过Linux内核与硬件交互

为了避免用户应用导致冲突甚至内核崩溃,用户应用与内核是分离的:
不管是内核还是用户应用都不可以直接访问物理内存,会对应的分配虚拟内存,映射到对应的物理内存.
1.进程的寻址空间会划分为两部分:内核空间,用户空间
    寻址地址是:无符号整数,从0开始,最大值取决于CPU的地址总线和寄存器的带宽,如32位系统的带宽为32,因此寻址空间就是2^32,而内存地址每一个值代表的就是一个内存单元,及1字节,所以32位系统的寻址空间最大为4GB,地位的3GB划分为用户空间,高位的1GB划分为内核空间,当然CPU也会将各种不同的指令划分为不同的风险等级(R0-R3,R3为最低,在Linux中只有R0和R3)
    用户空间只能执行受限的命令(Ring3),而且不嗯能够直接调用系统资源,必须通过内核提供的接口来访问
    内核空间可以执行特权指令(Ring0),调用一切系统资源
    
因此,用户的命令一般是执行在用户空间,内核的命令是执行在内核空间
一个进程在运行过程中,因为业务较多,当它运行在用户空间时,称为用户态,在运行在内核空间时,称为内核态,所以进程会在两个状态之间切换如:
    Linux系统为了提高IO效率,会在用户空间和内核空间都加入缓冲区:
        写数据时,要把用户缓冲区的数据拷贝到内核缓冲区,然后写入设备
        读数据时,要从设备读取数据到内核缓冲区,然后拷贝到用户缓冲区

Linux阻塞IO

阻塞IO就是两个阶段都必须阻塞等待:
# 读数据为例来分析IO流程,
当用户发送请求用来读取数据时,需要发送内核提供的接口指令read,并等待,然后进程切换到内核态,判断缓冲区是否有读取的数据,如果有直接拷贝到用户缓冲区,如果没有则需要寻址,此时内核等待数据就绪,然后将数据读取到内核缓冲区,然后再拷贝到用户缓冲区,这样影响IO 效率的就是缓冲区的拷贝,和用户等待的这些时间,那么Linux就需要来优化这两点来提高性能,

Linux非阻塞IO

# 读数据为例来分析IO流程:
非阻塞IO就是recvfrom操作会立即返回失败信息而不是阻塞用户进程
然后用户通过循环调用recvfrom指令来不停的问内核是否将数据准备就绪,如果准备就绪则放入内核缓冲区,然后拷贝到用户缓冲区,没有就绪则不停的调用
非阻塞IO在读数据的过程中是不会阻塞,但是数据拷贝的时候依然是阻塞的
非阻塞IO在不停的问内核是否准备好数据,并未做其他的事情,这中忙轮询并没有提升什么性能,相反,不停的让CPU空转,会导致CPU使用率的增加

LinuxIO多路复用

无论是阻塞IO还是非阻塞IO,用户应用在第一阶段都需要调用recvfrom来获取数据,差别在于无数据时的处理方案:
阻塞IO会进程阻塞,非阻塞IO会是CPU空转,不能充分发挥CPU的作用

Linux的文件描述符(File Desciptor):简称FD,是一个从0开始递增的无符号整数,用来关联Linux中的一个文件,在Linux中一切皆文件,例如常规文件,视频,硬件设备等,当然也包括网络套接字(Socket)

IO多路复用:是利用单个线程来同时监听多个FD,并在某个FD刻度,可写时得到通知,从而避免无效等待,充分利用CPU资源
    
用户调用select函数,然后内核检查多个FD有没有任意一个或多个已就绪,如果有则返回这一个或多个FD是readable状态,如果极端情况所有FD都没有就绪的,则双方等待,当用户拿到readable结果,则循环调用recvfrom函数去真正的读数据

因为recvfrom只能监听一个FD,而select可以监听多个FD

# 在linux系统中,实现IO多路复用的方式有三种:
# select
# poll
上两种方式:在内核和用户之间建立联系,当FD准备就绪后会告诉用户有FD准备好了,用户并不知道是哪个FD准备就绪,所以用户会循环所有的FD,然后一个个问是哪个FD准备就绪,直到找到对应的FD,然后执行recvfrom
# epoll
在内核和用户之间建立联系,当FD准备就绪后会告诉用户FD为几的文件准备好了,用户直接找到对应的FD,然后执行recvfrom

# 细节上的差异:
select模式存在三个问题:
1.能监听的FD最大不超过1024,,因为是用1024bit位来表示FD
2.每次select都需要把所有要监听的FD都拷贝到内核空间
3.每次都要遍历所有FD来判断就绪状态
poll模式问题:
poll利用链表解决了select中监听FD上限的问题,但依然要遍历所有FD,如果监听较多,性能会下降
epoll模式中如何解决这些问题?
1.基于epoll实例中的红黑树保存要监听的FD,理论上无上限,而且增删改查效率非常高,性能也不会随着要监听的FD的数量增多而下降
2.每个FD只需要执行一次epoll_ctl添加到红黑树,以后每次epoll_wait无需传递任何参数,无需重复拷贝FD到内核空间
3.内核会将就绪的FD直接拷贝到用户空间的指定位置,用户进程无需遍历所有的FD就能知道就绪的FD是谁

1677666778961

1677667171815

1677667878046

IO多路复用的事件通知机制

当FD有数据可读是,我们调用epoll_wait就可以得到通知,但是事件通知的模式有两种:
LevelTriggered:简称LT,当FD有数据可读时,会重复通知多次,直到数据处理完成,是Epoll的默认模式
EdgeTriggered:简称ET,当FD有数据可读时,只会通知一次,不管数据是否处理完成
举例说明:
1.假设客户端socket对应的FD已经注册到了epoll实例中
2.客户端socket发送了2kb数据
3.服务端调用epoll_wait,得到通知说FD就绪
4.服务端从FD读取了1kb数据
5.回到步骤3(LT模式会再次调用epoll_wait,处理剩下的数据,直到没有数据,ET则不会)


# 这样ET模式好像不好用啊,剩下的数据读不到啊,
1.可以再次调用epoll_ctl函数,将FD添加数组中
2.循环读完所有数据(但是不能采用阻塞IO读数据,因为阻塞IO在读完数据后就会夯住,导致死循环,所以要用非阻塞IO)

# LT模式存在的问题
1.重复通知对于效率(性能)会有影响
2.会造成惊群现象,一个FD就绪,一次一次的通知,所有的用户进程都会被唤醒,其实一两个进程就可以完成数据处理,没必要唤醒那么多进程

基于IO多路复用的Web服务流程

1677677890166

Linux信号驱动IO

1677678036697

Linux异步IO

1677678283304

# 缺点:
高并发情况下,用户应用不停的aio_read,提交任务给内核,会导致内核工作量过重,积累IO读写任务过多,占用大量内存,导致内存占用过多崩溃,所以需要用户对高并发做限制

同步异步

1677678394022

Redis网络模型

# redis是单线程还是多线程?
如果仅限于Redis的核心业务部分(对命令的处理),答案是单线程
如果聊整个Redis架构,那么答案是多线程
因为在Redis的版本迭代过程中,在两个重要的时间节点上引入了多线程的支持:
Redis4.0:引入多线程异步处理一些耗时较长的任务,如异步删除命令unlink
redis6.0:在核心网络模型中引入多线程,进一步提高对多核CPU的利用率
# 为什么redis速度那么快?
因为redis是纯内存操作,IO多路复用只是对其提升了一点点
    
# 为什么redis要选择单线程?
1.抛开持久化不谈,Redis是纯内存操作,执行速度非常快,它的性能瓶颈是网络延迟而不是执行速度,因此多线程并不会带来巨大提升
2.多线程会导致过多的上下文切换,带来不必要的开销,6.0虽然引入了多线程但是也会对线程数量做限制,也就是宿主机CPU的核数的1-2倍
3.引入多线程会面临线程安全问题,必然要引入线程锁这样的安全手段,代码复杂度增高,而且性能也会大打折扣

6.0之前单线程网络模型

1677679626546

为了提升单线程redis的网络性能,底层采用了IO多路复用的技术,并支持不同的多路复用实现,0并对这些实现进行封装,提供了统一的高性能事件库API库 AE:
ae_epoll   -- Linux的OS实现方式
ae_evpoll  -- sloresOS实现方式
ae_kqueue  -- uninx(macos)
ae_select  -- 其他
ae.c会对当前操作系统做判断,引入不同的文件,执行对应文件的统一接口的函数

# 流程:下图

1677680324637

1677680342546

1677680615359

1677680628601

1677680972970

1677681007302

1677681145992

1677681165433

整个流程就是redis的命令处理器读命令时,和写数据到客户端时是IO操作,会影响整个流程的性能,所以redis针对这两个IO操作加入了多线程,执行命令时还是由主线程执行

通信协议

RESP协议

redis是一个CS架构的软件,通信一般分两步(不包括pipeline和PubSub)
1.客户端向服务端发送一条命令
2.服务端解析并执行命令,返回响应给客户端
因此客户端发送命令的格式,服务端响应结果的格式必须有一个规范,这个规范就是通信协议
redis1.2版本引入了RESP协议
redis2.0版本中称为与Redis服务端通信的标准称为RESP2
redis6.0版本中,从2升级成了3,增加了更多的数据类型并且支持6.0的新特性--客户端缓存

但是3不是向下兼容的,所以redis6.0默认还是2版本协议

数据类型

在RESP中,通过首字节的字符来区分不同数据类型,常用的数据类型包括五种:
1.单行字符串:首字节是'+',后面跟上单行字符串,以CRLF("\r\n")结尾,例如返回"OK","+OK\r\n"
如果数据中本身带有"\r\n",就读不到完整数据,所以不允许数据中带有"\r\n",所以二进制不安全
2.错误(errors):首字节是"-",与单行字符串一致,因为是服务端返回的,所以无所谓
3.数值:首字节是":",与单行字符串一致
4.多行字符串:"首字节是$",表示二进制安全的字符串,最大支持512MB,类似SDS,会记录字符串字节数,然后根据读取,如"$5\r\nhello\r\n",数字如果为0,表示是空字符串,为-1,表示不存在
5.数组:首字节是"*",后面是数组元素个数,然后是元素,元素数据类型不限 

代码和redis通信

# 1.建立socket连接
# 2.获取输出流,输入流
# 3.发送请求 set name jack
# 4.解析响应
# 5.释放连接

内存回收策略

redis之所以性能强,最主要的原因就是基于内存存储,然而单节点的redis其内存大小不宜过大,会影响持久化或主从同步性能

# 可以通过配置文件修改redis的最大内存
maxmemory 1gb

当内存使用上限,就无法存储更多数据了

内存过期策略

1677684355302

1677684568337

可以通过expire命令给key设置TTL(存活时间)

当过期以后,再次访问对应key,显示为null,对应的内存也得到了释放,从而起到内存回收的目的

# Redis是如何知道一个key是否过期
利用了两个Dict分别记录key-value以及key-ttl

# 是不是TTL到期就删除

#惰性删除
当访问(增删改查)时,判断是否到期,如果到期则删除,这样如果没人访问,这个key会一直存在占用内存空间,所以需要周期删除
#周期删除
通过一个定时任务,周期性的抽样部分过期的key,然后执行删除,执行周期有两种:
    1.reids初始化时会设置一个定时任务serverCron(),按照server.hz的频率来执行过期key的清理,模式为SLOW,默认为第一次会在1ms后执行,以后每次都是100ms执行一次,server.hz也可以配置
    # SLOW模式规则
    	1.执行效率手server.hz影响,默认为10,每秒执行10次,每个执行周期100ms
        2.执行清理耗时不超过1次执行周期的25%
        3.逐个遍历db,再逐个遍历中的bucket,抽取20个key判断是否过期
        4.如果没达到时间上限(25ms)并且过期key比例大于10%,再进行一次抽样,否则结束
        
    2.redis的每个时间循环前会调用beforeSleep函数,执行过期key清理模式为FAST
    #FAST模式规则
    	1.执行频率手beforeSleep()调用频率影响,但两次FAST模式间隔不低于2ms
        2.执行清理耗时不超过1ms
        3.逐个遍历db,再逐个遍历中的bucket,抽取20个key判断是否过期
        4.如果没达到时间上限(25ms)并且过期key比例大于10%,再进行一次抽样,否则结束
        
# 两种策略同时使用

内存淘汰策略

redis内存使用达到设置的阈值时,Redis主动挑选部分Key删除以释放更多内存的流程
# 只要收到命令,在执行命令前,都会触发是否开启检查内存淘汰策略,如果设置了server.maxmemory并且没有执行lua脚本,就会尝试内存淘汰机制

# Redis支持8中不同策略来选择要删除key:
1.noeviction:不淘汰任何key,但是内存满时,不允许写入新数据,默认
2.volatile-ttl:对设置了TTL的key,比较key的剩余TTL值,TTL越小越先被淘汰
3.allkeys-random:对全体key抽样本,随机淘汰,直接从db的dict中随机挑选
4.volatile-random:对设置了TTL的key抽样本,随机淘汰,也就是db的expires中随机挑选
5.allkeys-lru:对全体key抽样本,基于LRU算法进行淘汰
5.volatile-lru:对设置了TTL的key抽样本,基于LRU算法进行淘汰
6.allkeys-lfu:对全体key抽样本,基于LFU算法进行淘汰
7.volatile-lfu:对设置了TTL的key抽样本,基于LFU算法进行淘汰
# LRU和LFU
LRU:最少最近使用,用当前时间减去最后一次访问时间,这个值越大,淘汰优先级越高
LFU:最少频率使用,会统计每个key的访问频率,值越小淘汰优先级越高

# 可以修改配置文件,来选择内存淘汰机制
maxmemory-policy noeviction

如何知道一个key的访问频率和访问时间?

1677686814807

# 逻辑访问次数运算方式:
# 访问的越多,计数器+1的概率越低,越久时间(分钟为单位)不访问,计数器-1的次数越多
1.生成0-1的随机数R
2.计算1/(就次数*lfu_log_fator + 1)记录为P,lfu_log_fator默认为10
3.如果R<P,则计数器+1,且最大不超过255
4.访问次数会随时间衰退,距离上一次访问时间每隔lfu_decay_time分钟(默认1),计数器-1

1677687271920

posted @ 2023-04-06 21:12  河图s  阅读(16)  评论(0)    收藏  举报