Redis的数据结构
Redis使用C语言编写。
简单动态字符串
C语言定义的一个结构体,结构体中除了存储字符使用的char数组以外,还有记录char数组字节长度的字段和未使用的字节数的字段。这样可以在O(1)时间复杂度下快速查询简单动态字符串长度,在修改简单动态字符串时可以更快的执行内存重分配,在N次修改中执行小于等于N次的内存重分配。同时,因为有了这两个字段(字节长度的字段和未使用的字节数的字段),简单动态字符串做了空间预分配和惰性空间释放的优化。
- 空间预分配:在一次修改,字符串长度增加的时候,不是按照实际的字符串长度进行内存分配,而是多分配一下额外的未使用的空间。下一次在修改是可能会不再需要重新分配内存。
- 惰性空间释放:一次修改字符串长度缩短的时候,不立即回收多余的未使用的空间,而是记录下来未使用的字节数,等待下一次使用。
很显然,当存储任何字符串数据的时候,在Redis内部都是以简单动态字符串这种数据结构进行存储的。
链表
链表是计算机技术中最常用,也是最基础的数据结构。在实际工程中应用广泛,是必须熟练的掌握的一种数据结构,我之前有写过详细的介绍链表的文档,包括使用Java如何编写一个链表,JDK中内置的链表源码解析,还有常见的关于链表的面试算法题,有需要的点开链接阅读。
鉴于链表在日常工程中的广泛使用,在Redis中存在链表数据结构也就不足为奇了。因为Redis使用的C语音没有内置线程的链表,所以Redis自己构建链表的实现。对于使用C语言的同学而言,Redis链表实现源码是不可多得的学习资料,学习源码就好比学习写作一样,只要读的多了,写的多了,水平才能自然而然的提高。Redis实现的是一种双向链表结构,链表的结点结构既有指向后继的指针,也有指向前驱的指针。并且定义了一个list的结构持有结点关联在一起的链表,list结构中包含头结点指针、尾结点指针,链表结点数量等,通过list操作链表更加方便。假设链表中有三个数据,分别是字符串“北京”,“上海”,“广州”,那么大致结构如下图所示,红色标注为list结构与链表结点的关联,“北京”,“上海”,“广州”三个字符串分别存在三个链表的结点中:
字典
字典用于保持键值对(key-value pair)的数据结构,一个键(key)可以与一个值(value)进行关联,每一个键都是独一无二的。
Redis的字典使用哈希表作为底层实现,这里有涉及到一个基础的数据结构——哈希表(关于Java实现的参考文档)。在Redis中定义哈希表结构使用分离链接法表示,哈希表结构与上面的链接相似,首先定义了dictEntry字典结点结构,又定义了dictht字典结构用于关联所有的dictEntry结构,例如,将分别为(“1”,“北京”),(“2”,“上海”),(“5”,“广州”)的三个键值对放入大小为4的哈希表中,哈希函数为mod4(按4取模),哈希表存储如下图所示:
上图中table是一个指向dictEntry数组的指针,size表示的哈希表大小;dictEntry数组就是一个经典的使用分离链接法表示的哈希表的实现。(“1”,“北京”),(“5”,“广州”)两个键值对产生冲突,被哈希函数索引到标识为的1的位置上,首先插入(“1”,“北京”),然后(“5”,“广州”)到来,在1的位置上已经存在结点,依次比较存在的结点,是否存在键为5的结点(哈希表中键是唯一的),发现没有重复,在(“1”,“北京”)结点之后完成插入。
在有了哈希表结构的基础上,Redis的字典结构对哈希表结构又做了一层的包装,定义dict结构关联dictht结构,在dict结构中包括具有两个元素的dictht数组,和一些功能性函数,例如计算哈希值的函数、复制函数等。整合在一起就是最终的字典结构,如下图:
上图Redis字典数据结构中关于一些功能函数的封装很好理解,是为了对字典基础操作进行服务。但是为什么会存储一个具有两个元素的dictht数组?因为明明一个dictht结构(如图Redis哈希表数据结构)就可以表示哈希表,并且存储数据。实际上字典也只使用ht[0]哈希表,ht[1]哈希表只在对ht[0]进行rehash的时候才会使用。
那么Redis如何进行rehash操作的呢?简单说,不是一次性完成rehash操作,而是渐进式的完成的。首先为ht[1]分配空间,在每次有字典操作请求的时候,除了执行指定操作以外,还有将一部分ht[0]的数据迁移到ht[1]中。至于哪部分执行迁移,是通过dict结构中一个rehash索引值来标注的,表示下一次迁移的数据是在ht[0]的哪个索引位置上进行。
- rehash索引值初始为-1表示不进行rehash
- 开始rehash时修改为0
- 开始rehash后,第一次对字典操作时,将索引0上的所有数据rehash到ht[1]上,rehash索引值加1
- 之后每次对Redis字典进行操作,都将rehash索引值位置上所有数据进行迁移
- 随着不断的对字典进行操作,最终ht[0]上的所有数据都会被rehash到ht[1]中
- 这时rehash索引值再次设置为-1,表示rehash结束
跳跃表
跳跃表(简称跳表)是增加了向前指针的有序链表,在每个结点中维持多个指向之后某个结点的指针。结点查找平均时间复杂度O(logN),最坏的时间复杂度O(N)。一个跳跃表数据结构具体例子如下图:
跳跃表如何快速查找?如上图,链表中一个有10个结点,结点的数据值分别是1-10。假如我们需要查找数字9是否在链表中,对于普通链表需要从第一个结点开始逐个遍历每个结点,直到找到或者到达尾部未找到;而对于跳跃表,我们从最上层的指针链开始,从第一个结点开始,发现指针指向结尾,那么向下移动一层(上面第二层),顺着指针找到4(比9小),继续找到6(仍旧比9小),再遍历发现到达末尾,然后向下再移动一层(上面第三层),从上次找到的6开始,沿着指针直接找到9。随着结点数量的增大,跳跃表的优势将更加明显,将需要遍历更少的结点,而且指针的层数越高遍历的越快。这就是跳跃可以快速进行查找的原因。
Redis中实现了跳跃表的结构,代码实现的数据结构与上图一样,同样因为数据存储的需要会添加一些适当的变量记录跳跃表的属性信息。因为跳跃表在实际的工程和考试中应用不多,这里不多做叙述。
不过,我们可以逆向思维来考虑,为什么Redis要实现跳跃表呢,是不是Redis提供给用户使用的那种数据对象会用到呢?没错,在数据对象的章节中,我们会提到一种数据对象——有序集合对象,顾名思义,如此看来是不是跳跃表的有序性和快速查找的特点正好适合于实现有序集合对象?
连续内存结构
连续内存结构不是正规的叫法,但是可以非常形象的介绍出下面两种数据结构的特征。它们的底层都是用连续内存结构实现的。
整数集合
当一个集合只包含整数值元素,并且这个集合的元素数量不多时,Redis使用整数集合数据结构存储。整数集合底层实现是整型数组。只是根据存储的整数长度,选择不同的编码方式,例如8位整型,16位整型,32位整型以及64位整型。
那么这里就涉及到一个概念升级,如果原来的整数集合中存储的都是8位整型的数字,这时添加一个16位整型的大数字,Redis就需要对整数集合进行升级,简而言之就是把8位整型数组换成16位整型的数组,并且将原有的整数调整为16位整型数字添加到16位整型的数组中,再添加新的数字就完成了升级。
压缩列表
压缩列表是一系列特定编码的连续内存块组成的顺序性数据结构。一个压缩列表可以包含任意多个结点,每个结点可以保存一个字节数组或者一个整数值。当一个列表只包含少量列表项,并且每个列表项要么就是小整型值,要么是长度比较短的字符串,这是Redis使用更为节约内存的压缩列表存储数据,而不是之前我们介绍过的链表结构。
压缩列表存储在连续的内存空间中,结构如下图所示:
zlbytes占4字节,记录整个压缩列表占用的内存字节数;zltail占4字节,记录压缩列表尾结点距离压缩列表的起始地址有多少字节;zllen占2字节,记录压缩列表包含的结点数量;zlend占1字节,特殊值0xFF用于标记压缩列表的末端。
entry1...entryN是压缩列表的结点,可以保存一个字节数组或者一个整数值。压缩列表结点结构如下图:
previous_entry_length记录前一个结点的长度;encoding记录结点的content属性所保存数据的类型以及长度;content保存结点的值,值的类型和长度由结点的encoding属性决定。
总而言之,压缩列表的压缩方式,就是在连续的内存空间中充分利用每一个字节,让它具有特别的含义,不浪费空间。说到这里,掌握Java有一定功力的同学,应该可以想到在Java的class文件中,就是使用的相似的方法进行编码的,打开一个class文件,会发现每一个16进制的数字都是具有特定的含义。
Redis的数据对象
Redis并没有直接使用上面介绍的这些数据结构来实现键值对数据库,而是基于这些数据结构创建了一个对象系统。对象系统包括字符串对象(Binary-safe strings)、列表对象(Lists)、集合对象(Sets)、有序集合对象(Sorted sets)、哈希对象(Hashes)以及更加高级的位数组(Bit arrays (or simply bitmaps))、HyperLogLogs和流(Streams)等数据对象。
我们这里只介绍基础的对象:字符串对象、列表对象、集合对象、有序集合对象、哈希对象,五种类型的对象至少用到了一种上面介绍的数据结构。
- 字符串对象
字符串对象底层使用的显然是简单动态字符串。下面看一个写入字符串对象的Redis命令的使用
SET capital Beijiing
其中capital是Redis键值对的键,而Beijing是键值对的值。这是在Redis中存储了一个字符串对象(字符串对象的内容就是Beijing),而不是哈希对象,也不是map。而且字符串对象可以是使用整数值实现的字符串对象保存整数,而浮点数转换成字符串值保存。一个字符串值最大存储512M。
- 列表对象
列表对象是根据元素插入的顺序形成的一组字符串的集合。底层实现根据存储的字符串的长度和元素的个数,分为压缩列表和链表。如果保存的所有字符串元素长度都小于64字节,并且字符串元素个数小于512个,使用压缩列表数据结构,否则使用链表数据结构。
RPUSH numbers "one" "two" "three"
上面命令,在键为numbers的键值对的值中存储一个列表对象,列表对象中包含3个字符串元素,分别是one,two和three。一个列表最大长度,即最大元素的个数为2^32-1(4294967295)。
- 集合对象
一组无序的,唯一的字符串元素集合。集合对象底层使用整数集合或者字典数据结构实现。集合对象保存的所有元素都是整数值,并且元素数量不超过512个时,使用整数集合,否则使用字典。
当使用字典数据结构实现集合对象是,集合对象的所有元素值分别作为字典结构的键存储,而字典结构对应的值全部被置为NULL。一个集合中成员的最大数量为2^32-1(4294967295)。
- 有序集合对象
一组有序的,唯一的字符串元素集合,顺序是按照每个元素所关联的一个浮点数(score)大小进行排序。底层实现使用压缩列表数据结构,每个集合元素使用两个紧挨在一起的压缩列表结点来保存,第一个结点保存元素的成员,第二结点保存元素的分值(score)。
如果有序集合元素数量大于128个,或者有元素的长度超过64字节,则转换为字典和跳跃表共同实现有序集合。同时使用字典和跳跃是为了充分利用两种数据结构的性能特点,字典数据结构可以快速查询集合中元素,跳跃表是有序的链表可以保持有序集合的有序性。一个有序集合中成员的最大数量也为2^32-1。
- 哈希对象
哈希对象是一个map结构,键和值都是字符串,类似于Ruby或者Python的哈希结构。当键和值的字符串长度都小于64字节,并且保存的键值对数量小于512个,使用压缩列表实现,否则使用字典数据结构。
与有序集合对象相同,压缩列表中每个键值对都紧挨在一起,前面的结点保存键,后面的结点保存值。而字典的结构与哈希对象结果正好一致,键对应键,值对应值。一个哈希对象中最大可以存储2^32-1个键值对。
- 其他对象
位数组:底层使用字符串值作为位数组。在实际生产中使用到位数组时,例如布隆过滤器,可以考虑使用。
HyperLogLogs:基于概率的数据结构,可以用于预测某个集合的基数等。
流:提供一种抽象日志数据类型的流式结构。 Introduction to Redis Streams