为什么Redis这么快?

185 阅读8分钟

本文参考:极客时间《Redis核心技术与实战》

Redis是一个高性能的键值数据库,它的性能这么优异主要源于以下几个方面:

  1. 内存数据库。Redis的键值操作是基于内存的,内存的访问速度很快。
  2. 高效的底层数据结构。Redis底层会用到压缩列表、跳表、哈希表等数据结构。
  3. 高性能IO模型。Redis使用基于多路复用的高性能IO模型。

下面会重点说一下底层数据结构和IO模型这两部分。

高效的底层数据结构

Redis支持的value类型有五种:String、List、Set、Sorted Set、Hash。

这些类型低层的数据结构分别是什么呢?在Redis中低层数据结构大致可分为六种:简单动态字符串、双向链表、压缩列表、哈希表、跳表、数组。上述五种value类型和这六种低层数据结构的关系如下:

  • String:简单动态字符串
  • List:双向链表、压缩列表
  • Set:哈希表、数组
  • Sorted Set:压缩列表、跳表
  • Hash:压缩列表、哈希表

List、Set、Sorted Set、Hash都对应两种低层数据结构,可以成为集合类型。

键值对组织方式

Redis中键值对的组织方式是哈希表。

使用哈希表可以实现对Key-Value键值对的快速访问。哈希表可以理解成是一个数组,那哈希表是如何存储不同类型的value的呢?其实这个哈希表中存储的元素是value的指针。不管是String还是其它类型,存储的都是指向value的指针。

每个哈希桶中存有执行key、value的指针。

使用哈希表,不可避免的可能存在哈希冲突的问题。Redis解决哈希冲突使用拉链法,并且还会进行rehash操作。

哈希冲突和Rehash

哈希冲突

哈希冲突是指,两个不同的Key通过哈希计算后被映射到同一个哈希桶中。

Redis解决哈希冲突的方法是拉链法。即同一个哈希桶中存储多个键值对,这些键值对通过链表存储。

当发生哈希冲突时,Redis通过将冲突的元素存储到一个链表中,来解决冲突问题。

冲突链表上的元素只能顺序逐一查找,随着存储的元素越来越多,冲突的元素也会越来越多,这会影响Redis的查询性能。

所以,Redis还会进行rehash操作。

Rehash

rehash操作是指,Redis会增加哈希桶的数量,使新增的元素能后分散在不同的哈希桶中,减少了单个桶中元素的数量。

如果直接在原全局哈希表上增加容量是不可以的,因为一般哈希算法和哈希表容量是有关联的,当哈希表容量变化时,哈希算法中的一些参数也会变化,key映射的位置也就变化了。所以在进行rehash的时候需要使用新的哈希表来进行扩容。

在Redis中,默认有两个全局哈希表:哈希表1和哈希表2。一开始默认使用的是哈希表1,哈希表2还未分配空间,随着写入数据越来越多,Redis会进行如下rehash操作:

  1. 给哈希表2分配空间,比如是哈希表1的两倍。
  2. 将哈希表1中的数据重新映射,并同步到哈希表2中。
  3. 释放哈希表1的空间。

在第二步中,涉及数据的拷贝,如果数据量很大的话,会消耗很多资源,造成Redis线程阻塞,处理请求的效率下降。

不可以一次性拷贝大量数据,那是否可以分阶段,每次只拷贝少量的数据呢?当然是可以的。

Redis采用的方式是,每次处理一个请求,将哈希表1当前请求的哈希桶的数据拷贝到哈希表2中。即渐进式rehash

各种低层数据类型的操作效率

数组

O(N)

双向链表

O(N)

哈希表

O(1)

压缩列表

压缩列表在表头有是三个字段,zlbytes、zltail、zllen,分别表示列表长度、列表末尾偏移量、列表中元素个数。

所以如果查询压缩列表第一个元素或者做后一个元素的复杂度是O(1),查询其他元素的复杂度是O(N)。

跳表

对于链表,如果我们顺序查询,那么时间复杂度是O(N)。当数据量非常大时,效率就比较低了。优化O(N),可以想办法将其优化为O(logN)。对于查询时间复杂度为O(logN)的数据结构,最常见的是二叉搜索树,那么就可以借鉴二叉搜索树的思想来优化链表查询。

将二叉搜索树最后一层看作完整链表的话,那么上边其它层次可以视为链表不同层级的索引。

通过添加索引的方式可以提高检索效率。

其查询时间复杂度是O(logN)。

高性能IO模型

我们常说Redis是单线程,是指Redis的网络IO和键值对操作是单线程的。

Redis单线程模型可以达到每秒数十万的处理级别,主要是因为以下两点:

  1. Redis大部分操作都是基于内存的
  2. Redis采用多路复用IO模型来处理网络请求

因为Redis是单线程的,如果某个请求特别耗时,就会导致其他请求被阻塞,Redis整体性能下降。

在网络IO中主要涉及的操作有:

  1. listen:监听客户端请求
  2. accept:创建客户端链接
  3. recv:接收客户端请求数据
  4. handle:处理客户端请求
  5. send:向客户端返回响应

那么在网络IO中可能存在的阻塞点主要有哪些呢?在网络IO中,主要的阻塞点有两个:accept和recv。

比如监听到客户端请求,要进行创建链接,但是一直未成功,那么就会阻塞在accept。

非阻塞IO

如果accept或者recv函数发生阻塞时,会使整个Redis线程阻塞。幸运的是,我们有非阻塞IO模型。

在非阻塞模式下,如果Redis调用accept一直未成功创建链接,那么accept会返回,这样Redis就可以继续处理其他请求。

非阻塞模式的使用主要体现在两个函数上:

  1. listen: 创建监听套接字。设置非阻塞模式时,accept()非阻塞。
  2. accept: 创建已链接套接字。设置非阻塞模式时,recv()、send()非阻塞。

在设置非阻塞模式时,需要有监听机制来保证,当有客户端想要创建链接时 或者 有客户端数据到达时通知Redis进行处理,并且监听的不同事件,对应的处理方法也不同,这就是多路复用IO。

多路复用IO

常见的多路复用IO机制有select、epoll等。

在多路复用机制下,允许同时存在多个监听套接字和多个已连接套接字。当这些套接字上有请求到达,会调用对应的事件处理函数,也就是基于事件的回调机制

当有请求到达时,会先讲请求放入事件队列中,Redis会不断的读取队列中的事件,并调用对应的处理函数。

虽然通过多路复用机制,使Redis可以快速响应客户端请求,但是对于请求Redis还是一个一个顺序处理的,比如客户端数据的读写。Redis 6.0的多线程机制一定程度提高了Redis对客户端数据读写的性能。

上边我们提到,如果某个请求特别耗时,那么会导致其他请求被阻塞。我们说了在socket处理阶段可能存在的耗时操作,那么在Redis具体执行操作阶段会有什么耗时操作吗?这当然有。

Server层的耗时操作

在Redis服务层面可能存在的耗时操作主要有以下几个:

  1. bigkey,bigkey的创建和释放都比较耗时。Redis 4.0提出的lazy-free机制,一定程度降低了bigkey释放的耗时。
  2. 低效查询,比如范围查询等。
  3. 大量key集中过期,耗时主要在删除过期key。
  4. RDB快照,耗时主要在fork子进程阶段。

其他特殊场景下可能存在的耗时操作:

  1. 缓存淘汰,当内存不足时,会进行缓存淘汰,加大了操作耗时。
  2. AOF刷盘,当开启always机制时,每次请求都会写磁盘,Redis性能会严重降低。