Redis为什么那么快

前言

Redis(Remote Dictionary Server ),即远程字典服务,是一种支持Key-Value等多种数据结构的存储系统。可用于缓存、事件发布或订阅、高速队列等场景。

当它被用作缓存,其功能相当于CPU中的一级缓存或者二级缓存,一定程度上可以避免web服务请求与数据库的响应速度较慢的情况。

Redis为什么那么快这一问题,网上已经有相当多的博主进行解答该问题,解释的角度大体相同,不过笔者在阅读该文献时,对其理解有新的角度,便与各位读者分享本篇文章。

Redis为什么那么快,这一问题笔者将使用时空观的思维来解释;众所周知,计算机业内经常都可以听到使用空间换时间或者时间换空间的角度,来提高某一软件的特性,Redis之所以快,其底层数据结构的设计也是使用这种框架思维发展而成的;为此,笔者阅读Redis相关文献后,总结出以下两点:

1、 使用空间换时间的方式,消耗一部分的内存空间,来减少数据响应时间

2、 尽可能节约内存空间的消耗,来提高数据的检索效率

以上两点的总结,即可解释Redis宏观与微观结构的设计。

宏观层面

宏观层面上分析,先封装Redis底层结构的设计,其运行速度快,主要是有以下两点原因:

1、 内存数据库,所有操作都在内存上进行

2、 实现的数据结构可以高效的增删改查

上面的第一点,Redis 是使用了⼀个哈希表保存所有键值对,哈希表的最大好处就是让我们可以用O(1) 的时间复杂度来快速查找到键值对,为此其解释就是使用空间换时间的方式,来提高数据响应时间。

上面的第二点,Redis 数据结构并不是指 String(字符串)对象、List(列表)对象、Hash(哈希)对象、Set(集合)对象和 Zset(有序集合)对象,因为这些是 Redis 键值对中值的数据类型,也就是数据的保存形式,这些对象的底层实现的方式就用到了数据结构,各读者阅读下文微观层面数据结构的剖析后,便可发现底层数据结构高效的增删改查,需要空间换时间和尽可能节约内存空间的消耗的两种方式才可完成。

微观层面

宏观层面中,已经提到了Redis键值对中值的五种常用对象,其底层对应的数据结构,可以从下图阅读得知:

在这里插入图片描述

接下来,将逐一解释其底层数据结构的实现。

SDS

Redis是使用c语言编写而成,Redis不直接使用c语言的字符串类型,而重新定义一个,主要是c语言原生的字符串类型以下三个缺点:

1、 获取字符串⻓度的时间复杂度为 O(N);

2、 字符串的结尾是以 “\0” 字符标识,字符串里面不能包含有 “\0” 字符,因此不能保存二进制数据;

3、 字符串操作函数不高效且不安全,比如有缓冲区溢出的风险,有可能会造成程序运行终止;

为此,定义了简单动态字符串(simple dynamic string,SDS)来解决上述提到的三个问题,下面先来看一下Redis5.0的SDS的结构定义:

// 五种SDS类型分别对应五种不同的初始长度 其中sdshdr5没有投入过使用

struct __attribute__((__packed__)) sdshdr5 {
  unsigned char flags; /* 3 lsb of type, and 5 msb of string length */
  char buf[];
};
struct __attribute__((__packed__)) sdshdr8 {
  //buf数组中已被使用的长度
  uint8_t len;         /* used */
  //buf数组的总长度
  uint8_t alloc;       /* excluding the header and null terminator */
  // sds类型标识
  unsigned char flags; /* 3 lsb of type, 5 unused bits */
  //用于存放字符串的数组
  char buf[];
};
struct __attribute__((__packed__)) sdshdr16 {
  uint16_t len;        /* used */
  uint16_t alloc;      /* excluding the header and null terminator */
  unsigned char flags; /* 3 lsb of type, 5 unused bits */
  char buf[];
};
struct __attribute__((__packed__)) sdshdr32 {
  uint32_t len;        /* used */
  uint32_t alloc;      /* excluding the header and null terminator */
  unsigned char flags; /* 3 lsb of type, 5 unused bits */
  char buf[];
};
struct __attribute__((__packed__)) sdshdr64 {
  uint64_t len;        /* used */
  uint64_t alloc;      /* excluding the header and null terminator */
  unsigned char flags; /* 3 lsb of type, 5 unused bits */
  char buf[];
};

结构中的每个成员变量分别介绍下:
len,记录了字符串⻓度。这样获取字符串⻓度的时候,只需要返回这个成员变量值就行,时间复杂度只需要 O(1)。
alloc,分配给字符数组的空间长度。这样在修改字符串的时候,可以通过 alloc - len 计算出剩余的空间大小,可以用来判断空间是否满足修改需求,如果不满足的话,就会自动将 SDS 的空间扩展至执行修改所需的大小,然后才执行实际的修改操作,所以使用 SDS 既不需要手动修改 SDS 的空间大小,也不会出现前面所说的缓冲区溢出的问题。
flags,⽤来表示不同类型的 SDS。⼀共设计了 5 种类型,分别是 sdshdr5、sdshdr8、sdshdr16、sdshdr32 和 sdshdr64,后面在说明区别之处。
buf[],字符数组,用来保存实际数据。不仅可以保存字符串,也可以保存二进制数据。

其中,len和alloc变量的定义,使用空间换时间的方式,来提高数据的响应时间,flags变量的定义主要是尽可能的少使用内存空间的方式,来提高数据的检索效率

quicklist

quicklist 就是双向链表和压缩列表组合,因为⼀个 quicklist 就是⼀个链表,而链表中的每个元素又是⼀个压缩列表。双向链表在学习链表这种数据结构时,应该都了解过;压缩列表,它也是一种底层的数据结构,但它在修改数据的时候,总有连锁更新的问题。

quicklist 的解决办法,通过控制每个链表节点中的压缩列表的大小或者元素个数,来规避连锁更新的问题。因为压缩列表元素越少或越小,连锁更新带来的影响就越小,从而提供了更好的访问性能。

quicklist 的结构体跟链表的结构体类似,都包含了表头和表尾。

typedef struct quicklist {
    //quicklist的链表头
    quicklistNode *head;
    //quicklist的链表尾
    quicklistNode *tail;
    //所有压缩列表中的总元素个数
    unsigned long count;
    //quicklistNodes的个数
    unsigned long len;
	...
} quicklist

区别在于 quicklist 的节点是quicklistNode,接下来看看,quicklistNode 的结构定义:

typedef struct quicklistNode {
    //前一个quicklistNode
    struct quicklistNode*prev;
    //下一个quicklistNode
    struct quicklistNode *next;
    //quicklistNode指向的压缩列表
    unsigned char *zl;
    //压缩列表的的字节大小
    unsigned int sz;
    //压缩列表的元素个数
    unsigned int count : 16;
}quicklistNode;

在向 quicklist 添加⼀个元素的时候,不会像普通的链表那样,直接新建⼀个链表节点。而是会检查插⼊位置的压缩列表是否能容纳该元素,如果能容纳就直接保存到 quicklistNode 结构⾥的压缩列表,如果不能容纳,才会新建⼀个新的 quicklistNode 结构。

quicklist 会控制 quicklistNode 结构里的压缩列表的大小或者元素个数,来规避潜在的连锁更新的风险,但是这并没有完全解决连锁更新的问题。

其中,quicklistNode的定义,更好的规避潜在的连锁更新风险,该做法就是空间换时间的方式,一定程度上,能够更好的维护quicklist

listpack

Redis 在 5.0 新设计⼀个数据结构叫 listpack,目的是替代压缩列表,它最大特点是 listpack 中每个节点不再包含前⼀个节点的长度了,压缩列表每个节点正因为需要保存前⼀个节点的⻓度字段,就会有连锁更新的隐患。

下面介绍一下listpack的数据结构:

struct lpentry {
   int<var> encoding;//编码方式
   optional byte[] content; //content的存储了当前元素的内容
   int<var> length; //length存储了当前字元素的长度
}lpentry;

主要包含三个方面内容:
encoding,定义该元素的编码类型,会对不同长度的整数和字符串进行编码;content,实际存放的数据;
length,encoding+data的总长度;

可以看到,listpack 没有压缩列表中记录前⼀个节点长度的字段了,listpack 只记录当前节点的⻓度,当我们向 listpack 加⼊⼀个新元素的时候,不会影响其他节点的长度字段的变化,从而避免了压缩列表的连锁更新问题。

其中,listpack源码规定listpack只进行新增和修改操作,不进行删除操作,该种方式内存利用率不高,这就是典型的空间换时间的方式

哈希表

哈希表优点在于,它能以 O(1) 的复杂度快速查询数据。将 key 通过 Hash 函数的计算,就能定位数据在表中的位置,因为哈希表实际上是数组,所以可以通过索引值快速查询到数据。

下面介绍一下哈希的数据结构:

typedef struct dictht { // 哈希表定义
    dictEntry **table; //二维数组
    unsigned long size; //表大小
    unsigned long sizemask; //hash取模的用的
    unsigned long used; //元素个数
} dictht;

typedef struct dictEntry { // 哈希实体
    void *key; //key键
    union { //value值
        void *val;
        uint64_t u64;// 无符合
        int64_t s64; //有符号
        double d;
    } v; // 联合体V
    struct dictEntry *next; //下一个哈希实体
} dictEntry;

使用哈希的数据结构,不可避免的是存在哈希冲突的问题,Redis 采用了链式哈希来解决哈希冲突。在不扩容哈希表的前提下,将具有相同哈希值的数据串起来,形成链接起,以便这些数据在表中仍然可以被查询到。

随着hash结构的使用,hash表的大小不能满足需求,造成过多hash碰撞后需要进行的扩容hash表的操作,即是rehash,其实通常的做法确实是建立一个额外的hash表,将原来的hash表中的数据在新的数据中进行重新输入,从而生成新的hash表。可能有的读者有疑问,为啥不一步到位,创建的时候就创建一个大容量的hash结构?对于这个问题,笔者在阅读的时候也产生疑惑,经过一番思考,笔者得出如下结论:

1、 hash结构未来被使用多大的空间无法预知,提前申请大空间会造成内存空间的浪费;

2、 用多少内存,在合理范围内扩容,可以尽可能的减少内存空间的浪费。

其中,哈希结构本身的特点即是空间换时间的方式,源码中rehash的使用,也在一定程度上减少内存空间浪费的方式;为此,哈希结构快,是其设计下了很大功夫

整数集合

下面介绍一下整数集合的数据结构:

typedef struct intset{
    //编码方式
    uint32_t encoding;
    //集合包含的元素数量
    uint32_t length;
    //保存元素的数组
    int8_t contents[];
}intset;

介绍了前面几种数据结构,整数集合的数据结构特点不明显,其最大的特点是整数集合的升级操作。

整数集合会有⼀个升级规则,就是当我们将⼀个新元素加⼊到整数集合里面,如果新元素的类型(int32_t)⽐整数集合现有所有元素的类型(int16_t)都要长时,整数集合需要先进⾏升级,也就是按新元素的类型(int32_t)扩展 contents 数组的空间大小,然后才能将新元素加⼊到整数集合里,当然升级的过程中,也要维持整数集合的有序性。

其中,整数集合升级操作规则的制定,在一定程度上,也是尽可能减少内存空间浪费的方式

跳表

链表在查找元素的时候,因为需要逐⼀查找,所以查询效率非常低,时间复杂度是O(N),于是就出现了跳表。跳表是在链表基础上改进过来的,实现了⼀种多层的有序链表,这样的好处是能快读定位数据。下图大概是跳表存储数据的示意图:

在这里插入图片描述

如果我们要在链表中查找节点 5这个元素,只能从头开始遍历链表,需要查找 5次,而使用了跳表后,只需要查找 2 次就能定位到节点 5,因为可以在头节点直接从 L2 层级跳到节点 3,然后再从L1层级跳到节点5。可以看到,这个查找过程就是在多个层级上跳来跳去,最后定位到元素。当数据量很大时,跳表的查找复杂度就是O(logN)。

下面介绍一下跳表的数据结构:

typedef struct zskiplistNode{
    //zset 对象的元素值
	sds ele;
    //元素权重值
	double score;
	//后向指针
    struct zskiplistNode *backward;
    //节点的leve1数组,保存每层上的前向指针和跨度
	struct zskiplistLevel {
    	struct zskiplistNode *forward;
        unsigned long span;
    }level[];
}zskiplistNode

跳表是⼀个带有层级关系的链表,而且每⼀层级可以包含多个节点,每⼀个节点通过指针连接起来,实现这⼀特性就是靠跳表节点结构体中的zskiplistLevel 结构体类型的 level 数组。

跳表结构里包含了:

1、 跳表的头尾节点,便于在O(1)时间复杂度内访问跳表的头节点和尾节点;
2、 跳表的长度,便于在O(1)时间复杂度获取跳表节点的数量;
3、 跳表的最大层数,便于在O(1)时间复杂度获取跳表中层高最大的那个节点的层数量。

其中,跳表定义了一系列变量,无非是在完成跳表的功能,即是在维护它可以在更短时间找到想要的元素,这也是空间换时间的方式,大大提高了查找效率

总结

上述,笔者使用时空观角度带大家剖析Redis底层数据结构的大体设计,想了解更多其底层数据结构的读者可以去阅读Redis源码。读者若发现文中有不足之处或者有更好的解释,欢迎在评论区讨论。

  • 1
    点赞
  • 4
    收藏
    觉得还不错? 一键收藏
  • 1
    评论
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值