读《Redis设计与实现》黄键宏著,笔记,第一部分。
第一部分 数据结构与对象
1.1 简单动态字符串
Redis 没有直接使用 C 语言传统的字符串表示(以空字符结尾的字符数组,以下简称 C 字符串),而是自己构建了一种名为简单动态字符串(simple dynamic string,SDS)的抽象类型,并将 SDS 用作 Redis 的默认字符串表示。
在 Redis 里面,C 字符串只会作为字符串字面量(string literal)用在一些无须对字符串进行修改的地方,比如日志打印。
SDS 的定义
struct sdshdr{
// 记录buf数组中已使用字节的数量
// 等于SDS所保存字符串的长度
int len;
// 记录buf数组中未使用字节的数量
int free;
// 字节数组,用于保存字符串
char buf[];
}
SDS 遵循 C 字符串以空字符结尾的惯例,保存空字符的 1 字节空间不计算在 SDS 的 len 属性里面,并且为空字符分配额外的 1 字节空间,以及添加空字符到字符串末尾等操作,都是由 SDS 函数自动完成的,所以这个空字符对于 SDS 的使用者来说是完全透明的。
遵循空字符结尾这一惯例的好处是,SDS 可以直接重用一部分 C 字符串函数库里面的函数。
SDS 与 C 字符串的区别
常数复杂度获取字符串长度:
-
C 字符串,不记录长度,需要遍历,复杂度为 O(N);
-
SDS,保留了 len 属性,复杂度为 O(1)。
杜绝缓冲区溢出
SDS 的 API 会检查已分配长度,防止溢出。
减少修改字符串时带来的内存重分配次数
SDS 实现了空间预分配和惰性释放两种优化策略。
空间预分配(增长):
- 当 len 小于 1MB 时,free 与 len 相同。
- 当 len 大于等于 1MB 时,free 等于 1MB。
减少连续执行字符串增长操作所需的内存分配次数。
惰性空间释放(缩短):
当 SDS 的 API 需要缩短 SDS 保存的字符串时,程序并不立即使用内存重分配来回收缩短后多出来的字节,而是使用 free 属性将这些字节的数量记录起来,并等待将来使用。
二进制安全
C 字符串会把被程序最先读取到的空字符误认为字符串结尾,限制了只能保留文本数据。
使用二进制安全的 SDS,可以保存任意格式的二进制数据。
兼容部分 C 字符串函数
C 字符串和 SDS 之间的区别
C 字符串 | SDS |
---|---|
获取字符串长度的复杂度为 O(N) | 获取字符串长度的复杂度为 O(1) |
API 是不安全的,可能会造成缓冲区溢出 | API 是安全的,不会造成缓冲区溢出 |
修改字符串长度 N 次必然需要执行 N 次内存重分配 | 修改字符串长度 N 次最多需要执行 N 次内存重分配 |
只能保存文本数据 | 可以保存文本或者二进制数据 |
可以使用所有<string.h> 库中的函数 | 可以使用一部分<string.h> 库中的函数 |
1.2 链表
链表在 Redis 中的应用非常广泛,比如列表键的底层实现之一就是链表。当一个列表键包含了数量比较多的元素,又或者列表中包含的元素都是比较长的字符串时,Redis 就会使用链表作为列表键的底层实现。
除了链表键以外,发布与订阅、慢查询、监视器等功能也用到了链表,Redis 服务器本身还使用链表来保存多个客户端的状态信息,以及使用链表来构建客户端输出缓冲区。
链表和链表节点的实现
typedef struct listNode{
// 前置节点
struct listNode *prev;
// 后置节点
struct listNode *next;
// 节点的值
void *value;
} listNode;
typedef struct list{
// 表头节点
listNode *Node;
// 表尾节点
listNode *tail;
// 链表所包含的节点数量
unsigned long len;
// 节点值赋值函数
void *(*dup)(void *ptr);
// 节点值释放函数
void (*free)(void *ptr);
// 节点值对比函数
int (*match)(void *ptr, void *key);
} list;
Redis 的链表实现的特性可以总结如下:
- 双端:链表节点带有 prev 和 next 指针,获取某个节点的前置节点和后置节点的复杂度都是 O(1)。
- 无环:表头节点的 prev 指针和表尾节点的 next 指针都指向 NULL,对链表的访问以 NULL 为终点。
- 带表头指针和表尾指针:通过 list 结构的 head 指针和 tail 指针,程序获取链表的表头节点和表尾节点的复杂度为 O(1)。
- 带链表长度计数器:程序使用 list 结构的 len 属性来对 list 持有的链表节点进行计数,程序获取链表中节点数量的复杂度为 O(1)。
- 多态:链表节点使用 void* 指针来保存节点值,并且可以通过 list 结构的 dup、free、match 三个属性为节点值设置类型特定函数,所以链表可以用于保存各种不同类型的值。
1.3 字典
字典,又称为符号表(symbol table)、关联数组(associative array)或映射(map),是一种用于保存键值对(key-value pair)的抽象数据结构。
在字典中,一个键(key)可以和一个值(value)进行关联(或者说将键映射为值),这些关联的键和值就称为键值对。
字典中的每个键都是独一无二的,程序可以在字典中根据键查找与之关联的值,或者通过键来更新值,又或者根据键来删除整个键值对。
Redis 的数据库就是使用字典来作为底层实现的,对数据库的增、删、查、改操作也是构建在对字典的操作之上的。
当一个哈希键包含的键值对比较多,又或者键值对中的元素都是比较长的字符串时,Redis 就会使用字典作为哈希键的底层实现。
字典的实现
Redis 的字典使用哈希表作为底层实现,一个哈希表里面可以又多个哈希表节点,而每个哈希表节点就保存了字典中的一个键值对。
哈希表
typedef struct dictht{
// 哈希表数组
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;
字典
typedef struct dict{
// 类型特定函数
dictType *type;
// 私有数据
void *privdata;
// 哈希表
dictht ht[2];
// rehash 索引
// 当 rehash 不在进行时,值为 -1
int trehashidx;
} dict;
typedef struct dictType{
// 计算哈希值的函数
unsigned int (*hashFunction)(const void *key);
// 复制键的函数
void *(*keyDup)(void *privdata, const void *key);
// 复制值的函数
void *(*valDup)(void *privdata, const void *obj);
// 对比键的函数
int (*keyCompare)(void *privdata, const void *kry1, const void *key2);
// 销毁键的函数
int (*keyDestructor)(void *privdata, void *key);
// 销毁值的函数
int (*valDestructor)(void *privdata, void *obj);
} dictType;
哈希算法
当要将一个新的键值对添加到字典里面时,程序需要先根据键值对的键计算出哈希值和索引值,然后再根据索引值,将包含新键值对的哈希表节点放到哈希表数组的指定索引上面。
Redis 计算哈希值和索引值的方法如下:
# 使用字典设置的哈希函数,计算键 key 的哈希值
hash = dict->type->hashFunction(key);
# 使用哈希表的 sizemask 属性和哈希值,计算出索引值
# 根据情况不同,ht[x]可以是 ht[0] 或者 ht[1]
index = hash & dict->ht[x].sizemask;
Redis 使用 MurmurHash2 算法来计算键的哈希值。
哈希冲突
当有两个或以上数量的键被分配到了哈希表数组的同一个索引上面时,我们称这些键发生了哈希冲突(collision)。
Redis 的哈希表使用链地址法(separate chaining)来接近哈希冲突。头插法。
rehash
随着操作的不断执行,哈希表保存的键值对会逐渐地增多或者减少,为了让哈希表的负载因子(load factor)维持在一个合理的范围之内,当哈希表保存的键值对数量太多或者太少时,程序需要对哈希表的大小进行相应的扩展或者收缩。
扩展和收缩哈希表的工作可以通过执行 rehash(重新散列)操作来完成,Redis 对字典的哈希表执行 rehash 的步骤如下:
- 为字典的 ht[1] 哈希表分配空间,整个哈希表的空间大小取决于要执行的操作,以及 ht[0] 当前包含的键值对数量(也即是 ht[0].used 属性的值):
- 如果执行的是扩展操作,那么 ht[1] 的大小为第一个大于等于 ht[0].used*2 的 2 的 n 次幂;
- 如果执行的是收缩操作,那么 ht[1] 的大小为第一个大于等于 ht[0].used 的 2 的 n 次幂。
- 将保存在 ht[0] 中的所有键值对 rehash 到 ht[1] 上面:rehash 指的是重新计算键的哈希值和索引值,然后将键值对放置到 ht[1] 哈希表的指定位置上。
- 当 ht[0] 包含的所有键值对都迁移到了 ht[1] 之后,释放 ht[0],将 ht[1] 设置为 ht[0],并在 ht[1] 新创建一个空白的哈希表,为下一次 rehash 做准备。
扩展:
- 服务器没有在执行 BGSAVE 命令或者 BGREWRITEAOF 命令时,负载因子大于等于 1;
- 服务器在执行 BGSAVE 命令或者 BGREWRITEAOF 命令时,负载因子大于等于 5;
# 负载因子 = 哈希表已保存节点数量 / 哈希表大小
load_factor = ht[0].used / ht[0].size
收缩:
哈希表的负载因子大于 0.1。
渐进式 rehash
扩展或收缩哈希时,并不是一次性、集中式地完成的,而是分多次、渐进式地完成的。
详细步骤:
- 为 ht[1] 分配空间,让字典同时持有 ht[0] 和 ht[1] 两个哈希表。
- 在字典中维持一个索引计数器变量 rehashidx,并将它的值设置为 0 ,表示 rehash 工作正式开始。
- 在 rehash 进行期间,每次对字典执行添加、删除、查找或者更新操作时,程序除了执行指定的操作以外,还会顺带将 ht[0] 哈希表在 rehashidx 索引上的所有键值对 rehash 到 ht[1],当 rehash 工作完成之后,程序将 rehashidx 属性的值增一.
- 随着字典操作的不断执行,最终在某个时间点上,ht[0] 的所有键值对都会被 rehash 至 ht[1],这时程序将 rehashidx 属性的值设为 -1,表示 rehash 操作已完成。
从而避免了集中式 rehash 而带来的庞大计算量。
1.4 跳跃表
跳跃表(skiplist)是一种有序数据结构,它通过在每个节点中维持多个指向其他节点的指针,从而达到快速访问节点的目的。
跳跃表支持平均 O(longN)、最坏 O(N) 复杂度的节点查找,还可以通过顺序性操作来批量处理节点。
性能堪比平衡树,实现比平衡树简单。
如果一个有序集合包含的元素数量比较多,又或者有序集合中元素的成员(member)是比较长的字符串时,Redis 就会使用跳跃表来作为有序集合键的底层实现。
和链表、字典等数据结构被广泛地应用在 Redis 内部不同,Redis 只在两个地方用到了跳跃表:
- 一个是实现有序集合键,
- 另一个是在集群节点中用作内部数据结构。
跳跃表节点
typedef struct zskiplistNode{
// 后退指针
struct zskiplistNode *backward;
// 分值
double score;
// 成员对象
robj *obj;
// 层
struct zskiplistLevel{
// 前进指针
struct zskiplistNode *forward;
// 跨度
unsigned int span;
} level[];
} zskiplistNode;
-
层
层高都是1至32之间的随机数。
跳跃表节点的 level 数组可以包含多个元素,每个元素都包含一个指向其他节点的指针,程序可以通过这些层来加快访问其他节点的速度,一般来说,层的数量越多,访问其他节点的速度就越快。
每个层都带有两个属性:前进指针和跨度。
-
前进指针:用于访问位于表尾方向的其他节点;
-
跨度:记录了前进指针指向节点和当前节点的距离。
跨度实际上是用来计算排位(rank)的:在查找某个节点的过程中,将沿途访问过的所有层的跨度累计起来,得到的结果就是目标节点在跳跃表中的排位。
-
-
后退指针
节点的后退指针(backward 属性)用于从表尾向表头方向访问节点。
-
分值和成员
节点的分值(score 值)是一个 double 类型的浮点数,跳跃表中的所有节点都按分值从小到大排序。
节点的成员对象(obj 属性)是一个指针,它指向一个字符串对象,而字符串对象则保存着一个 SDS 值。
成员对象是唯一,分值可能相同。
跳跃表
通过 zskiplist 跳跃表,可以很方便地对整个跳跃表进行处理,比如快速访问跳跃表的表头节点和表尾节点,或者快速地获取跳跃表节点数量(即跳跃表长度)等信息。
typedef struct zskiplist{
// 表头节点和表尾节点
struct zskiplistNode *header, *tail;
// 表中节点的数量
unsigned long length;
// 表中层数最大的节点的层数
int level;
}
1.5 整数集合
整数集合(intset)是集合键的底层实现之一,当一个集合只包含整数值元素,并且这个集合的元素数量不多时,Redis 就会使用整数集合作为集合键的底层实现。
整数集合的实现
typedef struct intset{
// 编码方式
uint32_t encoding;
// 集合包含的元素数量
uint32_t length;
// 保存元素的数组
int8_t contents[];
} intset;
contents 数组:是整数集合的底层实现:整数集合的每个元素都是 contents 数组的一个数组项(item),各个项在数组中按值得大小从小到大有序地排序,并且数组中不包含任何重复项。
length 属性:记录了整数集合包含得元素数量。
encoding 属性:虽然 intset 结构将 contents 属性声明为 int8_t 类型的数组,但实际上 contents 数组并不保存任何 int8_t 类型的值,contents 数组的真正类型取决于 encoding 属性的值。(组合表示)
升级
每当我们要将一个新元素添加到整数集合里面,并且新元素的类型比整数集合现有所有元素的类型都要长时,整数集合需要先进行升级(upgrade),然后才能将新元素添加到整数集合里面。
升级整数集合并添加新元素共分为三步进行:
- 根据新元素的类型,扩展整数集合底层数组的空间大小,并为新元素分配空间。
- 将底层数组现有的所有元素都转换成与新元素相同的类型,并将类型转换后的元素放置到正确的位上,而且在放置元素的过程中,需要继续维持底层数组的有序性质不变。
- 将新元素添加到底层数组里面。
因为每次向整数集合添加新元素都可能会引起升级,而每次升级都需要对底层数组中已有的所有元素进行类型转换,所以向整数集合添加新元素的时间复杂度为 O(N)。
升级的好处
- 提升灵活性
- 节省内存
降级
不支持降级,升级后就一直保持升级后的状态。
1.6 压缩列表
压缩列表(ziplist)是列表键和哈希键的底层实现之一。
当一个列表键只包含少量列表项,并且每个列表要么就是小整数值,要么就是长度比较短的字符串,那么 Redis 就会使用压缩列表来做列表键的底层实现。
另外,当一个哈希键只包含少量键值对,并且每个键值对的键和值要么就是小整数值,要么就是长度比较短的字符串,那么 Redis 就会使用压缩列表来做哈希键的底层实现。
压缩列表的构成
压缩列表是 Redis 为了节约内存而开发的,是由一系列特殊编码的连续内存块组成的顺序型(sequential)数据结构。
一个压缩列表可以包含任意多个节点(entry),每个节点可以保存一个字节数组或者一个整数值。
压缩列表的各个组成部分:
zlbytes | zltail | zllen | entry1 | entry2 | … | entrtyN | zlend |
---|---|---|---|---|---|---|---|
0x50§ | 0x3c | 0x3 | p+60 | oxFF |
说明:
属性 | 类型 | 长度 | 用途 |
---|---|---|---|
zlbytes | uint32_t | 4字节 | 记录整个压缩列表占用的内存字节数:在对压缩列表进行内存重分配,或者计算 zlend 的位置时使用。 |
zltail | uint32_t | 4字节 | 记录压缩列表表尾节点距离压缩列表的起始地址有多少个字节:通过这个偏移量,程序无须遍历整个压缩列表就可以确定表尾节点的地址。 |
zllen | uint16_t | 2字节 | 记录了压缩列表包含的节点数量:当这个属性的值小于 UINT16_MAX(65535)时,这个属性的值就是压缩列表包含节点的数量;当这个值等于UINT16_MAX时,节点的真实数量需要遍历整个压缩列表才能计算得出。 |
entryX | 列表节点 | 不定 | 压缩列表包含的各个节点,节点的长度由节点保存的内容决定。 |
zlend | unit8_t | 1字节 | 特殊值 0xFF(十进制255),用于标记压缩列表的末端。 |
压缩列表节点的构成
previous_entry_length | encoding | content |
---|---|---|
记录前一个节点的字节长度 | content 的数据的类型和长度 | 节点的值 |
previous_entry_length:
程序可以通过指针运算,根据当前节点的起始地址来计算出前一个节点的起始地址。
- 如果前一节点的长度小于 254 字节,那么 previous_entry_length 属性的长度为 1 字节:前一节点的长度就保存在这个节点里面。
- 如果前一节点的长度大于等于 254 字节,那么 previous_entry_length 属性的长度为 5 字节:其中属性的第一个字节会被设置为 0xFE(十进制254),而之后的四个字节则用于保存前一节点的长度。
压缩列表的从表尾向表头遍历操作就是使用这一原理实现的,只要我们拥有了一个指向某个节点起始地址的指针,那么通过这个指针以及这个节点的 previous_entry_length 属性,程序就可以一直向前一个节点回溯,最终到达压缩列表的表头节点。
encoding:
- 一字节、两字节或者五字节长,值的最高位00、01或者10的是字节数组编码:这种编码表示节点的 content 属性保存着字节数组,数组的长度由编码出去最高位之后的其他位记录;
- 一字节长,值的最高位以11开头的是整数编码:这种编码表示节点的 content 属性保存着整数值,整数值的类型和长度由编码除去最高两位之后的其他位记录;
content:
节点值可以是一个字节数组或者整数,值的类型和长度由字节的 encoding 属性决定。
字节数组编码:
编码 | 编码长度 | content 属性保存的值 |
---|---|---|
00bbbbbb | 1字节 | 长度小于等于63字节的字节数组 |
01bbbbbb xxxxxxxx | 2字节 | 长度小于等于16383字节的字节数组 |
10_ _ _ _ _ _ aaaaaaaa bbbbbbbb cccccccc dddddddd | 5字节 | 长度小于等于4294967295字节的字节数组 |
整数编码:
编码 | 编码长度 | content 属性保存的值 |
---|---|---|
11000000 | 1字节 | int16_t 类型的整数 |
11010000 | 1字节 | int32_t 类型的整数 |
11100000 | 1字节 | int64_t 类型的整数 |
11110000 | 1字节 | 24 位有符号整数 |
11111110 | 1字节 | 8 位有符号整数 |
1111xxxx | 1字节 | 使用这一编码的节点没有相应的content属性,因为编码本身的 xxxx 四个位已经保存了一个介于0和12之间的值,所以它无须 content 属性 |
连锁更新
Redis 在特殊情况下(previous_entry_length 在250~253时)产生的连续多次空间扩展操作称之为 “ 连锁更新 ”(cascade update)。
因为连锁更新在最坏的情况下需要对压缩列表执行 N 次空间重分配操作,而每次空间重分配的最坏复杂度 O(N),所以连锁更新的最坏复杂度为 O(N*N)。
要注意的是,尽管连锁更新的复杂度较高,但它真正造成性能问题的几率很低的:
- 首先,压缩列表里要恰好有多个连续的,长度介于250字节至253字节之间的节点,连锁更新才有可能被引发,在实际中,这种情况并不多见;
- 其次,即使出现连锁更新,但只要被更新的节点数量不多,就不会对性能造成任何影响。
1.7 快速列表
quicklist结构是在redis 3.2版本中新加的数据结构,用在列表的底层实现。
quicklist结构在quicklist.c中的解释为A doubly linked list of ziplists
意思为一个由ziplist组成的双向链表。
根据以上描述,总结出一下quicklist的特点:
- quicklist宏观上是一个双向链表,因此,它具有一个双向链表的有点,进行插入或删除操作时非常方便,虽然复杂度为O(n),但是不需要内存的复制,提高了效率,而且访问两端元素复杂度为O(1)。
- quicklist微观上是一片片entry节点,每一片entry节点内存连续且顺序存储,可以通过二分查找以 log2(n)log2(n) 的复杂度进行定位。
快速列表的实现
quicklist表头结构:
typedef struct quicklist {
//指向头部(最左边)quicklist节点的指针
quicklistNode *head;
//指向尾部(最右边)quicklist节点的指针
quicklistNode *tail;
//ziplist中的entry节点计数器
unsigned long count; /* total count of all entries in all ziplists */
//quicklist的quicklistNode节点计数器
unsigned int len; /* number of quicklistNodes */
//保存ziplist的大小,配置文件设定,占16bits
int fill : 16; /* fill factor for individual nodes */
//保存压缩程度值,配置文件设定,占16bits,0表示不压缩
unsigned int compress : 16; /* depth of end nodes not to compress;0=off */
} quicklist;
quicklist节点结构:
typedef struct quicklistNode {
struct quicklistNode *prev; //前驱节点指针
struct quicklistNode *next; //后继节点指针
//不设置压缩数据参数recompress时指向一个ziplist结构
//设置压缩数据参数recompress指向quicklistLZF结构
unsigned char *zl;
//压缩列表ziplist的总长度
unsigned int sz; /* ziplist size in bytes */
//ziplist中包的节点数,占16 bits长度
unsigned int count : 16; /* count of items in ziplist */
//表示是否采用了LZF压缩算法压缩quicklist节点,1表示压缩过,2表示没压缩,占2 bits长度
unsigned int encoding : 2; /* RAW==1 or LZF==2 */
//表示一个quicklistNode节点是否采用ziplist结构保存数据,2表示压缩了,1表示没压缩,默认是2,占2bits长度
unsigned int container : 2; /* NONE==1 or ZIPLIST==2 */
//标记quicklist节点的ziplist之前是否被解压缩过,占1bit长度
//如果recompress为1,则等待被再次压缩
unsigned int recompress : 1; /* was this node previous compressed? */
//测试时使用
unsigned int attempted_compress : 1; /* node can't compress; too small */
//额外扩展位,占10bits长度
unsigned int extra : 10; /* more bits to steal for future usage */
} quicklistNode;
快速列表的操作
插入一个entry节点
quicklist的插入:以一个已存在的entry前或后插入一个entry节点,非常的复杂,因为情况非常多。
-
当前quicklistNode节点的ziplist可以插入。
- 插入在已存在的entry前
- 插入在已存在的entry后
-
如果当前quicklistNode节点的
ziplist由于fill的配置,无法继续插入
。
- 已存在的entry是ziplist的头节点,当前quicklistNode节点前驱指针不为空,且是尾插
- 前驱节点可以插入,因此插入在前驱节点的尾部。
- 前驱节点不可以插入,因此要在当前节点和前驱节点之间新创建一个新节点保存要插入的entry。
- 已存在的entry是ziplist的尾节点,当前quicklistNode节点后继指针不为空,且是前插
- 后继节点可以插入,因此插入在前驱节点的头部。
- 后继节点不可以插入,因此要在当前节点和后继节点之间新创建一个新节点保存要插入的entry。
- 以上情况不满足,则属于将entry插入在ziplist中间的任意位置,需要分割当前quicklistNode节点。最后如果能够合并,还要合并。
push操作
push一个entry到quicklist头节点或尾节点中ziplist的头部或尾部。底层调用了ziplistPush操作。
pop操作
从quicklist的头节点或尾节点的ziplist中pop出一个entry,分该entry保存的是字符串还是整数。如果字符串的话,需要传入一个函数指针,这个函数叫_quicklistSaver(),真正的pop操作还是在这两个函数基础上在封装了一次,来操作拷贝字符串的操作。
1.8 对象*
Redis 并没有直接使用简单动态字符串(SDS)、双端链表、字典、压缩列表、整数集合等结构来实现键值对数据库,而是基于这些数据结构创建了一个对象系统,这个系统包含:
- 字符串对象
- 列表对象
- 哈希对象
- 集合对象
- 有序对象
这五种类型的对象,每种对象都用了至少一种前者数据结构。
好处:
- 通过这种五种不同类型的对象,Redis 可以在执行命令之前,根据对象的类型来判断一个对象是否可以执行给定的命令。
- 针对不同的使用场景,为对象设置多种不同的数据结构实现,从而优化对象在不同场景下的使用效率。
基于引用计数计数实现对象的自动释放和对象的共享机制。
Redis 对象带有访问时间记录信息,启动 maxmemory 功能下,空转大的键可以优先被删除。
1.8.1 对象的类型与编码
Redis 使用对象来表示数据库中的键和值,每次当我们在Redis的数据库中新创建一个键值对时,我们至少会创建两个对象,一个对象用作键值对的键(键对象),另一个对象用作键值对的值(值对象)。
Redis 中的每个对象都由一个 redisObject 结构表示
typedef struct redisObject{
// 类型
unsigned type:4;
// 编码
unsigned encoding:4;
// 指向底层实现数据结构的指针
void *ptr;
// LRU
unsigned lru:LRU_BITS; /* LRU time (relative to global lru_clock) or
* LFU data (least significant 8 bits frequency
* and most significant 16 bits access time). */
// 引用计数
int refcount;
} robj;
类型
对象的 type 属性记录了对象的类型。
对于Redis数据库保存的键值对来说,键总是一个字符串对象,值可以是字符串对象、列表对象、哈希对象、集合对象、有序集合对象。所以通常说 “ 字符串键 ”、“ 列表键 ” 是指键对应的值是 “ 字符串对象 ”、“ 列表对象 ”。
对象 | 对象 type 属性的值 | TYPE 命令的输出 |
---|---|---|
字符串对象 | REDIS_STRING | “string” |
列表对象 | REDIS_LIST | “list” |
哈希对象 | REDIS_HASH | “hash” |
集合对象 | REDIS_SET | “set” |
有序集合对象 | REDIS_ZSET | “zset” |
编码和底层实现
对象的 ptr 指针指向对象的底层实现数据结构,而这些数据结构由对象的 encoding 属性来决定。
对象的编码:
编码常量 | 编码所对应的底层数据结构 |
---|---|
REDIS_ENCODING_INT | long 类型的整数 |
REDIS_ENCODING_EMBSTR | embstr 编码的简单动态字符串 |
REDIS_ENCODING_RAW | 简单动态字符串 |
REDIS_ENCODING_HT | 字典 |
REDIS_ENCODING_LINKEDLIST | 双端链表 |
REDIS_ENCODING_ZIPLIST | 压缩列表 |
REDIS_ENCODING_INTSET | 整数集合 |
REDIS_ENCODING_SKIPLIST | 跳跃表和字典 |
不同类型和编码的对象:
类型 | 编码 | 对象 |
---|---|---|
REDIS_STRING | REDIS_ENCODING_INT | 使用整数值实现的字符串对象 |
REDIS_STRING | REDIS_ENCODING_EMBSTR | 使用 embstr 编码的简单动态字符串实现的字符串对象 |
REDIS_STRING | REDIS_ENCODING_RAW | 使用简单动态字符串实现的字符串对象 |
REDIS_LIST | REDIS_ENCODING_ZIPLIST | 使用压缩列表实现的列表对象 |
REDIS_LIST | REDIS_ENCODING_LINKEDLIST | 使用双端链表实现的列表对象 |
REDIS_LIST | REDIS_ENCODING_QUICKLIST | 使用快速列表实现的列表对象 |
REDIS_HASH | REDIS_ENCODING_ZIPLIST | 使用压缩列表实现的哈希对象 |
REDIS_HASH | REDIS_ENCODING_HT | 使用字典实现的哈希对象 |
REDIS_SET | REDIS_ENCODING_INTSET | 使用整数集合实现的集合对象 |
REDIS_SET | REDIS_ENCODING_HT | 使用字典实现的集合对象 |
REDIS_ZSET | REDIS_ENCODING_ZIPLIST | 使用压缩列表实现的有序集合对象 |
REDIS_ZSET | REDIS_ENCODING_SKIPLIST | 使用跳跃表和字典实现的有序集合对象 |
OBJECT ENCODING 对不同编码的输出:
对象所使用的底层数据结构 | 编码常量 | OBJECT ENCODING 命令输出 |
---|---|---|
整数 | REDIS_ENCODING_INT | “int” |
embstr编码的简单动态字符串 | REDIS_ENCODING_EMBSTR | “embstr” |
简单动态字符串 SDS | REDIS_ENCODING_RAW | “raw” |
字典 | REDIS_ENCODING_HT | “hashtable” |
双端链表 | REDIS_ENCODING_LINKEDLIST | “linkedlist” |
压缩列表 | REDIS_ENCODING_ZIPLIST | “ziplist” |
整数集合 | REDIS_ENCODING_INTSET | “intset” |
跳跃表和字典 | REDIS_ENCODING_SKIPLIST | “skiplist” |
快速列表 | REDIS_ENCODING_QUICKLIST | “quicklist” |
通过 encoding 属性来设定对象所使用的编码,而不是为特定类型的对象关联一种固有的编码,极大地提升了 Redis 的灵活性和效率,因为 Redis 可以根据不同的使用场景来为一个对象设置不同的编码,从而优化对象在某一场景下的效率。
以压缩列表和双端链表为例:
- 因为压缩列表比双端链表更节约内存,并且在元素数量较少时,在内存中以连续块方式保存的压缩列表比起双端链表可以更快被载入到缓存中;
- 随着列表对象包含的元素越来越多,使用压缩列表来保存元素的优势逐渐消失时,对象就会将底层实现从压缩列表转向功能更强、也更适合保存大量元素的双端链表上面。
1.8.2 字符串对象
字符串对象的编码可以是 int、raw 或者 embstr。
int 编码
如果一个字符串对象保存的是整数值,并且这个整数值可以用 long 类型来表示,那么字符串对象会将整数值保存在字符串对象结构的 ptr 属性里面(将 void* 转换为 long),并将字符串对象的编码设置为int。
raw 编码
如果一个字符串对象保存的是一个字符串值,并且这个字符串值的长度大于 39 字节,那么字符串对象将使用一个简单动态字符串(SDS)来保存这个字符串值,并将对象的编码设置为 raw。
embstr 编码
3.0~3.2 39字节,3.2以后 44 字节。和 SDS 的结构有关
如果一个字符串对象保存的是一个字符串值,并且这个字符串值得长度小于等于 39 字节,那么字符串对象将使用 embstr 编码的方式来保存这个字符串值。
embstr 编码是一种专门用于保存字符串的优化编码方式。
效率和 raw 编码一样,好处如下:
- embstr 编码将创建字符串对象所需的内存分配次数从 raw 编码的两次降低为一次。
- 释放 embstr 编码的字符串对象只需要调用一次内存释放函数,而释放 raw 编码的字符串对象需要调用两次内存释放函数。
- 因为 embstr 编码的字符串对象的所有数据都保存在一块连续的内存里面,所以这种编码的字符串对象比起 raw 编码的字符串对象能够更好地利用缓存带来的优势。
字符串对象保存各类型值的编码方式:
值 | 编码 |
---|---|
可以用 long 类型保存的整数 | int |
可以用 long double 类型保存的浮点数 | embstr 或者 raw |
字符串值,或者因为长度太大而没办法用 long 类型表示的整数, 又或者因为长度太大而没有办法用 long double 类型表示的浮点数 | embstr 或者 raw |
编码转换
int 编码的字符串对象和 embstr 编码的字符串对象在条件满足的情况下,会被转换为 raw 编码的字符串对象。
embstr 编码为只读的。当修改时,会自动变为 raw 编码。
字符串命令的实现
命令 | int 编码的实现方法 | embstr 编码的实现方法 | raw 编码的实现方法 |
---|---|---|---|
SET | 使用 int 编码保存值 | 使用 embstr 编码保存值 | 使用 raw 编码保存值 |
GET | 拷贝对象所保存的整数值,将这个拷贝转换成字符串值,然后向客户端返回这个字符串值 | 直接向客户端返回字符串值 | 直接向客户端返回字符串值 |
APPEND | 将对象转换成 raw 编码,然后按 raw 编码的方式指向此操作 | 将对象转换成 raw 编码,然后按 raw 编码的方式执行此操作 | 调用 sdscatlen 函数,给定字符串追加到现有字符串的末尾 |
INCRBYFLOAT | 取出整数值并将其转换成 long double 类型的浮点数,对这个浮点数进行加法计算,然后将得出的浮点数结果保存起来 | 取出字符串值并尝试将其转换成 long double 类型的浮点数,对这个浮点数进行假发计算,然后将得出的浮点数结果保存起来。如果字符串值不能被转换成浮点数,那么向客户端返回一个错误 | 取出字符串值并尝试将其转换成 long double 类型的浮点数,对这个浮点数进行加法计算,然后将得出的浮点数结果保存起来。如果字符串值不能被转换成浮点数,那么向客户端返回一个错误 |
INCRBY | 对整数值进行加法计算,得出的计算结果会作为整数被保存起来 | embstr 编码不能执行此命令,向客户端返回一个错误 | raw 编码不能执行此命令,向客户端返回一个错误 |
DECRBY | 对整数值进行减法计算,得出的计算结果会作为整数被保存起来 | embstr 编码不能执行此命令,向客户端返回一个错误 | raw 编码不能执行此命令,向客户端返回一个错误 |
STRLEN | 拷贝对象所保存的整数值,将这个拷贝转换成字符串值,计算并返回这个字符串值的长度 | 调用 sdslen 函数,返回字符串的长度 | 调用 sdslen 函数,返回字符串的长度 |
SETRANGE | 将对象转换成 raw 编码,然后按 raw 编码的方式执行此命令 | 将对象转换成 raw 编码,然后按 raw 编码的方式执行此命令 | 将字符串特定索引上的值设置为给定的字符 |
GETRANGE | 拷贝对象所保存的整数值,将这个拷贝转换成字符串值,然后取出并返回字符串指定索引上的字符 | 直接取出并返回字符串指定索引上的字符 | 直接取出并返回字符串指定索引的字符 |
1.8.3 列表对象
列表对象的编码可以是 ziplist 或者 linkedlist。
Redis中的列表对象在版本3.2之前,列表底层的编码是ziplist和linkedlist实现的,但是在版本3.2之后,重新引入了一个 quicklist 的数据结构,列表的底层都由quicklist实现。
在早期的设计中, 当列表对象中元素的长度比较小或者数量比较少的时候,采用ziplist来存储,当列表对象中元素的长度比较大或者数量比较多的时候,则会转而使用双向列表linkedlist来存储。
ziplist 编码
ziplist 编码的列表对象使用压缩列表作为底层实现,每个压缩列表节点(entry)保存了一个列表元素。
linkedlist 编码
linkedlist 编码的列表对象使用双端链表作为底层实现,每个双端链表节点(node)都保存了一个字符串对象,而每个字符串对象都保存了一个列表元素。
字符串对象是 Redis 五种类型的对象中唯一一种会被其他四种对象嵌套的对象。
quicklist 编码
quicklist 编码快速列表,一个由ziplist组成的双向链表。
编码转换
同时满足以下两个条件时使用 ziplist 编码:
- 列表对象保存的所有字符串元素的长度都小于 64 字节;
- 列表对象保存的元素数量小于 512 个。
不能满足这个两个条件的列表对象需要使用 linkedlist 编码。
列表命令的实现
命令 | ziplist 编码的实现方法 | linkedlist 编码的实现方法 |
---|---|---|
LPUSH | 调用 ziplistPush 函数,将新元素推入到压缩列表的表头 | 调用 listAddNodeHead 函数,将新元素推入到双端链表的表头 |
RPUSH | 调用 ziplistPush 函数,将新元素推入到压缩列表的表尾 | 调用 listAddNodeTail 函数,将新元素推入到双端链表的表尾 |
LPOP | 调用 ziplistIndex 函数定位压缩列表的表头节点,在向用户返回节点所保存的元素之后,调用 ziplistDelete 函数删除表头节点 | 调用 listFirst 函数定位双端链表的表头节点,在向用户返回节点所保存的元素之后,调用 listDelNode 函数删除表头节点 |
RPOP | 调用 ziplistIndex 函数定位压缩列表的表尾节点,在向用户返回节点所保存的元素之后,调用 ziplistDelete 函数删除表尾节点 | 调用 listLast 函数定位双端链表的表尾节点,在向用户返回节点所保存的元素之后,调用 listDelNode 函数删除表尾节点 |
LINDEX | 调用 ziplistIndex 函数定位压缩列表中的指定节点,然后返回节点所保存的元素 | 调用 listIndex 函数定位双端链表中的指定点,然后返回节点所保存的元素 |
LLEN | 调用 ziplistLen 函数返回压缩列表的长度 | 调用 listLength 函数返回双端链表的长度 |
LINSERT | 插入新节点到压缩列表的表头或者表尾时,使用 ziplistPush 函数;插入新节点到压缩列表的其他位置时,使用 ziplistInsert 函数 | 调用 listInsertNode 函数,将新节点插入到双端链表的指定位置 |
LREM | 遍历压缩列表节点,并调用 ziplistDelete 函数删除包含了给定元素的节点 | 遍历双端链表节点,并调用 listDelNode 函数删除包含了给定元素的节点 |
LTRIM | 调用 ziplistDeleteRange 函数,删除压缩列表中所有不再指定索引范围内的节点 | 遍历双端链表节点,并调用 listDelNode 函数删除链表中所有不再指定索引范围内的节点 |
LSET | 调用 ziplistDelete 函数,先删除压缩列表指定索引上的现有节点,然后调用 ziplistInsert 函数,将一个包含给定元素的新节点插入到相同索引上 | 调用 listIndex 函数,定位到双端链表指定索引上的节点,然后通过赋值操作更新节点的值 |
1.8.4 哈希对象
哈希对象的编码可以是 ziplist 或者 hashtable
ziplist 编码
ziplist 编码的哈希对象使用压缩列表作为底层实现,每次有新的键值对要假如到哈希对象时,程序会先将保存了键的压缩列表推入到压缩列表表尾,然后再将保存了值得压缩列表节点推入到压缩列表表尾。
- 保存了同一键值对的两个节点总是紧挨在一起,保存键的节点在前,保存值的节点在后;
- 先添加到哈希对象中的键值对会被放在压缩列表的表头方向,而后来添加到哈希对象中的键值对会被放在压缩列表的表尾。
hashtable 编码
hashtable 编码的哈希对象使用字典作为底层实现,哈希对象中的每个键值对都使用了一个字典键值对来保存:
- 字典的每个键都是一个字符串对象,对象中保存了键值对的键;
- 字典的每个值都是一个字符串对象,对象中保存了键值对的值。
编码转换
当哈希对象可以同时满足以下两个条件时,哈希对象使用 ziplist 编码:
- 哈希对象保存的所有键值对的键和值的字符串长度都小于 64 字节;
- 哈希对象保存的键值对数量小于 512 个;
不能满足这两个条件的哈希对象需要使用 hashtable 编码。
配置可以修改, hash-max-ziplist-value 选项和 hash-max-ziplist-entries 选项。
哈希命令的实现
命令 | ziplist 编码实现方法 | hashtable 编码实现方法 |
---|---|---|
HSET | 首先调用 ziplistPush 函数,将键推入到压缩列表的表尾,然后再调用 ziplistPush 函数,将值推入到压缩列表的表尾 | 调用 dictAdd 函数,将新节点添加到字典里面 |
HGET | 首先调用 ziplistFind 函数,在压缩列表中查找指定键所对应的节点,然后调用 ziplistNext 函数,将指针移动到键节点旁边的值节点,最后返回值节点 | 调用 dictFind 函数,在字典中查找给定键,然后调用 dictGetVal 函数,返回该键所对应的值 |
HEXISTS | 调用 ziplistFind 函数,在压缩列表中查找到指定键所对应的节点,如果找到的话说明键值对存在,没有找到的话就说明键值对不存在 | 调用 dictFind 函数,在字典中查找给定键,如果找到的话说明键值对存在,没找到的话就说明键值对不存在 |
HDEL | 调用 ziplistFind 函数,在压缩列表中查找到指定键所对应的节点,然后将相应的键节点、以及键节点旁边的置节点都删除掉 | 调用 dictDelete 函数,将指定键所对应的键值对从字典中删除掉 |
HLEN | 调用 ziplistLen 函数,取得压缩列表包含节点的总数量,将这个数量除以2,得出的结果就是压缩列表保存的键值对的数量 | 调用 dictSize 函数,返回字典包含的键值对数量,这个数量就是哈希对象包含的键值对数量 |
HGETALL | 遍历整个压缩列表,用 ziplistGet 函数返回所有键和值(都是节点) | 遍历整个字典,用 dictGetKey 函数返回字典的键,用 dictGetVal 函数返回字典的值 |
1.8.5 集合对象
集合对象的编码可以是 intset 或者 hashtable。
inset 编码
intset 编码的集合对象使用整数集合作为底层实现,集合对象包含的所有元素都保存在整数集合里面。
hashtable 编码
hashtable 编码的集合对象使用字典作为底层实现,字典的每个键都是一个字符串对象,每个字符串对象包含了一个集合元素,而字典的值则全部被设置为 NULL。
编码转换
当满足以下两条时,使用 intset 编码:
- 集合对象保存的所有元素都是整数值;
- 集合对象保存的元素数量不超过 512 个。
不能满足时,使用 hashtable 编码。
配置选项:set-max-intset-entries
集合命令的实现
命令 | intset 编码的实现方法 | hashtable 编码的实现方法 |
---|---|---|
SADD | 调用 intsetAdd 函数,将所有新元素添加到整数集合里面 | 调用 dictAdd 函数,以新元素为键,NULL 为值,将键值对添加到字典里面 |
SCARD | 调用 intsetLen 函数,返回整数集合所包含的元素数量,这个数量就是集合对象所包含的元素数量 | 调用 dictSize 函数,返回字典所包含的键值对数量,这个数量就是集合对象所包含的元素数量 |
SISMEMBER | 调用 intsetFind 函数,在整数集合中查找给定的元素,如果找到了说明元素存在于集合,没找到则说明元素不存在集合 | 调用 dictFind 函数,在字典的键中查找给定的元素,如果找到了说明元素存在于集合中,没找到说明元素不存在于集合中 |
SMEMBERS | 遍历整个整数集合,使用 intsetGet 函数返回集合对象 | 遍历整个字典,使用 dictGetKey 函数返回给字典的键作为集合元素 |
SRANDMEMBER | 调用 intsetRandom 函数,从整数集合中随机返回一个元素 | 调用 dictGetRandomKey 函数,从字典中随机返回一个字典键 |
SPOP | 调用 intsetRandom 函数,从整数集合中随机取出一个元素,在这个随机元素返回给客户端之后,调用 intsetRemove 函数,将随机元素从整数集合中删除掉 | 调用 dictGetRandomKey 函数,从字典中随机取出一个字典键,在将这个随机字典键的值返回给客户端之后,调用 dictDelete 函数,从字典中删除随机字典键所对应的键值对 |
SREM | 调用 intsetRemove 函数,从整数集合中删除所有给定的元素 | 调用 dictDelete 函数,从字典中删除所有键为给定元素的键值对 |
1.8.6 有序集合对象
有序集合对象的编码可以是 ziplist 或者 skiplist。
ziplist 编码
ziplist 编码的有序集合对象使用压缩列表作为底层实现,每个集合元素使用两个紧挨在一起的压缩列表节点来保存,第一个节点保存元素的成员(member),而第二个元素则保存元素的分值(score)。
压缩列表内的集合元素按照分值的从小到大进行排序,分值较小的元素被放置在靠近表头的位置,而分值较大的元素则被放置在靠近表尾的位置。
skiplist 编码
skiplist 编码的有序集合对象使用 zset 结构作为底层实现,一个 zset 结构同时包含一个字典和也给跳跃表:
typedef struct zset{
zskiplist *zsl;
dict *dict;
} zset;
zsl 跳跃表:
zset 结构中的 zsl 跳跃表按分值从小到大保存了所有集合元素,每个跳跃表节点都保存了一个集合元素:
跳跃表节点的 object 属性保存了元素的成员,而跳跃表节点的 score 属性则保存了元素的分值。
通过这个跳跃表,程序可以对有序集合进行范围操作。
dict 字典:
zset 结构中的 dict 字典为有序集合创建了一个从成员到分值的映射,字典中的每个键值对都保存了一个集合元素:
字典的键保存了元素的成员,而字典的值则保存了元素的分值。
通过这个字典,程序可以用 O(1) 复杂度查找给定成员的分值。
元素的成员和分值:
有序集合每个元素的成员都是一个字符串对象,而每个元素的分值都是一个 double 类型的浮点数。
zsl 跳跃表和 dict 字典,共享了元素的成员和分值。
同时使用 zsl 跳跃表和 dict 字典 可以互补两者的优缺点,提供整体的性能。
编码转换
当同时满足以下两点时,使用 ziplist 编码:
- 有序集合保存的元素数量小于 128 个;
- 有序集合保存的所有元素成员的长度都小于 64 字节;
不满足时,使用 skiplist 编码。
配置项:zset-max-ziplist-entries 和 zset-max-ziplist-value。
有序集合命令的实现
命令 | ziplist 编码的实现方法 | skiplist 编码的实现方法 |
---|---|---|
ZADD | 调用 ziplistInsert 函数,将成员和分值作为两个节点分别插入到压缩列表 | 先调用 zslInsert 函数,将新元素添加到跳跃表,然后调用 dictAdd 函数,将新元素关联到字典 |
ZCARD | 调用 ziplistLen 函数,获得压缩列表包含节点的数量,将这个数量除以 2 得出集合元素的数量 | 访问跳跃表数据结构的 length 属性,直接返回集合元素的数量 |
ZCOUNT | 遍历压缩列表,统计分值在给定范围内的节点的数量 | 遍历跳跃表,统计分值在给定范围内的节点的数量 |
ZRANGE | 从表头向表尾遍历压缩列表,返回给定索引范围内的所有元素 | 从表头向表尾遍历跳跃表,返回给力索引范围内的所有元素 |
ZREVRANGE | 从表尾向表头遍历压缩列表,返回给定索引范围内的所有元素 | 从表尾向表头遍历跳跃表,返回给力索引范围内的所有元素 |
ZRANK | 从表头向表尾遍历压缩列表,查找给定的成员,沿途记录经过节点的数量,当找到给定成员之后,途径节点的数量就是该成员所对应元素的排名 | 从表头向表尾遍历跳跃表,查找给定的成员,沿途记录经过节点的数量,当找到给定成员之后,途径节点的数量就是该成员所对应元素的排名 |
ZREVRANK | 从表尾向表头遍历压缩列表,查找给定的成员,沿途记录经过节点的数量,当找到给定成员之后,途径节点的数量就是该成员所对应元素的排名 | 从表尾向表头遍历跳跃表,查找给定的成员,沿途记录经过节点的数量,当找到给定成员之后,途径节点的数量就是该成员所对应元素的排名 |
ZREM | 遍历压缩列表,删除所有包含给定成员的节点,以及被删除成员节点旁边的分值节点 | 遍历跳跃表,删除所有包含了给定成员的跳跃表节点。并在字典中解除被删除元素的成员和分值的关联 |
ZSCORE | 遍历压缩列表,查询包含了给定成员的节点,然后取出成员节点旁边的分值节点保存的元素分值 | 直接从字典中取出给定成员的分值 |
1.8.7 对象的命令
多态命令:
任何类型的键都可以执行:DEL、EXPIRE、RENAME、TYPE、OBJECT等。
特定类型命令
encoding 类型判断
1.8.8 内存回收
Redis 在自己对象系统中构建了一个引用计数(reference counting)技术实现的内存回收机制,利用 redisObject 结构中的 refcount 属性。
- 在创建一个新对象时,引用计数的值会被初始化为 1;
- 当对象被一个新程序使用时,它的引用计数值会被增一;
- 当对象不再被一个程序使用时,它的引用计数会被减一;
- 当对象的引用计数值变为 0 时,对象所占用的内存会被释放。
1.8.9 共享对象
Redis 只对包含整数值的字符串对象进行共享。
复杂度的考虑。
1.8.10 对象的空转时长
lru 属性,记录了对象最后一次被命令程序访问的时间。
object idletime 命令打印空转时长
如果服务器打开了 maxmemory 选项,并且服务器用于回收的算法为 volatile-lru 或者 allkeys-lru,那么当服务器占用的内存数超过了 maxmemory 选项所设置的上限值时,空转时长较高的那部分键会优先被服务器释放,从而回收内存。