墨墨导读:本文节选自《Redis 5设计与源码分析》,主要为读者分析Redis高性能内幕,重点从源码层次讲解了Redis事件模型,网络IO事件重在使用IO复用模型,时间事件重在限制最大执行CPU时间。最后简单介绍了Redis的进程模型(以后不要简简单单说Redis是单进程单线程了),以及使用命令时需要注意的一些耗时操作。 参与作者墨天轮作者问答(点击“阅读原文可直接参与”)将有机会获得《Redis 5设计与源码分析》包邮到家! 书籍介绍 《Redis 5设计与源码分析》 多名专家联袂推荐,资深专家联合撰写,深入理解Redis 5设计精髓 系统讲解Redis 5设计、数据结构、底层命令实现,以及持久化、主从复制、集群 作者简介 陈雷,好未来学而思网校增长研发负责人,清华与北京邮电大学硕士,曾在百度、腾讯和滴滴等公司工作,12年后端架构经验。合著有《PHP7底层设计与源码实现》。 如何得到这本书? 【作者面对面问答】你可以在文末留言写出Redis相关的一些问题,将有机会得到《Redis 5设计与源码分析》作者的留言回复,作者会选取5条比较精致的问答赠与图书,以下这些问题供参考:
大家也可以去京东上自行购买:https://item.jd.com/12566383.html (复制链接至浏览器,即可查看) 活动截止:2019年9月24日12:00,我们会留言回复获奖者,敬请期待。 以下为《Redis 5设计与源码分析》节选:Redis为什么这么快之事件模型详解 Redis作为一个开源的基于内存的键值对存储系统,以出色的性能著称。官方提供的基准测试数据如下图所示, 摘自《How fast is Redis?》(https:///topics/benchmarks,复制链接至浏览器,即可查看): 图中横轴为连接数,纵轴为QPS;可以看到Redis单实例甚至可以达到10万+QPS。那么,Redis作为"单进程单线程"服务端程序,性能为何如此之高? Redis为什么这么快? Redis的高性能主要有以下几个原因: 1)基于内存 Redis是基于内存的存储系统,绝大部分的命令处理只是纯粹的内存操作,内存的读写速度是非常快的。 2)单进程单线程 单进程单线程处理请求,避免了不必要的上下文切换,同时不存在加锁释放锁等同步操作,而且实现简单;简单加高性能,何乐而不为呢? 不过,单进程单线程的方式无法发挥多核CPU性能,只能采取部署多个Redis实例来利用多核CPU,但这样会带来一定的运维复杂度。另外,单进程单线程处理某个命令请求而阻塞时,整个Redis服务无法处理其他请求。 注:实际上一个正在运行的Redis Server肯定不止一个进程/线程,详情见附录Redis进程模型小节 3)数据结构 Redis中的数据结构是专门设计的,大部分命令处理的时间复杂度都是O(1),少数命令才是O(logN)或者O(N);比如zset数据类型,为了兼顾范围查找与键查找效率,底层采用跳跃表与字典两种数据结构实现。 4)事件模型 Redis作为服务端程序,必然伴随着大量的客户端链接以及网络IO,网络IO通常是影响服务端程序性能的主要因素。 Redis高效的网络IO源于两点:非阻塞,且采用了成熟的IO多路复用模型select/epoll/kqueue/evport。IO多路复用通常有几个关键点:
Redis除了需要处理网络IO事件(文件事件)之外,还需要处理定时任务事件(时间事件);而定时任务事件的要求只有一点:不能影响命令处理主流程,即不能占用太多CPU时间; Redis事件模型即为文件事件与时间事件的管理模型;本文将会重点从源码角度为读者介绍Redis事件模型。 事件模型 Redis服务器是典型的事件驱动程序,事件驱动程序通常存在while/for循环,循环等待事件发生并处理,Redis也不例外,其事件循环如下:
函数aeProcessEvents为事件处理主函数,eventLoop表示服务端事件循环,类型为struct aeEventLoop,无论是文件事件还是时间事件都封装在该结构体,定义如下:
stop标识事件循环是否结束;events为文件事件数组,存储已经注册的文件事件;fired存储被触发的文件事件;Redis有多个定时任务,因此理论上应该有多个时间事件,多个时间事件形成链表,timeEventHead即为时间事件链表头结点;Redis服务器需要阻塞等待文件事件的发生,进程阻塞之前会调用beforesleep函数,进程因为某种原因被唤醒之后会调用aftersleep函数。Redis底层可以使用四种IO多路复用模型(kqueue、epoll等),apidata是对这四种模型的进一步封装,所以其类型为void*。 文件事件 Redis客户端通过TCP Socket与服务端交互,文件事件指的就是socket的可读可写事件。 socket读写操作有阻塞与非阻塞之分,采用阻塞模式时,一个进程只能处理一条网络连接的读写事件,为了同时处理条网络连接,通常会采用多线程或者多进程,效率低下;非阻塞模式下,可以使用目前比较成熟的IO多路复用模型select/epoll/kqueue/evport等,视不同操作系统而定。 这里只对epoll作简要介绍。epoll是linux内核为处理大量并发网络连接而提出的解决方案,能显著提升系统CPU利用率。epoll使用非常简单,总共只有三个API,epoll_create函数创建一个epoll专用的文件描述符,用于后续epoll相关API调用;epoll_ctl函数向epoll注册、修改或删除需要监控的事件;epoll_wait函数会阻塞进程,直到监控的若干网络连接有事件发生。
输入参数size通知内核程序期望注册的网络连接数目,内核以此判断初始分配空间大小;注意在linux2.6.8版本以后,内核动态分配空间,此参数会被忽略。返回参数为epoll专用的文件描述符,不再使用时应该及时关闭此文件描述符。
函数执行成功时返回0,否则返回-1,错误码设置在变量errno;输入参数含义如下:
其中events表示需要监控的事件类型,比较常用的是EPOLLIN文件描述符可读事件,EPOLLOUT文件描述符可写事件;data保存与文件描述符关联的数据。
函数执行成功时返回0,否则返回-1,错误码设置在变量errno;输入参数含义如下:
需要注意的是Redis并没有直接使用epoll提供的的API,而是同时支持四种IO多路复用模型,并将这些模型的API进一步统一封装,由文件ae_evport.c、ae_epoll.c、ae_kqueue.c和ae_select.c实现。
四个函数的输入参数含义如下:
而Redis在编译阶段,会检查操作系统支持的IO多路复用模型,并按照一定规则决定使用哪种模型。 以epoll为例,aeApiCreate函数是对epoll_create的封装;aeApiAddEvent函数用于添加事件,是对epoll_ctl的封装;aeApiDelEvent函数用于删除事件,是对epoll_ctl的封装;aeApiPoll是对epoll_wait的封装。此时eventLoop->apidata指向的结构体为:
其中epfd函数epoll_create返回的epoll文件描述符,events存储epoll_wait函数返回时已触发的事件数组。 这里只对等待事件函数aeApiPoll实现作简要介绍:
函数首先需要通过eventLoop->apidata字段获取到epoll模型对应的aeApiState结构体对象,才能调用epoll_wait函数等待事件的发生;而epoll_wait函数将已触发的事件存储到aeApiState对象的events字段,Redis再次遍历所有已触发事件,将其封装在eventLoop->fired数组,数组元素类型为结构体aeFiredEvent,只有两个字段,fd表示发生事件的socket文件描述符,mask表示发生的事件类型,如AE_READABLE可读事件和AE_WRITABLE可写事件。 上面简单介绍了epoll的使用,以及Redis对epoll等IO多路复用模型的封装,下面我们回到本小节的主题,文件事件。结构体aeEventLoop有一个关键字段events,类型为aeFileEvent数组,存储所有需要监控的文件事件。文件事件结构体定义如下:
其中mask存储监控的文件事件类型,如AE_READABLE可读事件和AE_WRITABLE可写事件;rfileProc为函数指针,指向读事件处理函数;wfileProc同样为函数指针,指向写事件处理函数;clientData指向对应的客户端对象。 调用aeApiAddEvent函数添加事件之前,首先需要调用aeCreateFileEvent函数创建对应的文件事件,并存储在aeEventLoop结构体的events字段,aeCreateFileEvent函数简单实现如下:
Redis服务器启动时需要创建socket并监听,等待客户端连接;客户端与服务器建立socket连接之后,服务器会等待客户端的命令请求;服务器处理完成客户端的命令请求之后,命令回复会暂时缓存在client结构体的buf缓冲区,待客户端文件描述符的可写事件发生时,才会真正往客户端发送命令回复。这些都需要创建对应的文件事件:
可以发现接收客户端连接的处理函数为acceptTcpHandler,此时还没有创建对应的客户端对象,因此函数aeCreateFileEvent第四个参数为NULL;接收客户端命令请求的处理函数为readQueryFromClient;向发送命令回复的处理函数为sendReplyToClient。 最后思考一个问题, aeApiPoll函数的第二个参数是时间结构体timeval,存储调用epoll_wait时传入的超时时间,那么这个时间怎么计算出来的呢?我们之前提过,Redis除了要处理各种文件事件外,还需要处理很多定时任务(时间事件),那么当Redis由于执行epoll_wait而阻塞时,恰巧定时任务到期而需要处理怎么办?要回答这个问题需要分析Redis事件循环的执行函数aeProcessEvents,函数在调用aeApiPoll之前会遍历Redis的时间事件链表,查找最早会发生的时间事件,以此作为aeApiPoll需要传入的超时时间。
时间事件 上一节介绍了Redis文件事件,已经知道事件循环执行函数aeProcessEvents的主要逻辑:1)查找最早会发生的时间事件,计算超时时间;2)阻塞等待文件事件的产生;3)处理文件事件;4)处理时间事件。时间事件的执行函数为processTimeEvents。 Redis服务器内部有很多定时任务需要执行,比如说定时清除超时客户端连接,定时删除过期键等,定时任务被封装为时间事件aeTimeEvent对象,多个时间事件形成链表,存储在aeEventLoop结构体的timeEventHead字段,其指向链表首节点。时间事件aeTimeEvent定义如下:
各字段含义如下:
时间事件执行函数processTimeEvents的处理逻辑比较简单,只是遍历时间事件链表,判断当前时间事件是否已经到期,如果到期则执行时间事件处理函数timeProc:
注意时间事件处理函数timeProc返回值retval,其表示此时间事件下次应该被触发的时间,单位毫秒,且是一个相对时间,即从当前时间算起,retval毫秒后此时间事件会被触发。 其实Redis只有一个时间事件,看到这里读者可能会有疑惑,服务器内部不是有很多定时任务吗,为什么只有一个时间事件呢?回答此问题之前我们需要先分析这个唯一的时间事件。Redis创建时间事件节点的函数为aeCreateTimeEvent,内部实现非常简单,只是创建时间事件并添加到时间事件链表。aeCreateTimeEvent函数定义如下:
其中输入参数eventLoop指向事件循环结构体;milliseconds表示此时间事件触发时间,单位毫秒,注意这是一个相对时间,即从当前时间算起,milliseconds毫秒后此时间事件会被触发;proc指向时间事件的处理函数;clientData指向对应的结构体对象;finalizerProc同样是函数指针,删除时间事件节点之前会调用此函数。 可以在代码目录全局搜索aeCreateTimeEvent,会发现确实只创建了一个时间事件
该时间事件在1毫秒后会被触发,处理函数为serverCron,参数clientData与finalizerProc都为NULL。而函数serverCron实现了Redis服务器所有定时任务的周期执行。
变量server.cronloops用于记录serverCron函数的执行次数,变量server.hz表示serverCron函数的执行频率,用户可配置,最小为1最大为500,默认为10。假设server.hz取默认值10,函数返回1000/server.hz会更新当前时间事件的触发时间为100毫秒后,即serverCron的执行周期为100毫秒。run_with_period宏定义实现了定时任务按照指定时间周期(ms)执行,其会被替换为一个if条件判断,条件为真才会执行定时任务,定义如下:
另外我们可以看到serverCron函数会无条件执行某些定时任务,比如清除超时客户端连接,以及处理数据库(清除数据库过期键等)。需要特别注意一点,serverCron函数的执行时间不能过长,否则会导致服务器不能及时响应客户端的命令请求。以过期键删除为例,分析下Redis是如何保证serverCron函数的执行时间。过期键删除由函数activeExpireCycle实现,由函数databasesCron调用,其函数是实现如下:
函数activeExpireCycle最多遍历dbs_per_call个数据库,并记录每个数据库删除的过期键数目;当删除过期键数目大于门限时,认为此数据库过期键较多,需要再次处理。考虑到极端情况,当数据库键数目非常多且基本都过期时,do-while循环会一直执行下去。因此我们添加timelimit时间限制,每执行16次do-while循环,检测函数activeExpireCycle执行时间是否超过timelimit,如果超过则强制结束循环。通过变量timelimit限制了activeExpireCycle函数执行时间不会过长。 初看timelimit的计算方式可能会比较疑惑,其计算结果使得函数activeExpireCycle的总执行时间最多占CPU时间的25%,即每秒钟函数activeExpireCycle的总执行时间为1000000 * 25 / 100。仍然假设server.hz取默认值10,即每秒钟函数activeExpireCycle执行10次,那么每次函数activeExpireCycle的执行时间最多为1000000 * 25 / 100 / 10,单位微妙。 附录 Redis进程模型 第一节我们说过,Redis是单进程单线程的服务,实际上一个正在运行的Redis Server肯定不止一个进程/线程,只是单进程单线程处理命令请求而已。下面我们简要介绍下Redis"真正"的进程模型。 如图: 1)主进程主线程用于处理网络请求,主要流程有:阻塞等待网络IO事件,解析并处理命令请求,处理定时任务; 2)主进程子线程用于处理一些耗时任务,比如删除大集合时,如果配置了懒删除策略,主进程主线程会断开该集合到数据库连接(软删除),同时将该任务添加到任务链表中,主进程子线程从该任务链表获取任务,删除并释放每一个集合元素。注意,添加以及消费任务时,需要加锁。有兴趣的读者可以研究bio.c源码文件; 3)Redis持久化方式有AOF和RDB两种;BGSAVE命令持久化RDB文件,BGREWRITEAOF进行AOF文件重写时,Redis都会fork子进程处理。 Redis耗时操作举例 前面说过,Redis大部分命令处理的时间复杂度都是O(1),少数命令才是O(logN)或者O(N),而有些命令更是非常耗时的。而,单进程单线程有一个缺点:处理某个命令请求而阻塞时,整个Redis服务无法处理其他请求。因此,在使用Redis时,我们必须很清楚每个命令请求的时间复杂度,对于耗时操作需要做相应处理才行,否则很可能会拖垮线上Redis服务。
1)keys,smembers等命令 keys命令用于查找所有符合指定模式(pattern)的key,smembers命令用于获取集合全集;这两个命令的时间复杂度都是O(N);显然,当数据库中元素或者集合中元素非常多时,命令将非常耗时。通常我们有两种方法可以处理: "拆":慢请求阻塞了快请求,那么我们可以将慢请求与快请求分开处理;通常Redis都会配置主从模式,简单快请求可以由主服务器处理,复杂慢请求由从服务器处理; 还是"拆":请求复杂耗时,那么我们可以将请求拆分为多个子请求,复杂度转移到业务服务器;比如keys可以由scan分组完成,smembers可以由sscan分组完成。 2)save命令 save命令用于持久化RDB文件;当数据量大的时候,会造成主进程主线程长时间阻塞。线上Redis服务应该禁止该命令。 3)fork影响 在执行RDB持久化BGSAVE,或者AOF文件重写时,或者master首次向slave同步数据时,Redis会fork子进程处理,fork也是一个比较耗时的操作,此时会阻塞Redis处理其他请求。 4)持久化影响 在子进程执行持久化BGSAVE操作时,会存在大量写磁盘操作;而,AOF持久化可以配置always或者everysec,执行命令请求后同样需要写磁盘,该过程可能会导致主进程主线程的短暂阻塞。 总结 本文主要为读者分析Redis高性能内幕,重点从源码层次讲解了Redis事件模型,网络IO事件重在使用IO复用模型,时间事件重在限制最大执行CPU时间。最后简单介绍了Redis的进程模型(以后不要简简单单说Redis是单进程单线程了),以及使用命令时需要注意的一些耗时操作。 |
|