Redis底层数据结构详解
Posted LuckyWangxs
tags:
篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了Redis底层数据结构详解相关的知识,希望对你有一定的参考价值。
Redis底层数据结构
在前面的文章有介绍了Redis五大数据类型的使用以及各类的应用场景,今天来介绍一下Redis五大数据类型底层的数据结构的实现
一、简单动态字符串SDS
1. SDS
SDS(simple dynamic string)
,即简单动态字符串的抽象类型,是Redis
默认的字符串,其定义如下(这是3.2之前的版本,后续深入讲解SDS
的优化时会讲3.2之后的版本SDS
的定义,目前只为了学习SDS
大体的设计原理):
struct sdshdr
//记录buf数组中已使用的数量, 也为字符串长度
int len;
//记录 buf 数组中未使用的数量
int free;
//字节数组,用于保存字符串
char buf[];
2. 为什么Redis没用C语言原生字符串?
Redis
是由C
语言开发的,一开始我也有疑问,为什么Redis
没有用C
语言原生的字符串来作为string
类型的底层数据结构呢?那么首先要先了解一下C
语言的字符串了
2.1 C语言中的字符串
所谓字符串本质上就是以\\0
(空字符)作为结尾的特殊字符数组,所以有以下几个点需要注意
① 当定义的字符数组最后没有以\\0
结尾且没有给定字符长度为实际长度加一时,它仅仅是字符数组而非字符串
# 它仅仅是一个字符数组而非字符串
char s1[] = 'h','e','l','l','0';
# 这才是一个字符串
char s2[] = 'h','e','l','l','0', '\\0';
② 如果没有以\\0
结尾,那么你需要定义数组的长度比实际长度多一位
# 这也是一个字符串
char s3[6] = 'h','e','l','l','0';
# 这仅仅是字符数组
char s4[5] = 'h','e','l','l','0';
③ 直接定义字符串
char s5 = "hello";
以上不管是哪种方式,在C
的底层,都会为这段字符串加上\\0
代表一个字符串的结尾
2.2 使用SDS的好处
- 二进制安全
Redis
不仅可以存储文本,还可以存储二进制文件,例如图片,而图片的内容很可能包含\\0
,如果用C
语言原生的字符串表示,那遇到\\0
后会认为字符串结束了,导致无法正常获取。而SDS
虽然底层也采用了char
数组且以\\0
结尾,但并非以\\0
来确定字符串是否结束的,而是以len
属性来判断字符串是否结束,所以可以确保二进制数据安全 - 查询字符串长度迅速
在C
语言中,查询字符串的长度,往往采用循环的方式,如此,时间复杂度为O(N)
,而SDS
封装了char
并扩展了len
属性,用它作为字符串长度,查询是直接返回即可,时间复杂度为O(1)
。可以使用命令查看string
类型的值的字符串长度
127.0.0.1:6379> set key str
OK
127.0.0.1:6379> strlen key
(integer) 3
- 减少内存重新分配次数
C
语言在对字符串操作前,需要进行至少一次的内存分配,而C
语言也并不会记录字符串的长度,所以修改也是会进行内存重新分配的,如果不重新分配,字符串变长就会导致内存溢出,如果字符串变短,那么会有内存泄漏的问题,而内存的分配是比较耗费性能的,针对这一弊端,SDS
进行了优化,利用属性len
和free
设计了空间预分配和惰性空间释放机制。
1. 空间预分配: 所谓预分配,也就是说在一次扩展操作中,扩展的空间大小会大于实际需要的空间大小,这样就可以减少连续对字符串进行增长操作时,对内存的重分配次数。
1.1 预分配规则:
SDS len<1M:分配len长度空间作为预分配空间;
SDS len>=1M:分配1M空间作为预分配空间;
这样,在下次进行字符操作的时候,如果所需要的空间小于当前SDS free空间,则可以直接行操作,而不需要再执行内存扩展,重分配操作。如此一来,使得扩展操作所需的内存重分配次数变为<=1
2. 惰性空间释放: 直白地说,当字符串有部分删除的时候,并不会立即执行内存重新分配,而是更改free
属性的值,释放的内存都赋给free
,以备下次字符串扩展的时候使用。此外Redis
也提供了主动释放未使用内存的方法,还是比较灵活的 - 缓冲区内存溢出问题规避
内存溢出即分配内存小于实际需要内存,C
语言在对字符串进行扩展时,要特别注意内存分配情况,比如两个字符串拼接,要严格保证内存足够。而SDS
在进行字符串修改的时候,会根据记录的len
属性检查内存空间是否满足,如果不满足,会进行相应的内存扩展,然后在进行修改,所以不会出现内存溢出。其实就相当于做了一层内存溢出的校验
二、链表linkedlist
linkedlist
是一个标准的双向链表数据结构,其定义如下:
typedef struct listNode
//前置节点
struct listNode *prev;
//后置节点
struct listNode *next;
//节点的值
void *value;
listNode
typedef struct list
//表头节点
listNode *head;
//表尾节点
listNode *tail;
//链表所包含的节点数量
unsigned long len;
//节点值复制函数
void (*free) (void *ptr);
//节点值释放函数
void (*free) (void *ptr);
//节点值对比函数
int (*match) (void *ptr,void *key);
list;
其特性如下:
① 双向:listNode
具有前后指针
② 记录长度:通过len
属性记录了链表长度,时间复杂度为O(1)
③ 值可存任意类型:可以看到listNode
的value
采用指针的方式,可以保存不同类型的值
④ 保存双端:获取头和为的时间复杂度为O(1)
三、压缩列表(ziplist)
压缩列表并不是对数据利用某种算法进行压缩,而是将数据按照一定规则编码在一块连续的内存区域,目的是节省内存。官网对ziplist
的定义如下:
ziplist是一种特殊编码的双链表,它的设计非常高效。它存储字符串和整数值,其中整数被编码为实际整数而不是一系列字符。它允许在O(1)时间内对列表的任一侧执行推送和弹出操作。但是,由于每个操作都需要重新分配ziplist使用的内存,因此实际的复杂性与ziplist使用的内存量有关
ziplist
是由一系列特殊编码的内存块构成的列表(像内存连续的数组,但每个元素长度不同), 一个 ziplist
可以包含多个节点(entry)
。ziplist
将表中每一项存放在前后连续的地址空间内,每一项因占用的空间不同,而采用变长编码。
当元素个数较少时,Redis
用 ziplist
来存储数据,当元素个数超过某个值时,链表键中会把 ziplist
转化为 linkedlist
1. ziplist底层存储结构
字段 | 类型 | 长度 | 作用 |
---|---|---|---|
zlbytes | nint32_t | 4字节 | 记录整个压缩列表占用的内存字节数,在对压缩列表进行内存重新分配或者计算zlend位置的时候使用 |
zltail | nint32_t | 4字节 | 记录到达尾节点的偏移量,通过偏移量可以在不遍历所有节点的情况下,直接找到尾节点 |
zllen | nint16_t | 2字节 | ziplist中节点的数量。当这个值小于65535时,通过它就可以得出列表中的节点数量,但是当这个值等于65535时,就需要通过实际遍历计数得出列表节点数了 |
entry? | 列表节点 | 不定长 | 压缩列表中的各个节点,数据存储于节点之上,由于数据内容不同,所以长度不定,采用了变长编码 |
zlend | nint8_t | 1字节 | 记录压缩列表的末端 |
2. entry节点的内部结构
previous_entry_length: 记录压缩列表前一个节点的字节长度,通过该值可以算出前一个节点的位置,用于从尾节点向前遍历。当前指针位置减去前一个节点的长度就是前一个节点的位置。
previous_entry_length是变长编码,有两种表示方法:
如果前一节点的长度小于 254 字节,则使用1字节(uint8_t)来存储prevrawlen;
如果前一节点的长度大于等于 254 字节,那么将第 1 个字节的值设为 254 ,然后用接下来的 4 个字节保存实际长度。
encoding/len: 当前节点的编码类型以及字节长度,用来解析content
用的。encoding类型一共有两种,一种字节数组一种是整数,encoding区域长度为1字节、2字节或者5字节长
content: 保存了当前节点的值
四、字典dict
字典是一种存储键值的抽象数据结构,学过Java
的朋友应该都知道HashMap
、Hashtable
,如果你知道这个,那么理解起来Redis
的字典可以说毫不费力。
Redis
中的字典以哈希表为底层数据结构,其定义如下:
typedef struct dictht
//哈希表数组
dictEntry **table;
//哈希表大小
unsigned long size;
//哈希表大小掩码,用于计算索引值
//总是等于 size-1
unsigned long sizemask;
//该哈希表已有节点的数量
unsigned long used;
dictht
由代码可以知道,哈希表是由dictEntry
类型的数组组成的,dictEntry
的结构如下:
typedef struct dictEntry
//键
void *key;
//值
union
void *val;
uint64_tu64;
int64_ts64;
v;
//指向下一个哈希表节点,形成链表
struct dictEntry *next;
dictEntry
看到这里,想必学Java
的同学已经非常通透了,数据结构与HashMap
非常类似,采用数组加链表的形式,解决哈希冲突也是利用了链地址法,且采用头插法,不了解的可以看下这篇HashMap底层原理
1. 扩容与缩容
当哈希表保存的键值对太多或者太少时,就要通过 rerehash(重新散列)来对哈希表进行相应的扩展或者收缩。步骤如下:
- 如果执行扩展操作,会基于原哈希表创建一个大小等于 原哈希表已使用的空间的2倍大小的哈希表。相反如果执行的是缩容操作,每次收缩是根据已使用空间缩小一倍创建一个新的哈希表。
- 重新利用哈希算法,计算索引值,然后将键值对放到新的哈希表位置上。
- 所有键值对都迁徙完毕后,释放原哈希表的内存空间
2. 渐进式rehash
上述介绍了扩容与缩容,那么Redis
并不是一次性将数据迁移到新哈希表的,这也是考虑到了性能问题,如果我们的数据量小还好,一次性迁移,对性能没什么影响,那如果就几十万几百万的数据,进行一次性迁移,肯定会影响到性能。所以Redis
采用了渐进式rehash
,也就是迁移分为多次渐进式完成,有两种迁移策略:
- 主动:当访问老记录时,则迁移一部分,不是按访问顺序进行迁移的,它有一定的迁移顺序
- 事件轮询:以时间轮询的方式触发迁移,每次迁移一批
3. 在rehash过程中数据如何存取
查询可能会设计两张哈希表,先从旧哈希表查,如果查不到,则会从新哈希表查。而插入数据只会往新的哈希表添加
五、整数集合intset
整数集合(intset)是Redis用于保存整数值的集合抽象数据类型,它可以保存类型为int16_t、int32_t 或者int64_t 的整数值,并且保证集合中不会出现重复元素,其定义如下:
typedef struct intset
//编码方式
uint32_t encoding;
//集合包含的元素数量
uint32_t length;
//保存元素的数组
int8_t contents[];
intset;
contents[]: 整数集合的每个元素都是 contents
数组的一个数据项,它们按照从小到大的顺序排列,并且不包含任何重复项。虽然该数组生命为int8_t
,但实际上并不保存任何int8_t
类型的值,具体的类型是由属性encoding
来决定的,而encoding
提供了三个值,分别为INTSET_ENC_INT16
、INTSET_ENC_INT32
、INTSET_ENC_INT64
length: 记录了 contents 数组的大小。
注意: 当我们新加入的元素的长度大于集合原来的元素长度时,需要对整数集合进行升级,根据新元素类型扩展整数集合中数组的大小,给新元素分配空间,将数组原有元素逐个转换成与新元素类型相同的元素存入数组,最后将新元素加入,这种升级是不可逆操作,一旦升级只能保持升级后的状态
六、跳表skiplist
在我看来,跳表是对标准链表的一种优化,我们都知道链表的查询时间复杂度是O(N)
,而将链表优化为跳表后,查询便趋近于二分查找。我们先来看一下它的结构:
跳表的数据结构有很多层,最底层链表包含了所有元素,每个节点都有两个指针,一个指向同层下一个节点,一个指向下一层同一个节点,我们看图可以理解为将节点向上冗余,做出索引层,仔细思考,有点二分搜索树的味道,如此一来,可以让查询趋近于二分查找,而且跳表还支持区间查找
跳表定义如下:
typedef struct zskiplist
//表头节点和表尾节点
structz skiplistNode *header, *tail;
//表中节点的数量
unsigned long length;
//表中层数最大的节点的层数
int level;
zskiplist;
多个跳表节点构成跳表,跳表节点定义如下:
typedef struct zskiplistNode
//层
struct zskiplistLevel
//前进指针
struct zskiplistNode *forward;
//跨度
unsigned int span;
level[];
//后退指针
struct zskiplistNode *backward;
//分值
double score;
//成员对象
robj *obj;
zskiplistNode
① 搜索:搜索还是比较好理解的,从最高层开始检索,如果比当前层的当前节点大并且比当前层的当前节点的下一个节点小,那么就向下寻找,也就是与当前层的当前节点的下一层的下一个节点比较,以此类推。例如,寻找节点11,先从最高层节点3开始,发现3<7<16
,那么就向下一层找,与下一层的节点3的下一个节点7比较,发现比7大,然后将7看作当前节点,重复上述步骤,即7<11<16
,继续与下一层的相同节点的下一个节点11比较,相等,返回
② 删除:在各个层中找到包含指定值的节点,然后将节点从链表中删除即可,如果删除以后只剩下头尾两个节点,则删除这一层。
③插入:跳表的插入比价复杂,选择在哪一层插入是随机的,有一种方法是假设抛一枚硬币,如果是正面就累加,直到遇见反面为止,最后记录正面的次数作为插入的层数。比如确定插入第x层,然后找到新节点在每一层的上一个节点,将新节点插入到每一层的上一个节点和下一个节点之间,插入是在底层到x层都发生的。
有关跳表的增删查我只能说这么多了,简单理解一下原理吧,这块比较复杂,我还没有那么多精力去深入了解,毕竟学Java
不想看那么多C
的代码哈哈哈
参考文章:
https://blog.csdn.net/u013536232/article/details/105476382/
https://zhuanlan.zhihu.com/p/102422311
https://www.cnblogs.com/ysocean/p/9080942.html#_label6
https://blog.csdn.net/qq193423571/article/details/81637075
以上是关于Redis底层数据结构详解的主要内容,如果未能解决你的问题,请参考以下文章