现在我们可以开始讨论一个硬核问题了—Redis的数据结构。在redis里常见的数据类型有String、Hash、Set、List、Zset五种常用结构,另外还有Hyperloglog、Geo、Streams等结构。这些结构的特征和应用场景我们在前面都介绍过,这里我们来研究一下其内部结构是怎么样的。
目录
1.redis数据结构基础-SDS
1.1 如何设计高性能的字符串
1.2 SDS基本结构
1.3 采用SDS存储的优点
1.4 redis的数据结构整体结构
2.跳表结构
2.1 跳表的结构
3. 压缩列表
3.1 压缩列表的结构
3.2 节点的结构
3.3 压缩列表的优缺点
4.字典结构
5.跳表结构
5.1 List简介
5.2 quicklist结构
1.redis数据结构基础-SDS
1.1 如何设计高性能的字符串
我们知道在C语言里字符串本质上就是一个字符类型的数组:
char buf[];
为了方便表示,C语言中,用“ ”表示字符串的结束,如果字符串中本身就有“ ”字符,字符串就会被截断,即非二进制安全;若通过某种机制,保证读写字符串时不损害其内容,则是二进制安全。
这时候我们就需要思考了:如何实现一个扩容方便且安全的字符串呢?
我们很容易想到,我们可以定义一个结构,里面除了字符数组,再增加几个信息,例如已占用空间大小,剩余空间大小等等,这样就不必需要前面的’ ‘来表示字符串结束了,也就是这样一个结构:
struct sds { int len;// buf 中已占用字节数 int free;// buf 中剩余可用字节数 char buf[];// 数据空间 };
虽然,作为顶级程序员的我们喜欢朴素地写代码,但是为了提高规格,我们给上面的结构起个名字吧,叫简单动态字符串(Simple Dynamic Strings,SDS)如何?
这就是Redis的基本数据结构之一的SDS,就是这么简单,其功能用于存储字符串和整型数据。SDS兼容C语言标准字符串 处理函数,且在此基础上保证了二进制安全。
在64位系统下,字段len和字段free都是int类型,各占 4个字节,后面紧接着存放字符串。
Redis 3.2之前的SDS也是这样设计的。这样设计有以下几个优点。
- 有单独的统计变量len和free(称为头部)。可以很方便地得到字符串长度。
- 内容存放在数组buf中,SDS对上层暴露的指针不是指向结构体SDS的指针,而是直接指向数组buf的指针。上层可像读取C字符串一样读取SDS的内容,兼容C语言处理字符串的各种函数。
- 由于有长度统计变量len的存在,读写字符串时不依赖“ ”终止符,保证了二进制安全。
我们说了这么多,就是简单定义一个结构而已吗?当然不是,因为有了这个之后,我们就可以进一步来优化了。
上面的buf[]还有个概念——柔性数组,上例中的buf[]是一个柔性数组。柔性数组成员(flexible
array member),也叫伸缩性数组成员,只能被放在结构体的末尾。包含柔性数组成员的结构体,通过malloc函数为柔性数组动态分配内存。
之所以用柔性数组存放字符串,是因为柔性数组的地址和结构体是连续的,这样查找内存更快(因为不需要额外通过指针找到字符串的位 置);可以很方便地通过柔性数组的首地址偏移得到结构体首地址,进而能很方便地获取其余变量。
1.2 SDS基本结构
到这里我们实现了一个最基本的动态字符串,但是该结构是否有改进的空间呢?我们从一个简单的问题开始思考:
不同长度的字符串是否有必要占用相同大小的头部?
一个int占4字节,在实际应用中,存放于 Redis中的字符串往往没有这么长,每个字符串都用4字节存储未免太浪费空间了。
我们考虑三种情况:短字符串,len和free的长度为1字节就够了;长字符串,用2字节或4字节;更长的字符串,用8字节。
这样确实更省内存,但依然存在以下问题。
- 问题1:如何区分这3种情况?
- 问题2:对于短字符串来说,头部还是太长了。以长度为1字节的字符串为例,len和free本身就占了2个字节,能不能进一步压缩呢?
对于问题1,我们考虑增加一个字段flags来标识类型,用最小的1字节来存储,且把flags加在柔性数组buf之前,这样虽然多了1字节,但通过偏移柔性数组的指针即能快速定位flags区分类型,也可以接受;对于问题2,由于len已经是最小的1字节了,再压缩只能考虑用位来存储长度了。
结合两个问题,5种类型(长度1字节、2字节、4字节、8字节、小于1字节)的SDS至少要用3位来存储类型(2^3 =8),1个字节8位,剩余的5位存储长度,可以满足长度小于32的短字符串。在Redis 5中, 我们用如下结构来存储长度小于32的短字符串:
struct __attribute__ ((__packed__))sdshdr5 { unsigned char flags; /* 低3位存储类型, 高5位存储长度 */ char buf[];/*柔性数组,存放实际内容*/ };
sdshdr5结构中,flags占1个字节,其低3位(bit)表示 type,高5位(bit)表示长度,能表示的长度区间为0~31(25 -1), flags后面就是字符串的内容。
而长度大于31的字符串,1个字节依然存不下。我们按之前的思 路,将len和free单独存放。sdshdr8、sdshdr16、sdshdr32和sdshdr64的结构相同,sdshdr16结构如图所示:
其中“表头”共占用了S[2(len)+2(alloc)+1(flags)]个字节。flags的内容与sdshdr5类似,依然采用3位存储类型,但之后剩余5位不存储长度(因为已经存不下了)。
在redis中,sdshdr8、sdshdr16、sdshdr32和sdshdr64的数据结构如下:
struct __attribute__((__packed__))sdshdr8 { uint8_t len; /* 已使用长度,用1字节存储 */ uint8_t alloc; /* 总长度,用1字节存储*/ unsigned char flags; /* 低3位存储类型, 高5位预留 */ char buf[];/*柔性数组,存放实际内容*/ }; struct __attribute__((__packed__))sdshdr16 { uint16_t len; /*已使用长度,用2字节存储*/ uint16_t alloc; /* 总长度,用2字节存储*/ unsigned char flags; /* 低3位存储类型, 高5位预留 */ char buf[];/*柔性数组,存放实际内容*/ }; struct __attribute__((__packed__))sdshdr32 { uint32_t len; /*已使用长度,用4字节存储*/ uint32_t alloc; /* 总长度,用4字节存储*/ unsigned char flags;/* 低3位存储类型, 高5位预留 */ char buf[];/*柔性数组,存放实际内容*/ }; struct __attribute__((__packed__))sdshdr64 { uint64_t len; /*已使用长度,用8字节存储*/ uint64_t alloc; /* 总长度,用8字节存储*/ unsigned char flags; /* 低3位存储类型, 高5位预留 */ char buf[];/*柔性数组,存放实际内容*/ };
可以看到,这4种结构的成员变量类似,唯一的区别是len和alloc的类型不同。结构体中4个字段的具体含义分别如下。
- len :表示buf中已占用字节数。
- alloc :表示buf中已分配字节数,不同于free,记录的是为buf分配的总长度。
- flags :标识当前结构体的类型,低3位用作标识位,高5位预 留。
- buf :柔性数组,真正存储字符串的数据空间。
上面的__attribute__((__packed__))主要是用来对齐的兵 ,进一步节省空间的, 我们了解一下就好。
上面的结构我们可以通过下面的图来进一步观察期结构:
这种处理思想在另外一个结构IntSet也有应用,就是处理整数的时候同样会根据数字的实际长度决定为其分配8位、16位还是32位的空间。
1.3 采用SDS存储的优点
采用SDS存储有什么优点呢?根据上面的分析,我们可以看到,主要有以下几点:
- 动态扩容,SDS可以动态增加内存空间,避免了静态数组的大小限制。
- 常数级复杂度获取字符串长度,SDS中的len属性表示字符串长度,可以在常数时间内获取字符串长度。
- 杜绝缓冲区溢出,SDS会检查内存是否足够,避免了缓冲区溢出的问题。
- 减少修改字符串的内存重新分配次数,SDS采用惰性空间释放和空间预分配的策略,可以减少修改字符串的内存重新分配次数。
- 二进制安全,SDS不以空字符串来判断字符串是否结束,而是以len属性表示的长度来判断字符串是否结束,所以支持存储任何二进制数据。
- 兼容部分C字符串函数,SDS的实现位于Redis的源代码中的src/sds.h和src/sds.c中,SDS在具体工作的时候可以重用C语言库<string.h>中的一部分函数。
1.4 redis的数据结构整体结构
在理解了SDS之后,我们可以来梳理一下redis的整体结构了,借用网友的图:
可以从宏观上看到各种常见数据结构否是封装再RedisObjct里的,其底层结构就是我们本章要分析的内容。包括快表、ziplist、HashTable、IntSet和ZSkipList等等。
在上图中,字符串里有三个类型:OBJ_ENCODING_INT,OBJ_ENCODING_RAW和OBJ_ENCODING_EMBSTR,其中int就是存储8个字节的长整型。embstr存储存储的是小于44字节的字符串,而raw存储大于44个字节的字符串。后两者的内存分配次数等不一样,这么做主要是为了进一步提高存储的效率。
通过上图,我们可以看到我们经常使用的数据结构与redis的底层实现结构并不是一一对应的,而是存在交叉对应关系的:
- 在redis里List类型使用的是一种叫QuickList快表的结构。
- Hash和集合Set都可以使用比较常规的HashTable类型。
- 哈希和有序集合也可以使用ZipList压缩列表来表示,它是一种紧凑的、压缩的数据结构,可以存储多个元素,并且支持在表头和表尾进行快速的插入和删除操作,可以有效地减少内存占用。
- 集合还可以使用IntSet来表示
- 有序集合还可以使用跳表来表示。
这个关系比较难记住,我们还是进一步来看后面几种结构是什么意思,然后再分析这种逻辑对应关系。
2.跳表结构
2.1 跳表的结构
我们先看面试特别喜欢考的跳表结构。
我们大部分都应该学过数据结构这门课,里面有种结构叫做链表,如下图所示。有序链表是所有元 素以递增或递减方式有序排列的数据结构,其中每个节点都有指向下个节点的next指针,最后一个节点的next指针指向NULL。
在有序链表,如果要查询值为51的元素,需要从第一个元素开始依次向后查找、比较才可以找到,查找顺序为 1→11→21→31→41→51,共6次比较,时间复杂度为O(N)。有序链表的插入和删除操作都需要先找到合适的位置再修改next指针,修改操作基 本不消耗时间,所以插入、删除、修改有序链表的耗时主要在查找元素上。
如果我们将有序链表中的部分节点分层,每一层都是一个有序链表。在查找时优先从最高层开始向后查找,当到达某节点时,如果next 节点值大于要查找的值或next指针指向NULL,则从当前节点下降一层继续向后查找,这样是否可以提升查找效率呢?
分层有序链表如图所示:
我们再次查找值为51的节点,查找步骤如下:
- 从第2层开始,1节点比51节点小,向后比较。
- 21节点比51节点小,继续向后比较。第2层21节点的next指针指向NULL,所以从21节点开始需要下降一层到第1层继续向后比较。
- 第1层中,21节点的next节点为41节点,41节点比51节点小,继 续向后比较。第1层41节点的next节点为61节点,比要查找的51节点大,所以从41节点开始下降一层到第0层继续向后比较。
- 在第0层,51节点为要查询的节点,节点被找到。
采用图3-2所示的数据结构后,总共查找4次就可以找到51节点,比有序链表少2次。当数据量大时,优势会更明显。
综上所述,通过将有序集合的部分节点分层,由最上层开始依次向 后查找,如果本层的next节点大于要查找的值或next节点为NULL,则从 本节点开始,降低一层继续向后查找,依次类推,如果找到则返回节 点;否则返回NULL。采用该原理查找节点,在节点数量比较多时,可以跳过一些节点,查询效率大大提升,这就是跳跃表的基本思想。
跳跃表的实现过程下图所示:
从图3-3中我们可以看出跳跃表有如下性质。
- 1)跳跃表由很多层构成。
- 2)跳跃表有一个头(header)节点,头节点中有一个64层的结构,每层的结构包含指向本层的下个节点的指针,指向本层下个节点中间所 跨越的节点个数为本层的跨度(span)。
- 3)除头节点外,层数最多的节点的层高为跳跃表的高度 (level),图3-3中跳跃表的高度为3。
- 4)每层都是一个有序链表,数据递增。 5)除header节点外,一个元素在上层有序链表中出现,则它一定会在下层有序链表中出现。
- 6)跳跃表每层最后一个节点指向NULL,表示本层有序链表的结束。
- 7)跳跃表拥有一个tail指针,指向跳跃表最后一个节点。
- 8)最底层的有序链表包含所有节点,最底层的节点个数为跳跃表的长度(length)(不包括头节点),上图中跳跃表的长度为7。
- 9)每个节点包含一个后退指针,头节点和第一个节点指向NULL,其他节点指向最底层的前一个节点。
跳跃表每个节点维护了多个指向其他节点的指针,所以在跳跃表进 行查找、插入、删除操作时可以跳过一些节点,快速找到操作需要的节点。归根结底,跳跃表是以牺牲空间的形式来达到快速查找的目的。跳跃表与平衡树相比,实现方式更简单,只要熟悉有序链表,就可以轻松地掌握跳跃表。
我们可以看到跳表的优势是可以在有序的序列中快速查找。如果要插入元素,也可以在定位之后找到需要插入的位置,这样保证增删元素之后的链表仍然是有序的。
正是因为其典型的有序性,因此适合在有序集合SortedSet中使用。
3. 压缩列表
3.1 压缩列表的结构
压缩列表ziplist本质上就是一个字节数组,是Redis为了节约内存而设计的一种线性数据结构,可以包含多个元素,每个元素可以是一个字节数组或一个整数。
Redis的有序集合、散列和列表都直接或者间接使用了压缩列表。 当有序集合或散列表的元素个数比较少,且元素都是短字符串时, Redis便使用压缩列表作为其底层数据存储结构。而后面要介绍的快速链表就是双向链表与压缩列表的组合。
Redis中的压缩列表(ZipList)是由一系列的节点(entry)组成的。每个节点可以是一个字节数组(byte array)、一个整数或者一个指针。在ZipList中,每个节点的大小是不固定的,取决于节点所包含的数据类型和数据大小。ZipList中节点的个数也是不固定的,可以根据需要动态增加或减少。
ZipList的结构如下图所示:
其含义如下:
- zlbytes字段的类型是uint32_t, 这个字段中存储的是整个ziplist所占用的内存的字节数。
- zltail字段的类型是uint32_t, 它指的是ziplist中最后一个entry的偏移量. 用于快速定位最后一个entry, 以快速完成pop等操作。
- zllen字段的类型是uint16_t, 它指的是整个ziplit中entry的数量. 这个值只占2bytes(16位): 如果ziplist中entry的数目小于65535(2的16次方), 那么该字段中存储的就是实际entry的值. 若等于或超过65535, 那么该字段的值固定为65535, 但实际数量需要一个个entry的去遍历所有entry才能得到。
- zlend是一个终止字节, 其值为全F, 即0xff. ziplist保证任何情况下, 一个entry的首字节都不会是255。
3.2 节点的结构
在压缩列表中,每个节点的结构如下:
+--------+--------+
| prevlen| encoding| data |
+--------+--------+
prevlen是前一个节点的长度(单位为字节),encoding是数据的编码方式,data是节点的实际数据。
在压缩列表中,prevlen和encoding都是可选的。当节点的前一个节点的长度小于254字节时,prevlen字段被省略,encoding字段存储在数据之前,否则prevlen字段占用5个字节,encoding字段存储在prevlen后面的5个字节中。
根据不同的数据类型,压缩列表中节点的编码方式也有所不同,下面是常用的节点编码方式:
- 字节数组(byte array):
结构如下:
+--------+--------+---------------+
| prevlen| 0xc000 | length |
+--------+--------+---------------+
| data |
+---------------------------------+
其中,0xc000是一个特殊的编码方式,用于标识节点存储的是字节数组。length是字节数组的长度,data是字节数组的实际数据。
- 整数:
+--------+--------+--------+
| prevlen| int | int |
+--------+--------+--------+
其中,int是一个整数,可以是8位、16位或32位的有符号整数。
- 指针
+--------+--------+--------+
| prevlen| 0x01 | ptr |
+--------+--------+--------+
3.3 压缩列表的优缺点
优点:
- 紧凑的存储结构使得压缩列表的空间占用更小,可以在一定程度上减少内存碎片的发生。
- 压缩列表支持动态增加和删除节点,可以随着数据的增长而自动扩容或缩容,不需要预先分配空间。
- 压缩列表的节点采用紧凑的存储方式,使得节点访问和遍历的效率较高。同时,压缩列表支持从头和尾部两个方向同时遍历节点。
缺点:
- 节点大小不固定,当节点的大小随着数据的增长而不断变化时,可能会导致内存碎片的发生,从而增加了内存分配和释放的成本。
- 压缩列表不支持快速的节点插入和删除操作,因为在插入或删除节点时,需要对后面的节点进行移动,会导致频繁的内存复制操作,从而影响性能。如果需要频繁进行插入和删除操作,建议使用链表等其他数据结构。
- 压缩列表的节点的数据类型和大小有限制,不适合存储大量的大型数据。例如,压缩列表最大支持512MB的大小,单个节点最大支持64KB的大小,单个整数节点最大支持32位的有符号整数。如果需要存储大量的大型数据,建议使用其他数据结构,例如哈希表或有序集合。
4.字典结构
字典结构是几乎所有都要重点设计的结构,因为应用实在太多了。
字典(Dictionary)也称为哈希表(Hash Table)。字典(Dictionary)是一种高效的数据结构,用于存储键值对,常用于实现哈希表。用于快速存取和查找数据的数据结构,它的内部实现是一个数组加上链表或者红黑树。在 Redis 中,字典被广泛用于实现哈希表键值对的存储和查找,例如 Redis 中的 Hash 类型就是基于字典实现的在本文中,我们将深入了解Redis中的字典/哈希表,包括字典的结构和操作等。
Hash的基础就是一个大号的数组,他存储的时候首先会根据一定的规则来计算当前这个元素要存在数组的哪个位置,查找的时候也是先计算好要到数组的哪个位置下寻找。
数据访问的基本结构:
但是数组的大小毕竟是有限的,如果要存的数据很多,两个元素计算后得到的索引位置可能是一样的,这时候,一般会采取链的方式将其连接在一起,如下所示:
上面的结构是一个基本的Hash结构,但是在redis里进一步封装成如下的结构:
从最底层到最高层是dictEntry->dictht->dict的三级结构。其中dictEntry[]就是上图的的Hash结构,数组是我们存储的元素的数组,如果里面的相互映射到同一个位置,就用链的方式链接在一起。而dichth则是将整个Hash结构进行了一次封装,里面增加了大小、掩码等信息。dichth则被dict进一步封装。
在这里我们看到一个dict里会有两个ht,这是为什么呢?这个是为了方便扩容的。
这个作用类似JVM分代垃圾回收模型里S0和S1的作用。
redis的Hash默认使用的是ht[0],ht[1]不会初始化和分配空间。正常情况下哈希表dictht是用链地址法来解决碰撞问题的,在这种情况下,哈希表的性能取决于数组的大小,以及它保存的结点的数量之间的比例。如果是1:1,则性能还不错,如果结点非常多,那这时候有些位置需要使用链表来链接大量元素,因此hash的优势就不存在了。
如果单个哈希表的节点数量过多,哈希表就需要进行扩容,这就是reHash的过程。reHash的步骤是:
- 1.为字符ht[1]哈希表分配空间,ht[1]的大小为第一个大于等于ht[0].used*2的2的N次方幂。比如已经使用了10000,那就是16384。
- 2.将所有的ht[0]上的节点rehash到ht[1]上, 重新计算hash值和索引,然后放入指定的位置。
- 3.当ht[0]全部迁移到了ht[1]之后 ,释放ht[0]的空间,将ht[1]设置为ht[0]表,并创建新的ht[1],为下次rehash做准备。
既然说到扩容了,那什么时候会触发扩容呢?这个比例默认是5,也就是已经存储的元素数与hash的ht[0]之间的比例是5的时候会触发。
同样是扩容,那Java的HashMap是如何扩容的呢?这个感兴趣的同学可以研究一下。
5.跳表结构
quicklist是Redis底层最重要的数据结构之一,它是Redis对外提供的 6种基本数据结构中List的底层实现,在Redis 3.2版本中引入。在引入 quicklist之前,Redis采用压缩链表(ziplist)以及双向链表(adlist)作 为List的底层实现。当元素个数比较少并且元素长度比较小时,Redis采 用ziplist作为其底层存储;当任意一个条件不满足时,Redis采用adlist作 为底层存储结构。这么做的主要原因是,当元素长度较小时,采用 ziplist可以有效节省存储空间,但ziplist的存储空间是连续的,当元素个 数比较多时,修改元素时,必须重新分配存储空间,这无疑会影响 Redis的执行效率,故而采用一般的双向链表。
quicklist是综合考虑了时间效率与空间效率引入的新型数据结构, 我们这里给大家展示一下跳表的结构以及如何工作的。
quicklist由List和ziplist结合而成,ziplist在前面已经介绍。本节将对List以及quicklist进行简单概述。
5.1 List简介
链表是这样一种数据结构,其中的各对象按线性顺序排列。链表与数组的不同点在于,数组的顺序由下标决定,链表的顺序由对象中的指 针决定。List是链型数据存储常用的数据结构,可以是单向链表、双向 链表,可以是排序链表、无序链表,可以是循环链表、非循环链表。链表具有可快速插入、删除的优点。由于List查找复杂度为O(n),n为元素 个数,所以不适用于快速查找的场合。Redis 3.2版本之前使用的双向非循环链表的基本结构如图所示。
5.2 quicklist结构
quicklist是Redis 3.2中新引入的数据结构,能够在时间效率和空间 效率间实现较好的折中。Redis中对quciklist的注释为A doubly linked list of ziplists。顾名思义,quicklist是一个双向链表,链表中的每个节点是 一个ziplist结构。quicklist可以看成是用双向链表将若干小型的ziplist连 接到一起组成的一种数据结构。当ziplist节点个数过多,quicklist退化为 双向链表,一个极端的情况就是每个ziplist节点只包含一个entry,即只 有一个元素。当ziplist元素个数过少时,quicklist可退化为ziplist,一种极端的情况就是quicklist中只有一个ziplist节点。
如前文所述,quicklist是一个由ziplist充当节点的双向链表。quicklist的存储结构下图所示。 quicklist有如下几种核心结构:
typedef struct quicklist { quicklistNode *head; quicklistNode *tail; unsigned long count; /* total count of all entries in all ziplists */ unsigned long len; /* number of quicklistNodes */ int fill : 16; /* fill factor for individual nodes */ unsigned int compress : 16; /* depth of end nodes not to compress;0=off */ } quicklist;
我们将上述图示画出来就是这样的:
其中head、tail指向quicklist的首尾节点;count为quicklist中元素总 数;len为quicklist Node(节点)个数;fill用来指明每个quicklistNode中 ziplist长度,当fill为正数时,表明每个ziplist最多含有的数据项数。
如果为负数,则可以表示ziplist结点最大可以为多少,如果是-1~-5 则可以分别表示最大为4KB、8KB~64KB。
考虑quicklistNode节点个数较多时,我们经常访问的是两端的数据,为了进一步节省空间,Redis 允许对中间的quicklistNode节点进行压缩,通过修改参数list-compress- depth进行配置,即设置compress参数,该项的具体含义是两端各有 compress个节点不压缩,当compress为1时,quicklistNode个数为3时,其结构如下图所示。
我们可以再看一个网友画的更加形象的图示:
两端各有2个橙黄色的节点,是没有被压缩的。它们的数据指针zl指向真正的ziplist。中间的其它节点是被压缩过的,它们的数据指针zl指向被压缩后的ziplist结构,即一个quicklistLZF结构。
左侧头节点上的ziplist里有2项数据,右侧尾节点上的ziplist里有1项数据,中间其它节点上的ziplist里都有3项数据(包括压缩的节点内部)。这表示在表的两端执行过多次push和pop操作后的一个状态。
现在我们来大概计算一下quicklistNode结构中的count字段这16bit是否够用。
Redis 6.0 版本中的快表(QuickList)与 Redis 4.0 版本中的快表基本结构相同,都是由多个 quicklistNode 节点组成,其中每个节点都包含一个 ziplist 和一些元数据信息。快表中的元素按照从表头到表尾的顺序依次存储。
这里还包含大量的细节和精心设计的优化策略,这里我们就不再展开了。花了一天的时间,终于将redis的数据结构底层给梳理了一遍。
就凭这个,要不给我点个赞呀!