探究 | 谁再说Redis慢,我跟谁急!

网友投稿 539 2023-05-22

探究 | 谁再说Redis慢,我跟谁急!

探究 | 谁再说Redis慢,我跟谁急!

作为一名服务端工程师,工作中你肯定和 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 字段将多出来的空间记录下来。如果后续有变更操作,直接使用 free 中记录的空间,减少了内存的分配。

③二进制安全

你已经知道了 Redis 可以存储各种数据类型,那么二进制数据肯定也不例外。但二进制数据并不是规则的字符串格式,可能会包含一些特殊的字符,比如 '\0' 等。

前面我们提到过,C 中字符串遇到 '\0' 会结束,那 '\0' 之后的数据就读取不上了。但在 SDS 中,是根据 len 长度来判断字符串结束的。

看,二进制安全的问题就解决了。

双端链表

列表 List 更多是被当作队列或栈来使用的。队列和栈的特性一个先进先出,一个先进后出。双端链表很好的支持了这些特性。

双端链表

①前后节点

链表里每个节点都带有两个指针,prev 指向前节点,next 指向后节点。这样在时间复杂度为 O(1) 内就能获取到前后节点。

②头尾节点

你可能注意到了,头节点里有 head 和 tail 两个参数,分别指向头节点和尾节点。

这样的设计能够对双端节点的处理时间复杂度降至 O(1) ,对于队列和栈来说再适合不过。同时链表迭代时从两端都可以进行。

③链表长度

头节点里同时还有一个参数 len,和上边提到的 SDS 里类似,这里是用来记录链表长度的。

因此获取链表长度时不用再遍历整个链表,直接拿到 len 值就可以了,这个时间复杂度是 O(1)。

你看,这些特性都降低了 List 使用时的时间开销。

压缩列表

双端链表我们已经熟悉了。不知道你有没有注意到一个问题:如果在一个链表节点中存储一个小数据,比如一个字节。那么对应的就要保存头节点,前后指针等额外的数据。

这样就浪费了空间,同时由于反复申请与释放也容易导致内存碎片化。这样内存的使用效率就太低了。

于是,压缩列表上场了!

它是经过特殊编码,专门为了提升内存使用效率设计的。所有的操作都是通过指针与解码出来的偏移量进行的。

并且压缩列表的内存是连续分配的,遍历的速度很快。

字典

Redis 作为 K-V 型数据库,所有的键值都是用字典来存储的。

日常学习中使用的字典你应该不会陌生,想查找某个词通过某个字就可以直接定位到,速度非常快。

这里所说的字典原理上是一样的,通过某个 key 可以直接获取到对应的 value。

字典又称为哈希表,这点没什么可说的。哈希表的特性大家都很清楚,能够在 O(1) 时间复杂度内取出和插入关联的值。

跳跃表

作为 Redis 中特有的数据结构-跳跃表,其在链表的基础上增加了多级索引来提升查找效率。

这是跳跃表的简单原理图,每一层都有一条有序的链表,最底层的链表包含了所有的元素。这样跳跃表就可以支持在 O(logN) 的时间复杂度里查找到对应的节点。

下面这张是跳表真实的存储结构,和其它数据结构一样,都在头节点里记录了相应的信息,减少了一些不必要的系统开销。

合理的数据编码

对于每一种数据类型来说,底层的支持可能是多种数据结构,什么时候使用哪种数据结构,这就涉及到了编码转化的问题。

那我们就来看看,不同的数据类型是如何进行编码转化的:

String:存储数字的话,采用 int 类型的编码,如果是非数字的话,采用 raw 编码。List:字符串长度及元素个数小于一定范围使用 ziplist 编码,任意条件不满足,则转化为 linkedlist 编码。Hash:hash 对象保存的键值对内的键和值字符串长度小于一定值及键值对。Set:保存元素为整数及元素个数小于一定范围使用 intset 编码,任意条件不满足,则使用 hashtable 编码。Zset:zset 对象中保存的元素个数小于及成员长度小于一定值使用 ziplist 编码,任意条件不满足,则使用 skiplist 编码。

合适的线程模型

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 模式

反应器模式,你可能不太认识,如果看完上文的话应该会有点印象。涉及到 Redis 线程它是一个绕不过去的话题。

①传统阻塞 IO 模型

在讲反应器模式前,这里有必要提一下传统阻塞 IO 模型的处理方式。

在传统阻塞 IO 模型中,由一个独立的 Acceptor 线程来监听客户端的连接,每当有客户端请求过来时,它就会为客户端分配一个新的线程来进行处理。

当同时有多个请求过来,服务端对应的就会分配相应数量的线程。这就会导致 CPU 频繁切换,浪费资源。

有的连接请求过来不做任何事情,但服务端还会分配对应的线程,这样就会造成不必要的线程开销。

这就好比你去餐厅吃饭,你拿着菜单看了半天发现真他娘的贵,然后你就走人了。

这段时间等你点菜的服务员就相当于一个对应的线程,你要点菜可以看作一个连接请求。

同时,每次建立连接后,当线程调用读写方法时,线程会被阻塞,直到有数据可读可写,在此期间线程不能做其它事情。

还是上边餐厅吃饭的例子,你出去转了一圈发现还是这家性价比最高。回到这家餐厅又拿着菜单看了半天,服务员也在旁边等你点完菜为止。

这个过程中服务员什么也不能做,只能这么干等着,这个过程相当于阻塞。

你看这样的方式,每来一个请求就要分配一个线程,并且还得阻塞地等线程处理完。

有的请求还只是过来连接下,什么操作也不干,还得为它分配一个线程,对服务器资源要求那得多高啊。

遇到高并发场景,不敢想象。对于连接数目比较小的的固定架构倒是可以考虑。

②伪异步 IO 模型

你可能了解过一种通过线程池优化的解决方案,采用线程池和任务队列的方式。这种被称作伪异步 IO 模型。

当有客户端接入时,将客户端的请求封装成一个 task 投递到后端线程池中来处理。线程池维护一个消息队列和多个活跃线程,对消息队列中的任务进行处理。

这种解决方案,避免了为每个请求创建一个线程导致的线程资源耗尽问题。但是底层仍然是同步阻塞模型。

如果线程池内的所有线程都阻塞了,那么对于更多请求就无法响应了。因此这种模式会限制最大连接数,并不能从根本上解决问题。

我们继续用上边的餐厅来举例,餐厅老板在经营了一段时间后,顾客多了起来,原本店里的 5 个服务员一对一服务的话根本对付不过来。

于是老板采用 5 个人线程池的方式。服务员服务完一个客人后立刻去服务另一个。

这时问题出现了,有的客人点菜特别慢,服务员就得等待很长时间,直到客人点完为止。

如果 5 个客人都点的特别慢的话,这 5 个服务员就得一直等下去,就会导致其余的顾客没有人服务的状态。这就是我们上边所说的线程池所有线程都被阻塞的情况。

那么这种问题该如何解决呢?别急, Reactor 模式就要出场了。

③Reactor 设计模式

Reactor 模式的基本设计思想是基于 I/O 复用模型来实现的。

这里说下 I/O 复用模型。和传统 IO 多线程阻塞不同,I/O 复用模型中多个连接共用一个阻塞对象,应用程序只需要在一个阻塞对象等待。

当某个连接有新的数据可以处理时,操作系统通知应用程序,线程从阻塞状态返回,开始进行业务处理。

什么意思呢?餐厅老板也发现了顾客点餐慢的问题,于是他采用了一种大胆的方式,只留了一个服务员。

当客人点餐的时候,这个服务员就去招待别的客人,客人点好餐后直接喊服务员来进行服务。

这里的顾客和服务员可以分别看作多个连接和一个线程。服务员阻塞在一个顾客那里,当有别的顾客点好餐后,她就立刻去服务其他的顾客。

了解了 Reactor 的设计思想后,我们再来看下今天的主角单 Reactor 单线程的实现方案:

Reactor 通过 I/O 复用程序监控客户端请求事件,收到事件后通过任务分派器进行分发。

针对建立连接请求事件,通过 Acceptor 处理,并建立对应的 handler 负责后续业务处理。

针对非连接事件,Reactor 会调用对应的 handler 完成 read→业务处理→write 处理流程,并将结果返回给客户端。

整个过程都在一个线程里完成:

单线程时代

了解了 Reactor 模式后,你可能会有一个疑问,这个和我们今天的主题有什么关系呢。可能你不知道的是,Redis 是基于 Reactor 单线程模式来实现的。

IO多路复用程序接收到用户的请求后,全部推送到一个队列里,交给文件分派器。

对于后续的操作,和在 Reactor 单线程实现方案里看到的一样,整个过程都在一个线程里完成,因此 Redis 被称为是单线程的操作。

对于单线程的 Redis 来说,基于内存,且命令操作时间复杂度低,因此读写速率是非常快的。

多线程时代

Redis6 版本中引入了多线程。上边已经提到过 Redis 单线程处理有着很快的速度,那为什么还要引入多线程呢?单线程的瓶颈在什么地方?

我们先来看第二个问题,在 Redis 中,单线程的性能瓶颈主要在网络IO操作上。

也就是在读写网络 read/write 系统调用执行期间会占用大部分 CPU 时间。如果你要对一些大的键值对进行删除操作的话,在短时间内是删不完的,那么对于单线程来说就会阻塞后边的操作。

回想下上边讲得 Reactor 模式中单线程的处理方式。针对非连接事件,Reactor 会调用对应的 handler 完成 read→业务处理→write 处理流程,也就是说这一步会造成性能上的瓶颈。

Redis 在设计上采用将网络数据读写和协议解析通过多线程的方式来处理,对于命令执行来说,仍然使用单线程操作。

总结

基于内存实现:

数据都存储在内存里,减少了一些不必要的 I/O 操作,操作速率很快。

高效的数据结构:

底层多种数据结构支持不同的数据类型,支持 Redis 存储不同的数据。不同数据结构的设计,使得数据存储时间复杂度降到最低。

合理的数据编码:

根据字符串的长度及元素的个数适配不同的编码格式。

合适的线程模型:

I/O 多路复用模型同时监听客户端连接;单线程在执行过程中不需要进行上下文切换,减少了耗时。

Reactor 模式:

传统阻塞 IO 模型客户端与服务端线程 1:1 分配,不利于进行扩展。伪异步 IO 模型采用线程池方式,但是底层仍然使用同步阻塞方式,限制了最大连接数。Reactor 通过 I/O 复用程序监控客户端请求事件,通过任务分派器进行分发。

单线程时代:

基于 Reactor 单线程模式实现,通过 IO 多路复用程序接收到用户的请求后,全部推送到一个队列里,交给文件分派器进行处理。

多线程时代:

单线程性能瓶颈主要在网络 IO 上。将网络数据读写和协议解析通过多线程的方式来处理 ,对于命令执行来说,仍然使用单线程操作。

作者:小莱,一枚后端工程师

编辑:陶家龙

版权声明:本文内容由网络用户投稿,版权归原作者所有,本站不拥有其著作权,亦不承担相应法律责任。如果您发现本站中有涉嫌抄袭或描述失实的内容,请联系我们jiasou666@gmail.com 处理,核实后本网站将在24小时内删除侵权内容。

上一篇:Percolator 和 TiDB 事务算法
下一篇:解析 TiDB 在线数据同步工具 Syncer
相关文章