深入分析Redis的五种数据结构及实现原理

前序

Redis 数据库里面的每个键值对(key-value-pair)都是由对象(object)组成的,每次在数据库中新创建一个键值对时,Redis都会帮我们至少会创建两个对象,分别是键对象和值对象,其中:

  • Redis数据库里的键总是一个字符串对象(string object);
  • 而Redis数据库键的值则可以是字符串对象、列表对象(list object)、哈希对象(hash object)、集合对象(set object)、有序集合对象(sorted set object)这五种对象中的其中一种。

比如,执行以下命令将在数据库中创建一个键为字符串对象,值也为字符串对象的键值对:

127.0.0.1:6379> SET msg "hello world"
OK
127.0.0.1:6379> TYPE msg
string

而执行以下命令将在数据库中创建一个键为字符串对象,值为列表对象的键值对:

127.0.0.1:6379> RPUSH numbers 1 3 5 7 9
(integer) 5
127.0.0.1:6379> TYPE numbers
list

接下来将对以上提到的五种不同类型的对象进行介绍,剖析这些对象所使用的底层数据结构,并说明这些数据结构是如何深刻地影响的功能和性能的。

对象与数据结构

Redis中共有字符串对象、列表对象、哈希对象、集合对象、有序集合对象五种不同类型的对象,每种对象底层都用到了至少一种下面即将介绍的数据结构,并且每个对象都由一个redisObject结构表示,该结构中和保存数据有关的三个属性分别是type、encoding、ptr属性:

typedef struct redisObject {
  // 类型,上面提到的5种对象 (TYPE key)
  unsigned type;
  // 编码 记录了对象所使用的数据结构作为对象的底层实现 (OBJECT ENCODING key)
  unsigned encoding;
  // 指向底层实现数据结构的指针
  void *ptr;
}

对象的编码

编码常量底层数据结构
REDIS_ENCODING_HT字典
REDIS_ENCODING_INTSET整数集合
REDIS_ENCODING_LINKEDLIST双端链表
REDIS_ENCODING_RAW简单动态字符串
REDIS_ENCODING_SKIPLIST跳表
REDIS_ENCODING_ZIPLIST压缩列表

1. 简单动态字符串(SDS)

1.1 结构与实现

Redis没有使用C语言传统的字符串表示(以空字符结尾’\0’的字符串数组),而是自己构建了一个名为简单动态字符串(Simple Dynamic String, SDS)的抽象类型,并将SDS作为Redis的默认字符串表示。

SDS的定义和结构如下所示:

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

在这里插入图片描述
相比C语言字符串,有以下优点:

  • 常数O(1)时间复杂度获取整个字符串的长度
  • 杜绝缓冲区溢出(buffer overflow),即分配的内存不足以容纳字符串,自动扩容机制
  • 减少修改字符串时带来的内存重分配次数,即内存空间预分配与惰性释放
  • 二进制安全,可以保存非文本类型的数据

空间预分配
用于字符串增长操作,当字符串增长时,程序会先检查需不需要对SDS空间进行扩展,如果需要扩展,程序不仅会为SDS分配修改所必要的空间,还会为SDS分配额外的未使用空间。(分配和len属性同样大小的未使用空间, 即修改后的长度+ 相同长度free+1)

在这里插入图片描述
惰性空间释放

用于优化SDS的字符串收缩(trim)操作,当字符串收缩时,程序不会立即执行内存重分配来回收收缩后内存多出来的空间,而是使用free属性记录下来,以备将来使用。SDS有提供API去主动释放未使用的内存空间。
在这里插入图片描述

1.2 C字符串与SDS之间的区别

C字符串SDS
API是不安全的,可能会造成缓冲区溢出API是安全的,不会造成缓冲区溢出
只能保存文本数据可以保存文本或者二进制数据
修改字符串长度N次必然需要执行N次内存重分配修改字符串长度N次至多需要执行N次内存重分配
获取字符串长度的复杂度为O(N)获取字符串长度的复杂度为O(1)

1.3 SDS的应用

在redis内部用作所有键值对的键底层数据结构、内部的异常信息封装等。

2. 链表

2.1 结构与实现

链表作为一种常用的数据结构,提供了高效的节点重排能力,以及顺序性的节点访问方式,并且可以通过增删链表中任意节点来灵活地调整链表长度。

链表的单个节点和整个链表的定义和结构如下所示

typedef struct listNode {
  // 节点的值
  void *value;
  // 前置节点
  struct listNode *prev;
  // 后置节点
  struct listNode *next;
} listNode;


typedef struct list {
  // 表头节点
  listNode *head;
  // 表尾节点
  listNode *tail;
  // 链表节点数量,即长度
  unsigned long len;
} list

结构示意图如下:
在这里插入图片描述
由上图可以看出Redis并不是仅仅使用多个listNode结构组成链表,而是提供了表头指针head,表尾指针tail,以及链表长度计数器len,这样设计有如下好处:

  • 无环链表:表头节点的prev指针和表尾节点的next指针都指向NULL,
  • 常数级别时间复杂度获取链表的表头节点和表尾节点,为O(1);
  • 带链表长度计数器,常数级别时间复杂度获取整个链表中节点的数量,为O(1);

2.2 链表的应用

发布与订阅、慢查询、监视器等功能,redis服务器本身还使用链表来保存多个客户端的状态信息。

news.it 频道和它的三个订阅者:
在这里插入图片描述

向 news.it 频道发送消息:
在这里插入图片描述
Redis 中发布与订阅字典的示例:
在这里插入图片描述

3. 字典

3.1 结构与实现

字典,又称为符号表(symbol table),映射(map),是一种用于保存键值对(key-value-pair)的抽象数据结构。在Redis中,字典被用于Redis数据库的底层实现,也就是说我们存储的五大Redis数据类型String,List,Set,Zset,Hash 都被存储在字典里。

Redis的字典底层采用哈希表实现,一个哈希表内可包含多个哈希节点,并且每个哈希节点就保存了一个键值对。

typedef struct dictht {
  // 哈希节点数组
  dictEntry **table;
  // 哈希表大小
  unsigned long size;
  // 哈希表大小掩码,用于计算哈希节点的索引值
  // 值总是size-1
  unsigned long sizemask;
  // 哈希表中哈希节点数量
  unsigned long used;
}dictht;

dictht与dict结构如下图所示:
在这里插入图片描述

typedef struct dict {
  // 哈希表数组
  dictht ht[2];
  // rehash index 非处于rehash时,值为-1
  int trehashidx
}dict;

在这里插入图片描述
可以从 dictht 结构图中看出,Redis中的哈希表采用的是链地址法来解决键冲突,每个哈希表节点都有一个next指针,其指向下一个哈希节点,并且因为字典中的dictEntry节点组成的链表没有指向链表表尾的指针,所以为了速度的考虑,新节点总是添加到链表的表头位置(复杂度O(1)),即头插法。

包含两个键值对的哈希表:

在这里插入图片描述
使用链表来解决k2 和k1 冲突:
在这里插入图片描述

3.2 rehash(重新散列)

随着字典增删操作的不断执行,哈希表保存的键值对会逐渐地增多或者减少,为了让哈希表的负载因子维持在一个合理的范围之内,那么哈希表保存的键值对数量太多或者太少时,都需要对哈希表的大小进行相应的扩容和收缩。

而这扩容和收缩哈希表的工作可以通过执行rehash操作来完成,具体步骤如下:

为ht[1]哈希表分配内存空间用于存储rehash后的键值对

将保存在ht[0]哈希表内所有键值对rehash到ht[1]上面

当ht[0]包含的所有键值对都rehash到了ht[1]上之后(ht[0]变成空的哈希表),释放ht[0],将ht[1]设置成ht[0],并在ht[1]新创建一个空白哈希表,为下次rehash做准备。

这里Redis引入了渐进式rehash的概念,顾名思义,rehash的步骤是分多次、渐进式地完成的,其主要原因在于,如果ht[0]里保存了较多数量的键值对,比如100百万个,那么一次性将这些键值对全部rehash到ht[1]的话,如此庞大的计算量可能会导致服务器在一段时间内停止服务。渐进式rehash的步骤如下:

为ht[1]分配内存空间,让字典同时持有两个数组ht[0]、ht[1]

在字典中维护一个索引计数器rehashidx,将他的值设置为0,并在每次rehash结束后自增1,直到ht[0]中没有键值对

在rehash进行期间,每次对字典执行添加、删除、查找等操作时,程序除了执行以上操作外,顺便将ht[0]中索引为rehashidx下的所有键值对rehash到ht[1]中,之后rehashidx自增1

随着字典操作次数的增加,rehashidx的值不断增大,渐渐将ht[0]中的键值对都rehash到ht[1]中

3.3 字典的应用

在Redis中,字典被用于Redis数据库的底层实现

4. 跳跃表

跳跃表(skip list)是一种有序数据结构,其实质是一种可以进行二分查找的有序链表。跳表在原有的有序链表上增加了多级索引,通过索引来实现快速查询(平均O(logN), 最差O(N))。跳表不仅能提高搜索性能,同时也可以提高插入和删除操作的性能。

4.1 跳跃表的查询

如下图所示:

有序链表:
图片

对于一个单链表来说,即使链表中的数据是有序的,如果我们想要查找某个数据,也必须从头到尾的遍历链表,很显然这种查找效率是十分低效的,时间复杂度为O(n),例如查找52这个节点,查找路线: (11 → 13 → 17 → 22 → 27 → 35 → 41 → 52 )。

如果对链表建立一级“索引”,每两个结点提取一个结点到上一级,我们把抽取出来的那一级叫做索引或者索引层,如下图所示:

带一级索引的链表:
在这里插入图片描述
假设还是要查找52这个结点,那么就可以先查找第一级索引(Index1),依次是11,17, 27,41,52在41和59之间,所以查找下一级链表,也就是原始链表,查到52这个结点,查找路线:(11 → 17 → 27 → 41 → 52),查找效率大大提升。

当然我们可以在这个基础之上再多建立一级索引(Index2),如下图所示:

带二级索引的链表:
在这里插入图片描述
还是查找52这个结点,就先在第二级索引(Index2)中查找11,27,52介于27和59之间,跳入下一级链表(Index1),52介于41与59之间,跳入下一级链表,然后找到52,路线:(11 → 27 → 41 → 52),相比查找效率又有所提升。

由上图分析可知,这种查找效率的提升是建立在很多级索引之上的,跳跃表由于增加了多级索引,对内存的占用和使用有所增加,所以跳跃表是一种典型的空间换时间思想的实现。

4.2 跳跃表的更新

在单链表中,一旦定位好要插入或删除的位置,执行插入或删除结点的时间复杂度是很低的,就是O(1)。但是为了保证原始链表中数据的有序性,我们需要先找到执行操作链表的位置,这个查找的操作就会比较耗时,就是O(n)。

但是对于跳表来说,由于存在多级索引,查找某个数据应该插入或删除的位置的时间复杂度只需O(logn), 如下图所示:
在这里插入图片描述
综上可得,跳跃表插入和删除的时间复杂度为:O(logn)(二分查找的复杂度),支持高效的动态插入。

4.3 跳跃表的应用

适用场景:节点增加和更新比较少,查询频次较多的情况。
和链表、字典等数据结构被广泛地使用在Redis内部不同,Redis只在两个地方使用到了跳跃表:
作为有序集合(zset)键的底层数据结构之一
在集群节点中用作内部数据结构
除在Redis之外,Lucene, ElasticSearch都有使用跳跃表。

5. 整数集合

5.1 结构与实现

整数集合(intset)是集合键的底层实现之一,当一个集合只包含整数值元素,并且这个集合的元素数量不多时,Redis就会使用整数集合作为集合键的底层实现。

如下所示,创建了一个只包含5个元素的集合键test,并且集合中的所有元素都是整数值,那么这个集合键的底层实现就会是整数集合

127.0.0.1:6379> SADD test 1 3 5 7 9
(integer) 5
127.0.0.1:6379> TYPE test
set
127.0.0.1:6379> OBJECT ENCODING test
"intset"

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

typedef struct intset {
  // 保存元素的数组, 数组中的元素从大到小有序排列
  int8_t contents[];
  // 整数集合中元素的编码方式
  uint32_t encoding;
  // 整数集合包含的元素数量
  uint32_t length;
} intset;

contents数组就是整数集合的底层实现,这个数组以有序、无重复的方式保存集合元素。
在这里插入图片描述

5.2 升级

每向整数集合中添加新元素时,会对新元素的值进行判断,如果该值超过了整数集合中所有元素的编码形式的取值范围,那么就会对整数集合中的所有元素先进行升级,也就是将集合中的元素的编码形式转换为更大的编码方式,再将新元素加入的新编码方式的整数集合中,并且整数集合不支持降级行为。

升级过程:int16_t --> int32_t --> int64_t

升级的好处:

  • 提升灵活性:整数集合中只保存同一编码encoding的数据,添加不同的编码方式的数据会自动进行升级,不必担心程序的类型出错
  • 节约内存:如果集合中的数据只需要使用16位就可以表示了,那么Redis会自动使用int16_t,只有在元素需要更高位数来表示时才会进行编码转换

6. 其他

除以上介绍的数据结构之外,还有经过一系列特殊编码的连续内存块组成的顺序序列数据结构的压缩列表(ziplist),它和整数集合(intset)一样,是为了提高内存的存储效率而设计的。在Redis 3.2版本之后,还引入了快速列表quickList,它替代了压缩列表作为列表键底层的数据结构。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

Java码农杂谈

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值