一文看懂redis各种数据类型的底层实现
redis已经成为了现今构建互联网应用最常用的中间件之一,它对使用者暴露的数据类型有string、list、hash、set、sorted set等,我们在使用这些数据类型的同时,肯定也会对其内部的设计和实现感兴趣。这篇文章将探究这些数据类型底层的数据结构实现,比如sds、ziplist、quicklist、dict、skiplsit等
本文引用的redis源码版本为redis 6.2
一、SDS#
redis string对象底层使用SDS来实现。
redis虽然使用C语言开发,却没有直接使用C语言的默认字符串,而是自己构建了一种名叫SDS(simple dynamic string)的数据结构。
熟悉go语言的同学会发现,这个数据结构跟go语言中的slice切片的底层实现非常相似,下面来详细解析
sds结构定义在sds.h中
typedef char *sds;
sds定义成char *类型,这是为了和传统C语言的字符串保持兼容,但是sds并不等同char *,实际上sds还包含了一个header结构
struct __attribute__ ((__packed__)) sdshdr5 {
unsigned char flags; /* 3 lsb of type, and 5 msb of string length */
char buf[];
};
struct __attribute__ ((__packed__)) sdshdr8 {
uint8_t len; /* used */
uint8_t alloc; /* excluding the header and null terminator */
unsigned char flags; /* 3 lsb of type, 5 unused bits */
char buf[];
};
struct __attribute__ ((__packed__)) sdshdr16 {
uint16_t len; /* used */
uint16_t alloc; /* excluding the header and null terminator */
unsigned char flags; /* 3 lsb of type, 5 unused bits */
char buf[];
};
struct __attribute__ ((__packed__)) sdshdr32 {
uint32_t len; /* used */
uint32_t alloc; /* excluding the header and null terminator */
unsigned char flags; /* 3 lsb of type, 5 unused bits */
char buf[];
};
struct __attribute__ ((__packed__)) sdshdr64 {
uint64_t len; /* used */
uint64_t alloc; /* excluding the header and null terminator */
unsigned char flags; /* 3 lsb of type, 5 unused bits */
char buf[];
};
一个完证的sds字符串,由内存地址上前后相邻的两部分组成:一个header,和一个char [] 字节数组。下面来看header的构成:
-
len
:sds字符串的长度 -
alloc
:sds字符串底层字节数组的最大容量(不包括header),即buf数组的长度-1(不包括结束符 ‘\0’) -
buf
:用于保存字符串的字节数组,没有具体长度标识,是一个柔性数组,柔性数组并不占用结构体的空间 -
flags
:用来标记不同类型的sds,只使用了低3位来标记,高5位暂未使用(除了sdshdr5)
根据alloc
和len
的类型不同,sds分为几种类型
- sdshdr5:使用高5位(5 msb)来表示len, alloc固定是5 msb的最大值 2^5,因而没有办法动态扩容
- sdshdr8:长度为小于2^8的字符串
- sdshdr16:长度为小于2^16的字符串
- sdshdr32:长度为小于2^32的字符串
- sdshdr64:其他所有长度都使用此类
__attribute__ ((__packed__))
关键字用来告诉编译器这个结构体不在遵循内存对齐规则,而是字段成员紧致排列,方便通过偏移量来访问结构体中的字段,比如buf
向前偏移一个字节,就可以访问到flags
字段,以此来判断这个sds的类型
之所以要定义5种header,是为了能让不同的sds字符串可以使用不同的header,短的sds字符串能够使用小的header,尽可能的节省内存。
上图展示了两个有不同header sds字符串的内存布局,以及如何根据sds字符串指针获取对应header起始指针。先从sds字符指针向地址偏移一个字节获取到flag,从flag的低3bit得到header的类型,知道了header的类型,也就很容易的能够计算出header的起始指针位置。
sds字符串的header,其实隐藏在真正的字符串数据的前面(低地址方向)。这样定义有如下几个好处:
- header和数据相邻,这有利于减少内存碎片,提高存储效率
- 虽然header有多个类型,但sds可以用统一的char *来表达。且它与传统的C语言字符串保持类型兼容。我们可以直接把它传给C函数,比如使用printf进行打印。
SDS特点(区别于C字符串)#
- 内存预分配
- redis作为数据库,字符串数据经常被频繁修改。C语言的字符串增长或者缩短,就必须对整个底层数组进行一次重新分配,SDS可以在字符串增长时,给SDS分配额外未使用空间(buf),以减少内存分配次数
- 字符串小于1M时,翻倍扩容,大于1M时,每次增加1M
- 惰性释放
- SDS缩短字符串时,只需要通过len字段记录新的字符串长度,而不必立即重新分配内存
- SDS也提供了相应的API,让我们在需要的时候释放SDS未使用的空间
- 二进制安全
- SDS使用len字段而不依懒于空字符
\0
来判断字符串的结尾,所以可以使用buf来保存任何二进制格式的数据 - 比如我们可以使用redis来保存ProtoBuf压缩过的二进制数据
- SDS使用len字段而不依懒于空字符
- 兼容C字符串
- SDS被定义为char *,并且会在字符串的末尾加上
\0
,所以能够兼容C字符串,以复用C字符串相关函数
- SDS被定义为char *,并且会在字符串的末尾加上
二、链表List#
redis3.2之前,list对象的底层实现之一是链表,但在3.2版本之后,list类型就改成用quicklist+ziplist来实现了。不过链表依然在发布订阅、监视器、保存客户端状态信息保存、客户端输出缓冲区等场景被使用,所以做个简单介绍
双链表list结构定义在adlist.h中
typedef struct listNode {
struct listNode *prev;
struct listNode *next;
void *value;
} listNode;
typedef struct list {
listNode *head;
listNode *tail;
void *(*dup)(void *ptr);
void (*free)(void *ptr);
int (*match)(void *ptr, void *key);
unsigned long len;
} list;
listNode
用两个指针 prev
、next
保存左右节点,用void *指针value
保存节点的值
list
保存了头尾指针head
、tail
,方便链表的操作。len
来记录链表的长度。dup
、free
、match
这三个成员是三个函数的指针,指向用于实现多态链表所需的类型特定函数
- dup用于复制节点值
- free用于释放节点保存的值
- match用于比较节点的值与输入值是否相等
三、压缩列表ziplist#
zset和hash对象在元素个数比较少的时候,底层使用压缩列表ziplist来进行存储。
ziplist是redis为了节约内存而设计的,是由一系列特殊编码的连续内存块组成的序列型数据结构。它本质上就是一大块连续的字节数组,但是会比常规的数字更节省内存,因为数组要求每个元素的大小相同,这就导致很多内存的浪费。而压缩列表的元素长度则是动态的、不固定的。
压缩列表#
属性 | 类型 | 长度 | 用途 |
---|---|---|---|
zlbytes |
uint32_t |
4 字节 |
记录整个压缩列表占用的内存字节数:在对压缩列表进行内存重分配, 或者计算 zlend 的位置时使用。 |
zltail |
uint32_t |
4 字节 |
记录表尾节点与起始地址的偏移量: 用来计算表位节点的地址 |
zllen |
uint16_t |
2 字节 |
记录了压缩列表包含的节点数量: 当这个属性的值小于 UINT16_MAX (65535 )时, 这个属性的值就是压缩列表包含节点的数量; 当这个值等于 UINT16_MAX 时, 节点的真实数量需要遍历整个压缩列表才能计算得出。 |
entryX |
列表节点 | 不定 | 压缩列表包含的各个节点,节点的长度由节点保存的内容决定。 |
zlend |
uint8_t |
1 字节 |
特殊值 0xFF (十进制 255 ),用于标记压缩列表的末端。 |
压缩列表节点#
每个压缩列表节点有三部分构成
-
prevrawlen 表示前一个节点的长度
- 当ziplist倒叙遍历时,可以根据prevrawlen计算出前一个节点的起始地址
- 前一个节点的长度小于254字节,那么prevlen使用一个字节
- 前一个节点的长度大于254字节,那么prevlen使用5个字节,第一个字节固定是0xFE(254),后四个字节用来保存前一个节点的长度(第一个字节为什么不是255呢?因为255被
zlend
使用了)
-
encoding 记录了节点的长度,和content属性所保存的数据的类型
00
、01
或者10
的是字节数组编码(一个字节,两个字节,五个字节)。数组的长度由出去最高两位之后的其他位记录11
开头的是整数编码,整数的类型由除去最高两位的- encoding告诉我们content表示的是字符串(字节数组)还是整数类型(int16_t、int32_t...)
字节数组编码
编码 编码长度 content
属性保存的值00bbbbbb
1
字节长度小于等于 63
字节的字节数组。01bbbbbb xxxxxxxx
2
字节长度小于等于 16383
字节的字节数组。10______ aaaaaaaa bbbbbbbb cccccccc dddddddd
5
字节长度小于等于 4294967295
的字节数组。整数编码
编码 编码值 编码长度 content
属性保存的值11000000
0xC0
1
字节int16_t
类型的整数。11010000
0xD0
1
字节int32_t
类型的整数。11100000
0xE0
1
字节int64_t
类型的整数。11110000
0xF0
1
字节24
位有符号整数。11111110
0xFE
1
字节8
位有符号整数。1111xxxx
1
字节使用这一编码的节点没有相应的 content
属性, 因为编码本身的xxxx
的值在0001
和1101
之间,表示从1到13,所以它无须content
属性。 -
content 保存节点的值,可以保存任意二进制序列(字节数组)
压缩列表插入和查找的平均复杂度为O(N),因为ziplist没有指针,所以每次插入或者删除节点,都要重新调整节点的位置,因而会发生内存拷贝,所以ziplist只适合用来保存少量的数据。
四、快速列表quicklist#
linkedlist 每个节点只能保存一个元素,而且需要使用 prev 和 next 两个指针,占据了16个字节,空间附加值太高,而且每个节点都是单独分配,会加剧内存的碎片化。因此在 redis3.2 版本之后,使用了新的 quicklist+ziplist 结构代替了 linkedlist 来实现 list 列表对象。
quicklist还是一个双向链表,只不过每个节点都是一个压缩列表,先来看两个相关配置参数:
- 压缩列表的长度由参数
list-max-ziplist-size
来控制,用户可以自己设置,默认值是 -2代表8KB(正数代表个数,负数的意义参考redis.conf) - 当列表很长时,两端的数据是最容易被访问的,而中间的数据访问频次比较低,所以redis提供了一个选项,能够把中间的节点的数据进行压缩,进一步节约内存。参数
list-compress-depth
代表quicklist两端不被压缩的节点个数,默认是特殊值0,表示都不压缩
quicklist相关结构在quicklist.h中定义
typedef struct quicklistNode {
struct quicklistNode *prev;
struct quicklistNode *next;
unsigned char *zl;
unsigned int sz; /* ziplist size in bytes */
unsigned int count : 16; /* count of items in ziplist */
unsigned int encoding : 2; /* RAW==1 or LZF==2 */
unsigned int container : 2; /* NONE==1 or ZIPLIST==2 */
unsigned int recompress : 1; /* was this node previous compressed? */
unsigned int attempted_compress : 1; /* node can't compress; too small */
unsigned int extra : 10; /* more bits to steal for future usage */
} quicklistNode;
typedef struct quicklistLZF {
unsigned int sz; /* LZF size in bytes*/
char compressed[];
} quicklistLZF;
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 : QL_FILL_BITS; /* fill factor for individual nodes */
unsigned int compress : QL_COMP_BITS; /* depth of end nodes not to compress;0=off */
unsigned int bookmark_count: QL_BM_BITS;
quicklistBookmark bookmarks[];
} quicklist;
tip:quicklistNode结构体中count、encoding等字段后面跟着一个冒号和一个数字,这是C语言结构体
位域
的语法,它告诉编译器,这个字段所使用的位的宽度,可以把数据以位的形式紧凑存储,并允许程序员对此结构的位进行操作
quicklist节点#
quicklistNode字段含义如下:
- prev、next:是前后指针
- zl:是一个char* 字节数组指针,如果节点没有被压缩,它指向一个ziplist;否则,它指向一个
quicklistLZF
结构 - sz:size,ziplist的字节大小
- count: 节点的元素个数
- encoding:标识节点的编码方式,1代表ziplist,2代表压缩过的节点(quicklistLZF)
- container:1代表直接存储数据(预留,实际未实现),2代表使用ziplist存储数据
- recompress:如果我们访问了被压缩的节点数据,需要把数据暂时解压,这时设置recompress=1,表示后面需要把数据重新压缩
压缩过的节点#
quicklistLZF表示一个被压缩过的ziplist,redis使用LZF算法压缩ziplist
- sz:压缩后的大小
- compressed:一个柔性数组,存储压缩后的数据
quicklist#
struct quicklist 是快速列表的的真正结构
- head、tail 首位指针
- count:元素个数(所有节点元素个数的总和)
- len:节点数
- fill:保存
list-max-ziplist-sized
的值,表示ziplist的容量 - compress:保存
list-compress-depth
的值,表示节点压缩深度
上图是一个 list-max-ziplist-size=3 list-compress-dept=2 的quicklist 示意图
五、字典dict#
redis的hash对象底层使用字典来实现,字典是一种保存键值对的抽象数据结构,在redis中应用相当广泛,redis的数据库也是使用字典来作为底层实现的
dict相关结构在dict.h中定义
typedef struct dictEntry {
void *key;
union {
void *val;
uint64_t u64;
int64_t s64;
double d;
} v;
struct dictEntry *next;
} dictEntry;
typedef struct dictType {
uint64_t (*hashFunction)(const void *key);
void *(*keyDup)(void *privdata, const void *key);
void *(*valDup)(void *privdata, const void *obj);
int (*keyCompare)(void *privdata, const void *key1, const void *key2);
void (*keyDestructor)(void *privdata, void *key);
void (*valDestructor)(void *privdata, void *obj);
int (*expandAllowed)(size_t moreMem, double usedRatio);
} dictType;
/* This is our hash table structure. Every dictionary has two of this as we
* implement incremental rehashing, for the old to the new table. */
typedef struct dictht {
dictEntry **table;
unsigned long size;
unsigned long sizemask;
unsigned long used;
} dictht;
typedef struct dict {
dictType *type;
void *privdata;
dictht ht[2];
long rehashidx; /* rehashing not in progress if rehashidx == -1 */
int16_t pauserehash; /* If >0 rehashing is paused (<0 indicates coding error) */
} dict;
哈希表#
dictht定义了哈希表的结构, ht是hash table意思
-
table
:是一个元素为dictEntry *的数组的指针,每个dictEntry保存一个键值对 -
size
:是table数组的长度 -
sizemask
:总是等于size-1,一个键的哈希值可以和sizemask做与运算(比取余效率高),快速得到其在数组中的索引位置 -
used
:记录哈希表已有键值对个数
哈希表节点#
dictEntry定义了哈希表节点
-
key
:用来保存键值对中的键,void *指针类型,可以指向任何值 -
v
:用来保存键值对中的值,是一个union结构,可以直接保存uint64_t、int64_t或double类型,也可以是void* -
next
:指向另一个哈希表节点,当多个键的索引相同时,组成链表来解决哈希冲突问题,这种通过链表解决哈希冲突的方法通常叫做链地址法(拉链法)。速度考虑,新节点总是被添加在链表头的位置(复杂度O(1))
字典#
dict就是redis的字典结构了
-
type
:指向一个dictType结构,用来实现不同类型的多态字典 -
privdata
:保存了需要传给特定类型函数的可选参数 -
ht
:包含了两个dctht,一般情况下,字典只会使用ht[0],ht[1]只会在对ht[0]进行rehash的时候使用。 -
rehashidx
:记录了rehash的进度,没有在进行rehash的话,值为-1 -
pauserehash
:大于0时表示rehash被暂停
dictType则保存了一些用于特定类型键值对的函数
-
hashFunction
:用来计算键的哈希值 -
keyDup
、valDup
、keyDestructor
、valDestructor
:分别是复制键和值的函数,销毁键和值的函数 -
keyCompare
:比较键的函数
rehash#
当哈希表保存过多的键值对时,哈希冲突的可能性大大增减,为了保持O(1)的访问复杂度,需要对哈希表进行扩容;当哈希表中的键被大量的删除,缩容能释放哈希表的内存占用。
负载因子(load factor)表示哈希表已存储的元素个数和哈希表数组的比值 load_factor = ht[0].uesd / ht[0].size
- 当哈希表的负载因子大于1的时候,redis会对哈希表执行扩容操作(如果服务器正在执行RDB备份,或者AOF文件重写,负载因子大于5才开始扩容)
- 当负载因子小于0.1的时候,会执行缩容操作
rehash的过程
- 为ht[1]分配表空间,其大小总是2^n,即每次都是2的整数次幂的倍数扩缩容,这样sizemask才能用来快速计算哈希值的索引位置
- 将保存在ht[0]上的键值对rehash到ht[1]上,即重新计算键的哈希值和其在ht[1]索引值,然后将键值对放在ht[1]指定的位置上
- 当ht[0]包含的所有键值对都迁移到了ht[1]上后,释放ht[0],将ht[1]设置为ht[0],然后在ht[1]新建一个空表哈希表,为下次rehash做准备
渐进式rehash
rehash这个动作不是一次性集中完成的,而是分多次,渐进式的
- rehashidx记录着rehash的进度,他的值是当前需要被rehash的索引位置,值设置为0,代表rehash开始(没有rehash时是-1)
- rehash期间,每次对字典执行增删改查的同时,也会将ht[0]上rehashidx索引位置的全部键值对rehash到ht[1]上,然后将rehashidx的值加一
- 除了在对字典操作时会执行rehash操作,服务器的定时任务也会主动的进行rehash,可以通过activerehashing参数配置,默认值yes
- rehash结束后rehashidx会被设置为-1
- 渐进式rehash执行期间,新添加到字典的价值对会被保存在ht[1]中,查找、删除、更新的操作会先在ht[0]中查找对应的键,如果没找到的话,就会继续在ht[1]里面进行查找,然后执行对应的逻辑
六、跳跃表skiplist#
跳跃表是一种有序的数据结构,有两大特点
- 支持平均O(logN)时间复杂度的节点查找
- 可以通过顺序性操作来批量处理节点
大部分情况下跳跃表的效率可以和平衡树媲美,而且跳跃表实现更简单,范围查询也比平衡树更方便,效率更好
跳跃表是zset的实现之一(集合包含元素较少多,或者字符串较长时)
redis跳跃表由zskiplistNode
和 zskiplist
实现,在server.h中定义
#define ZSKIPLIST_MAXLEVEL 32 /* Should be enough for 2^64 elements */
#define ZSKIPLIST_P 0.25 /* Skiplist P = 1/4 */
typedef struct zskiplistNode {
sds ele; //sds 字符串
double score; //分值
struct zskiplistNode *backward; //后退指针,每次只能退一个节点
struct zskiplistLevel {
struct zskiplistNode *forward; //每层都有一个前进指针,层数越高,跨度越大
unsigned long span; //跨度记录两个节点之间的距离
} level[]; //层高在1-32之间,根据幂次定律,越大的数出现的概率越小
} zskiplistNode;
typedef struct zskiplist {
struct zskiplistNode *header, *tail;
unsigned long length;
int level;
} zskiplist;
先看两个相关的常量
ZSKIPLIST_MAXLEVEL
:跳跃表的最大层数为32层ZSKIPLIST_P
:如果一个节点有第i层(i>=1)指针,那么它有第(i+1)层指针的概率为p=1/4。
跳跃表结构#
zskiplist结构,跟普通的双链表很像
-
header
、tail
:分别指向跳跃表的头节点、尾结点 -
length
:跳跃表的长度,即节点个数(头节点不计算在内) -
level
:跳跃表当前的最大层高,即层数最大的节点的层高(头节点的层数不计算在内)
跳跃表节点#
跳跃表的节点zskiplistNode
ele
:保存了一个sds,即zset中的成员,成员不可重复score
:ele对应的分数,跳跃表中,跳跃表节点按照分数从小到大排序backward
:节点的后退指针,指向前一个节点,用来从后向前遍历跳跃表level
:level数组也是柔性数组,具体层数不确定,在新建节点的时候会根据ZSKIPLIST_P计算出当前节点的层数,越大的层数出现的概率越低forward
:每一层里有一个前进指针,指向后面的某个节点span
:记录前进指针的跨度,是当前节点和前进指针指向的节点的距离,用于计算元素排名(rank)
上图展示了一个跳跃表示例,以及遍历一个跳跃表的过程(虚线)
skiplist与平衡树的比较#
- 在做范围查找的时候,平衡树比skiplist操作要复杂。在平衡树上,我们找到指定范围的小值之后,还需要以中序遍历的顺序继续寻找其它不超过大值的节点。如果不对平衡树进行一定的改造,这里的中序遍历并不容易实现。而在skiplist上进行范围查找就非常简单,只需要在找到小值之后,对第1层链表进行若干步的遍历就可以实现。
- 平衡树的插入和删除操作可能引发子树的调整,逻辑复杂,而skiplist的插入和删除只需要修改相邻节点的指针,操作简单又快速。
- 从内存占用上来说,skiplist比平衡树更灵活一些。一般来说,平衡树每个节点包含2个指针(分别指向左右子树),而skiplist每个节点包含的指针数目平均为1/(1-p),具体取决于参数p的大小。如果像Redis里的实现一样,取p=1/4,那么平均每个节点包含1.33个指针,比平衡树更有优势。
- 从算法实现难度上来比较,skiplist比平衡树要简单得多。
七、整数集合#
intset是set集合类型的底层实现之一,当set的元素数量小于set-max-intset-entries
(默认值 512),并且只包含整数类型时,redis会使用intset作为set的底层实现
intsest可以保存int16_t、int32_t、int64_t类型的整数,源码定义在intset.h和intset.c
typedef struct intset {
uint32_t encoding;
uint32_t length;
int8_t contents[];
} intset;
#define INTSET_ENC_INT16 (sizeof(int16_t)) // 值为2
#define INTSET_ENC_INT32 (sizeof(int32_t)) // 值为4
#define INTSET_ENC_INT64 (sizeof(int64_t)) // 值为8
contents
:contents柔性数组是整数集合的底层实现,集合元素在数组中从小到大排列,并且不重复。虽然其声明为int8_t类型的数组,但实际上contents数组的真正类型取决于encoding的值encoding
:encoding的值决定了contents数组的类型,有三种取值- INTSET_ENC_INT16,表示contents是一个int16_t类型的数组
- INTSET_ENC_INT32,表示contents是一个int32_t类型的数组
- INTSET_ENC_INT64,表示contents是一个int64_t类型的数组
length
:记录了整数集合的元素个数
如果我们将一个新元素插入到整数集合中,并且新元素的类型比现有的所有元素类型都要大,整数集合就要先进行升级操作。升级的步骤大致如下:
- 根据新元素的类型为整数集合底层数组重新分配空间大小(包含新元素的空间)
- 将底层数组的类型都调整为新的类型,并从后向前依次将他们放到升级后的位置上
- 触发升级的新元素要么小于所有元素(负数),要么大于所有元素,所以新元素要么放在数组开头,要么放在数组末尾
intset通过自动升级底层数组来适应新元素,所以我们可以随意的将int16_t、int32_t、int64_t类型的整数添加到集合中,而不是像普通的数组那样只能保存一种类型的元素;另外又可以确保升级只在需要的时候进行,以此来尽量的节省内存。
另外,intset不支持降级操作。
不像ziplist能够存储任何二进制序列,每个元素采用变长编码,intset只能用来存储整数,而且每个元素的长度相同(编码相同)
以上我们陆续介绍了redis中用到的主要数据结构,但redis并没有直接使用这些数据结构来实现数据库的各种类型,而是基于这些数据结构创建了一个对象系统。下一篇文章,将详细的介绍redis的对象系统。
参考:
【推荐】国内首个AI IDE,深度理解中文开发场景,立即下载体验Trae
【推荐】编程新体验,更懂你的AI,立即体验豆包MarsCode编程助手
【推荐】抖音旗下AI助手豆包,你的智能百科全书,全免费不限次数
【推荐】轻量又高性能的 SSH 工具 IShell:AI 加持,快人一步
· 全程不用写代码,我用AI程序员写了一个飞机大战
· DeepSeek 开源周回顾「GitHub 热点速览」
· MongoDB 8.0这个新功能碉堡了,比商业数据库还牛
· 记一次.NET内存居高不下排查解决与启示
· 白话解读 Dapr 1.15:你的「微服务管家」又秀新绝活了