深度解析单线程的Redis快的原理

前言

对于我们开发同学来说,一说到Redis,第一反应肯定是说“快”;为什么要用Redis,因为它快。那为什么Redis这么快,况且它还是一个单线程的,这你能受的了么,哈哈。我们用了很多数据库,为什么Redis能有这么快的性能呢?我们先大体上猜测总结一下,当然这不是最终结论:

  1. Redis是个内存数据库,不论是读、写操作都是在内存上完成的,而内存的速度本身就是很快的(至于内存为什么快,自己查资料吧);
  2. 可能与Redis的数据结构有关系。为什么这么猜测呢,因为我们之前学的mysql也好、Mongo也好,都与数据结构有很大的关系,良好的数据结构,能够起到很大的作用。
    那么带着上面的猜测,我们开始往下看。

Redis的数据结构

1.此结构非彼结构

说的Redis的数据结构,可能有很多同学不加思索的就说,Redis支持5中数据结构:String、List、Hash、Set、SortedSet。但我今天说的并不是这么东西,上面5种应该称之为Redis支持的数据类型可能更合适些。我们今天要说的数据结构是指这些数据类型的底层实现。

2.Redis的底层数据结构

Redis底层一共使用了6中数据结构,分别是简单动态字符串、双向链表、压缩列表、哈希表、跳表和整数数组,每一种或几种数据结构来支撑Redis的各种数据类型。
在这里插入图片描述
下面我们就上面说的六种数据结构做一个简单的介绍。

1.简单动态字符串(simple dynamic string,SDS)

这里我们不给出它的定义,这个结构是redis自己定义实现的一个抽象数据类型,它的结构体如下(Redis3.2之前):

struct sdshdr{
     //记录buf数组中已使用字节的数量
     //等于 SDS 保存字符串的长度
     int len;
     //记录 buf 数组中未使用字节的数量
     int free;
     //字节数组,用于保存字符串
     char buf[];
}

在这里插入图片描述

从上面的图中我们看出,这种结构具有以下特点或者说优点:
3. 读取字符串的长度,时间复杂度为O(1);——实现了快
4. 因为len的存在,能够有效防止内存溢出,及时进行扩容;
5. 有效减少内存重新分配的次数,因为内存扩容进行重新分配,是很耗时的,减少分配次数——实现了快;当然这主要包括以下两个方面:空间预分配与惰性释放。当字符串的值变大时,扩容重新分配的内存空间比其实际要用的要大,这样后续字符串增大时,就不需要每次都进行重新分配了;当字符串的值变小时,此时也不立即缩小内存空间,使用 free 属性将这些字节的数量记录下来,等待后续使用。
6. 二进制安全,所有 SDS 的API 都是以处理二进制的方式来处理 buf 里面的元素,并且以 len 属性表示的长度来判断字符串是否结束,这样就能避免在存储一些二进制文件(如图片等)时,读取出现异常的情况;

2.双向链表

双向链表我们都比较熟悉了,这是我们最最常用的一种数据结构,它包括前置节点、后置节点、当前节点的值,这里我们就不再细说了。Redis在此基础上,提供了一个操作链表的数据结构,结构体如下:

typedef struct list{
     //表头节点
     listNode *head;
     //表尾节点
     listNode *tail;
     //链表所包含的节点数量
     unsigned long len;
     //节点值复制函数
     void (*free) (void *ptr);
     //节点值释放函数
     void (*free) (void *ptr);
     //节点值对比函数
     int (*match) (void *ptr,void *key);
}list;

从上面的结构体我们看出,Redis最主要的是提供了一个len,可以用O(1)的时间复杂度直接获取链表的长度,体现了一个快;Redis实现的链表,具有以下特点:
7. 双端:链表具有前置节点和后置节点的引用,获取这两个节点时间复杂度都为O(1);
8. 无环:表头节点的 prev 指针和表尾节点的 next 指针都指向 NULL,对链表的访问都是以 NULL 结束。
9. 带链表长度计数器:通过 len 属性获取链表长度的时间复杂度为 O(1)。

3.压缩列表(ziplist)

压缩列表(ziplist),看到"压缩“这两个字,我们第一时间就想到它是不是利用某种算法把原来大的数据压缩小了,比如把100M压缩到了10M,其实不是的,这是一个理解的误区。压缩列表(ziplist)是Redis为了节省内存而开发的,是由一系列特殊编码的连续内存块组成的顺序型数据结构,一个压缩列表可以包含任意多个节点(entry),每个节点可以保存一个字节数组或者一个整数值。对于集合类型,如果元素个数较少,那么这时候就会采用ziplist作为底层数据结构,来较少内存的占用,但其实这样会增加读写的效率,但因为这只有在数据量较小的情况下来试用,所以对性能的影响并不会很明显,这就是利用时间换空间,而这里牺牲的时间却又很少,所以是很值得这么做的。

在这里插入图片描述

4.Hash表

一个哈希表,简单来说,就是一个数组,数组中的每个元素称之为哈希桶。哈希桶中的元素存储的数据并不是值本身,而是指向他们(键值对entry)的指针。哈希表能够实现时间复杂度为O(1)的快速查找(因为数组的随机访问的时间复杂度为O(1)),只要计算出key的哈希值,就知道了哈希桶的位置,从而快速访问到entry元素。
根据上面的理论发现,似乎这种数据结构与数据的多少无关,时间复杂度都是O(1),不论你的数据是100还是100万,查询速度都是一样的,但很显然,现实却不是这样的,随着数据的大量增加,我们发现操作突然变慢了很多,那这是什么原因呢?
可能大多数同学都知道,这是因为哈希冲突问题。所谓哈希冲突,就是两个或者多个key的哈希值正好落在同一个哈希桶中。要解决哈希冲突,我们最容易想到的就是在哈希桶中的多个元素用一个链表来保存,它们之间依次用指针连接,这就是所谓的链式哈希。但是链式哈希有个问题,就是随着哈希冲突链越长,那么在这个链表上查找的速度就会越来越慢,耗时过长。这对于以“快”著称的Redis来说,显然是不合理的。那么怎么办呢?
此时,我们就要做rehash的操作,增加哈希桶的数量,减少每个哈希桶中元素的数量,降低链表的长度。那么下面又摆在我们面前一个问题,那就是如何快速的做rehash?
redis是采用了两个哈希表,来循环交替进行rehash的操作,主要有以下三步:

  • 给哈希表2分配更大的空间;
  • 把哈希表1的数据重新映射并复制到哈希表2中;
  • 释放哈希表1的空间。
    这个过程其实很简单,就是用两块内存,来互相转移数据。但是在第二步中如果一次性进行大量数据的复制,必然会造成线程阻塞,那应该怎么办呢?此时redis很巧妙的使用了一种渐进式rehash的方式。简单说,就是redis仍然正常处理客户端请求,但是没处理一个请求,从哈希表1中的第一个索引位置开始,顺带着将这个索引位置上的所有元素都复制到哈希表2,;然后下一个客户端请求来的时候,也同样这样处理从,从而把一次性拷贝的开销分摊到多次处理请求的过程中,避免了耗时的操作,保证了“快”。
    注意:在进行渐进式rehash期间,hash表中元素的删除查找更新等操作可能会在两个哈希表上进行,第一个哈希表没有找到,就会去第二个哈希表上进行查找。但是进行 增加操作,一定是在新的哈希表上进行的。
    那么到了这里,我们可以想一想,redis是一个基于内存的k-v形式的缓存数据库,那么它的键(key)和值(value)是什么样的结构组织呢?其实从大了说,就是一个哈希表,我们称为全局哈希表,这张全局哈希表保存了所有的键值对,可以让我们用O(1)的时间复杂度来快速查找键值对。

5.跳表(skiplist)

跳表是一个有序数据结构,它通过在每个节点中维持多个指向其他节点的指针,从而达到快速访问的目的。它有如下性质:

  • 它由很多层构成
  • 每一层都是一个有序的链表,排列顺序为由高层到底层,都至少包含两个链表节点,分别是前面的head节点和后面的nil节点
  • 最底层的链表包含了所有的元素
  • 如果一个元素出现在某一个层链表中,那么在该层之下的链表中也全都会出现,
  • 链表中的每个节点都包含两个指针,一个是指向同一层的下一个链表节点,另一个是指向下一层的同一个链表节点
1.跳表的简单演变

加入有下面一个有序链表:
在这里插入图片描述
我要搜索出10,那么我只能从1开始进行遍历查找,需要查找3次,也就是要找3个路径;要搜索出27,那么我只能从1开始进行遍历查找,需要查找7次,也就是要找7个路径;联想到之前的二叉树,我们可以把某些节点提取出来作为索引,如下图:
在这里插入图片描述
此时我要搜索出10,只需要遍历二层,查找1次,也就是要找1个路径;要搜索出27,那么我从二层开始,只需要4次,就能搜索出27,这样所有的次数就减少了,但我们也能够清楚的看到,这样会导致空间变大。
其实跳表就类似于上面的这个形式,只是加入了一下规则约束而已。
如下图,就是一个简单的跳表
在这里插入图片描述

2.跳表的简单操作
  • **搜索:**从最高层的链表节点开始,如果比当前节点要大和比当前层的下一个节点要小,那么则往下找,也就是和当前层的下一层的节点的下一个节点进行比较,以此类推,一直找到最底层的最后一个节点,如果找到则返回,反之则返回空。
  • **插入:**首先确定插入的层数,有一种方法是假设抛一枚硬币,如果是正面就累加,直到遇见反面为止,最后记录正面的次数作为插入的层数。当确定插入的层数k后,则需要将新元素插入到从底层到k层。
  • **删除:**在各个层中找到包含指定值的节点,然后将节点从链表中删除即可,如果删除以后只剩下头尾两个节点,则删除这一层。

6.整数数组(intset)

整数数组或者称为整数集合(intset)是Redis用于保存整数值的集合抽象数据类型,它可以保存类型为int16_t、int32_t 或者int64_t 的整数值,并且保证集合中不会出现重复元素。它的结构体如下:

typedef struct intset{
     //编码方式
     uint32_t encoding;
     //集合包含的元素数量
     uint32_t length;
     //保存元素的数组
     int8_t contents[];
}intset;
  • **集合扩容(升级):**当我们新增的元素类型比原集合元素类型的长度要大时,需要对整数集合进行升级,才能将新元素放入整数集合中。扩容能够具体步骤:
      1、根据新元素类型,扩展整数集合底层数组的大小,并为新元素分配空间。
      2、将底层数组现有的所有元素都转成与新元素相同类型的元素,并将转换后的元素放到正确的位置,放置过程中,维持整个元素顺序都是有序的。
      3、将新元素添加到整数集合中(保证有序)。
  • **集合降级:**整数集合不支持降级操作,一旦对数组进行了升级,编码就会一直保持升级后的状态。

以上,我们简单的介绍了redis使用的6中数据结构,下面我们看一下这些数据结构在redis中是怎么使用的

3.Redis的底层数据结构是如何支撑Redis的5大数据类型的

1.Redis中的键和值

从面向对象的角度来说,redis没创建一个键值对,要创建两个对象,一个是键(key)对象,一个是值对象(value),但是我们知道,value在redis中都是以二进制形式存在,那么redis是怎么区分string、list、hash、set、sortedSet的?其实Redis内部存在一个RedisObject的结构体:

typedef struct redisObject{
     //类型
     unsigned type:4;
     //编码
     unsigned encoding:4;
     //指向底层数据结构的指针
     void *ptr;
     //引用计数
     int refcount;
     //记录最后一次被程序访问的时间
     unsigned lru:22;
}robj

每个对象都会对应这么一个结构体的实例。**注意:**在Redis中,键总是一个字符串对象,而值可以是字符串、列表、集合等对象,所以我们通常说的键为字符串键,表示的是这个键对应的值为字符串对象,我们说一个键为集合键时,表示的是这个键对应的值为集合对象。为了我们以后的学习,这里我简单介绍一下这个结构体。

1.结构体中的type属性

对象的type属性记录了对象的类型,也就是那5大数据类型。redis中可以通过下面的命令查看某个value的数据类型:

type key
type的值数据类型
REDIS_STRING“string”
REDIS_LIST“list”
REDIS_HASH“hash”
REDIS_SET“set”
REDIS_ZSET“zset”
2.结构体中的encoding 属性和 *prt 指针

每个redisObject对象的 prt 指针指向对象底层的数据结构,而具体是指向哪些数据结构是由 encoding 属性来决定。

encoding底层数据结构
REDIS_ENCODING INTlong 类型的整数
REDIS_ENCODING_EMBSTRembstr编码的简单动态字符串
REDIS_ENCODING_RAW简单动态字符串
REDIS_ENCODING_HT哈希表
REDIS_ENCODING_LINKEDLIST双向链表
REDIS_ENCODING_ZIPLIST压缩列表
REDIS_ENCODING_INTSET整数数组(集合)
REDIS_ENCODING_SKIPLIST跳表

而每种类型的对象都至少使用了两种不同的编码:

数据类型encoding底层数据结构
stringREDIS_ENCODING INTlong 类型的整数
stringREDIS_ENCODING_EMBSTRembstr编码的简单动态字符串
stringREDIS_ENCODING_RAW简单动态字符串
listREDIS_ENCODING_ZIPLIST压缩列表
listREDIS_ENCODING_LINKEDLIST跳表
hashREDIS_ENCODING_HT哈希表
hashREDIS_ENCODING_ZIPLIST压缩列表
setREDIS_ENCODING_HT哈希表
setREDIS_ENCODING_INTSET整数数组(集合)
zsetREDIS_ENCODING_ZIPLIST压缩列表
zsetREDIS_ENCODING_SKIPLIST跳表

具体操作中,可以通过如下命令查看:

OBJECT ENCODING    key

2.Redis的数据类型(字符串)

1.字符串的三种编码

字符串是Redis最基本的数据类型,不仅所有key都是字符串类型,其它几种数据类型构成的元素也是字符串。
**注意:**注意字符串的长度不能超过512M。
字符串对象的编码可以是int,raw或者embstr。其中,int保存的是可以用 long 类型表示的整数值;raw保存长度大于44字节的字符串(redis3.2版本之前是39字节,之后是44字节);embstr 是专门用来保存短字符串的一种优化编码,保存长度小于44字节的字符串(redis3.2版本之前是39字节,之后是44字节)。

2.embstr 和raw比较

embstr的使用只分配一次内存空间(因此redisObject和sds是连续的),而raw需要分配两次内存空间(分别为redisObject和sds分配空间)。因此与raw相比,embstr的好处在于创建时少分配一次空间,删除时少释放一次空间,以及对象的所有数据连在一起,寻找方便。而embstr的坏处也很明显,如果字符串的长度增加需要重新分配内存时,整个redisObject和sds都需要重新分配空间,因此redis中的embstr实现为只读。

3.三种编码的转换
  • 当 int 编码保存的值不再是整数,或大小超过了long的范围时,自动转化为raw;
  • embstr 是只读的,对embstr对象进行修改时,都会先转化为raw再进行修改,因此,只要是修改embstr对象,修改后的对象一定是raw的,无论是否达到了44个字节。

3.Redis的数据类型(List)

list 列表,它是简单的字符串列表,按照插入顺序排序,你可以添加一个元素到列表的头部(左边)或者尾部(右边),它的底层实际上是个链表结构。

1.List的两种编码

List编码可以是 ziplist(压缩列表) 和 linkedlist(双端链表)。

2.List的两种编码的转换

当同时满足下面两个条件时,使用ziplist(压缩列表)编码:

  • 列表保存元素个数小于512个;
  • 每个元素长度小于64字节
    只要有一项不满足,就会使用双向链表。

4.Redis的数据类型(Hash)

1.Hash的两种编码

哈希的编码可以是 ziplist 或者 hashtable,当使用ziplist,也就是压缩列表作为底层实现时,新增的键值对是保存到压缩列表的表尾.比如我们存储一个学生的信息(s_1):

hset s_1 name  "jane"
hset s_1 sex "male"
hset s_1 age 12

在这里插入图片描述
根据前面的数据结构的介绍,压缩列表是Redis为了节省内存而开发的,是由一系列特殊编码的连续内存块组成的顺序型数据结构,相对于字典数据结构,压缩列表用于元素个数少、元素长度小的场景。其优势在于集中存储,节省空间。
当使用 哈希表编码时,底层使用哈希表,哈希对象中的每个键值对都使用一个字典键值对。

2.Hash的两种编码的转换

当同时满足下面两个条件时,使用ziplist(压缩列表)编码:

  • 列表保存元素个数小于512个;
  • 每个元素长度小于64字节
    只要有一项不满足,就会使用哈希表。

5.Redis的数据类型(Set)

set 是 string 类型(整数也会转换成string类型进行存储)的无序集合。它有两个特点:集合中的元素是无序的,因此不能通过索引来操作元素;集合中的元素不能有重复。

1.Set的两种编码

Set编码可以是整数数组(inset)或者 哈希表(hashtable)。
intset 编码的集合对象使用整数集合作为底层实现,集合对象包含的所有元素都被保存在整数集合中。
hashtable 编码的集合对象使用 hashTable作为底层实现,每个键都是一个字符串对象,这里的每个字符串对象就是一个集合中的元素,而值则全部设置为 null。这里可以类比Java集合中HashSet 集合的实现,HashSet 集合是由 HashMap 来实现的,集合中的元素就是 HashMap 的key,而 HashMap 的值都设为 null。

2.Set的两种编码的转换

当同时满足下面两个条件时,使用inset(整数数组)编码:

  • 列表保存元素个数小于512个;
  • 每个元素都是整数
    只要有一项不满足,就会使用哈希表。

6.Redis的数据类型(SortedSet)

SortedSet是一个有序集合,但它不同于数组这种以下表作为序号,而是以每个元素的score(分数)的大小来作为排序序号。

1.SortedSet的两种编码

SortedSet的编码可以是 ziplist (压缩列表)或者( skiplist(跳表)+哈希表);
(1)**使用ziplist:**此时每个集合元素使用两个紧挨在一起的压缩列表节点来保存,第一个节点保存元素的成员(member),第二个节点保存元素的分值(score),压缩列表内的集合元素按照分值从小到大的顺序进行排列,小的放置在靠近表头的位置,大的放置在靠近表尾的位置。如下的示例:

zadd age 15 jane 13 jack 18 lily 16 tony

在这里插入图片描述
在这里插入图片描述
(2)**使用skiplist:**其实并不是单纯的使用skiplist,这里还要加上哈希表作为组合,即(跳表+哈希表)来共同完成这个底层数据的支持。skiplist编码的有序集合底层是一个命名为zset的结构体,而一个zset结构同时包含一个字典和一个跳跃表。跳跃表按score从小到大保存所有集合元素。而字典则保存着从member到score的映射,这样就可以用O(1)的复杂度来查找member对应的score值。虽然同时使用两种结构,但它们会通过指针来共享相同元素的member和score,因此不会浪费额外的内存。它的结构体基本形式如下:

typedef struct zset{
     //跳跃表
     zskiplist *zsl;
     //字典
     dict *dice;
} zset;
2.SortedSet的两种编码的转换

当同时满足下面两个条件时,使用ziplist(压缩列表)编码:

  • 列表保存元素个数小于128个;
  • 保存的所有元素长度都小于64字节
    只要有一项不满足,就会使用skiplist +hash。

3.skiplist与搜索树、哈希表的比较

  • skiplist和各种搜索树(如二叉搜索树、二叉平衡树AVL、红黑树等)的元素是有序排列的,而哈希表不是有序的,在哈希表上适合做单个key的查找,不适宜做范围查找。
  • 在做范围查找的时候,二叉平衡树比skiplist操作要复杂。在平衡树上,我们找到指定范围的小值之后,还需要以中序遍历的顺序继续寻找其它不超过大值的节点。如果不对平衡树进行一定的改造,这里的中序遍历并不容易实现。而在skiplist上进行范围查找就非常简单,只需要在找到小值之后,对第1层链表进行若干步的遍历就可以实现,虽然简单,但skiplist并不意味着比二叉平衡树效率要高;
  • 二叉平衡树的插入和删除操作可能引发子树的调整,逻辑复杂,而skiplist的插入和删除只需要修改相邻节点的指针,操作简单又快速。
  • 从内存占用上来说,skiplist比平衡树更灵活一些。一般来说,平衡树每个节点包含2个指针(分别指向左右子树),而skiplist每个节点包含的指针数目平均为1/(1-p),具体取决于参数p的大小。如果像Redis里的实现一样,取p=1/4,那么平均每个节点包含1.33个指针,比平衡树更有优势;
  • 查找单个key,skiplist和平衡树的时间复杂度都为O(log n),大体相当;而哈希表在保持较低的哈希值冲突概率的前提下,查找时间复杂度接近O(1),性能更高一些;
  • 从算法实现难度上来比较,skiplist比平衡树要简单得多,但这个我认为不能作为选择skiplist的一个主要考虑的因素。不应该考虑算法难易,而最多的还是时间与空间的衡量;

4.小结

通过上面的分析,正式因为redis底层采用了这么多的数据结构,并且能够根据不同的情况下(如元素多少、元素类型)来决定采用哪种或者哪几种数据结构,从而达到搜索效率与空间的平衡。也正是基于这一点,才能让我们的redis使用起来效率更高、更快。

Redis的单线程

理解Redis的单线程

上面我们讨论了redis的底层数据结构,正是这些数据底层数据接口,一定程度了支撑了redis的性能。我们知道redis是一个单线程的,是说整个redis就一个线程么?其实不是,我们通常所说的redis的单线程,指的是redis的网络IO和键值对的读写是单线程来完成的,这是redis 对外提供键值存储服务的主要流程。但 Redis 的其他功能,比如持久化、异步删除、集群数据同步等,其实是由主线程fork出额外的线程执行的。我们印象中,多线程要比单线程性能更高一些,并且多线程在现代编程中被大量使用,但为什么大名鼎鼎的redis不适用多线程呢?

Redis为什么使用单线程

对我程序员来说,我们知道,使用多线程能够增加系统的吞吐量,更好的压榨cpu。但是多线程有什么弊端呢?想想我们处理并发的时候,最头疼的就是多线程中的共享变量的访问控制。并发读还好说一下,涉及到并发写,就更比较困难,就拿java来说,它几乎所有的并发包的核心也就是控制共享资源的并发访问,这也是最难的,即使我们写的很小心,还是很容易出现各种各样奇怪的问题和bug。所以,利用各种额外的机制来保证共享资源的正确性和可靠性,本身就是一种额外的开销。除此之外,采用多线程,也会造成上下文的来回切换,也在一定程度上影响性能。我们拿redis的api来举个例子,list,假如采用多线程,那么线程A执行lpush,使的list的数量+1,线程B执行lpop,使的list的数量-1,如果是多线程,就不能保证这每个操作都是原子性的,而要保证多线程是原子性的,还要采用额外的手段,把两个操作进行串行化处理。所以,redis为了避免多线程带来的这些问题,所以采用了单线程来进行处理核心业务流程。

多路复用机制

关于多路复用,这里不再叙述了,请参考另外两篇博客:
《BIO、NIO、select、poll、epoll》
《IO多路复用:Redis中经典的Reactor设计模式》

小结

通过上面的分析,我们总结一下redis快的原因,主要有以下几点:
1、Redis基于内存操作:绝大部分的请求为纯粹的内存操作,而且使用hash结构存储数据,查找和操作的时间复杂度均为O(1)。
2、Redis根据不同的情况,采用不了不同的底层数据结构和算法,提高效率:
3、单线程-IO多路复用模型:单线程的设计省去了很多的麻烦:比如上下文切换、资源竞争、CPU切换消耗以及各种锁操作等等问题,而IO多路复用模型的使用更让Redis提升了效率。

  • 1
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值