Redis 的五种基本类型(原理篇)
良心公众号
关注不迷路
Redis 是一个速度非常快的非关系型数据库,它可以存储键 (key) 与 5 种不同类型的值 (value) 之间的映射,可以将存储在内存的键值对数据持久化到硬盘,可以使用复制特性来扩展读性能,还可以使用客户端分片来扩展性能。
《Redis In Action》Josiah L. Carlson 著
在之前的 Redis 的五种基本类型(实战篇)一文中,主要是从实践的角度出发,利用思维导图的形式,盘点了五种基本类型的实践操作及一些适用场景。本文则将从原理的角度,对 Redis 的五种基本类型抽丝剥茧,深入考察其原理,从而对其有更加深刻的认识(跟面试官谈笑风生)。
如下图所示,图中展示了 Redis 的 key-value 的存储结构和五种基本数据类型。
而 Redis 对于上述五种数据类型的支持,是依靠下图中所示的六种数据结构来实现的。
那么问题来了,Redis 支持的数据类型是五种,但底层的涉及的数据结构却有六种,这是为什么呢?让我们带着这个小小的疑问,来开启对上述六种数据结构的探索吧。
01
SDS (简单动态字符串)
对于 String 类型的底层实现,Redis 并没有直接使用 C 语言的字符串表示, 而是构建了一种被称为 SDS (简单动态字符串) 的抽象类型。其底层数据结构如下所示:
struct sdshdr {
// 记录 buf 数组中已使用字节的数量
int len;
// 记录 buf 数组中未使用字节的数量
int free;
// 字节数组,用于保存字符串
char buf[];
};
基于上述 SDS 的数据结构,结合源码中部分方法的具体实现 (在此并未列出),我们可以看出,Redis 的 简单动态字符串数据结构有如下特点:
由于 len 字段记录了 buf 数组中已使用的字节数,因此,获取字符串长度的复杂度为 O(1) 。
由于 len 字段和 free 字段分别记录了 buf 数组中已使用和未使用的字节数,在进行字符串修改或者拼接的时候,会预先进行 buf 数组空间使用情况的判断,因此,其 API 是安全的,不会造成缓冲区溢出。
同样,由于 len 字段和 free 字段分别记录了 buf 数组中已使用和未使用的字节数,因此,对于字符串是否结束的判断,并不以 '\0' 表示的空字符串为依据,因此,Redis 不仅可以保存文本数据,还可以保存二进制数据 (例如图片、音频、视频、压缩文件等)。
SDS 采用了空间预分配和惰性空间释放的内存管理策略,从而使得修改字符串长度 N 次最多需要执行 N 次内存重分配。
02
List (链表)
对于 List 类型的底层实现,由于 C 语言并没有内置链表的数据结构,因此,Redis 自己实现了链表的数据结构。
基础的链表结点 listNode 数据结构,如下所示:
typedef struct listNode {
// 前驱节点
struct listNode *prev;
// 后继节点
struct listNode *next;
// 节点值
void *value;
} listNode;
Redis 在实现 listNode 的基础上,对其进行封装,得到了 list 数据结构,如下所示:
typedef struct list {
// 表头节点
listNode *head;
// 表尾节点
listNode *tail;
// 链表所包含的节点数量
unsigned long len;
// 节点值复制函数
void *(*dup)(void *ptr);
// 节点值释放函数
void (*free)(void *ptr);
// 节点值对比函数
int (*match)(void *ptr, void *key);
} list;
基于上述 listNode 和 list 的数据结构,结合源码中部分方法的具体实现 (在此并未列出),我们可以看出,Redis 的链表数据结构有如下特点:
双向无环链表:listNode 数据结构带有 prev 和 next 指针, 分别用于指向当前的节点的前驱节点和后继节点,因此,获取某个节点的前驱节点和后继节点的时间复杂度都是 O(1)。表头节点的 prev 指针和表尾节点的 next 指针都指向 NULL, 对链表的访问以 NULL 为终点。
表头指针和表尾指针:list 数据结构带有 head 指针和 tail 指针, 分别用于获取链表的表头节点和表尾节点,其时间复杂度为 O(1)。
链表长度计数器:list 数据结构的 len 字段用于存储链表当前的节点数量,因此,获取链表中节点数量的复杂度为 O(1)。
多态:链表节点使用 void* 指针来保存节点值, 并且可以通过 list 数据结构所提供的 dup,free,match 三个属性为节点值设置类型特定函数, 因此,链表可以用于保存各种不同类型的值。
03
Dict (字典)
Redis 的字典数据结构使用哈希表作为底层实现,其代码如下所示:
typedef struct dict {
// 类型特定方法
dictType *type;
// 私有数据
void *privdata;
// 哈希表
dictht ht[2];
// rehash 索引
int rehashidx;
} dict;
可以看出,Redis 的 dict 数据结构是通过 dictht 哈希表来实现的,从代码中可以看出,ht 属性是一个包含两个元素的数组,数组中的每个元素都是一个 dictht 哈希表,之所以有两个元素的原因是,通常情况下,dict 只使用 ht[0] 哈希表,当需要对 ht[0] 哈希表进行 rehash 时,才需要用到 ht[1] 哈希表。其中,dictht 数据结构的代码如下所示:
typedef struct dictht {
// 哈希表数组
dictEntry **table;
// 哈希表大小
unsigned long size;
// 哈希表大小掩码,用于计算索引值
// 总是等于 size - 1
unsigned long sizemask;
// 该哈希表已有节点的数量
unsigned long used;
} dictht;
可以看出,dictht 数据结构底层的主体是 table 数组,其存储的元素类型是一个被称为 dictEntry 的数据结构。每一个 dictEntry 结构保存一对键值对。其代码如下所示:
typedef struct dictEntry {
// 键
void *key;
// 值
union {
void *val;
uint64_tu64;
int64_ts64;
} v;
// 指向下个哈希表节点,形成链表
struct dictEbtry *next;
} dictEntry;
可以看出,dictEntry 中的 key 属性用于存储键值对的键,v 属性用于存储键值对的值。基于此,从而实现了对字典类型的支持。
基于上述 dict,dictht,dictEntry 的数据结构,结合源码中部分方法的具体实现 (在此并未列出),我们可以看出,Redis 的字典数据结构有如下特点:
Redis 中的字典使用哈希表作为底层实现, 每个字典带有两个哈希表, 一个用于平时使用, 另一个仅在进行 rehash 时使用。
哈希表使用链地址法来解决键冲突, 被分配到同一个索引上的多个键值对会连接成一个单向链表。
在对哈希表进行扩展或者收缩操作时, 程序需要将现有哈希表包含的所有键值对 rehash 到新哈希表里面, 并且这个 rehash 过程并不是一次性地完成的, 而是渐进式地完成的。
04
Skiplist (跳表)
跳表是面试过程中考察的热点数据结构,它是一种有序的数据结构,通过在每个节点上维护多个指向其他节点的指针,从而达到快速访问节点的目的。由于跳表支持平均 O(logn),最坏 O(n) 的查找时间复杂度,在大部分情况下,其性能甚至不差于平衡树,因此,在面试过程中,经常会被拿来和平衡树进行比较,比如,为什么 Redis 实现有序集合采用的是跳表,而不是红黑树?对红黑书不太了解的小伙伴请戳这里——如果你问我红黑树的话,我可就不困了!在了解跳表的具体实现之后,这个问题就比较清楚了!
Redis 通过 zskiplist 的结构体实现了跳表,其代码如下所示:
typedef struct zskiplist {
// 表头节点和表尾节点
struct zskiplistNode *header, *tail;
// 表中节点数量
unsigned long length;
// 表中层数最大的节点的层数
int level;
} zskiplist;
可以看出,zskiplist 维护了两个类型为 zskiplistNode 的节点指针,分别用来指向表头节点和表尾节点。zskiplistNode 数据结构的代码实现如下所示:
typedef struct zskiplistNode {
// 后退指针
struct zskiplistNode *backward;
// 分值
double score;
// 成员对象
robj *obj;
// 层
struct zskiplistLevel {
// 前进指针
struct zskiplistNode *forward;
// 跨度
unsigned int span;
} level[];
} zskiplistNode;
基于上述 zskiplist 和 zskiplistNode 的数据结构,结合源码中部分方法的具体实现,我们可以看出,Redis 的跳表数据结构有如下特点:
跳表是有序集合的底层实现之一,其实现由 zskiplist 和 zskiplistNode 两个结构组成, 其中 zskiplist 用于保存跳跃表信息(比如表头节点、表尾节点、长度), zskiplistNode 表示跳跃表节点。
每个跳跃表节点的层高都是 1 至 32 之间的随机数。
在同一个跳跃表中, 多个节点可以包含相同的分值, 但每个节点的成员对象必须唯一。
跳跃表中的节点按照分值大小进行排序, 当分值相同时, 节点按照成员对象的大小进行排序。
Redis 之所以采用跳表而非红黑树的数据结构实现有序集合的原因主要有两个:
跳表实现比红黑树实现更加简单;
跳表的数据结构决定它比红黑树可以更好地支持范围查询。
05
Intset (整数集合)
Redis 通过使用整数集合的数据结构来保存整数值,整数集合可以保存的类型为 int16_t,int32_t,或者 int64_t 的整数值,并能保证集合中不会出现重复元素。其代码如下所示:
typedef struct intset {
// 编码方式
uint32_t encoding;
// 集合包含的元素数量
uint32_t length;
// 保存元素的数组
int8_t contents[];
} intset;
可以看出,整数集合的数据结构,其底层主体是 contents 数组,用于存储整数集合中的每个元素,元素在数组中按从小到大的顺序有序排列,并且数组中不包含重复元素。
对于整数集合,由于其支持保存 int16_t,int32_t,或者 int64_t 的整数值,因此它涉及一个比较有趣的操作——升级。
所谓的升级,是指当我们向整数集合中添加一个新元素时,如果待添加的元素比整数集合中所有元素的类型都要长时,就需要先对整数集合进行升级,然后再进行添加。升级的步骤分为以下三步:
根据新元素的类型,扩展 contents 数组的空间大小,并为新元素分配空间;
对 contents 数组中存储的所有元素进行类型转换,然后将其存放进数组中,并保证有序性;
将新元素添加至 contents 数组。
升级操作的目的在于,在保证操作灵活性的前提下,尽量节约内存空间。
整数集合不支持降级。
基于上述 intset 的数据结构,结合源码中部分方法的具体实现,我们可以看出,Redis 的整数集合数据结构有如下特点:
整数集合是集合键的底层实现之一。
整数集合的底层实现为数组, 这个数组以有序、无重复的方式保存集合元素。
在添加新元素时,需要先判断是否需要进行升级, 不支持降级操作。
06
Ziplist (压缩列表)
压缩列表是列表键和哈希键的底层实现之一。
当一个列表键只包含少量列表项,并且每个列表向要么是小整数值,要么是短字符串时,Redis 会采用压缩列表来作为列表键的底层实现。
当一个哈希键只包含少量键值对,并且每个键值对的键和值要么是小整数值,要么是短字符串时,Redis 会采用压缩列表来作为哈希键的底层实现。
可以看出,Redis 为了保证极致的性能,在不同的场景下,采用了不同的实现方式,这也就是文章一开始所提到的,为什么 Redis 支持五种数据类型,却有六种底层实现。
Redis 的压缩列表数据结构有如下特点:
压缩列表是一种为节约内存而开发的顺序型数据结构。
压缩列表被用作列表键和哈希键的底层实现之一。
压缩列表可以包含多个节点,每个节点可以保存一个字节数组或者整数值。
综上所述,redis 的五种数据类型所涉及到的底层数据结构就总结到这里了。
欢迎关注【有理想的菜鸡】公众号,大家一起讨论技术,共同成长!
【参考资料】
《Redis 设计与实现》黄健宏 著
https://github.com/redis/redis
学习 | 工作 | 分享
👆长按关注“有理想的菜鸡”
只有你想不到,没有你学不到