本文摘录总结自《Redis设计与实现》一书。
Redis 是一个键值对数据库(key-value DB),数据库的值可以是字符串、集合、列表等多种类型的对象,而数据库的键则总是字符串对象。
一、内部数据结构
1. 简单动态字符串
(1) SDS的定义
SDS (Simple Dynamic String,简单动态字符串)是 Redis 底层所使用的字符串表示,几乎所有的 Redis 模块中都用了 SDS。
SDS的定义如下:
typedef char *sds;
struct sdshdr{
// buf已占用长度
int len;
// buf剩余可用长度
int free;
// 实际保存字符串数据的地方
char buf[];
};
(2) SDS的优点
对比 C 字符串,SDS 有以下特性:
- 可以高效地执行长度计算;
- 可以高效地执行追加操作;
- 二进制安全。
1) 高效地执行长度计算
C 字符串计算字符串长度的复杂度为 O(n),而 SDS 通过 len 属性,sdshdr 可以实现复杂度为O(1) 的长度计算操作。
2) 高效地执行追加操作
C 字符串对字符串进行 N 次追加,必须对字符串进行 N 次内存重分配。而 SDS 通过对 buf 分配一些额外的空间,并使用 free记录未使用空间的大小,sdshdr 可以让执行追加操作所需的内存重分配次数大大减少,当调用 SET命令创建 sdshdr 时,sdshdr的 free 属性为 0,Redis 也没有为 buf 创建额外的空间,而在执行 APPEND 之 后,Redis 为 buf 创建了多于所需空间一倍的大小。这样一来,如果将来再次对同一个 sdshdr进行追加操作,只要追加内容的长度不超过 free 属性的值,那么就不需要对 buf 进行内存重分配。代价是多占用了一些内存,而且这些内存不会被主动释放。
3) 二进制安全
C 字符串以 \0
标志字符串的结尾,所以字符串中不能出现 \0
,这不是二进制安全的。SDS 通过 len 属性来决定字符串的长度,从而字符串中可以出现 \0
。
2. 双端链表
(1) 双端链表的定义
双端链表的实现由 listNode 和 list 两个数据结构构成
链表节点的定义如下:
typedef struct listNode{
// 前驱节点
struct listNode *prev;
// 后继节点
struct listNode *next;
// 值,void *表示对值的类型不做限制
void *value;
} listNode;
链表的定义如下:
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;
(2) 双端链表的优点
list类型保留了三个函数指针 —— dup、 free 和 match,分别用于处理值的复制、释放和对比匹配。
- listNode带有 prev 和 next 两个指针,因此,遍历可以双向进行:从表头到表尾,表尾到表头;
- list保存了 head 和 tail 两个指针,因此,对链表的表头和表尾进行插入的复杂度都为 O(1),这是高效实现 LPUSH 、 RPOP 、 RPOPLPUSH 等命令的关键;
- list 带有保存节点数量的 len 属性,所以计算链表长度的复杂度仅为 O(1),这也保证了 LLEN 命令不会成为性能瓶颈。
3. 字典
(1) 字典的定义
字典(dictionary),又名映射(map)或关联数组(associative array),是一种抽象数据结构,由一集键值对(key-value pairs)组成,各个键值对的键各不相同,程序可以添加新的键值对到字典中,或者基于键进行查找、更新或删除等操作。
Redis 选择了高效、实现简单的哈希表,作为字典的底层实现。
字典的定义:
typedef struct dict {
// 类型特定函数
dictType *type;
// 私有数据
void *privdata;
// 哈希表(2个)
dictht ht[2];
// 记录rehsh进度的标志,当rehash未进行时,值为-1
int rehashidx;
} dict;
哈希表的定义:
typedef struct dictht {
// 哈希表节点指针数组(俗称桶,bucket)
dictEntry **table;
// 指针数组大小
unsigned long size;
// 指针数组大小掩码,用于计算索引值
// 总是等于 size - 1
unsigned long sizemask;
// 该哈希表已有节点的数量
unsigned long used;
} dictht;
哈希表节点的定义:
typedef struct dictEntry {
// 键
void *key;
// 值
union {
void *val;
uint64_t u64;
int64_t s64;
} v;
// 指向后继节点
struct dictEntry *next;
} dictEntry;
(2) 字典的用途
字典的主要用途有以下两个:
-
实现数据库键空间(key space);
-
用作 Hash 类型键的底层实现之一。
Redis 是一个键值对数据库,数据库中的键值对由字典保存:每个数据库都有一个对应的字典,这个字典被称之为键空间(key space)。
(3) 字典的插入
字典虽然创建了两个哈希表,但正在使用的只有 0 号哈希表。
-
ht[0]->table 的空间分配将在第一次往字典添加键值对时进行;
-
ht[1]->table 的空间分配将在 rehash 开始时进行。
将给定的键值对添加到字典可能会引起一系列复杂的操作:
- 如果字典为未初始化(即字典的 0 号哈希表的 table 属性为空),则程序需要对 0 号哈希表进行初始化;
- 如果在插入时发生了键碰撞,则程序需要处理碰撞;
- 如果插入新元素,使得字典满足了 rehash 条件,则需要启动相应的 rehash 程序;
当程序处理完以上三种情况之后,新的键值对才会被真正地添加到字典上。此外 dictht 使用链地址法(又称拉链法)来处理键碰撞:当多个不同的键拥有相同的哈希值时,哈希表用一个链表将这些键连接起来。
(4) 字典的扩展
1) rehash时机
指针数组大小(size 属性)与保存节点数量(used 属性)之间的比率ratio = used / size
满足以下任何一个条件的话,rehash 过程就会被激活:
-
自然 rehash : ratio >= 1,且变量 dict_can_resize 为真;
-
强制 rehash : ratio 大于变量 dict_force_resize_ratio(目前版本中,dict_force_resize_ratio 的值为 5)。
2) rehash流程
字典的 rehash 操作实际上就是执行以下任务:
-
创建一个比 ht[0]->table 更大的 ht[1]->table;
-
将 ht[0]->table 中的所有键值对迁移到 ht[1]->table;
-
将原有 ht[0] 的数据清空,并将 ht[1] 替换为新的 ht[0]。
在 rehash 的最后阶段,程序会执行以下工作:
-
释放 ht[0] 的空间;
-
用 ht[1] 来代替 ht[0],使原来的 ht[1] 成为新的 ht[0];
-
创建一个新的空哈希表,并将它设置为 ht[1];
-
将字典的 rehashidx 属性设置为 -1,标识 rehash 已停止。
3) 渐进式rehash
rehash 程序并不是在激活之后,就马上执行直到完成的,而是分多次、渐进式地完成的。因为要求服务器必须阻塞直到 rehash 完成,这对于 Redis 服务器本身是不能接受的。渐进式 rehash 主要由 _dictRehashStep
和 dictRehashMiliseconds
两个函数进行:
-
_dictRehashStep
用于对数据库字典、以及哈希键的字典进行被动 rehash。每次执行_dictRehashStep
,ht[0]->table 哈希表第一个不为空的索引上的所有节点就会全部迁移到 ht[1]->table。在 rehash 开始进行之后(d->rehashidx不为 -1),每次执行一次添加、查找、删除操作,_dictRehashStep
都会被执行一次; -
dictRehashMiliseconds
则由 Redis 服务器常规任务程序(server cron job)执行,用于对数据库字典进行主动 rehash。dictRehashMiliseconds
可以在指定的毫秒数内,对字典进行 rehash 。当 Redis 的服务器常规任务执行时。dictRehashMiliseconds
会被执行,在规定的时间内,尽可能地对数据库字典中那些需要 rehash 的字典进行 rehash ,从而加速数据库字典的 rehash 进程。
4) rehash的额外措施
在哈希表进行 rehash 时,字典还会采取一些特别的措施,确保 rehash 顺利、正确地进行:
- 因为在 rehash 时,字典会同时使用两个哈希表,所以在这期间的所有查找、删除等操作,除了在 ht[0] 上进行,还需要在 ht[1] 上进行;
- 在执行添加操作时,新的节点会直接添加到 ht[1] 而不是 ht[0],这样保证 ht[0] 的节点数量在整个 rehash 过程中都只减不增。
(5) 字典的收缩
如果哈希表的可用节点数比已用节点数大很多的话,那么也可以通过对哈希表进行 rehash 来收缩(shrink)字典。收缩 rehash 和上面展示的扩展 rehash 的操作几乎一样,字典收缩和字典扩展的区别是:
- 字典的扩展操作是自动触发的(不管是自动扩展还是强制扩展);
- 而字典的收缩操作则是由程序手动执行。
4. 跳跃表
(1) 跳跃表的定义
跳跃表(skiplist)是一种随机化的数据,跳跃表以有序的方式在层次化的链表中保存元素,效率和平衡树媲美 —— 查找、删除、添加等操作都可以在对数期望时间下完成,并且比起平衡树来说,跳跃表的实现要简单直观得多。
跳跃表主要由以下部分构成:
-
表头(head):负责维护跳跃表的节点指针;
-
跳跃表节点:保存着元素值,以及多个层;
-
层:保存着指向其他元素的指针。高层的指针越过的元素数量大于等于低层的指针,为了提高查找的效率,程序总是从高层先开始访 问,然后随着元素值范围的缩小,慢慢降低层次;
-
表尾:全部由 NULL组成,表示跳跃表的末尾。
跳跃表的定义:
typedef struct zskiplist{
// 头节点,尾节点
struct zskiplistNode *header, *tail;
// 节点数量
unsigned long length;
// 目前表内节点的最大层数
int level;
} zskiplist;
跳跃表节点的定义:
typedef struct zskiplistNode {
// 后退指针
struct zskiplistNode *backward;
// 分值
double score;
// 成员对象
robj *obj;
// 层
struct zskiplistLevel {
// 前进指针
struct zskiplistNode *forward;
// 跨度
unsigned int span;
} level[];
} zskiplistNode;
(2) redis中的跳跃表
为了满足自身的功能需要,Redis 基于原始的跳跃表进行了以下修改:
- 允许重复的 score 值:多个不同的 member 的 score 值可以相同;
- 进行对比操作时,不仅要检查 score值,还要检查 member:当 score 值可以重复时,单靠 score 值无法判断一个元素的身份,所以需要连 member 域都一并检查才行;
- 每个节点都带有一个高度为 1 层的后退指针,用于从表尾方向向表头方向迭代:当执行 ZREVRANGE 或 ZREVRANGEBYSCORE 这类以逆序处理有序集的命令时,就会用到这个属性。
二、内存映射数据结构
内存映射数据结构是一系列经过特殊编码的字节序列,创建它们所消耗的内存通常比作用类似的内部数据结构要少得多,如果使用得 当,内存映射数据结构可以为用户节省大量的内存。不过,因为内存映射数据结构的编码和操作方式要比内部数据结构要复杂得多,所以内存映射数据结构所占用的 CPU 时间会比作用类似 的内部数据结构要多。
1. 整数集合
(1) 整数集合的定义
整数集合(intset)用于有序、无重复地保存多个整数值,根据元素的值,自动选择该用什么长度的整数类型来保存元素。整数集合会使用最长元素的类型来保存所有元素,当新加入的元素的长度比当前最大长度大时,就要对所有元素进行升级。
(2) 整数集合元素的添加和升级
添加新元素时有三种情况:
- 元素已存在于集合,不做动作;
- 元素不存在于集合,并且添加新元素并不需要升级;
- 元素不存在于集合,但是要在升级之后,才能添加新元素。
升级时需要完成以下几个任务:
- 对新元素进行检测,看保存这个新元素需要什么类型的编码;
- 将集合 encoding 属性的值设置为新编码类型,并根据新编码类型,对整个 contents 数组进行内存重分配;
- 调整 contents 数组内原有元素在内存中的排列方式,从旧编码调整为新编码;
- 将新元素添加到集合中。
整个过程中,最复杂的就是第三步
插入和删除元素时,需要对元素进行移动,所以时间复杂度为O(n)
(3) 整数集合的搜索
有两种方式读取 intset的元素,一种是 _intsetGet
,另一种是 intsetSearch
:
_intsetGet
接受一个给定的索引 pos,并根据 intset->encoding 的值进行指针运算,计算出给定索引在 intset->contents 数组上的 值;intsetSearch
则使用二分查找算法,判断一个给定元素在 contents 数组上的索引。
2. 压缩列表
(1) 压缩列表的定义
压缩列表是由一系列特殊编码的内存块构成的列表,更加节约内存。一个 ziplist 可以包含多个节点(entry),每个节点可以保存一个长度受限的字 符数组(不以 \0
结尾的 char数组)或者整数,包括:
- 字符数组
- 长度小于等于 63( 2 6 − 1 2^6-1 26−1)字节的字符数组
- 长度小于等于 16383( 2 14 − 1 2^{14}-1 214−1) 字节的字符数组
- 长度小于等于 4294967295( 2 32 − 1 2^{32}-1 232−1)字节的字符数组
- 整数
- 4位长,介于 0 至 12 之间的无符号整数
- 1字节长,有符号整数
- 3字节长,有符号整数
- int16_t 类型整数
- int32_t 类型整数
- int64_t 类型整数
因为压缩列表 header 部分的长度总是固定的(4字节 + 4字节 + 2字节),因此将指针移动到表头节点的复杂度为常数时间; 除此之 外,因为表尾节点的地址可以通过 zltail 计算得出,因此将指针移动到表尾节点的复杂度也为常数时间。
(2) 节点的构成
-
pre_entry_length 记录了前一个节点的长度,通过这个值,可以进行指针计算,从而跳转到上一个节点;
-
encoding 和 length 两部分一起决定了 content 部分所保存的数据的类型(以及长度),根据列表长度的不同,length 属性的数据类型也不同;
-
content部分保存着节点的内容。
创建新压缩列表时,空白压缩列表的表头、表尾和末端处于同一地址
(3) 插入新元素
根据新节点添加位置的不同,这个工作可以分为两类来进行:
-
将节点添加到压缩列表末端:在这种情况下,新节点的后面没有任何节点;
-
将节点添加到某个/某些节点的前面:在这种情况下,新节点的后面有至少一个节点。
1) 插入到压缩列表末端
将新节点添加到压缩列表的末端需要执行以下三个步骤:
-
记录到达压缩列表末端所需的偏移量(因为之后的内存重分配可能会改变压缩列表的地址,因此记录偏移量而不是保存指针);
-
根据新节点要保存的值,计算出编码这个值所需的空间大小,以及编码它前一个节点的长度所需的空间大小,然后对 ziplist 进行内存重分配;
-
设置新节点的各项属性: pre_entry_length、 encoding、 length和 content;
-
更新压缩列表的各项属性,比如记录空间占用的 zlbytes,到达表尾节点的偏移量 zltail,以及记录节点数量的 zlen。
2) 插入到某个节点前
将一个新节点添加到某个/某些节点的前面要复杂得多,因为这种操作除了将新节点添加到压缩列表以外,还可能引起后续一系列节点的改变。
假设我们要将一个新节点 new 添加到节点 prev 和 next 之间:
首先为新节点扩大压缩列表的空间:
设置 new 节点的各项值
新的 new 节点取代原来的 prev 节点,成为了 next 节点的新前驱节点。在某个/某些节点的前面添加新节点之后,由于前面节点的长度可能会影响后面节点长度属性的数据类型,所以程序必须沿着路径挨个检查后续的节点,是否满足新长度的编码要求,直到遇到 一个能满足要求的节点(如果有一个能满足,则这个节点之后的其他节点也满足),或者到达压缩列表的末端 zlend 为止,这种检查操作的复杂度为 O( n 2 n^2 n2)。
删除节点和添加操作的步骤类似。
可以对压缩列表进行从前向后的遍历,或者从后先前的遍历。
三、Redis数据类型
Redis有5大数据类型——字符串、哈希表、列表、集合和有序集合。
1. 对象处理机制
(1) 对象共享
有一些对象在 Redis 中非常常见,比如命令的返回值 OK、 EROR、 WRONGTYPE 等字符,另外,一些小范围的整数,比如个位、十位、 百位的整数都非常常见。
为了利用这种常见情况,Redis 在内部使用了一个 Flyweight 模式 : 通过预分配一些常见的值对象,并在多个数据结构之间共享这些对象,程序避免了重复分配的麻烦,也节约了一些 CPU 时间。
(2) 引用计数以及对象的销毁
Redis 的对象系统使用了引用计数技术来负责维持和销毁对象,它的运作机制如下:
- 每个 redisObject 结构都带有一个 refcount 属性,指示这个对象被引用了多少次。 当新创建一个对象时,它的 refcount 属性被设置为 1;
- 当对一个对象进行共享时,Redis 将这个对象的 refcount增一。 当使用完一个对象之后,或者取消对共享对象的引用之后,程序将对象的 refcount减一;
- 当对象的 refcount 降至 0时,这个 redisObject 结构,以及它所引用的数据结构的内存,都会被释放。
2. 字符串
字符串类型分别使用 REDIS_ENCODING_INT 和 REDIS_ENCODING_RAW 两种编码:
-
REDIS_ENCODING_INT 使用 long 类型来保存 long 类型值;
-
REDIS_ENCODING_RAW 则使用 sdshdr 结构来保存 sds(也即是 char*)、 longlong、 double 和 longdouble 类型值。 换句话来说,在 Redis 中,只有能表示为 long 类型的值,才会以整数的形式保存,其他类型的整数、小数和字符串,都是用 sdshdr 结构来保存。
3. 哈希表
REDIS_HASH(哈希表)使用压缩列表和字典两种编码方式。
因为压缩列表比字典更节省内存,所以程序在创建新 Hash 键时,默认使用压缩列表作为底层实现,当有需要时,程序才会将底层实现从压缩列表转换到字典。
当哈希表使用字典编码时,程序将哈希表的键(key)保存为字典的键,将哈希表的值(value)保存为字典的值。 哈希表所使用的字典的键和值都是字符串对象。
当使用压缩列表编码哈希表时,程序通过将键和值一同推入压缩列表,从而形成保存哈希表所需的键-值对结构,新添加的 key-value 对会被添加到压缩列表的表尾。
4. 列表
REDIS_LIST(列表)使用压缩列表和双端链表这两种方式编码。
因为双端链表占用的内存比压缩列表要多,所以当创建新的列表键时,列表会优先考虑使用压缩列表作为底层实现,并且在满足以下任意条件时,才从压缩列表实现转换到双端链表实现。
- 试图往列表新添加一个字符串值,且这个字符串的长度超过 server.list_max_ziplist_value(默认值为 64);
- 压缩链表包含的节点超过 server.list_max_ziplist_entries(默认值为 512)。
5. 集合
REDIS_SET(集合)使用整数集合和字典两种方式编码。
第一个添加到集合的元素,决定了创建集合时所使用的编码:
-
如果第一个元素可以表示为 longlong 类型值(也即是,它是一个整数),那么集合的初始编码为整数集合;
-
否则,集合的初始编码为字典。
如果一个集合使用整数集合编码,那么当以下任何一个条件被满足时,这个集合会被转换成字典编码:
-
整数集合保存的整数值个数超过 server.set_max_intset_entries(默认值为 512);
-
试图往集合里添加一个新元素,并且这个元素不能被表示为 longlong 类型(也即是,它不是一个整数)。
当使用字典编码时,集合将元素保存到字典的键里面,而字典的值则统一设为 NULL。
6. 有序集合
REDIS_ZSET(有序集)使用压缩列表和跳跃表两种方式编码。
在通过 ZADD 命令添加第一个元素到空 key 时,程序通过检查输入的第一个元素来决定该创建什么编码的有序集。 如果第一个元素符合以下条件的话,就创建一个整数集合编码的有序集:
- 服务器属性 server.zset_max_ziplist_entries 的值大于 0(默认为 128);
- 元素的 member 长度小于服务器属性 server.zset_max_ziplist_value 的值(默认为 64)。
否则,程序就创建一个跳跃表编码的有序集。
对于一个压缩列表编码的有序集,只要满足以下任一条件,就将它转换为跳跃表编码:
-
压缩列表所保存的元素数量超过服务器属性 server.zset_max_ziplist_entries 的值(默认值为 128);
-
新添加元素的 member 的长度大于服务器属性 server.zset_max_ziplist_value 的值(默认值为 64)。
当使用压缩列表编码时,每个有序集元素以两个相邻的压缩列表节点表示,第一个节点保存元素的 member 域,第二个元素保存元素的 score 域。多个元素之间按 score 值从小到大排序,如果两个元素的 score 相同,那么按字典序对 member 进行对比,决定那个元素排在前面,那个元素排在后面。
有序集合同时使用字典和跳跃表两个数据结构来保存有序集元素。
通过使用字典结构,并将 member 作为键,score 作为值,有序集可以在 复杂度内:
-
检查给定 member 是否存在于有序集(被很多底层函数使用);
-
取出 member 对应的 score 值(实现 ZSCORE 命令)。
另一方面,通过使用跳跃表,可以让有序集支持以下两种操作:
- 在期望时间、 最坏时间内根据 score 对 member 进行定位(被很多底层函数使用);
- 范围性查找和处理操作,这是(高效地)实现 ZRANGE 、 ZRANK 和 ZINTERSTORE 等命令的关键。
通过同时使用字典和跳跃表,有序集可以高效地实现按成员查找和按顺序查找两种操作。