Redis 是一个开源的的数据结构存储系统,它可以用作数据库、缓存和消息代理。由于其高效的数据处理能力和灵活的数据结构,Redis 在许多场景中得到了广泛的应用,了解 Redis 的底层数据结构对于深入理解其性能和功能至关重要。
Redis 的底层数据结构主要包括字简单动态字符串、链表、字典、跳跃表、整数集合、压缩列表等。本文将详细介绍 Redis 的底层数据结构,帮助读者更好地理解和使用 Redis。
简单动态字符串(Simple Dynamic String,简称SDS)是Redis底层实现中使用的一种字符串表示方式,它具有自我调整和内存优化功能。SDS的结构如下:
struct sdshdr {
// 记录buf数组中已使用字节的数量
// 等于SDS所保存字符串的长度
int len;
//记录buf数组中未使用字节的数量
int free;
// 字符数组,用于保存字符串
char buf[];
}
SDS遵循C字符串以空字符结尾的惯例,保存空字符的1字节空间不计算在SDS的len属性里面
Redis对SDS的内存管理进行了优化。当字符串长度小于16个字节时,Redis会尝试将字符串直接存储在SDS结构中。如果字符串长度超过16个字节,Redis会使用额外的内存来存储字符串。此外,当未使用的空间大于16个字节时,Redis会尝试回收未使用的空间,以减少内存浪费。
C字符串并不记录自身的长度信息,获取一个C字符串的长度,必须遍历整个字符串,对遇到的字符进行计数,直到遇到代表字符串结尾的空字符为止,复杂度为O(n);而SDS在len属性中记录了SDS的本身长度,复杂度为O(1)。
C字符串不记录自身长度容易造成缓冲区溢出,SDS的空间分配策略完全杜绝了发生缓冲区的可能性。当SDS API需要对SDS进行修改时,API会先检查SDS的空间是否满足修改所需的要求,如果不满足的话,API会自动将SDS的空间扩展至执行修改所需的大小,然后才执行实际的修改操作。
通过未使用空间,SDS实现了空间预分配和惰性空间释放两种优化策略:
C字符串中的字符必须符合某种编码,并且除了字符串的末尾之外,字符串里面不能包含空字符。Redis使用字节数组保存一系列的二进制数据,使用len属性的值而不是空字符判断字符串是否结束。
sdsnew(const char *init):创建一个新的SDS,并以C字符串init进行初始化。
sdsempty():创建一个空的SDS。
sdslen(const sds s):返回SDS中存储的字符串的长度。
sdsavail(const sds s):返回SDS中未使用的字节数。
sdsdup(const sds s):复制一个SDS,并返回副本。
sdscpy(sds s, const char *t):将C字符串t复制到SDS s 中。
sdscat(sds s, const char *t):将C字符串t追加到SDS s 的末尾。
sdscatsds(sds s, const sds t):将另一个SDS t 追加到SDS s 的末尾。
sdscmp(const sds s1, const sds s2):比较两个SDS的内容。
sdsfree(sds s):释放一个SDS的内存。
Redis的链表是一种双向链表,每个节点都包含了一个指向下一个节点和上一个节点的指针。这种设计使得Redis的链表具有高效的插入、删除和搜索操作。
节点结构:每个节点都包含了一个指向下一个节点的指针(next)和一个指向前一个节点的指针(prev)。此外,节点还包含了存储数据的空间。
typedef struct listNode {
// 前置节点
struct listNode *prev;
// 后置节点
struct listNode *next;
// 节点的值
void *value;
}
链表结构:多个节点组合在一起,形成了Redis中的链表。链表的头部节点包含了一个指向第一个节点的指针(head)和一个指向最后一个节点的指针(tail)。
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;
Redis的字典是一个哈希表,用于存储键值对数据。在Redis中,字典的键是唯一的,而值可以是任意类型。与传统的哈希表不同,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_t u64;
int64_t s64;
} v;
// 指向下个哈希表节点,形成链表
struct dictEntry *next;
} dictEntry;
Redis中的字典由dict结构表示:
typedef struct dict{
// 类型特定函数
dictType *type;
// 私有数据
void *privdata;
// 哈希表
dictht ht[2];
// rehash索引,当rehash不再进行时,值为-1
int trehashidx;
} dict;
当字典被用作数据库的底层实现,或者哈希键的底层实现时,Redis使用MurmurHash2算法来计算键的哈希值。哈希表使用链地址法来解决冲突,被分配到同一个索引上的多个键值对会连接成一个单向链表。
随着操作的不断执行,哈希表保存的键值对会逐渐地增多或者减少,为了让哈希表的负载因子(load factor)维持在一个合理的范围之内,当哈希表保存的键值对数量太多或者太少时,程序需要对哈希表的大小进行相应的扩展或者收缩,也就是进行rehash。
rehash的步骤如下:
1、为字典中的ht[1]哈希表分配空间
例如执行扩展操作,如果ht[0].used为5,那么第一个大于等于ht[0].used*2的2n为16,也就是ht[1]的大小为16。
2、将保存在ht[0)中的所有键值对rehash到ht[I]上面:rehash指的是重新计算键的哈希值和索引值,然后将键值对放置到ht[1]哈希表的指定位置上。
3、当ht[0]包含的所有键值对都迁移到了ht[1]之后(ht[0]变为空表),释放ht[0],将ht[1]设置为ht[0],并在ht[1]新创建一个空白哈希表,为下一次rehash做准备。
由于哈希表中可能保存着大量的键值对,如果要一次性将这些键值对全部rehash到ht[1]的话,庞大的计算量可能会导致服务器在一段时间内停止服务。因为,为了避免rehash对服务器性能造成影响,服务器会分多次、渐进式地将ht[0]里面的键值对慢慢地rehash到ht[1]。
以下是哈希表新进式rehash的详细步骤:
dictCreate 创建一个新的字典
dictAdd 将给定的键值对添加到字典里面
dictReplace 将给定的键值对添加到字典里面,如果键已经存在于字典,那么用新值取代原有的值
dictFetchValue 返回给定键的值
dictGetRandomKey 从字典中随机返回一个键值对
dictDelete 从字典中删除给定键所对应的键值对
dictRelease 释放给定字典,以及字典中包含的所有键值对
跳跃表(skipList)是一种有序数据结构,它通过在每个节点中维持多个指向其他节点的指针,从而达到快速访问节点的目的。Redis只在两个地方用到了跳跃表,一个是实现有序集合键,另一个是在集群节点中用作内部数据结构。
Redis的跳跃表有zskiplistNode和zskiplist两个结构定义,其中zskiplistNode结构用于表示跳跃表节点,而zskiplist结构用于保存跳跃表节点的相关信息,比如节点的数量,以及指向表头节点和表尾节点的指针等等。
跳跃表节点的定义如下:
typedef struct zskiplistNode {
// 后退指针
struct zskiplistNode *backward;
// 分值
double score;
// 成员对象
robj *obj;
// 层
struct zskiplistLevel {
// 前进指针
struct zskiplistNode *forward;
// 跨度
unsigned int span;
} level[];
} zskiplistNode;
跳跃表节点的level数组可以包含多个元素,每个元素都包含一个指向其他节点的指针,程序可以通过这些层来加快访问其他节点的速度。
每个层都由一个指向表尾方向的前进指针,用于从表头向表尾方向访问节点。
层的跨度用于记录两个节点之间的距离,两个节点之间的跨度越大,它们相距得就越远。指向NULL的所有前进指针的跨度都为0,因为他们没有连向任何节点。
节点的后退指针用于从表尾向表头方向访问节点,因为每个节点都只有一个后退指针,所以每次只能后退至前一个节点。
节点的分支是一个double类型的浮点数,跳跃表中的所有节点都按分值从小到大来排列。节点的成员对象是一个指针,它指向一个字符串对象,而字符串对象则保存着一个SDS值。
通过zskiplist结构来持有跳跃表节点,程序可以更方便地对整个跳跃表进行处理,zskiplist的结构定义如下:
typedef struct zskiplist {
// 表头节点和表尾节点
struct skiplistNode *header, *tail;
// 表中节点的数量
unsigned long length;
// 表中层数最大的节点的层苏
int level;
} zskiplist;
zsICreate 创建一个新的跳跃表
zslFree 释放给定跳跃表,以及表中包合的所有节点
zslInsert 将包含给定成员和分值的新节点添加到跳妖表中
zslDelete 删除跳跃表中包含给定成员和分值的节点
zslGetRank 返回包含给定成员和分值的节点在跳跃表中的排位
zslGetElementByRank 返回跳跃表在给定排位上的节点
整数集合(intset)是集合键的底层实现之一,当一个集合只包括整数值元素,并且这个集合的元素数量不多时,Redis就会使用整数集合作为集合键的底层实现。
整数集合的结构如下:
typedef struct intset {
// 编码方式
unint32_t encoding;
// 集合包含的元素数量
unint32_t length;
// 保存元素的数组
int8_t contents[];
} intset;
虽然intset结构将contents属性声明为int8_t类型的数组,但实际上contents数组真正的类型取决于encoding属性的值。
每当我们要将一个新元素添加到整数集合里面,并且新元素的类型比整数集合现有元素的类型都要长时,整数集合需要先进行升级,然后才能将新元素添加集合里面。
升级整数集合并添加新元素共分为三步进行:
整数集合不支持降级操作,一旦对数组进行了升级,编码就会一直保持升记后的状态。升级的好处有以下两点:
intsetNew 创建一个新的整数集合
intsetAdd 将给定元素添加到整数集合里面
intsetRemove 从整数集合中移除给定元素
intsetFind 检查给定值是否存在于集合
intsetRandom 从整数集合中随机返回一个元素
intsetGet 取出底层数组在给定索引上的元素
intsetLen 返回整数集合包含的元素个数
intsetBlobLen 返回整数集合占用的内存字节数
压缩列表(ziplist)是列表键和哈希键的底层实现之一。当一个列表键只包含少量列表项,并且每个列表项要么就是小整数值,要么就是长度比较短的字符串,那么Redis会使用压缩列表来做列表键的底层实现。
压缩列表是Redis为了节约内存而开发的,是由一系列特殊编码的连续内存组成的顺序型数据结构。压缩列表各个组成部分的详细说明:
属性 | 类型 | 长度 | 用途 |
---|---|---|---|
zlbytes | uint32_t | 4字节 | 记录整个压缩列表占用的内在字节数:在对压缩列表进行内存重分配,或者计算zlend的位置时使用 |
zltail | uint32_t | 4字节 | 记录压缩列表表尾节点距离压缩列表的起始地址有多少字节:通过这个偏移量,程序无须遍历整个压缩列表就可以确定表尾节点的地址 |
zllen | uint16_t | 2字节 | 记录了压缩列表包含的节点数量 |
entryX | 列表节点 | 不定 | 压缩列表包含的各个节点,节点的长度由节点保存的内容决定 |
zlend | uint8_t | 1字节 | 特殊值0xFF(十进制255),用于标记压缩列表的末端 |
每个压缩列表节点可以保存一个字节数组或者一个整数值,每个压缩列表节点都由previous_entry_length、encoding、content三个部分组成。
节点的previous_entry_length属性以字节为单位,记录了压缩列表中前一个节点的长度。previous_entry_length属性的长度可以是1字节或者5字节。因为节点的previous_entry_length属性记录了前一个节点的长度,所以程序可以通过指针运算,根据当前节点的起始地址计算出前一个节点的起始地址。
节点的encoding属性记录了节点的content属性所保存数据的类型以及长度。
节点的content属性负责保存节点的值,节点值可以是一个字节数组或者整数,值的类型和长度由节点的encoding属性决定。
前面说过,每个节点的previous_entry_length属性都记录了前一个节点的长度:
如果在一个压缩列表中,有多个连续的、长度介于250字节到253字节之间的节点。当我们新插入一个长度大于等于254字节的节点时,后面节点的previous_entry_length属性会从原来的1字节扩展为5字节长,这会导致后面节点的长度也大于253字节,由此引发连锁反应:程序需要不断对压缩列表执行空间重分配操作,称之为“连锁更新”。尽管连锁更新的复杂度较高,但是造成性能问题的几率还是很低的:
ziplistNew 创建一个新的压缩列表
ziplistPush 创建一个包含给定值的新节点,并将这个新节点添加到压缩列表的表头或者表尾
ziplistInsert 将包含给定值的新节点插入到给定节点之后
ziplistIndex 返回压缩列表给定索引上的节点
ziplistFind 在压缩列表中查找并返回包含了给定值 的节点
ziplistNext 返回给定节点的下一个节点
ziplistPrev 返回给定节点的前一个节点
ziplistGet 获取给定节点所保存的值
ziplistDelete 从压缩列表中删除给定的节点
ziplistDeleteRange 酬除压缩列表在给定索引上的连续多个节点
ziplistBlobLen 返回压缩列表目前占用的内存字节数
ziplistLen 返回压缩列表目前包含的节点数量
对于Redis数据库保存的键值对来说,键总是一个字符串对象,而值则可以是字符串对象、列表对象、哈希对象、集合对象或者有序集合对象的其中一种。
字符串对象的编码可以是int、raw或者embstr。
embstr编码的字符串对象实际上是只读的,embstr编码的字符串对象在执行修改命令之后,总会变成一个raw编码的字符串对象。
列表对象的编码可以是ziplist或者linkedlist。ziplist编码的列表对象使用压缩列表作为底层实现,每个压缩列表节点都保存了一个列表元素。linkedlist编码的列表对象使用双端链表作为底层实现,每个双端链表节点都保存了一个字符串对象,而每个字符串对象保存了一个列表元素。
当列表对象可以同时满足以下两个条件时,列表对象使用ziplist编码:
哈希对象的编码可以是ziplist或者hashtable。
ziplist编码的哈希对象使用压缩列表作为底层实现,每当有新的建值对要加入到哈希对象时,程序会先将保存了键的压缩列表节点推入压缩列表表尾,然后再将保存了值的压缩列表节点推入压缩列表表尾,因此:
hashtable编码的哈希对象使用字典作为底层实现,哈希对象中的每个键值对使用一个字典键值对来保存:
当哈希对象可以同时满足以下两个条件,哈希对象使用ziplist编码:
集合对象的编码可以是intset或者hashtable:
当集合对象可以同时满足以下两个条件时,对象使用intset编码:
有序集合的编码可以是ziplist或者skiplist。ziplist编码的有序集合对象使用压缩列表作为底层实现,每个集合元素使用两个紧挨在一起的压缩列表节点来保存,第一个节点保存元素的成员,而第二个元素保存元素的分值。skiplist编码的有序集合对象使用zset结构作为底层实现,一个zset结构同时包含一个字典和一个跳跃表,数据结构如下:
typedef struct zset {
zskiplist *zsl;
dict *dict;
} zset;
虽然zset结构同时使用跳跃表和字典来保存有序集合元素,但这两种数据结构都会通过指针来共享相同元素的成员和分值,所以同时使用跳跃表和字典来保存集合元素不会产生任何重复成员或者分支,也不会因此浪费额外的内存。
当有序集合对象可以同时满足以下两个条件时,对象使用ziplist编码:
Redis在自己的对象系统中构建了一个引用计数技术实现的内存回收机制,类似jvm的引用计数法。
除了用于实现引用计数内存回收机制之外,对象的引用计数属性还带有对象共享的作用。也就是说,当有多个键对应的值对象是一样的时,可以让多个键共享同一个值对象,需要执行以下两个步骤:
目前,Redis会在初始化服务器时,创建一万个字符串对象,这些对象包含了从0-9999的所有整数值,当服务器需要用到值为0-9999的字符串对象时,服务器就会使用这些共享对象,而不是新创建对象。