当前位置: 首页 > 后端技术 > Java

面试官:Redis单线程已经很快了,为什么6.0还要引入多线程?有什么好处?

时间:2023-04-01 22:08:37 Java

作者:Java斗皇\链接:https://www.jianshu.com/p/ba2...Redis作为一个基于内存的缓存系统,一直以高性能着称,因为没有上下文切换和无锁操作,即使在单线程处理的情况下,读取速度依然可以达到11万次/s,写入速度可以达到81000次/s。但是单线程的设计也给Redis带来了一些问题:只能使用一个CPU核;如果删除的key过大(比如Set类型有百万个对象),服务器会被阻塞几秒;QPS很难再提高。针对以上问题,Redis在4.0和6.0版本分别引入了LazyFree和多线程IO,并逐渐过渡到多线程,下面会详细介绍。单线程原理说Redis是单线程的,那么单线程是怎么体现的呢?如何支持并发客户端请求?为了搞清楚这些问题,我们先来了解一下Redis是如何工作的。Redis服务端是一个事件驱动的程序,服务端需要处理以下两类事件:文件事件:Redis服务端通过sockets与客户端(或其他Redis服务端)连接,文件事件是服务端的抽象套接字操作;服务端与客户端的通信会产生相应的文件事件,服务端会通过监听和处理这些事件来完成一系列的网络通信操作,如连接接受、读取、写入、关闭等;时间事件:Redis服务器中的一些操作(比如serverCron函数)需要在给定的时间点执行,时间事件是服务器对这类定时操作的抽象,比如过期key清理,服务状态统计等.如上图所示,Redis抽象了文件事件和时间事件,时间轮换训练器会监控I/O事件表。一旦文件事件就绪,Redis会先处理文件事件,再处理时间事件。在以上所有的事件处理中,Redis都是以单线程的形式处理的,所以Redis是单线程的。另外,如下图所示,Redis基于Reactor模型开发了自己的I/O事件处理器,即文件事件处理器。Redis在I/O事件处理中采用I/O多路复用技术,同时监听多个socket,并为socket关联不同的事件处理函数,通过一个线程实现多客户端并发处理。由于这样的设计,在数据处理的时候避免了加锁操作,既使得实现足够简单,又保证了它的高性能。当然Redis单线程只是指它的事件处理。事实上,Redis并不是单线程的。比如在生成RDB文件时,会fork一个子进程来实现。当然,这不是本文要讨论的内容。LazyFree机制上面我们知道,Redis在处理客户端命令时是单线程运行的,处理速度非常快,在此期间不会响应其他客户端请求,但是如果客户端长时间发送命令到Redis,比如删除一个包含百万对象的Setkey,或者执行flushdb、flushall操作,Redis服务器需要回收大量的内存空间,导致服务器卡住几秒,对于一个高负载缓存系统。为了解决这个问题,在Redis4.0中引入了LazyFree,将慢操作异步化,这也是在事件处理上向多线程迈进了一步。作者在他的博客中说,解决操作慢的问题,可以使用渐进式处理,即加一个时间事件,比如删除一个有百万对象的Setkey时,只删除大key的一部分在一次数据,最终实现大key的删除。但是这种方案可能会导致采集速度赶不上创建速度,最终导致内存耗尽。所以Redis最终的实现方式是异步删除大key,采用非阻塞删除(对应命令UNLINK),大key的空间回收由单独的线程实现。主线程只做关系释放,可以快速返回并继续处理其他事件,避免服务器长时间阻塞。以删除(DEL命令)为例,看看Redis是如何实现的。下面是删除函数的入口。其中lazyfree_lazy_user_del是是否修改DEL命令的默认行为。一旦启用,DEL将以UNLINK的形式执行。voiddelCommand(client*c){delGenericCommand(c,server.lazyfree_lazy_user_del);}/*这个命令实现了DEL和LAZYDEL。*/voiddelGenericCommand(client*c,intlazy){intnumdel=0,j;对于(j=1;jargc;j++){expireIfNeeded(c->db,c->argv[j]);//根据配置判断是否以惰性形式执行DELintdeleted=lazy?dbAsyncDelete(c->db,c->argv[j]):dbSyncDelete(c->db,c->argv[j]);如果(删除){signalModifiedKey(c,c->db,c->argv[j]]);notifyKeyspaceEvent(NOTIFY_GENERIC,"del",c->argv[j],c->db->id);服务器脏++;数字++;}}addReplyLongLong(c,numdel);}同步删除很简单,只要删除key和value即可。如果有内部引用,则进行递归删除,这里不再介绍。让我们看看异步删除。Redis在回收对象时,会先计算回收收益。只有当回收收益超过一定值时,才会打包成一个job加入到异步处理队列中。否则直接同步回收,效率更高。循环收益的计算也很简单。比如对于String类型,循环收益值为1,对于Set类型,循环收益为集合中的元素个数。/*从数据库中删除键、值和相关的过期条目(如果有)。*如果有足够的分配来释放值对象,则可以将其放入惰性释放列表中,而不是同步释放。惰性空闲列表*将在不同的bio.c线程中回收。*/#defineLAZYFREE_THRESHOLD64intdbAsyncDelete(redisDb*db,robj*key){/*从expires字典中删除条目不会释放*键的sds,因为它与主字典共享。*/if(dictSize(db->e??xpires)>0)dictDelete(db->e??xpires,key->ptr);/*如果该值由一些分配组成,以惰性方式释放*实际上只是更慢......所以在一定限制下我们只是同步释放*对象。*/dictEntry*de=dictUnlink(db->dict,key->ptr);如果(de){robj*val=dictGetVal(de);//计算值的回收收益size_tfree_effort=lazyfreeGetFreeEffort(val);/*如果释放object的工作太多了,通过将对象添加到惰性空闲列表来在后台执行*。*请注意,如果对象是共享的,则现在不可能回收它。这种情况很少发生,但是有时Redis核心部分的实现*可能会调用incrRefCount()来保护*对象,然后调用dbDelete()。在这种情况下,我们将*通过并到达dictFreeUnlinkedEntry()调用,这将*等同于仅调用decrRefCount()。*///只有回收收益超过一定值,才会执行异常删除,否则还是会退化到同步删除if(free_effort>LAZYFREE_THRESHOLD&&val->refcount==1){atomic_objects(1lazyfree);bioCreateBackgroundJob(BIO_LAZY_FREE,val,NULL,NULL);dictSetVal(db->dict,de,NULL);}}/*释放key-val对,或者如果我们将val*字段设置为NULL以便稍后延迟释放它,则只释放key。*/if(de){dictFreeUnlinkedEntry(db->dict,de);如果(server.cluster_enabled)slotToKeyDel(key->ptr);返回1;}否则{返回0;Redis通过引入一个threadedlazyfree,实现了LazyforSlowOperation操作,避免删除大键、FLUSHALL、FLUSHDB时服务器阻塞。当然,在实现这个功能的时候,不仅引入了lazyfreethreads,还改进了Redis聚合类型的存储结构。因为Redis内部使用了很多共享对象,比如客户端输出缓存。当然,Redis并没有使用锁来避免线程冲突。锁竞争会导致性能下降。相反,共享对象被删除并直接使用数据副本。如下,ZSet节点值在3.x和6.x的不同实现。//3.2.5版本ZSet节点实现,值定义robj*obj/*ZSETs使用特殊版本的Skiplists*/typedefstructzskiplistNode{robj*obj;双倍分数;结构zskiplistNode*向后;结构zskiplistLevel{结构zskiplistNode*转发;无符号整数跨度;}level[];}zskiplistNode;//6.0.10版本的ZSet节点实现,值定义为sdsele/*ZSETs使用特殊版本的Skiplists*/typedefstructzskiplistNode{sdsele;双倍分数;结构zskiplistNode*向后;结构zskiplistLevel{结构zskiplistNode*转发;无符号长跨度;}level[];}zskiplistNode;去掉共享对象不仅实现了lazyfree功能,也让Redis进入多线程成为可能,正如作者所说的那样:现在聚合数据类型的值完全不共享,客户端输出缓冲区不'也包含共享对象,有很多可以利用的地方。比如终于可以在Redis中实现线程化的I/O,这样不同的客户端由不同的线程来服务。这意味着我们将有e仅在访问数据库时使用全局锁,但客户端读/写系统调用甚至客户端发送的命令的解析都可能发生在不同的线程中。多线程I/O及其局限Redis在4.0Free版本引入了Lazy,从那时起Redis就有了一个LazyFree线程专门用于大key的回收。同时也去掉了聚合类型的共享对象,带来了多线程的可能。Redis不负众望。多线程I/O的实现原理就像之前官方回复的一样,Redis的性能瓶颈不在CPU上,而在内存和网络上。所以6.0发布的多线程并没有把事件处理改成多线程,而是在I/O上。另外,如果事件处理改成多线程,不仅会造成锁竞争,还会频繁的上下文切换,即使用分段锁来减少竞争,也会对Redis内核造成较大的改变,性能可能会有所不同。不一定有明显改善。上图中红色部分是Redis实现的多线程部分,利用多核来分担I/O读写负载。事件处理线程每获取到一个可读事件,就会将所有准备好的可读事件分配给I/O线程等待。所有I/O线程完成读操作后,事件处理线程开始进行任务处理。处理完成后,写事件也分配给I/O线程,等待所有I/O线程完成写操作。以读取事件处理为例,看一下事件处理线程任务分发过程:*/listIterli;列表节点*ln;listRewind(server.clients_pending_read,&li);intitem_id=0;//将等待的客户端分配给I/O线程while((ln=listNext(&li))){client*c=listNodeValue(ln);inttarget_id=item_id%服务器。io_threads_num;listAddNodeTail(io_threads_list[target_id],c);项目编号++;}.../*等待所有其他线程结束他们的工作。*///轮训等待所有I/O线程完成处理while(1){unsignedlongpending=0;for(intj=1;jconn);}else{serverPanic("io_threads_op值未知");}}listEmpty(io_threads_list[id]);io_threads_pending[id]=0;如果(tio_debug)printf("[%ld]完成\n",id);}}上面实现的限制从上面看,6.0版本的多线程并不是完全的多线程。I/O线程只能同时执行读取或写入操作。在此期间,事件处理线程一直处于等待状态,不是流水线模型。round-robin等待开销较多Tair多线程实现原理相比6.0版本的多线程,Tair的多线程实现更加优雅。如下图,Tair的MainThread负责客户端连接建立,IOThread负责请求读取、响应发送、命令解析等,WorkerThread专门负责事件处理。IOThread读取用户的请求并进行解析,然后将解析结果以命令的形式放入队列中,发送给WorkerThread进行处理。WorkerThread在command处理完成后产生response,通过另一个queue发送给IOThread。为了提高线程的并行性,在IOThread和WorkerThread之间使用无锁队列和管道进行数据交换,整体性能会更好。总结Redis4.0引入了LazyFree线程,解决了大键删除导致服务器阻塞的问题,并在6.0版本引入了I/OThread线程,正式实现了多线程,但是和Tair相比,不是很优雅,性能提升根据压力测试,多线程版本的性能是单线程版本的两倍,Tair多线程版本的性能是单线程版本的三倍。在作者看来,Redis多线程无非两种思路,I/O线程和慢命令线程,正如作者在其博客中所说:I/O线程不会发生在RedisAFAIK中,因为经过深思熟虑我认为没有充分的理由就很复杂。许多Redis设置实际上是网络或内存绑定的。此外,我真的相信无共享设置,所以我想扩展Redis的方式是改进对在同一主机上执行的多个Redis实例的支持,尤其是通过Redis集群。相反,我真正想要的是慢操作线程,以及Redis模块系统,我们已经朝着正确的方向前进。然而在未来(不确定是在Redis6还是7中)我们将在模块系统中获得键级锁定,这样线程就可以完全控制键来处理慢速操作。现在模块可以执行命令并可以以完全分离的方式为客户端创建回复,但仍然要访问共享数据集,全局锁是nededed:这会消失。Redis的作者更喜欢使用集群的方式来解决I/O线程化,尤其是在6.0版本发布的原生RedisClusterProxy的背景下,使得集群更易于使用。此外,作者更喜欢慢操作线程(如4.0版本发布的LazyFree)来解决多线程问题。后续版本会不会改进IOThread的实现,使用Module来优化慢操作,确实值得期待。近期热点文章推荐:1.1,000+Java面试题及答案(2021最新版)2.别在满屏的if/else中,试试策略模式,真的很好吃!!3.操!Java中xx≠null的新语法是什么?4、SpringBoot2.5发布,深色模式太炸了!5.《Java开发手册(嵩山版)》最新发布,赶快下载吧!感觉不错,别忘了点赞+转发!