Redis网络模型,内存淘汰

目录

一        网络模型

1.1        用户空间和内核空间

1.2        阻塞IO

1.3        非阻塞IO

1.4        IO多路复用

1.4.1        引入IO多路复用

1.4.2        文件描述符

1.4.3        IO多路复用-select

1.4.3        IO多路复用-poll

1.4.3        IO多路复用-epoll

1.4.4        IO多路复用中三种模式总结

1.4.5        IO多路复用-事件通知机制

1.4.6        IO多路复用-web服务流程

1.4.7        IO多路复用-信号驱动IO

1.4.8        IO多路复用-异步IO

1.5       Redis到底是单线程还是多线程?

1.6        Redis网络模型

1.6.1        源码

1.6.2        流程图

1.6.3        多路复用总结

1.7        Redis通信协议

1.7.1        RESP协议

1.7.2        RESP协议-数据类型

​编辑  ​编辑

1.7.3        模拟Redis客户端

二        内存淘汰

2.1        Redis内存策略

2.1.1        过期策略

2.1.1.1        RedisDB结构

2.1.1.2        过期删除策略

2.1.1.3        过期策略-惰性删除

2.1.1.4        过期策略-周期删除

2.1.1.5        Slow和Fast总结

2.1.2        过期策略总结

2.1.3        淘汰策略

2.1.3.1        内存淘汰策略​编辑

2.1.3.2        内存淘汰流程图


一        网络模型

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流程:

  1.         创建pollfd数组,向其中添加关注的fd信息,数组大小自定义
  2.         调用poll函数,将pollfd数组拷贝到内核空间,转链表存储,无上限
  3.         内核遍历fd,判断是否就绪
  4.         数据就绪或超时后,拷贝pollfd数组到用户空间,返回就绪fd数量n
  5.         用户进程判断n是否大于0
  6.         大于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模式存在的三个问题:

  1.         能监听的FD最大不超过1024
  2.         每次select都需要把所有要监听的FD都拷贝到内核空间
  3.         每次都要遍历所有FD来判断就绪状态

2:poll模式的问题:

  1.         poll利用链表解决了select中监听FD上限的问题,但依然要遍历所有FD,如果监听较多,性能会下降;
  2.         并且还是没有解决两次数据拷贝和数组的遍历。

3:epoll模式中如何解决这些问题的?

  1.         基于epoll实例中的红黑树保存要监听的FD,理论上无上限,而且增删改查效率都非常高
  2.         每个FD只需要执行一次epoll_ctl添加到红黑树,以后每次epol_wait无需传递任何参数,无需重复拷贝FD到内核空间
  3.         利用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:一次性的将数据全部读取走。

举个栗子:

  1.         假设一个客户端socket对应的FD已经注册到了epoll实例中
  2.         客户端socket发送了2kb的数据
  3.         服务端调用epoll_wait,得到通知说FD就绪
  4.         服务端从FD读取了1kb数据
  5.         回到步骤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模式规则:

  1.         执行频率受server.hz影响,默认为10,即每秒执行10次,每个执行周期100ms。
  2.         执行清理耗时不超过一次执行周期的25%.默认slow模式耗时不超过25ms
  3.         逐个遍历db,逐个遍历db中的bucket,抽取20个key判断是否过期
  4.         如果没达到时间上限(25ms)并且过期key比例大于10%,再进行一次抽样,否则结束

FAST模式规则(过期key比例小于10%不执行 ):

  1.         执行频率受beforeSleep()调用频率影响,但两次FAST模式间隔不低于2ms
  2.         执行清理耗时不超过1ms
  3.         逐个遍历db,逐个遍历db中的bucket,抽取20个key判断是否过期
  4.         如果没达到时间上限(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:

  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算法进行淘汰
  6. volatile-lru: 对设置了TTL的key,基于LRU算法进行淘汰
  7. allkeys-lfu: 对全体key,基于LFU算法进行淘汰
  8. 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被访问都计数,而是通过运算:

  1. 生成0~1之间的随机数R
  2. 计算 1 / (旧次数 * lfu_log_factor + 1),记录为P,lfu_log_factor默认是10,可配置
  3. 如果 R < P ,则计数器 + 1,且最大不超过255
  4. 访问次数会随时间衰减,距离上一次访问时间每隔 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数据;

        删除完毕后,判断内存是否充足,充足就结束;反之,就接着从一开始执行,进入循环,直至内存够用结束。

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值