目录
一 网络模型
1.1 用户空间和内核空间
任何Linux发行版(ubuntu,centos),其系统内核都是Linux,对linux的封装。我们的应用都需要通过Linux内核与硬件交互。
用户在操作时,都是基于用户应用去操作,经过linux媒介访问系统内核,最后由内核去访问计算的硬件;
为了避免用户应用导致冲突甚至内核崩溃,用户应用与内核是分离的,将内存分为两部分:
1:进程的寻址空间会划分为两部分:内核空间、用户空间
2:用户空间只能执行受限的命令(Ring3),而且不能直接调用系统资源,必须通过内核提供的接口来访问
3:内核空间可以执行特权命令(Ring0),调用一切系统资源。
上图的这种模式,用户空间去内核空间读取数据时,每次都是直接进行调用,没有缓存空间,效率比较低。所以就在下图中加入了缓存空间。
Linux系统为了提高IO效率,会在用户空间和内核空间都加入缓冲区:
1:写数据时,要把用户缓冲数据拷贝到内核缓冲区,然后写入设备
2:读数据时,要从设备读取数据到内核缓冲区,然后拷贝到用户缓冲区
流程:用户空间要读取数据,会去访问内核空间,如果没有查询到数据,就会等待内核空间的数据就绪(阻塞等待),等内核空间查询到数据后,经网卡将数据读取到内核缓冲区,最后再将数据拷贝到用户缓冲区,用户空间再进行处理。(阻塞IO)
两阶段的堵塞:
一阶段:用户进程执行receFrom命令后,堵塞等待内核响应
二阶段:内核查询到数据了,在响应给用户进程前,用户进程还是在阻塞等待内核响应。
1.2 阻塞IO
用户空间阻塞等待内核空间的数据返回,内核空间中等待数据就绪。
顾名思义,阻塞IO就是两个阶段都必须阻塞等待:
阶段一:
1:用户进程尝试读取数据(比如网卡数据)
2:此时数据尚未到达,内核需要等待数据
3:此时用户进程也处于阻塞状态
阶段二:
1:数据到达并拷贝到内核缓冲区,代表已就绪
2:将内核数据拷贝到用户缓冲区
3:拷贝过程中,用户进程依然阻塞等待 拷贝完成,
4:用户进程解除阻塞,处理数据
可以看到,阻塞IO模型中,用户进程在两个阶段都是阻塞状态。
1.3 非阻塞IO
非阻塞IO的recvfrom操作会立即返回结果而不是阻塞用户进程。
阶段一:
1:用户进程尝试读取数据(比如网卡数据),调用内核系统
2:此时数据尚未到达,内核需要等待数据
3:但是会直接返回异常给用户进程
4:用户进程拿到error后,再次尝试读取
5:循环往复,直到内核这边数据就绪,内核会结束阻塞。
阶段二:
1:将内核数据拷贝到用户缓冲区
2:拷贝过程中,用户进程依然阻塞等待
3:拷贝完成,用户进程解除阻塞,处理数据
可以看到,非阻塞IO模型中,用户进程在第一个阶段是非阻塞,第二个阶段是阻塞状态。虽然是非阻塞,但是需要频繁请求,性能并没有得到提高。而且忙等机制会导致CPU空转,CPU使用率暴增。
1.4 IO多路复用
1.4.1 引入IO多路复用
无论是阻塞IO还是非阻塞IO,用户应用在一阶段都需要调用recvfrom来获取数据,差别在于无数据时的处理方案:
1:如果调用recvfrom时,恰好没有数据,阻塞IO会使CPU阻塞,非阻塞IO使CPU空转,都不能充分发挥CPU的作用。
2:如果调用recvfrom时,恰好有数据,则用户进程可以直接进入第二阶段,读取并处理数据
在单线程情况下,只能依次处理IO事件,如果正在处理的IO事件恰好未就绪(数据不可读或不可写),线程就会被阻塞,所有IO事件都必须等待,性能自然会很差。
比如,服务员给顾客点餐,分两步: 顾客思考要吃什么(等待数据就绪),顾客想好了,开始点餐(读取数据);但是用户想的这个过程中,有其他客户想好了,但是由于第一个客户把占着线程,其他的用户只能等待。
所以我们有两种优化的方式:
方案一:增加更多服务员(多线程),这个成本高了
方案二:不排队,谁想好了吃什么(数据就绪了),服务员就给谁点餐(用户应用就去读取数据)
所以,引入了IO多路复用。
1.4.2 文件描述符
文件描述符(File Descriptor):简称FD,是一个从0 开始的无符号整数,用来关联Linux中的一个文件。在Linux中,一切皆文件,例如常规文件、视频、硬件设备等,当然也包括网络套接字(Socket)。(每一个文件的特殊标示,主键id)
IO多路复用:是利用单个线程来同时监听多个FD,并在某个FD可读、可写时得到通知,从而避免无效的等待,充分利用CPU资源。
IO多路复用执行逻辑:与之前的阻塞和非阻塞IO的区别就是,不是直接调用recvfrom去内核查询数据,而是先通过select去监听多个socket(点餐的客户),并阻塞等待数据(想好吃什么);监听到之后才会去执行recvfrom去拷贝数据。
阶段一:
1:用户进程调用select,指定要监听的FD集合
2:内核监听FD对应的多个socket
3:任意一个或多个socket数据就绪则返回readable
4:此过程中用户进程阻塞
阶段二:
1:用户进程找到就绪的socket(循环获取到就绪的socket)
2:依次调用recvfrom读取数据
3:内核将数据拷贝到用户空间
4:用户进程处理数据
总结:
IO多路复用是利用单个线程来同时监听多个FD,并在某个FD可读、可写时得到通知,从而避免无效的等待(第一个霸占线程,其他无法进行),充分利用CPU资源。不过监听FD的方式、通知的方式又有多种实现,常见的有: select poll epoll
差异:
select和poll只会通知用户进程有FD就绪,但不确定具体是哪个FD,需要用户进程逐个遍历FD来确认;
epoll则会在通知用户进程FD就绪的同时,把已就绪的FD写入用户空间。
1.4.3 IO多路复用-select
结构体解析:
select函数:有3个要监听的文件标识符,1,2,5,这个nfds就是6,下面的三个指针类型的集合,数据类型是fd_set,是一个long类型的bit数组,最大长度不能超过1024,默认也就是1024个0,如果有某一个fd就绪了,就变成1;*timeout是超时配置;
右图源码解析:
有三个要读取数据的fd,1,2,5;首先,在用户空间创建一个*readfds的bit数组,在1,2,5的位置上将0变成1,然后执行select函数,nfds为6(5+1),*readfds指向rfds,然后进行拷贝,将这个bit数组拷贝到内核空间,拷贝完成后,便利这个fd_set类型的数组,看看是哪几个fd要读取数据,判断这几个数据有没有就绪,没有就绪且超时了就先阻塞,这时这个内核中的bit数组全是0,直至数据就绪被唤醒,这时,加入有两个fd:1和2就绪了,就将1和2位置上的0变成1,然后拷贝回用户空间,将之前的bit数组覆盖,然后在用户空间再次遍历这个bit数组,查询出是哪几个fd查询到数据了,最后进行数据处理。
select模式存在的问题:
1:需要将整个fd_set从用户空间拷贝到内核空间,select结束还要再次拷贝回用户空间
2:select无法得知具体是哪个fd就绪,需要遍历整个fd_set
3:fd_set监听的fd数量不能超过1024
我的理解是每次调用select函数,传递的都是全量的数据。
1.4.3 IO多路复用-poll
poll模式对select模式做了简单改进,但性能提升不明显,只是将长度扩展了,采用链表的方式,道理上讲是可以无限制的,但是数量越多,性能肯定会有所下降。
IO流程:
- 创建pollfd数组,向其中添加关注的fd信息,数组大小自定义
- 调用poll函数,将pollfd数组拷贝到内核空间,转链表存储,无上限
- 内核遍历fd,判断是否就绪
- 数据就绪或超时后,拷贝pollfd数组到用户空间,返回就绪fd数量n
- 用户进程判断n是否大于0
- 大于0则遍历pollfd数组,找到就绪的fd
1.4.3 IO多路复用-epoll
首先是epoll_create方法,创建一个eventpoll实例,对象有两个变量,rb_root,是一个红黑树,上面的每一个节点都是我们要监听的FD,还有一个链表rdlist,存储的是就绪的FD,这个函数已返回一个句柄epfd,这个就是这个eventpoll对象的唯一标示;
创建完成后,就可以通过epoll_ctl函数将FD添加到红黑树中,拷贝到内核空间,只会操作这一次,减少了无数次的拷贝,并且会为这个FD添加一个回调机制的函数,这个FD就绪的时候,就会触发这个回调函数,将这个FD拷贝到rdlist中;其中,epfd就是上面epoll实例的句柄,op是要对fd执行的操作,fd记录要被监听的FD,epoll_event才是要被监听的事件,和op不一样;
描述 | |
---|---|
EPOLL_CTL_ADD | 在epoll的监视列表中添加一个文件描述符(即参数fd),指定监视的事件类型(参数event) |
EPOLL_CTL_MOD | 修改监视列表中已经存在的描述符(即参数fd)对应的监视事件类型(参数event) |
EPOLL_CTL_DEL | 将某监视列表中已经存在的描述符(即参数fd)删除,参数event传NULL |
epoll_wait函数是用来监听rdlist中是否有已就绪的FD的,如果在规定时间内没有监听到就绪的FD,就返回一个0;epoll_wait中的*events中,这是一个数组对象,刚开始是空的,会指向用户空间的某一个地址;如果监听到这个rdlist中有FD就绪后,这个函数会返回一个已就绪的FD数量,并且会将这些实际上就绪的FD对象拷贝到用户空间的events上。
1.4.4 IO多路复用中三种模式总结
1:select模式存在的三个问题:
- 能监听的FD最大不超过1024
- 每次select都需要把所有要监听的FD都拷贝到内核空间
- 每次都要遍历所有FD来判断就绪状态
2:poll模式的问题:
- poll利用链表解决了select中监听FD上限的问题,但依然要遍历所有FD,如果监听较多,性能会下降;
- 并且还是没有解决两次数据拷贝和数组的遍历。
3:epoll模式中如何解决这些问题的?
- 基于epoll实例中的红黑树保存要监听的FD,理论上无上限,而且增删改查效率都非常高
- 每个FD只需要执行一次epoll_ctl添加到红黑树,以后每次epol_wait无需传递任何参数,无需重复拷贝FD到内核空间
- 利用ep_poll_callback机制来监听FD状态,无需遍历所有FD,因此性能不会随监听的FD数量增多而下降。
1.4.5 IO多路复用-事件通知机制
当FD有数据可读时,我们调用epoll_wait(或者select、poll)可以得到通知。但是事件通知的模式有两种:建议用ET模式,可以通过下面的两种补充方式完善缺点;
LevelTriggered:简称LT,也叫做水平触发。只要某个FD中有数据可读,每次调用epoll_wait都会得到通知。这种可能会造成惊群现象,FD中现在只哟2kb的数据,按理讲,唤醒两个线程就能做完,但是LT模式可能会唤醒多个线程,造成资源浪费。
EdgeTriggered:简称ET,也叫做边沿触发。只有在某个FD有状态变化时,调用epoll_wait才会被通知。这种,可以进行优化,有两种方式可以保证数据被读取完;问题是:在第一次发送给用户内核后,就绪的列表就会将此次同步的几个FD移除;所以优化这种情况就可以。
1:移除后,在此调用epoll_ctl方法,再次将FD对象添加到就绪列表中,直至数据被读完。
2:一次性的将数据全部读取走。
举个栗子:
- 假设一个客户端socket对应的FD已经注册到了epoll实例中
- 客户端socket发送了2kb的数据
- 服务端调用epoll_wait,得到通知说FD就绪
- 服务端从FD读取了1kb数据
- 回到步骤3(再次调用epoll_wait,形成循环)
结果:
如果我们采用LT模式,因为FD中仍有1kb数据,则第⑤步依然会返回结果,并且得到通知;
如果我们采用ET模式,因为第③步已经消费了FD可读事件,第⑤步FD状态没有变化,因此epoll_wait不会返回,数据无法读取,客户端响应超时。
结论:
LT:事件通知频率较高,会有重复通知,影响性能
ET:仅通知一次,效率高。
可以基于非阻塞IO循环读取解决数据读取不完整问题(阻塞:类似于一个while..true的操作,这种即使数据读完,也会卡住,无法执行下面的操作;非阻塞:for循环的形式,返回一个标记,用来表示是否还有数据未读完)
select和poll仅支持LT模式,epoll可以自由选择LT和ET两种模式
1.4.6 IO多路复用-web服务流程
流程和之前的epoll流程差不多。就是多了判断事件类型这个节点。判断这个ssfd是服务端的还是客户端的,是否可读;
1.4.7 IO多路复用-信号驱动IO
信号驱动IO是与内核建立SIGIO的信号关联并设置回调,当内核有FD就绪时,会发出SIGIO信号通知用户,期间用户应用可以执行其它业务,无需阻塞等待。
阶段一: 用户进程调用sigaction,注册信号处理函数,内核返回成功,开始监听FD,用户进程不阻塞等待,可以执行其它业务;当内核数据就绪后,回调用户进程的SIGIO处理函数;
阶段二: 用户进程收到SIGIO回调信号,表示FD已就绪;调用recvfrom就行读取;内核将数据拷贝到用户空间(这个过程还是会阻塞);读取到数据后,用户进程处理数据。
1.4.8 IO多路复用-异步IO
异步IO的整个过程都是非阻塞的,用户进程调用完异步API后就可以去做其它事情,内核等待数据就绪并拷贝到用户空间后才会递交信号,通知用户进程。
阶段一: 用户进程调用aio_read,创建信号回调函数,内核等待数据就绪,用户进程无需阻塞,可以做任何事情;
阶段二: 当内核数据就绪时,内核数据拷贝到用户缓冲区,拷贝完成之后,内核才会递交信号触发aio_read中的回调函数,最后用户进程才会处理数据,相当于用户进程下单吃饭,等到商家将饭放上饭桌,并且给你说饭做好了(内核将数据返回给用户进程了,且通知了),这个时候,用户才开始处理。
关于同步和异步,关键看数据在内核空间与用户空间的拷贝过程(数据读写的IO操作),也就是阶段二是同步还是异步。并不是看是阻塞IO还是非阻塞IO。
1.5 Redis到底是单线程还是多线程?
如果仅仅聊Redis的核心业务部分(命令处理),答案是单线程
如果是聊整个Redis,那么答案就是多线程
在Redis版本迭代过程中,在两个重要的时间节点上引入了多线程的支持:
Redis v4.0:引入多线程异步处理一些耗时较旧的任务,例如异步删除命令unlink Redis v6.0:在核心网络模型中引入 多线程,进一步提高对于多核CPU的利用率
因此,对于Redis的核心网络模型,在Redis 6.0之前确实都是单线程。是利用epoll(Linux系统)这样的IO多路复用技术在事件循环中不断处理客户端情况。
为什么Redis要选择单线程?
抛开持久化不谈,Redis是纯内存操作,执行速度非常快,它的性能瓶颈是网络延迟而不是执行速度,因此多线程并不会带来巨大的性能提升。
多线程会导致过多的上下文切换,带来不必要的开销,会增加CPU的消耗;就算真的开启多线程,一般上线程数也是和CPU的内核数一样,最多会是CPU内核数的一两倍;
引入多线程会面临线程安全问题,必然要引入线程锁这样的安全手段,实现复杂度增高,而且性能也会大打折扣。
1.6 Redis网络模型
1.6.1 源码
初始化服务,进去会先创建一个aeEventLoop,类似于epool实例对象,有红黑树和就绪列表了;
然后去监听服务端的ip和端口;这个方法会创建serverSocket对象,且得到了它对应的FD对象;
注册一个连接处理器acceptTcpHandler,将serverSocket(FD)注册到之前创建的epoll实例上,并且监听FD上面发上的事件;然后当FD上面真的有事件发生的时候(有客户端连接了,serverSocket可读),要去处理(拿到这些客户端的FD);
对应下面的两个图做的事情。剩下的事就是等待epoll_wait,等待事件就绪。
但是下面并没有去执行epoll_wait相关的方法,而是去执行了beforeSleep方法,因为一旦调用了epoll_wait方法,就有可能陷入沉睡,因为可能没有事件在规定的时间内就绪;所以也可以认为,在调用apoll_wait的时候,就已经沉睡过了。
然后,去执行aeMain方法,这个方法中,会不停的循环执行,去判断是否有就绪的FD(类似epoll_wait方法),有,就去循环遍历这些就绪的FD;但是由于是项目启动,现在只有一个FD,就是serverSocket对象,就会触发它之前对应的处理器acceptTcpHandler,做的事情就是去接收客户端socket,得到FD对象,读事件,然后注册到epoll实例的红黑树上,并且还会给FD对象绑定读处理器,下面这个图。客户端发起的是读请求,所以是读处理器。
现在就有两个处理器了,分别处理serverSocket和处理clientSocket上的事件。是ssfd可读事件,就会走serverSocke的处理器,去处理对应的事情;如果不是,就是客户端的读事件,会触发clientSocket的处理器,读取请求数据,然后响应数据返回。
1.6.2 流程图
折合流程和上面1.6.1的流程差不多;都是先有一个serviceSocket对象,一个aeEventLoop对象(epoll实例),将serverSocket对象的FD添加到aeEventLoop上,然后执行beforeSleep,aeApiPoll(epoll_wait),这时,等待FD就绪,现在只有一个,就是serverSocket,当FD可读的时候,也就是有客户端连接了,就会触发tcpAccepthandler,去处理这些连接的客户端socket,将它们注册到aeEventLoop上,这时,FD对象就多了起来,继续执行beforeSleep,aeApiPoll(epoll_wait),这时又有FD就绪了,就要区分是哪种了,如果是serverSocket的,就按照之前的逻辑再走一次,反之,是client的FD就绪了,就会去执行readQueryFromClient,解析请求命令,然后响应给各自的client。
剩下的就是解析客户端的请求,写出相应数据;
解析连接信息,获得client信息数据,然后将请求命令解析,存储到数组中,set name java,就会解析成三个sds字符串存进数组中;然后去处理命令processCommand;
处理命令:先获取数组中的第一个串,就就要执行的命令类型,因为各种数据类型的命令,都会存放到一个map中,所以可以直接找到对应的命令操作;比如是set,就会找到setCommand命令,查询到命令后开始执行,执行完成后响应给client;
响应时也是先将结果写到客户端缓冲区,如果缓存区(字节数组)写不下,会写到一个c-reply的链表中,理论上链表是无上限的,这时候所有的响应都写到了缓存区中;
然后将命令排队后写入一个队列中,等待被写出;
beforeSleep,用来写出;方法中会生成一个迭代器,指向上面的队列,取出待写的客户端命令,循环过程中,会监听客户端的写事件,并且绑定一个写处理器,然后将响应写到客户端socket;
1.6.3 多路复用总结
因为redis是基于内存操作的,网络延迟的影响远远没有IO的影响大,无论是数据库还是磁盘读写,影响性能的永远是IO;
所以,在处理客户端的请求命令和将响应数据写给客户端的时候,开启多线程,增加性能。其他操作仍然是单线程执行。
1.7 Redis通信协议
1.7.1 RESP协议
Redis是一个CS架构的软件,通信一般分两步(不包括pipeline和PubSub):
- 客户端(client)向服务端(server)发送一条命令
- 服务端解析并执行命令,返回响应结果给客户端
因此客户端发送命令的格式、服务端响应结果的格式必须有一个规范,这个规范就是通信协议。
1.7.2 RESP协议-数据类型
注意:一个汉字的字节数是3,和字母的不一样,一个字母的字节就是1.
- 单行字符串:首字节是 ‘+’ ,后面跟上单行字符串,以CRLF( "\r\n" )结尾。例如返回"OK": "+OK\r\n";这种格式不安全,字符串中间有换行符。
- 错误(Errors):首字节是 ‘-’ ,与单行字符串格式一样,只是字符串是异常信息,例如:"-Error message\r\n"
- 数值:首字节是 ‘:’ ,后面跟上数字格式的字符串,以CRLF结尾。例如:":10\r\n"
- 多行字符串:首字节是 ‘$’ ,表示二进制安全的字符串,最大支持512MB:
- 如果大小为0,则代表空字符串:"$0\r\n\r\n"
- 如果大小为-1,则代表不存在:"$-1\r\n"
- 数组:首字节是 ‘*’,后面跟上数组元素个数,再跟上元素,元素数据类型不限:
注意中文的字节码。
1.7.3 模拟Redis客户端
import java.io.*;
import java.net.Socket;
import java.nio.charset.StandardCharsets;
import java.util.ArrayList;
import java.util.List;
public class Main {
static Socket s;
static PrintWriter writer;
static BufferedReader reader;
public static void main(String[] args) {
try {
// 1.建立连接
String host = "192.168.150.101";
int port = 6379;
s = new Socket(host, port);
// 2.获取输出流、输入流
writer = new PrintWriter(new OutputStreamWriter(s.getOutputStream(), StandardCharsets.UTF_8));
reader = new BufferedReader(new InputStreamReader(s.getInputStream(), StandardCharsets.UTF_8));
// 3.发出请求
// 3.1.获取授权 auth 123321
sendRequest("auth", "123321");
Object obj = handleResponse();
System.out.println("obj = " + obj);
// 3.2.set name 虎哥
sendRequest("set", "name", "虎哥");
// 4.解析响应
obj = handleResponse();
System.out.println("obj = " + obj);
// 3.2.set name 虎哥
sendRequest("get", "name");
// 4.解析响应
obj = handleResponse();
System.out.println("obj = " + obj);
// 3.2.set name 虎哥
sendRequest("mget", "name", "num", "msg");
// 4.解析响应
obj = handleResponse();
System.out.println("obj = " + obj);
} catch (IOException e) {
e.printStackTrace();
} finally {
// 5.释放连接
try {
if (reader != null) reader.close();
if (writer != null) writer.close();
if (s != null) s.close();
} catch (IOException e) {
e.printStackTrace();
}
}
}
private static Object handleResponse() throws IOException {
// 读取首字节
int prefix = reader.read();
// 判断数据类型标示
switch (prefix) {
case '+': // 单行字符串,直接读一行
return reader.readLine();
case '-': // 异常,也读一行
throw new RuntimeException(reader.readLine());
case ':': // 数字
return Long.parseLong(reader.readLine());
case '$': // 多行字符串
// 先读长度
int len = Integer.parseInt(reader.readLine());
if (len == -1) {
return null;
}
if (len == 0) {
return "";
}
// 再读数据,读len个字节。我们假设没有特殊字符,所以读一行(简化)
return reader.readLine();
case '*':
return readBulkString();
default:
throw new RuntimeException("错误的数据格式!");
}
}
private static Object readBulkString() throws IOException {
// 获取数组大小
int len = Integer.parseInt(reader.readLine());
if (len <= 0) {
return null;
}
// 定义集合,接收多个元素
List<Object> list = new ArrayList<>(len);
// 遍历,依次读取每个元素
for (int i = 0; i < len; i++) {
list.add(handleResponse());
}
return list;
}
// set name 虎哥
private static void sendRequest(String ... args) {
writer.println("*" + args.length);
for (String arg : args) {
writer.println("$" + arg.getBytes(StandardCharsets.UTF_8).length);
writer.println(arg);
}
writer.flush();
}
}
模拟一个redis客户端,去调用redis服务端,发送命令,并且接收命令解析响应数据。
二 内存淘汰
2.1 Redis内存策略
Redis之所以性能强,最主要的原因就是基于内存存储。然而单节点的Redis其内存大小不宜过大,会影响持久化或主从同步性能。
我们可以通过修改配置文件来设置Redis的最大内存:maxmemory 1gb
当内存使用达到上限时,就无法存储更多数据了。为了解决这个问题,Redis提供了一些策略实现内存回收: 内存过期策略 内存淘汰策略
2.1.1 过期策略
在创建key的时候,尽量设置过期时间,方便内存回收,set key value expire time 或者是
set key value , expire key time这种分开设置。
2.1.1.1 RedisDB结构
创建一个key时,*dict会指向我们的key和value;*expires会指向key和过期时间;这两比较重要。
2.1.1.2 过期删除策略
Redis是如何知道一个key是否过期呢?
利用两个Dict分别记录key-value对及key-ttl对
是不是TTL到期就立即删除了呢?
惰性删除 周期删除
2.1.1.3 过期策略-惰性删除
顾明思议并不是在TTL到期后就立刻删除,而是在访问一个key的时候(增删改查),检查该key的存活时间,如果已经过期才执行删除。
在执行写,读这些操作的时候,并不是直接就去操作key了,而是先通过expireNeeded方法去判断这个key是不是过期了,可以直接过去redisDB结构中的*epires直接拿到对应key的过期时间;过期了,就执行删除操作,反之,就正常执行逻辑。
弊端:加入很多key过期了,但是我们不需要在此访问了,这时候就无法将这些过期的key回收掉;
2.1.1.4 过期策略-周期删除
顾明思议是通过一个定时任务(只有一个,启动时初始的),周期性的抽样部分过期的key,然后执行删除。执行周期有两种:
Redis服务初始化函数initServer()中设置定时任务,按照server.hz的频率来执行过期key清理,模式为SLOW,默认是10ms执行一次,也就是一秒十次;
Redis的每个事件循环前会调用beforeSleep()函数,执行过期key清理,模式为FAST,这个beforeSleep函数,在redis的网络模型中有用到,也就是在每次事件触发的时候,都会执行周期删除,这种频率比较高。
redis启动的时候,initServer方法会进行初始化,比如之前的创建epoll实例,现在的创建定时器任务;第一次执行,是服务启动1毫秒后;获取当前的时间,保证后续的每次都是间隔10ms去执行的,在启动方法中,设置的周期策略是SLow模式,beforeSleep中时Fast模式。
2.1.1.5 Slow和Fast总结
SLOW模式规则:
- 执行频率受server.hz影响,默认为10,即每秒执行10次,每个执行周期100ms。
- 执行清理耗时不超过一次执行周期的25%.默认slow模式耗时不超过25ms
- 逐个遍历db,逐个遍历db中的bucket,抽取20个key判断是否过期
- 如果没达到时间上限(25ms)并且过期key比例大于10%,再进行一次抽样,否则结束
FAST模式规则(过期key比例小于10%不执行 ):
- 执行频率受beforeSleep()调用频率影响,但两次FAST模式间隔不低于2ms
- 执行清理耗时不超过1ms
- 逐个遍历db,逐个遍历db中的bucket,抽取20个key判断是否过期
- 如果没达到时间上限(1ms)并且过期key比例大于10%,再进行一次抽样,否则结束
2.1.2 过期策略总结
RedisKey的TTL记录方式:
在RedisDB中通过一个Dict记录每个Key的TTL时间
过期key的删除策略:
惰性清理:每次查找key时判断是否过期,如果过期则删除
定期清理:定期抽样部分key,判断是否过期,如果过期则删除。
定期清理的两种模式:
SLOW模式执行频率默认为10,每次不超过25ms
FAST模式执行频率不固定,但两次间隔不低于2ms,每次耗时不超过1ms
2.1.3 淘汰策略
就是当Redis内存使用达到设置的上限时,主动挑选部分key删除以释放更多内存的流程。过期策略也无法满足,内存还是不够。Redis会在处理客户端命令的方法processCommand()中尝试做内存淘汰:
传入的*c中包含了传递的命令,也就是在执行所有的命令时,都回去判断内存是否充足;进入方法后,设置有redis的内存上限以及现在没有lua脚本正在运行,就会执行内存淘汰方法;然后判断是否内存溢出,溢出就放弃执行。
2.1.3.1 内存淘汰策略
Redis支持8种不同策略来选择要删除的key:
- noeviction: 不淘汰任何key,但是内存满时不允许写入新数据,默认就是这种策略。直接报错。
- volatile-ttl: 对设置了TTL的key,比较key的剩余TTL值,TTL越小越先被淘汰(快过期)
- allkeys-random:对全体key ,随机进行淘汰。也就是直接从db->dict中随机挑选
- volatile-random:对设置了TTL的key ,随机进行淘汰。也就是从db->expires中随机挑选。
- allkeys-lru: 对全体key,基于LRU算法进行淘汰
- volatile-lru: 对设置了TTL的key,基于LRU算法进行淘汰
- allkeys-lfu: 对全体key,基于LFU算法进行淘汰
- volatile-lfu: 对设置了TTL的key,基于LFI算法进行淘汰
后6种,都是两两一对的,区别在于去查询哪个key,RedisDB对象中,有两个dict对象,分别是:指向key和value的以及只想key和过期时间的;allkey的,去第一个dict对象找,volatile的,去第二个dict找。
比较容易混淆的有两个:
LRU(Least Recently Used),最少最近使用。用当前时间减去最后一次访问时间,这个值越大则淘汰优先级越高。值越大说明时越久前访问的。
LFU(Least Frequently Used),最少频率使用。会统计每个key的访问频率,值越小淘汰优先级越高。值越小说明这个key的访问频率很低;
type:对象类型(string,list,set,zset,hash);
encoding:编码格式;
*ptr:指向数据存储的位置;
refcount:引用次数;
lru:这个是根据我们配置的策略所决定的,配置的是lru相关的两个策略之一,这个位置存储的就是最近访问的时间(秒级);配置的是lfu,这个存储的就是最近访问的时间(分钟级)和逻辑访问次数。
LFU的访问次数之所以叫做逻辑访问次数,是因为并不是每次key被访问都计数,而是通过运算:
- 生成0~1之间的随机数R
- 计算 1 / (旧次数 * lfu_log_factor + 1),记录为P,lfu_log_factor默认是10,可配置
- 如果 R < P ,则计数器 + 1,且最大不超过255
- 访问次数会随时间衰减,距离上一次访问时间每隔 lfu_decay_time 分钟,计数器 -1
第一次计算,旧次数是0,计算出P为1,第一次必定加1,
之后,随着频率以及请求基数的增加,这个旧次数一定会越来越大,但是最大不可能超过255,到达255后,就不会在此增加;随着这个旧次数的增加,R<P的概率会越来越低,但是也是会增加的,直至255;
逻辑访问次数并不是不会减少的,有一个key,长期没有被访问到,随着时间的衰减,计数器就会-1,但是最小是0.
2.1.3.2 内存淘汰流程图
流程开始:首先会判断内存是否充足,充足直接结束;反之就去判断内存淘汰策略是不是noeviction,是的话也结束,这种设置不会淘汰任何key;淘汰策略不是这个,就去根据具体的淘汰策略,判断是从redisDB对象的哪个dict指针查询数据:allkey和volatile;
一:然后看是那种具体的淘汰策略:随机淘汰的,遍历DB(redis有16个,从0到15,用的时候尽量默认用0,可以省去后边很多次循环),随机删除key,然后判断内存是否满足,满足就结束;反之,就继续执行,看是哪一种策略,进入循环,直至内存够用结束;
如果不是随机淘汰策略,TTL(根据过期时间,剩余时间少的优先淘汰),LRU(基于LRU算法进行淘汰,最少最近使用的,用当前时间减去最后一次访问时间,这个值越大则淘汰优先级越高,值越大,说明最近访问时间越小,很长时间没有访问了),LFU(最少频率使用。会统计每个key的访问频率,逻辑访问数,值越小淘汰优先级越高。);
判断完这三大种的淘汰策略后,准备一个池子,用来存放那些要被删除的key;
1:然后开始获取DB,便利数据库中的元素,但是并不是全部都要求遍历,随机挑选一定数量的key,根据内存淘汰策略,不同的算法,算出本次循环中最应该被淘汰的key;
2:之前准备的池子中,空的,就直接放入之前准备的池子中,非空的,池子中有数据,还要将根据算法算出来的key和池子中的key做对比,判断出哪些key更该被杀,然后放入池子中;按照统一出来的idleTime进行升序存入;
判断是否还有下一个数据库,有,就接着从1开始执行,到2,循环执行,直至无数据库为止;
现在无数据库需要去判断了,就倒序从池子中取数据进行删除,因为之前是按照升序存入的,开始一次删除key数据;
删除完毕后,判断内存是否充足,充足就结束;反之,就接着从一开始执行,进入循环,直至内存够用结束。