前往小程序,Get更优阅读体验!
立即前往
首页
学习
活动
专区
工具
TVP
发布
社区首页 >专栏 >一文理解Redis底层数据结构

一文理解Redis底层数据结构

作者头像
全菜工程师小辉
发布2021-06-25 20:54:36
1.2K0
发布2021-06-25 20:54:36
举报
文章被收录于专栏:后端开发你必须学会的干货

Redis的5种常见数据结构:字符串(String)、列表(List)、散列(Hash)、集合(Set)、有序集合(Sorted Set)。这些都是Redis对外暴露的数据结构,本文将介绍这些数据结构的底层数据结构的实现。

Redis底层数据结构有六种:

  • 简单动态字符串(SDS)
  • 列表
  • 字典
  • 整数集合
  • 跳跃表
  • 压缩列表
  • 快速列表

简单动态字符串(SDS)

SDS是"simple dynamic string"的缩写。Redis中所有场景中出现的字符串,基本都是由SDS来实现的。

使用场景:

  • 所有非数字的key。例如:set msg "hello world"中的key msg.
  • 字符串数据类型的值。例如:set msg "hello world"中的msg的值"hello wolrd"
  • 非字符串数据类型中的“字符串值”。例如:RPUSH fruits "apple" "banana" "cherry"中的"apple" "banana" "cherry"

SDS结构图:

  • len:记录当前已使用的字节数(不包括'\0'),获取SDS长度的复杂度为O(1)(C 语言中获取字符串长度的时间复杂度为 O(N))。此外,len值还避免了二进制安全与缓存区溢出的问题。
  • alloc:记录当前字节数组总共分配的字节数量(不包括'\0')。
  • flags:标记当前字节数组的属性,是sdshdr8还是sdshdr16等。
  • buf:字节数组,用于保存字符串,包括结尾空白字符'\0'。
代码语言:javascript
复制
// flags值定义(为了节约头部空间,在Redis3.2开始,增加flag字段。SDS由一种数据结构变成了5种数据结构,会根据SDS存储的内容长度来选择不同的结构,以达到节省内存的效果)
#define SDS_TYPE_5  0
#define SDS_TYPE_8  1
#define SDS_TYPE_16 2
#define SDS_TYPE_32 3
#define SDS_TYPE_64 4

注:

  1. 二进制安全:通俗的讲,C语言中,用“\0”表示字符串的结束,如果字符串本身就有“\0”字符,字符串就会被截断,即非二进制安全;若通过某种机制,保证读写字符串时不损害其内容,这就是二进制安全。
  2. 因为C字符串不记录自身的长度,所以strcat会假定用户在执行这个函数时,已经为dest分配足够多的内存了,可以容纳src字符串中的所有内容,而一旦这个假设不成立,就会产生缓存区溢出。

频繁内存分配问题处理

每次增长或者缩短一个字符,程序都需要对保存这个字符串的数组进行一次内存重新分配操作。因为内存重分配涉及复杂的算法,并且可能需要执行系统调用,所以它通常是一个比较耗时的操作。

为了避免C字符串的这种缺陷,SDS通过未使用空间解除了字符串长度和底层数组长度之间的关联。通过未使用空间,SDS实现了空间预分配和惰性空间释放两种优化策略。

  1. 空间预分配

空间预分配用于优化SDS的字符串增长操作。当SDS的API对一个SDS进行修改,并且需要对SDS进行空间扩展的时候,程序不仅会为SDS分配修改所必须要的空间,还会为SDS分配额外的未使用空间。其中,额外分配的未使用空间数量由以下公式决定:

  • 如果对SDS进行修改之后,SDS的长度将小于1MB,那么程序分配和len属性同样大小的未使用空间。
  • 如果对SDS进行修改之后,SDS的长度将大于等于1MB,那么程序会分配1MB的未使用空间。

在扩展SDS空间之前,SDS API会先检查未使用空间是否足够,如果足够的话,API就会直接使用未使用空间,而无需执行内存重分配。通过空间预分配策略,Redis可以减少连续执行字符串增长操作所需的内存重分配次数。

  1. 惰性空间释放

惰性空间释放用于优化SDS的字符串缩短操作。当SDS的API需要缩短SDS保存的字符串时,程序不会立即使用内存重分配来回收缩短后多出来的字节,而是使用free属性将这些字节的数量记录下来,并等待将来使用。

通过惰性空间释放策略,SDS避免了缩短字符串时所需的内存重分配操作,并为将来可能的增长操作提供了优化。

与此同时,SDS也提供了响应的API可以在有需要时,真正的释放SDS里面的未使用空间,所以不用担心惰性空间释放策略会造成内存浪费。

列表

列表在Redis中应用的非常广,列表的底层实现就是链表。此外,Redis的发布与订阅、慢查询、监视器等功能也用到了链表。

列表特点:

  • 双端链表:带有指向前置节点和后置节点的指针,获取这两个节点的复杂度为O(1)。
  • 无环:表头节点的prev和表尾节点的next都指向NULL,对链表的访问以NULL结束。
  • 链表长度计数器:带有len属性,获取链表长度的复杂度为O(1)。
  • 多态:链表节点使用 void*指针保存节点值,可以保存不同类型的值。

列表结构图:

列表的数据结构(adlist.h/listNode与adlist.h/list):

listNode:

  • prev:前置节点。
  • next:后置节点。
  • value:节点值。

list:

  • head:链表头节点。
  • tail:链表尾节点。
  • len:链表所包含的节点数量。
  • dup:函数,用于复制ptr值,实现深度复制。
  • free:函数,用于释放对应类型结构的内存。
  • match:函数,用于对比链表节点所保存的值和另一个输入值是否相等。

字典

字典,又称为符号表(symbol table)、关联数组(associative array)或映射(map),是一种用于保存键值对的抽象数据结构。字典中的每一个键都是唯一的,可以通过键查找与之关联的值,并对其修改或删除。

Redis的键值对存储就是用字典实现的,散列(Hash)的底层实现之一也是字典。

字典的结构图(与JDk中的HashMap结构很相似):

字典的数据结构(dict.h/dictht与dict.h/dict):

dict:

  • type:针对不同类型的键值对,用于创建多类型的字典
  • privdata:针对不同类型的键值对,用于创建多类型的字典
  • ht:两个元素的数组,包含两个dictht哈希表,一般字典只使用ht[0]哈希表,ht[1]哈希表会在对ht[0]哈希表进行rehash(重哈希)的时候使用,即当哈希表的键值对数量超过负载数量过多的时候,会将键值对迁移到ht[1]上
  • rehashidx:rehashidx也是跟rehash相关的,rehash的操作不是瞬间完成的,rehashidx记录着rehash的进度,图中没有进行rehash,它的值为-1

dictht:

  • table:哈希链表(包含了一个节点类型为dictEntry的链表)
  • size:哈希链表大小
  • sizemask:哈希链表大小掩码,用于计算索引值,等于size-1
  • used:哈希链表已有节点的数量

dictEntry:

  • key:键
  • next:下一个dictEntry节点
  • value:union类型,支持不同类型的值

渐进式hash

字典类型容量变化过程叫做rehash。需要满足一定的条件才能触发扩容机制:

  1. 服务器当前没有进行BGWRITEAOF或者BGSAVE命令,且当前键值对个数超过一维数组的大小,才会触发扩容。
  2. 如果当前键值对个数超过一维数组大小的五倍,无论是否在进行BGWRITEAOF或者BGSAVE命令,都会强制扩容。
  3. 如果当前键值对个数少于一维数组大小的十分之一,则触发缩容过程。缩容不会考虑当前服务器是否在进行BGWRITEAOF或者BGSAVE命令。

渐进式hash的过程,简单来说类似数据库的迁移,读的时候先读ht[0],读不到读ht[1];写的时候只写ht[1];ht[0]数据慢慢地往ht[1]上搬。

当ht[0]的所有键值都迁至ht[1]之后,ht[0]变为空表,释放ht[0]。并将ht[1]设置为ht[0],并在ht[1]新创建一个空白哈希表,将rehashidx属性的值设为-1,表示rehash操作已完成。

具体步骤如下:

  1. 为字典的备用哈希表分配空间:如果执行的是扩展操作,那么备用哈希表的大小为第一个大于等于(已用节点个数)*2的2n(2的n次方幂) 如果执行的是收缩操作,那么备用哈希表的大小为第一个大于等于(已用节点个数)的2n
  2. 在字典中维持一个索引计数器变量rehashidx,并将它的值设置为0,表示rehash工作正式开始(为-1时表示没有进行rehash)。
  3. rehash进行期间,每次对字典执行添加、删除、查找或者更新操作时,程序除了执行指定的操作以外,还会顺带将ht[0]哈希表在rehashidx索引上的所有键值对rehash到ht[1],当一次rehash工作完成之后,程序将rehashidx属性的值+1。同时在serverCron中调用rehash相关函数,在1ms的时间内,进行rehash处理,每次仅处理少量的转移任务(100个元素)。随着字典操作的不断执行,最终在某个时间点上,ht[0]的所有键值对都会被rehash至ht[1],这时程序将rehashidx属性的值设为-1,表示rehash操作已完成。

这里比较下Redis的渐进hash与JDk中HashMap的resize过程。如果对HashMap不了解,可以查看《详解并发下的HashMap以及JDK8的优化》。

整数集合

整数集合(intset)是Redis用于保存整数值的集合抽象数据结构,可以保存类型为int16_t、int32_t、int64_t的整数值,并且保证集合中不会出现重复元素 整数集合是集合(Set)的底层实现之一,如果一个集合只包含整数值元素,且元素数量不多时,会使用整数集合作为底层实现

整数集合的结构图:

整数集合的数据结构(inset.h/inset):

intset:

  • encoding:决定contents数组的真正类型,如INTSET_ENC_INT16、INTSET_ENC_INT32、INTSET_ENC_INT64。
  • length:记录整数集合的元素数量,即contents数组长度
  • contents:整数集合的每个元素在数组中按值的大小从小到大排序,且不包含重复项。

整数集合的升级

当想要添加一个新元素到整数集合中时,并且新元素的类型比整数集合现有的所有元素的类型都要长,整数集合需要先进行升级,才能将新元素添加到整数集合里面。每次想整数集合中添加新元素都有可能会引起升级,每次升级都需要对底层数组已有的所有元素进行类型转换。

升级添加新元素:

  • 根据新元素类型,扩展整数集合底层数组的空间大小,并为新元素分配空间。
  • 把数组现有的元素都转换成新元素的类型,并将转换后的元素放到正确的位置,且要保持数组的有序性。
  • 添加新元素到底层数组。

整数集合的升级策略可以提升整数集合的灵活性,并尽可能的节约内存。另外,整数集合不支持降级,一旦升级,编码就会一直保持升级后的状态。

跳跃表

一个普通的单链表查询一个元素的时间复杂度为O(N),即便该单链表是有序的。使用跳跃表(SkipList)是来解决查找问题的,它是一种有序的数据结构,不属于平衡树结构,也不属于Hash结构,它通过在每个节点维持多个指向其他节点的指针,而达到快速访问节点的目的 跳跃表是有序集合(Sorted Set)的底层实现之一,如果有序集合包含的元素比较多,或者元素的成员是比较长的字符串时,Redis会使用跳跃表做有序集合的底层实现。

跳跃表其实可以把它理解为多层的链表,它有如下的性质:

  • 多层的结构组成,每层是一个有序的链表
  • 最底层(level 1)的链表包含所有的元素
  • 跳跃表的查找次数近似于层数,时间复杂度为O(logn),插入、删除也为 O(logn)
  • 跳跃表是一种随机化的数据结构(通过抛硬币来决定层数)

有关跳跃表的讲解,可以查看《有关跳跃表的干货都在这里

跳跃表的结构图:

  • 每个跳跃表节点的层数在1-32之间
  • 一个跳跃表中,节点按照分值大小排序,多个节点的分值是可以相同的,相同时,节点按成员对象大小排序
  • 每个节点的成员变量必须是唯一的

压缩列表

压缩列表(ziplist)是为了节约内存而设计的,是由一系列特殊编码的连续内存块组成的顺序性(sequential)数据结构,一个压缩列表可以包含多个节点,每个节点可以保存一个字节数组或者一个整数值。

压缩列表是列表(List)和散列(Hash)的底层实现之一,一个列表只包含少量列表项,并且每个列表项是小整数值或比较短的字符串,会使用压缩列表作为底层实现(在3.2版本之后是使用quicklist实现)。

压缩列表的结构图:

一个压缩列表可以包含多个节点(entry),每个节点可以保存一个字节数组或者一个整数值。

压缩列表的数据结构:

  • zlbytes:记录整个压缩列表占用的内存字节数,在压缩列表内存重分配,或者计算zlend的位置时使用。
  • zltail:记录压缩列表表尾节点距离压缩列表的起始地址有多少字节,通过该偏移量,可以不用遍历整个压缩列表就可以确定表尾节点的地址。
  • zllen:记录压缩列表包含的节点数量,但该属性值小于UINT16_MAX(65535)时,该值就是压缩列表的节点数量,否则需要遍历整个压缩列表才能计算出真实的节点数量。
  • entryX:压缩列表的节点。
  • zlend:特殊值0xFF(十进制255),用于标记压缩列表的末端。

压缩列表节点的构成

每个压缩列表节点可以保存一个字节数字或者一个整数值。压缩列表节点的数据结构:

  • previous_entry_ength:记录压缩列表前一个字节的长度。
  • encoding:节点的encoding保存的是节点的content的内容类型。
  • content:content区域用于保存节点的内容,节点内容类型和长度由encoding决定。

快速列表

考虑到链表的附加空间相对太高,prev和next指针就要占去16个字节(64bit系统的指针是8个字节)。另外每个节点的内存都是单独分配,会加剧内存的碎片化,影响内存管理效率。因此Redis3.2版本开始对列表数据结构进行了改造,使用快速列表(quicklist)代替了压缩列表和列表。

快速列表的结构图:

快速列表的数据结构:

quicklistNode:

  • prev: 指向链表前一个节点的指针。
  • next: 指向链表后一个节点的指针。
  • zl: 数据指针。如果当前节点的数据没有压缩,那么它指向一个ziplist结构;否则,它指向一个quicklistLZF结构。
  • sz: 表示zl指向的ziplist的总大小(包括zlbytes, zltail, zllen, zlend和各个数据项)。需要注意的是:如果ziplist被压缩了,那么这个sz的值仍然是压缩前的ziplist大小。
  • count: 表示ziplist里面包含的数据项个数。这个字段只有16bit。稍后我们会一起计算一下这16bit是否够用。
  • encoding: 表示ziplist是否压缩了(以及用了哪个压缩算法)。目前只有两种取值:2表示被压缩了(而且用的是LZF压缩算法),1表示没有压缩。
  • container: 是一个预留字段。本来设计是用来表明一个quicklist节点下面是直接存数据,还是使用ziplist存数据,或者用其它的结构来存数据(用作一个数据容器,所以叫container)。但是,在目前的实现中,这个值是一个固定的值2,表示使用ziplist作为数据容器。
  • recompress: 当我们使用类似lindex这样的命令查看了某一项本来压缩的数据时,需要把数据暂时解压,这时就设置recompress=1做一个标记,等有机会再把数据重新压缩。
  • attempted_compress: 这个值只对Redis的自动化测试程序有用。
  • extra: 其它扩展字段。

quickList:

  • head: 指向头节点(左侧第一个节点)的指针。
  • tail: 指向尾节点(右侧第一个节点)的指针。
  • count: 所有ziplist数据项的个数总和。
  • len: quicklist节点的个数。
  • fill: 16bit,ziplist大小设置,存放list-max-ziplist-size参数的值。
  • compress: 16bit,节点压缩深度设置,存放list-compress-depth参数的值。

压缩深度

quicklist默认的压缩深度是0,也就是不压缩。压缩的实际深度由配置参数list-compress-depth决定。为了支持快速的push/pop操作,quicklist的首尾两个ziplist不压缩,此时深度就是1;如果深度为2,就表示quicklist的首尾第一个 ziplist以及首尾第二个ziplist都不压缩。

zipList长度

quicklist 内部默认单个ziplist长度为8k字节,超出了这个字节数,就会新起一个ziplist。ziplist的长度由配置参数list-max-ziplist-size决定。

编码

上面介绍了Redis的主要底层数据结构,包括简单动态字符串(SDS)、链表、字典、跳跃表、整数集合、压缩列表。但是Redis并没有直接使用这些数据结构来构建数据库,而是基于这些数据结构创建不同的编码,然后由不同条件下的不同编码来实现Redis的这些数据类型:字符串(String)、列表(List)、散列(Hash)、集合(Set)、有序集合(Sorted Set)。

接下来就介绍Redis五种数据结构对应的编码。

字符串对象的编码

上面介绍了SDS,但这只是字符串对象的其中一种实现。字符串对象的编码可能有三种:int、raw、embstr。

  1. int 如果一个字符串对象,保存的值是一个整数值,并且这个整数值在long的范围内,那么Redis用整数值来保存这个信息,并且将字符串编码设置为 int。
  2. raw 如果字符串对象保存的是一个字符串, 并且长度大于32个字节,它就会使用前面讲过的SDS(简单动态字符串)数据结构来保存这个字符串值,并且将字符串对象的编码设置为raw。
  3. embstr 如果字符串对象保存的是一个字符串,但是长度小于32个字节,它就会使用embstr来保存了,embstr编码不是一个数据结构,而是对SDS的一个小优化,当使用SDS 的时候,程序需要调用两次内存分配,来给字符串对象和SDS各自分配一块空间,而embstr只需要一次内存分配,因为他需要的空间很少,所以采用连续的空间保存,即将SDS的值和字符串对象的值放在一块连续的内存空间上。这样能在短字符串的时候提高一些效率。

浮点数如何保存:

Redis的字符串数据类型是支持保存浮点数,并且支持对浮点数进行加减操作,但是Redis在底层是把浮点数转换成字符串值,然后按照上述编码规则。对浮点数进行操作时,也是从字符串转换成浮点数进行计算,然后再转换成字符串进行保存的。

编码转换条件:

如果对一个int编码的字符串对象,修改它成非整数值,则对象就会使用raw编码。而Redis没有为embstr编码提供任何的修改操作,embstr编码的值是只读的,只要发生修改,立刻将编码转换成raw。

编码

使用条件

int

可以用long保存的整数

raw

长度大于32的字符串

embstr

字符串长度小于32字节(或者浮点数转换后满足)

列表对象的编码

在 Redis 3.2 版本之前,列表对象底层由 压缩列表和双向链表配合实现,当元素数量较少的时候,使用压缩列表,当元素数量增多,就开始使用普通的双向链表保存数据。

但是这种实现方式不够好,双向链表中的每个节点,都需要保存前后指针,这个内存的使用量 对于Redis这个内存数据库来说极其不友好。

因此在 3.2 之后的版本,Redis新实现了一个数据结构,叫做快速列表(quicklist)。所有列表的底层实现都是这个数据结构了。它的底层实现基本上就是将 双向链表和压缩列表进行了结合,用双向的指针将压缩列表进行连接,这样不仅避免了压缩列表存储大量元素的性能压力,同时避免了双向链表连接指针占用空间过多的问题。

编码

使用条件

quicklist

集合对象的编码

集合对象的编码可以是intset或者hashtable。

当集合中的所有元素都是整数,且元素的数量不大于512个的时候,使用intset编码。

当元素不符合全部为整数值且元素个数小于512时,集合对象使用的编码方式为 hashtable。

编码

使用条件

intset

所有元素都是整数且元素个数小于 512

hashtable

其他数据

有序集合对象的编码

有序集合对象的编码可以是ziplist以及skiplist。

当使用ziplist编码时,有序集合对象的实现数据结构为压缩列表。当条件变化,ziplist编码会转换成skiplist编码。

当使用skiplist编码的时候,内部使用zset 来实现数据的保存,zset的定义如下:

代码语言:javascript
复制
typedef struct zset{
  zskiplist *zsl;
  dict *dict;
}zset;

为什么需要同时使用跳跃表以及字典呢?

  • 当只使用字典来实现,可以以O(1)的时间复杂度获取成员的分值,但是由于字典是无序的,当需要进行范围性操作的时候,需要对字典中的所有元素进行排序,这个时间复杂度至少需要 O(nlogn)。
  • 当只使用跳跃表来实现,可以在O(logn)的时间进行范围排序操作,但是如果要获取到某个元素的分值,时间复杂度也是O(logn)。

因此,将字典和跳跃表结合进行使用,可以在O(1)的时间复杂度下完成查询分值操作,而对一些范围操作使用跳跃表可以达到O(logn)的时间复杂度。

编码

使用条件

ziplist

元素数量少于128且所有元素成员的长度小于64字节

skiplist

不满足上述条件的其他情况

散列对象

散列对象的编码可以是ziplist或者hashtable.

ziplist编码下的哈希对象,使用了压缩列表作为底层实现数据结构,用两个连续的压缩列表节点来表示哈希对象中的一个键值对。实现方式类似于上面的有序集合的场景。

哈希结构本身在结构上和字典颇为相似,因此哈希对象中的每一个键值对都是字典中的一个键值对。

  • 字典的每一个键都是一个字符串对象,对象中保存了键值对的键。
  • 字典的每一个值都是一个字符串对象,对象中保存了键值对的值。

编码

使用条件

ziplist

键值对的键和值的长度都小于64字节,且键值对个数小于512

hastable

不满足上述条件的其他情况

总结

基础数据类型

可能的编码方式

字符串

int, raw, embstr

列表

之前是 ziplist, linkedlist。3.2开始都是quicklist

集合

intset, hashtable

有序集合

ziplist, skiplist

散列

ziplist, hashtable

参考文档:

  1. 《Redis设计与实现》
  2. https://github.com/redis/redis
  3. 《Redis 深度历险:核心原理和应用实践》
本文参与 腾讯云自媒体同步曝光计划,分享自微信公众号。
原始发表:2021-06-07,如有侵权请联系 cloudcommunity@tencent.com 删除

本文分享自 全菜工程师小辉 微信公众号,前往查看

如有侵权,请联系 cloudcommunity@tencent.com 删除。

本文参与 腾讯云自媒体同步曝光计划  ,欢迎热爱写作的你一起参与!

评论
登录后参与评论
0 条评论
热度
最新
推荐阅读
目录
  • 简单动态字符串(SDS)
    • 频繁内存分配问题处理
    • 列表
    • 字典
      • 渐进式hash
      • 整数集合
        • 整数集合的升级
        • 跳跃表
        • 压缩列表
          • 压缩列表节点的构成
          • 快速列表
            • 压缩深度
              • zipList长度
              • 编码
                • 字符串对象的编码
                  • 列表对象的编码
                    • 集合对象的编码
                      • 有序集合对象的编码
                        • 散列对象
                          • 总结
                          相关产品与服务
                          文件存储
                          文件存储(Cloud File Storage,CFS)为您提供安全可靠、可扩展的共享文件存储服务。文件存储可与腾讯云服务器、容器服务、批量计算等服务搭配使用,为多个计算节点提供容量和性能可弹性扩展的高性能共享存储。腾讯云文件存储的管理界面简单、易使用,可实现对现有应用的无缝集成;按实际用量付费,为您节约成本,简化 IT 运维工作。
                          领券
                          问题归档专栏文章快讯文章归档关键词归档开发者手册归档开发者手册 Section 归档