当前位置: 首页 > 科技观察

探索-谁说Redis慢,我跟谁急!

时间:2023-03-16 01:02:11 科技观察

作为一名服务端工程师,在工作中一定和Redis打过交道。你必须知道Redis为什么快,至少你已经为面试做好了准备。很多人知道Redis的速度快只是因为它是基于内存实现的,但是对于其他的原因却模棱两可。图片来自Pexels,今天就和小编一起来看看吧:思维导图是靠记忆的,开篇有提到,这里简单说一下。Redis是一个基于内存的数据库,所以难免要和磁盘数据库进行类比。对于磁盘数据库来说,需要将数据读入内存,这个过程会受到磁盘I/O的限制。对于内存数据库来说,数据本身就存在于内存中,所以没有这样的开销。高效的数据结构Redis中有多种数据类型,每种数据类型的底层由一种或多种数据结构支撑。正是因为有了这些数据结构,Redis在存储和读取上的速度才没有受到阻碍。这些数据结构有什么特点呢?下面我们来读一读:简单动态字符串这个词大家可能不太熟悉,但是换成SDS你就一定知道了。这是为了处理字符串。了解C语言的人都知道它有处理字符串的方法。而Redis是用C语言实现的,为什么要重复造轮子呢?我们来看以下几点:①字符串长度处理这张图展示了C语言中字符串是如何存储的。获取Redis的长度,需要从头开始遍历,直到遇到'\0'。在Redis中如何实现?使用len字段记录当前字符串的长度。获取长度只需要获取len字段即可。你看,差距不言而喻。前者遍历的时间复杂度为O(n),在Redis中可以得到O(1),速度明显提升。②内存重新分配C语言修改字符串时,会重新分配内存。修改越频繁,内存分配就越频繁。但是内存分配是消耗性能的,所以性能下降在所难免。但是Redis会涉及到频繁的字符串修改操作,这种内存分配方式显然不适合。所以SDS实现了两种优化策略:空间预分配:在修改SDS和扩展空间时,除了分配必要的空间外,还会分配额外未使用的空间。具体分配规则如下:修改SDS后,如果len长度小于1M,则额外分配一个与len长度相同的未使用空间。如果修改后的长度大于1M,则分配1M的使用空间。惰性空间释放:当然也有空间分配对应的空间释放。当SDS被缩短时,并不会回收多余的内存空间,而是使用free域来记录多余的空间。如果后续有change操作,则直接使用free中记录的空间,减少内存分配。③二进制安全你已经知道Redis可以存储各种数据类型,二进制数据当然也不例外。但是二进制数据不是常规的字符串格式,可能包含一些特殊字符,如'\0'等。前面我们说过,C中的字符串遇到'\0'就会结束,'\0'之后的数据是读不到的。但是在SDS中,字符串的结尾是根据len的长度来判断的。看,二进制安全问题解决了。双端链表List更多的用作队列或者栈。队列和栈的特点是先进先出和先进后出。双端链表很好地支持这些特性。双端链表①前后节点链表中的每个节点都有两个指针,prev指向上一个节点,next指向下一个节点。这样就可以在O(1)的时间复杂度内得到前后节点。②头节点和尾节点大家可能注意到了,头节点中有两个参数head和tail,分别指向头节点和尾节点。这样的设计可以将双端节点的处理时间复杂度降低到O(1),非常适合队列和栈。同时可以从两端迭代链表。③链表长度的头节点还有一个参数len,和上面说的SDS类似,用来记录链表的长度。因此,在获取链表长度时,不需要遍历整个链表,直接获取len值即可。这个时间复杂度是O(1)。你看,这些特性减少了使用List时的时间开销。压缩列表双端链表我们已经很熟悉了。不知道大家有没有注意到一个问题:如果在一个链表节点中存储一个小数据,比如一个字节。那么相应的头结点、前后指针等附加数据都要保存。这样既浪费空间,又容易因反复申请和释放而造成内存碎片。这个内存使用效率太低了。所以,压缩列表开始发挥作用了!它经过特殊编码和设计以提高内存使用效率。所有操作都是通过指针和解码后的偏移量来执行的。而且压缩列表的内存是连续分配的,遍历速度非常快。DictionaryRedis是一个K-V数据库,所有的键值都存储在字典中。你应该熟悉日常学习中使用的词典。想找某个词,直接通过某个词定位即可,速度非常快。这里说的字典基本都是一样的,直接通过某个key就可以得到对应的值。字典也叫哈希表,这个没啥好说的。哈希表的特性众所周知,关联值的检索和插入的时间复杂度为O(1)。跳表作为Redis中特有的一种数据结构,在链表中加入了多级索引,以提高查找效率。这是跳表的简单示意图。每一层都有一个有序的链表,最底层的链表包含了所有的元素。这样跳表就可以支持在O(logN)的时间复杂度内找到对应的节点。下面是跳表的真实存储结构。和其他数据结构一样,在头节点记录相应的信息,减少了一些不必要的系统开销。合理的数据编码对于每一种数据类型,底层支持的可能是多种数据结构。什么时候使用哪种数据结构,这就涉及到编码转换的问题。那么我们来看看不同数据类型是如何编码转换的:String:如果存储的是数字,则使用int类型编码;如果不是数字,则使用原始编码。List:如果字符串长度和元素个数小于一定范围,则使用ziplist编码。如果不满足任何条件,则将其转换为链表编码。哈希:哈希对象中存储的键值对中的键和值字符串的长度小于某个值和键值对。Set:将元素保存为整数,如果元素个数小于一定范围,则使用intset编码。如果不满足任何条件,则使用哈希表编码。zset:如果zset对象中存储的元素个数小于某个值且成员长度小于某个值,则使用ziplist编码。如果不满足任何条件,则使用跳过列表编码。合适的线程模型Redis之所以快,另一个原因是它使用了合适的线程模型:I/O多路复用模型I/O:网络I/O;多通道:多个TCP连接;多路复用:共享一个线程或进程。在生产环境中使用,通常是多个客户端连接Redis,然后各自向Redis服务器发送命令,最后由服务器处理这些请求并返回结果。为了处理大量请求,Redis使用I/O多路复用器同时监听多个套接字,并将这些事件压入一个队列,然后一个一个地执行。最后将结果返回给客户端。避免上下文切换你一定听说过Redis是单线程的。那么单线程Redis为什么快呢?因为多线程在执行过程中需要进行CPU上下文切换,而这个操作是比较耗时的。Redis是基于内存实现的。对于内存来说,无上下文切换的效率是最高的。在一个CPU上进行多次读写,是内存的最佳方案。单线程模型顺便说一下为什么Redis是单线程的。Redis使用的是Reactor单线程模型,大家可能不太熟悉。没关系,你只需要了解一个大概的概念。这张图中,接收到用户的请求后,全部推入一个队列,然后交给文件事件派发器,是一种单线程的工作方式。Redis基于它工作,所以Redis是单线程的。Redis的单线程和多线程Redis是单线程的,这是人尽皆知的道理。现在不一样了,Redis变了。再说这句话,我得带着质疑的语气跟你争论。意志不坚定的人,可以投降而随从他人。它是什么样的?读者请和小赖一起往下读:Reactor模式Reactor模式,你可能不太了解,但是看完上面的你应该有点印象了。说到Redis线程,就是一个绕不过去的话题。①传统blockingIO模型在说reactor模式之前,有必要提一下传统blockingIO模型的处理方式。在传统的阻塞IO模型中,一个独立的Acceptor线程监听客户端的连接。每当客户端请求时,它都会分配一个新的线程供客户端处理。当多个请求同时到来时,服务器端会分配相应数量的线程。这样会导致CPU频繁切换,浪费资源。有些连接请求什么都不做,但服务器也会分配相应的线程,这样会造成不必要的线程开销。就像你去饭店吃饭,看菜单看了半天,发现真他妈贵,然后就走了。在这段时间里,等待你点菜的服务员相当于一个对应的线程。如果要点餐,可以看作是连接请求。同时,每次连接建立后,当线程调用读写方法时,线程会被阻塞,直到有数据可读和可写,期间线程不能做其他事情。还是上面那个餐厅吃饭的例子。你出去转转,发现还是这家餐厅性价比最高。回到这家餐厅,看了很久菜单,服务员也在等你点完。在这个过程中,服务员除了等待什么也做不了。这个过程相当于阻塞。你看这样,每次有请求过来,都要分配一个线程,一直阻塞到线程处理完。有些请求只是过来连接,什么都不做,还得给它分配一个线程,需要大量的服务器资源。遇到高并发场景,不敢想象。对于连接数比较少的固定架构可以考虑。②伪异步IO模型大家可能知道一个通??过线程池优化的方案,使用线程池和任务队列。这称为伪异步IO模型。当客户端访问时,将客户端的请求封装成任务,交给后端线程池处理。线程池维护着一个消息队列和多个活跃的线程来处理消息队列中的任务。这种方案避免了每次请求都创建一个线程导致线程资源耗尽的问题。但是底层还是同步阻塞模型。如果线程池中的所有线程都被阻塞,则无法再响应任何请求。所以这种方式会限制最大连接数,不能从根本上解决问题。我们继续以上面的餐厅为例。餐厅老板经营了一段时间后,客人就多了起来。店里原来的5个服务员,一对一的服务实在应付不过来。于是老大采用了5个个人线程池的方法。服务生服务完一位顾客后,立即去服务另一位顾客。这时候,问题就出现了。有的客人点菜速度很慢,服务员要等很久才能等到客人点完。如果5个顾客都点的很慢,这5个服务员就得一直等下去,就会导致剩下的顾客没人服务的状态。这就是我们上面提到的线程池中的所有线程都被阻塞的情况。那么如何解决这类问题呢?别着急,Reactor模式即将问世。③Reactor设计模式Reactor模式的基本设计思想是基于I/O多路复用模型实现的。这是I/O多路复用模型。不同于传统的IO多线程阻塞,I/O多路复用模型中的多个连接共享一个阻塞对象,应用程序只需要等待一个阻塞对象。当一个连接有新数据可以处理时,操作系统通知应用程序,线程从阻塞状态返回,开始业务处理。你是什??么意思?餐厅老板也发现了顾客点菜慢的问题,于是采取了大胆的办法,只留下一名服务员。顾客点单,服务员去招待其他顾客,顾客点单后直接叫服务员服务。这里的顾客和服务员可以分别看成是多连接和一个线程。女服务员卡在一个顾客那里,其他顾客点了,她就马上去服务其他顾客。了解了Reactor的设计思想后,我们来看看今天Reactor的单线程实现:Reactor通过I/O多路复用程序监听客户端请求事件,接收到事件后通过任务调度器进行分发。对于连接建立请求事件,通过Acceptor进行处理,建立相应的handler负责后续的业务处理。对于非连接事件,Reactor会调用相应的handler完成读取→业务处理→写入流程,并将结果返回给客户端。整个过程在一个线程中完成:了解了单线程时代的Reactor模式后,你可能会有疑问,这和我们今天的话题有什么关系。你可能不知道的是,Redis是基于Reactor单线程模式实现的。IO多路复用程序收到用户的请求后,将它们全部推入一个队列,交给文件调度器。对于后续的操作,在Reactor单线程实现中看到,整个过程在一个线程中完成,所以Redis被称为单线程操作。对于单线程Redis,它是基于内存的,命令操作时间复杂度低,所以读写速度非常快。在多线程时代,Redis6版本引入了多线程。上面说到Redis单线程处理速度非常快,为什么要引入多线程呢?单线程的瓶颈在哪里?我们先来看第二个问题。在Redis中,单线程的性能瓶颈主要在网络IO操作上。即大部分CPU时间会在读写网络的读写系统调用执行期间被占用。如果要删除一些大的键值对,短时间内无法删除,所以对于单线程来说,会阻塞后续操作。回忆一下上面提到的Reactor模式下的单线程处理方式。对于非连接事件,Reactor会调用相应的handler来完成读取→业务处理→写入流程,也就是说这一步会造成性能瓶颈。Redis旨在通过多线程处理网络数据读写和协议解析。对于命令执行,仍然使用单线程操作。总结基于内存实现:数据存储在内存中,减少了一些不必要的I/O操作,运行速度非常快。高效的数据结构:多种底层数据结构,支持不同的数据类型,支持Redis存储不同的数据。不同数据结构的设计最大限度地降低了数据存储的时间复杂度。合理的数据编码:根据字符串的长度和元素个数,适配不同的编码格式。合适的线程模型:I/O多路复用模型同时监听客户端连接;单线程执行时不需要上下文切换,减少耗时。Reactor模式:在传统的阻塞IO模型中,客户端和服务端线程1:1分配,不利于扩展。伪异步IO模型采用了线程池的方式,但是底层还是采用了同步阻塞的方式,限制了最大连接数。Reactor通过I/O多路复用器监听客户端请求事件,并通过任务调度器进行分发。单线程时代:基于Reactor的单线程模式,通过IO多路复用程序接收到用户请求后,将所有请求推送到一个队列中,交给文件调度器处理。多线程时代:单线程性能瓶颈主要在网络IO上。网络数据读写和协议解析采用多线程方式处理。对于命令执行,仍然使用单线程操作。作者:小赖后端工程师编辑:陶家龙来源:转载自公众号IT民工(ID:kejishuqian)