《redis设计与实现》读书笔记
前言,找了好几本redis系统讲解的书,从评价上,也从自己的看后体验上,这本书是最佳的。
1、数据结构与对象
1.1、简单动态字符串
SDS:Redis 没有直接使用 C 语言传统的字符串表示(以空字符结尾的字符数组,以下简称 C 字符串), 而是自己构建了一种名为简单动态字符串(simple dynamic string,SDS)的抽象类型, 并将 SDS 用作 Redis 的默认字符串表示。
C的字符串只会作为字符串字面量存在,使用场景是无需对字符串修改的地方,比如日志。
当redis面对一个可以被修改的字符串的值的时候,就需要SDS,在redis的数据库中,包含字符串值的键值对在底部都是SDS实现的。
举个例子
redis> SET msg "hello world"
OK
- 键值对的键是一个字符串对象, 对象的底层实现是一个保存着字符串
"msg"
的 SDS 。 - 键值对的值也是一个字符串对象, 对象的底层实现是一个保存着字符串
"hello world"
的 SDS 。
SDS的定义
struct sdshdr {
// 记录 buf 数组中已使用字节的数量
// 等于 SDS 所保存字符串的长度
int len;
// 记录 buf 数组中未使用字节的数量
int free;
// 字节数组,用于保存字符串
char buf[];
};
free
属性的值为0
, 表示这个 SDS 没有分配任何未使用空间。len
属性的值为5
, 表示这个 SDS 保存了一个五字节长的字符串。buf
属性是一个char
类型的数组, 数组的前五个字节分别保存了'R'
、'e'
、'd'
、'i'
、's'
五个字符, 而最后一个字节则保存了空字符'\0'
。
SDS和C字符串区别
- 根据传统, C 语言使用长度为
N+1
的字符数组来表示长度为N
的字符串, 并且字符数组的最后一个元素总是空字符'\0'
- 因为 C 字符串并不记录自身的长度信息, 所以为了获取一个 C 字符串的长度, 程序必须遍历整个字符串, 对遇到的每个字符进行计数, 直到遇到代表字符串结尾的空字符为止, 这个操作的复杂度为
。
- SDS 在
len
属性中记录了 SDS 本身的长度, 所以获取一个 SDS 长度的复杂度仅为。
- 杜绝缓冲区溢出, C 字符串不记录自身长度带来的另一个问题是容易造成缓冲区溢出(buffer overflow)。
- 空间预分配,当 SDS 的 API 对一个 SDS 进行修改, 并且需要对 SDS 进行空间扩展的时候, 程序不仅会为 SDS 分配修改所必须要的空间, 还会为 SDS 分配额外的未使用空间。
- 如果对 SDS 进行修改之后, SDS 的长度(也即是
len
属性的值)将小于1 MB
, 那么程序分配和len
属性同样大小的未使用空间, 这时 SDSlen
属性的值将和free
属性的值相同。 举个例子, 如果进行修改之后, SDS 的len
将变成13
字节, 那么程序也会分配13
字节的未使用空间, SDS 的buf
数组的实际长度将变成13 + 13 + 1 = 27
字节(额外的一字节用于保存空字符)。 - 如果对 SDS 进行修改之后, SDS 的长度将大于等于
1 MB
, 那么程序会分配1 MB
的未使用空间。 举个例子, 如果进行修改之后, SDS 的len
将变成30 MB
, 那么程序会分配1 MB
的未使用空间, SDS 的buf
数组的实际长度将为30 MB + 1 MB + 1 byte
。
- 如果对 SDS 进行修改之后, SDS 的长度(也即是
- 惰性空间释放,当 SDS 的 API 需要缩短 SDS 保存的字符串时, 程序并不立即使用内存重分配来回收缩短后多出来的字节, 而是使用
free
属性将这些字节的数量记录起来, 并等待将来使用。
总结一下就是
C 字符串 | SDS |
---|---|
获取字符串长度的复杂度为 ![]() |
获取字符串长度的复杂度为 ![]() |
API 是不安全的,可能会造成缓冲区溢出。 | API 是安全的,不会造成缓冲区溢出。 |
修改字符串长度 N 次必然需要执行 N 次内存重分配。 |
修改字符串长度 N 次最多需要执行 N 次内存重分配。 |
只能保存文本数据。 | 可以保存文本或者二进制数据。 |
可以使用所有 <string.h> 库中的函数。 |
可以使用一部分 <string.h> 库中的函数。 |
SDS的核心api
函数 | 作用 | 时间复杂度 |
---|---|---|
sdsnew |
创建一个包含给定 C 字符串的 SDS 。 | ![]() N 为给定 C 字符串的长度。 |
sdsempty |
创建一个不包含任何内容的空 SDS 。 | ![]() |
sdsfree |
释放给定的 SDS 。 | ![]() |
sdslen |
返回 SDS 的已使用空间字节数。 | 这个值可以通过读取 SDS 的 len 属性来直接获得, 复杂度为 ![]() |
sdsavail |
返回 SDS 的未使用空间字节数。 | 这个值可以通过读取 SDS 的 free 属性来直接获得, 复杂度为 ![]() |
sdsdup |
创建一个给定 SDS 的副本(copy)。 | ![]() N 为给定 SDS 的长度。 |
sdsclear |
清空 SDS 保存的字符串内容。 | 因为惰性空间释放策略,复杂度为 ![]() |
sdscat |
将给定 C 字符串拼接到 SDS 字符串的末尾。 | ![]() N 为被拼接 C 字符串的长度。 |
sdscatsds |
将给定 SDS 字符串拼接到另一个 SDS 字符串的末尾。 | ![]() N 为被拼接 SDS 字符串的长度。 |
sdscpy |
将给定的 C 字符串复制到 SDS 里面, 覆盖 SDS 原有的字符串。 | ![]() N 为被复制 C 字符串的长度。 |
sdsgrowzero |
用空字符将 SDS 扩展至给定长度。 | ![]() N 为扩展新增的字节数。 |
sdsrange |
保留 SDS 给定区间内的数据, 不在区间内的数据会被覆盖或清除。 | ![]() N 为被保留数据的字节数。 |
sdstrim |
接受一个 SDS 和一个 C 字符串作为参数, 从 SDS 左右两端分别移除所有在 C 字符串中出现过的字符。 | ![]() M 为 SDS 的长度, N 为给定 C 字符串的长度。 |
sdscmp |
对比两个 SDS 字符串是否相同。 | ![]() N 为两个 SDS 中较短的那个 SDS 的长度。 |
1.2、链表
链表提供了高效的节点重排能力, 以及顺序性的节点访问方式, 并且可以通过增删节点来灵活地调整链表的长度。
作为一种常用数据结构, 链表内置在很多高级的编程语言里面, 因为 Redis 使用的 C 语言并没有内置这种数据结构, 所以 Redis 构建了自己的链表实现。
链表在 Redis 中的应用非常广泛, 比如列表键的底层实现之一就是链表: 当一个列表键包含了数量比较多的元素, 又或者列表中包含的元素都是比较长的字符串时, Redis 就会使用链表作为列表键的底层实现。
redis> LLEN integers
(integer) 1024
redis> LRANGE integers 0 10
1) "1"
2) "2"
3) "3"
4) "4"
5) "5"
6) "6"
7) "7"
8) "8"
9) "9"
10) "10"
11) "11"
除了链表键之外, 发布与订阅、慢查询、监视器等功能也用到了链表, Redis 服务器本身还使用链表来保存多个客户端的状态信息, 以及使用链表来构建客户端输出缓冲区(output buffer), 本书后续的章节将陆续对这些链表应用进行介绍。
链表结构如下
typedef struct listNode {
// 前置节点
struct listNode *prev;
// 后置节点
struct listNode *next;
// 节点的值
void *value;
} 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;
list
结构为链表提供了表头指针 head
、表尾指针 tail
, 以及链表长度计数器 len
, 而 dup
、 free
和 match
成员则是用于实现多态链表所需的类型特定函数:
dup
函数用于复制链表节点所保存的值;free
函数用于释放链表节点所保存的值;match
函数则用于对比链表节点所保存的值和另一个输入值是否相等。
redis的链表有如下特性
- 双端: 链表节点带有
prev
和next
指针, 获取某个节点的前置节点和后置节点的复杂度都是。
- 无环: 表头节点的
prev
指针和表尾节点的next
指针都指向NULL
, 对链表的访问以NULL
为终点。 - 带表头指针和表尾指针: 通过
list
结构的head
指针和tail
指针, 程序获取链表的表头节点和表尾节点的复杂度为。
- 带链表长度计数器: 程序使用
list
结构的len
属性来对list
持有的链表节点进行计数, 程序获取链表中节点数量的复杂度为。
- 多态: 链表节点使用
void*
指针来保存节点值, 并且可以通过list
结构的dup
、free
、match
三个属性为节点值设置类型特定函数, 所以链表可以用于保存各种不同类型的值。
链表操作的api
函数 | 作用 | 时间复杂度 |
---|---|---|
listSetDupMethod |
将给定的函数设置为链表的节点值复制函数。 | ![]() |
listGetDupMethod |
返回链表当前正在使用的节点值复制函数。 | 复制函数可以通过链表的 dup 属性直接获得, ![]() |
listSetFreeMethod |
将给定的函数设置为链表的节点值释放函数。 | ![]() |
listGetFree |
返回链表当前正在使用的节点值释放函数。 | 释放函数可以通过链表的 free 属性直接获得, ![]() |
listSetMatchMethod |
将给定的函数设置为链表的节点值对比函数。 | ![]() |
listGetMatchMethod |
返回链表当前正在使用的节点值对比函数。 | 对比函数可以通过链表的 match 属性直接获得,![]() |
listLength |
返回链表的长度(包含了多少个节点)。 | 链表长度可以通过链表的 len 属性直接获得, ![]() |
listFirst |
返回链表的表头节点。 | 表头节点可以通过链表的 head 属性直接获得, ![]() |
listLast |
返回链表的表尾节点。 | 表尾节点可以通过链表的 tail 属性直接获得, ![]() |
listPrevNode |
返回给定节点的前置节点。 | 前置节点可以通过节点的 prev 属性直接获得, ![]() |
listNextNode |
返回给定节点的后置节点。 | 后置节点可以通过节点的 next 属性直接获得, ![]() |
listNodeValue |
返回给定节点目前正在保存的值。 | 节点值可以通过节点的 value 属性直接获得, ![]() |
listCreate |
创建一个不包含任何节点的新链表。 | ![]() |
listAddNodeHead |
将一个包含给定值的新节点添加到给定链表的表头。 | ![]() |
listAddNodeTail |
将一个包含给定值的新节点添加到给定链表的表尾。 | ![]() |
listInsertNode |
将一个包含给定值的新节点添加到给定节点的之前或者之后。 | ![]() |
listSearchKey |
查找并返回链表中包含给定值的节点。 | ![]() N 为链表长度。 |
listIndex |
返回链表在给定索引上的节点。 | ![]() N 为链表长度。 |
listDelNode |
从链表中删除给定节点。 | ![]() |
listRotate |
将链表的表尾节点弹出,然后将被弹出的节点插入到链表的表头, 成为新的表头节点。 | ![]() |
listDup |
复制一个给定链表的副本。 | ![]() N 为链表长度。 |
listRelease |
释放给定链表,以及链表中的所有节点。 | ![]() N 为链表长度。 |
1.3、字典(map)
字典, 又称符号表(symbol table)、关联数组(associative array)或者映射(map), 是一种用于保存键值对(key-value pair)的抽象数据结构。
字典在 Redis 中的应用相当广泛, 比如 Redis 的数据库就是使用字典来作为底层实现的, 对数据库的增、删、查、改操作也是构建在对字典的操作之上的。
redis> SET msg "hello world"
OK
在数据库中创建一个键为 "msg"
, 值为 "hello world"
的键值对时, 这个键值对就是保存在代表数据库的字典里面的。
字典的数据结构
typedef struct dictht {
// 哈希表数组
dictEntry **table;
// 哈希表大小
unsigned long size;
// 哈希表大小掩码,用于计算索引值
// 总是等于 size - 1
unsigned long sizemask;
// 该哈希表已有节点的数量
unsigned long used;
} dictht;
table
属性是一个数组, 数组中的每个元素都是一个指向dict.h/dictEntry
结构的指针, 每个dictEntry
结构保存着一个键值对。size
属性记录了哈希表的大小, 也即是table
数组的大小, 而used
属性则记录了哈希表目前已有节点(键值对)的数量。sizemask
属性的值总是等于size - 1
, 这个属性和哈希值一起决定一个键应该被放到table
数组的哪个索引上面。
dictEntry结构
typedef struct dictEntry {
// 键
void *key;
// 值
union {
void *val;
uint64_t u64;
int64_t s64;
} v;
// 指向下个哈希表节点,形成链表
struct dictEntry *next;
} dictEntry;
哈希算法
当要将一个新的键值对添加到字典里面时, 程序需要先根据键值对的键计算出哈希值和索引值, 然后再根据索引值, 将包含新键值对的哈希表节点放到哈希表数组的指定索引上面。
# 使用字典设置的哈希函数,计算键 key 的哈希值
hash = dict->type->hashFunction(key);
# 使用哈希表的 sizemask 属性和哈希值,计算出索引值
# 根据情况不同, ht[x] 可以是 ht[0] 或者 ht[1]
index = hash & dict->ht[x].sizemask;
举例子,如果要放入redis一个k0和v0,先对k取hash,hash = dict->type->hashFunction(k0);
接着使用算法 index = hash & dict->ht[0].sizemask = 8 & 3 = 0; 得出索引值。
当字典被用作数据库的底层实现, 或者哈希键的底层实现时, Redis 使用 MurmurHash2 算法来计算键的哈希值。
键冲突问题
当有两个或以上数量的键被分配到了哈希表数组的同一个索引上面时, 我们称这些键发生了冲突(collision)。
Redis 的哈希表使用链地址法(separate chaining)来解决键冲突: 每个哈希表节点都有一个 `next` 指针, 多个哈希表节点可以用 `next` 指针构成一个单向链表, 被分配到同一个索引上的多个节点可以用这个单向链表连接起来, 这就解决了键冲突的问题。这个思路和java中的hashmap或者hashset的思路是一致的。
rehash操作
随着操作的不断执行, 哈希表保存的键值对会逐渐地增多或者减少, 为了让哈希表的负载因子(load factor)维持在一个合理的范围之内, 当哈希表保存的键值对数量太多或者太少时, 程序需要对哈希表的大小进行相应的扩展或者收缩。
rehash步骤如下
- 为字典的
ht[1]
哈希表分配空间, 这个哈希表的空间大小取决于要执行的操作, 以及ht[0]
当前包含的键值对数量 (也即是ht[0].used
属性的值):- 如果执行的是扩展操作, 那么
ht[1]
的大小为第一个大于等于ht[0].used * 2
的(
2
的n
次方幂); - 如果执行的是收缩操作, 那么
ht[1]
的大小为第一个大于等于ht[0].used
的。
- 如果执行的是扩展操作, 那么
- 保存在
ht[0]
中的所有键值对 rehash 到ht[1]
上面: rehash 指的是重新计算键的哈希值和索引值, 然后将键值对放置到ht[1]
哈希表的指定位置上。 - 当
ht[0]
包含的所有键值对都迁移到了ht[1]
之后 (ht[0]
变为空表), 释放ht[0]
, 将ht[1]
设置为ht[0]
, 并在ht[1]
新创建一个空白哈希表, 为下一次 rehash 做准备。
渐进式的rehash
如果 ht[0]
里只保存着四个键值对, 那么服务器可以在瞬间就将这些键值对全部 rehash 到 ht[1]
; 但是, 如果哈希表里保存的键值对数量不是四个, 而是四百万、四千万甚至四亿个键值对, 那么要一次性将这些键值对全部 rehash 到 ht[1]
的话, 庞大的计算量可能会导致服务器在一段时间内停止服务。
因此, 为了避免 rehash 对服务器性能造成影响, 服务器不是一次性将 ht[0]
里面的所有键值对全部 rehash 到 ht[1]
, 而是分多次、渐进式地将 ht[0]
里面的键值对慢慢地 rehash 到 ht[1]
。
步骤
1. 为 `ht[1]` 分配空间, 让字典同时持有 `ht[0]` 和 `ht[1]` 两个哈希表。
2. 在字典中维持一个索引计数器变量 `rehashidx` , 并将它的值设置为 `0` , 表示 rehash 工作正式开始。
3. 在 rehash 进行期间, 每次对字典执行添加、删除、查找或者更新操作时, 程序除了执行指定的操作以外, 还会顺带将 `ht[0]` 哈希表在 `rehashidx` 索引上的所有键值对 rehash 到 `ht[1]` , 当 rehash 工作完成之后, 程序将 `rehashidx` 属性的值增一。
4. 随着字典操作的不断执行, 最终在某个时间点上, `ht[0]` 的所有键值对都会被 rehash 至 `ht[1]` , 这时程序将 `rehashidx` 属性的值设为 `-1` , 表示 rehash 操作已完成。
字典操作的api
函数 | 作用 | 时间复杂度 |
---|---|---|
dictCreate |
创建一个新的字典。 | ![]() |
dictAdd |
将给定的键值对添加到字典里面。 | ![]() |
dictReplace |
将给定的键值对添加到字典里面, 如果键已经存在于字典,那么用新值取代原有的值。 | ![]() |
dictFetchValue |
返回给定键的值。 | ![]() |
dictGetRandomKey |
从字典中随机返回一个键值对。 | ![]() |
dictDelete |
从字典中删除给定键所对应的键值对。 | ![]() |
dictRelease |
释放给定字典,以及字典中包含的所有键值对。 | ![]() N 为字典包含的键值对数量。 |
1.4、跳跃表
Redis 的跳跃表由 redis.h/zskiplistNode
和 redis.h/zskiplist
两个结构定义, 其中 zskiplistNode
结构用于表示跳跃表节点, 而 zskiplist
结构则用于保存跳跃表节点的相关信息, 比如节点的数量, 以及指向表头节点和表尾节点的指针, 等等。
节点结构
typedef struct zskiplistNode {
// 后退指针
struct zskiplistNode *backward;
// 分值
double score;
// 成员对象
robj *obj;
// 层
struct zskiplistLevel {
// 前进指针
struct zskiplistNode *forward;
// 跨度
unsigned int span;
} level[];
} zskiplistNode;
- 跳跃表是有序集合的底层实现之一, 除此之外它在 Redis 中没有其他应用。
- Redis 的跳跃表实现由
zskiplist
和zskiplistNode
两个结构组成, 其中zskiplist
用于保存跳跃表信息(比如表头节点、表尾节点、长度), 而zskiplistNode
则用于表示跳跃表节点。 - 每个跳跃表节点的层高都是
1
至32
之间的随机数。 - 在同一个跳跃表中, 多个节点可以包含相同的分值, 但每个节点的成员对象必须是唯一的。
- 跳跃表中的节点按照分值大小进行排序, 当分值相同时, 节点按照成员对象的大小进行排序。
1.5、整数集合
整数集合(intset)是集合键的底层实现之一: 当一个集合只包含整数值元素, 并且这个集合的元素数量不多时, Redis 就会使用整数集合作为集合键的底层实现。
redis> SADD numbers 1 3 5 7 9
(integer) 5
redis> OBJECT ENCODING numbers
"intset"
typedef struct intset {
// 编码方式
uint32_t encoding;
// 集合包含的元素数量
uint32_t length;
// 保存元素的数组
int8_t contents[];
} intset;
整数集合的升级
- 根据新元素的类型, 扩展整数集合底层数组的空间大小, 并为新元素分配空间。
- 将底层数组现有的所有元素都转换成与新元素相同的类型, 并将类型转换后的元素放置到正确的位上, 而且在放置元素的过程中, 需要继续维持底层数组的有序性质不变。
- 将新元素添加到底层数组里面。
升级的好处
1、提升灵活性
因为 C 语言是静态类型语言, 为了避免类型错误, 我们通常不会将两种不同类型的值放在同一个数据结构里面。
比如说, 我们一般只使用 int16_t
类型的数组来保存 int16_t
类型的值, 只使用 int32_t
类型的数组来保存 int32_t
类型的值, 诸如此类。
但是, 因为整数集合可以通过自动升级底层数组来适应新元素, 所以我们可以随意地将 int16_t
、 int32_t
或者 int64_t
类型的整数添加到集合中, 而不必担心出现类型错误, 这种做法非常灵活。
2、节约内存
当然, 要让一个数组可以同时保存 int16_t
、 int32_t
、 int64_t
三种类型的值, 最简单的做法就是直接使用 int64_t
类型的数组作为整数集合的底层实现。 不过这样一来, 即使添加到整数集合里面的都是 int16_t
类型或者 int32_t
类型的值, 数组都需要使用 int64_t
类型的空间去保存它们, 从而出现浪费内存的情况。
而整数集合现在的做法既可以让集合能同时保存三种不同类型的值, 又可以确保升级操作只会在有需要的时候进行, 这可以尽量节省内存。
比如说, 如果我们一直只向整数集合添加 int16_t
类型的值, 那么整数集合的底层实现就会一直是 int16_t
类型的数组, 只有在我们要将int32_t
类型或者 int64_t
类型的值添加到集合时, 程序才会对数组进行升级。
注意:整数集合不支持降级
整数集合操作的api
函数 | 作用 | 时间复杂度 |
---|---|---|
intsetNew |
创建一个新的整数集合。 | ![]() |
intsetAdd |
将给定元素添加到整数集合里面。 | ![]() |
intsetRemove |
从整数集合中移除给定元素。 | ![]() |
intsetFind |
检查给定值是否存在于集合。 | 因为底层数组有序,查找可以通过二分查找法来进行, 所以复杂度为 ![]() |
intsetRandom |
从整数集合中随机返回一个元素。 | ![]() |
intsetGet |
取出底层数组在给定索引上的元素。 | ![]() |
intsetLen |
返回整数集合包含的元素个数。 | ![]() |
intsetBlobLen |
返回整数集合占用的内存字节数。 | ![]() |
- 整数集合是集合键的底层实现之一。
- 整数集合的底层实现为数组, 这个数组以有序、无重复的方式保存集合元素, 在有需要时, 程序会根据新添加元素的类型, 改变这个数组的类型。
- 升级操作为整数集合带来了操作上的灵活性, 并且尽可能地节约了内存。
- 整数集合只支持升级操作, 不支持降级操作。
1.6、压缩列表
压缩列表是 Redis 为了节约内存而开发的, 由一系列特殊编码的连续内存块组成的顺序型(sequential)数据结构。
一个压缩列表可以包含任意多个节点(entry), 每个节点可以保存一个字节数组或者一个整数值。
属性 | 类型 | 长度 | 用途 |
---|---|---|---|
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 ),用于标记压缩列表的末端。 |
每个压缩列表节点可以保存一个字节数组或者一个整数值, 其中, 字节数组可以是以下三种长度的其中一种:
- 长度小于等于
63
()字节的字节数组;
- 长度小于等于
16383
() 字节的字节数组;
- 长度小于等于
4294967295
()字节的字节数组;
而整数值则可以是以下六种长度的其中一种:
4
位长,介于0
至12
之间的无符号整数;1
字节长的有符号整数;3
字节长的有符号整数;int16_t
类型整数;int32_t
类型整数;int64_t
类型整数。
压缩操作的api
函数 | 作用 | 算法复杂度 |
---|---|---|
ziplistNew |
创建一个新的压缩列表。 | ![]() |
ziplistPush |
创建一个包含给定值的新节点, 并将这个新节点添加到压缩列表的表头或者表尾。 | 平均 ![]() ![]() |
ziplistInsert |
将包含给定值的新节点插入到给定节点之后。 | 平均 ![]() ![]() |
ziplistIndex |
返回压缩列表给定索引上的节点。 | ![]() |
ziplistFind |
在压缩列表中查找并返回包含了给定值的节点。 | 因为节点的值可能是一个字节数组, 所以检查节点值和给定值是否相同的复杂度为 ![]() ![]() |
ziplistNext |
返回给定节点的下一个节点。 | ![]() |
ziplistPrev |
返回给定节点的前一个节点。 | ![]() |
ziplistGet |
获取给定节点所保存的值。 | ![]() |
ziplistDelete |
从压缩列表中删除给定的节点。 | 平均 ![]() ![]() |
ziplistDeleteRange |
删除压缩列表在给定索引上的连续多个节点。 | 平均 ![]() ![]() |
ziplistBlobLen |
返回压缩列表目前占用的内存字节数。 | ![]() |
ziplistLen |
返回压缩列表目前包含的节点数量。 | 节点数量小于 65535 时 ![]() 65535 时 ![]() |
1.7、redis中的对象
Redis 使用对象来表示数据库中的键和值, 每次当我们在 Redis 的数据库中新创建一个键值对时, 我们至少会创建两个对象, 一个对象用作键值对的键(键对象), 另一个对象用作键值对的值(值对象)。
Redis 中的每个对象都由一个 redisObject
结构表示, 该结构中和保存数据有关的三个属性分别是 type
属性、 encoding
属性和 ptr
属性:
typedef struct redisObject {
// 类型
unsigned type:4;
// 编码
unsigned encoding:4;
// 指向底层实现数据结构的指针
void *ptr;
// ...
} robj;
redis中的五种对象
类型常量 | 对象的名称 |
---|---|
REDIS_STRING |
字符串对象 |
REDIS_LIST |
列表对象 |
REDIS_HASH |
哈希对象 |
REDIS_SET |
集合对象 |
REDIS_ZSET |
有序集合对象 |
# 键为字符串对象,值为字符串对象
redis> SET msg "hello world"
OK
redis> TYPE msg
string
# 键为字符串对象,值为列表对象
redis> RPUSH numbers 1 3 5
(integer) 6
redis> TYPE numbers
list
# 键为字符串对象,值为哈希对象
redis> HMSET profile name Tome age 25 career Programmer
OK
redis> TYPE profile
hash
# 键为字符串对象,值为集合对象
redis> SADD fruits apple banana cherry
(integer) 3
redis> TYPE fruits
set
# 键为字符串对象,值为有序集合对象
redis> ZADD price 8.5 apple 5.0 banana 6.0 cherry
(integer) 3
redis> TYPE price
zset
不同类型和编码的对象
类型 | 编码 | 对象 |
---|---|---|
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_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 命令可以查看一个数据库键的值对象的编码
1.7.1、字符串对象
字符串对象的编码可以是 int
、 raw
或者 embstr
。
如果一个字符串对象保存的是整数值, 并且这个整数值可以用 long
类型来表示, 那么字符串对象会将整数值保存在字符串对象结构的 ptr
属性里面(将 void*
转换成 long
), 并将字符串对象的编码设置为 int
。
如果字符串对象保存的是一个字符串值, 并且这个字符串值的长度大于 39
字节, 那么字符串对象将使用一个简单动态字符串(SDS)来保存这个字符串值, 并将对象的编码设置为 raw
。
如果字符串对象保存的是一个字符串值, 并且这个字符串值的长度小于等于 39
字节, 那么字符串对象将使用 embstr
编码的方式来保存这个字符串值。
embstr编码
embstr
编码是专门用于保存短字符串的一种优化编码方式, 这种编码和 raw
编码一样, 都使用 redisObject
结构和 sdshdr
结构来表示字符串对象, 但 raw
编码会调用两次内存分配函数来分别创建 redisObject
结构和 sdshdr
结构, 而 embstr
编码则通过调用一次内存分配函数来分配一块连续的空间, 空间中依次包含 redisObject
和 sdshdr
两个结构。
embstr
编码将创建字符串对象所需的内存分配次数从raw
编码的两次降低为一次。- 释放
embstr
编码的字符串对象只需要调用一次内存释放函数, 而释放raw
编码的字符串对象需要调用两次内存释放函数。 - 因为
embstr
编码的字符串对象的所有数据都保存在一块连续的内存里面, 所以这种编码的字符串对象比起raw
编码的字符串对象能够更好地利用缓存带来的优势。
编码转换
通过 APPEND 命令, 向一个保存整数值的字符串对象追加了一个字符串值, 因为追加操作只能对字符串值执行, 所以程序会先将之前保存的整数值 10086
转换为字符串值 "10086"
, 然后再执行追加操作, 操作的执行结果就是一个 raw
编码的、保存了字符串值的字符串对象。
字符串相关命令的api
命令 | int 编码的实现方法 |
embstr 编码的实现方法 |
raw 编码的实现方法 |
---|---|---|---|
SET | 使用 int 编码保存值。 |
使用 embstr 编码保存值。 |
使用 raw 编码保存值。 |
GET | 拷贝对象所保存的整数值, 将这个拷贝转换成字符串值, 然后向客户端返回这个字符串值。 | 直接向客户端返回字符串值。 | 直接向客户端返回字符串值。 |
APPEND | 将对象转换成 raw 编码, 然后按raw 编码的方式执行此操作。 |
将对象转换成 raw 编码, 然后按raw 编码的方式执行此操作。 |
调用 sdscatlen 函数, 将给定字符串追加到现有字符串的末尾。 |
INCRBYFLOAT | 取出整数值并将其转换成 longdouble 类型的浮点数, 对这个浮点数进行加法计算, 然后将得出的浮点数结果保存起来。 |
取出字符串值并尝试将其转换成long double 类型的浮点数, 对这个浮点数进行加法计算, 然后将得出的浮点数结果保存起来。 如果字符串值不能被转换成浮点数, 那么向客户端返回一个错误。 |
取出字符串值并尝试将其转换成 longdouble 类型的浮点数, 对这个浮点数进行加法计算, 然后将得出的浮点数结果保存起来。 如果字符串值不能被转换成浮点数, 那么向客户端返回一个错误。 |
INCRBY | 对整数值进行加法计算, 得出的计算结果会作为整数被保存起来。 | embstr 编码不能执行此命令, 向客户端返回一个错误。 |
raw 编码不能执行此命令, 向客户端返回一个错误。 |
DECRBY | 对整数值进行减法计算, 得出的计算结果会作为整数被保存起来。 | embstr 编码不能执行此命令, 向客户端返回一个错误。 |
raw 编码不能执行此命令, 向客户端返回一个错误。 |
STRLEN | 拷贝对象所保存的整数值, 将这个拷贝转换成字符串值, 计算并返回这个字符串值的长度。 | 调用 sdslen 函数, 返回字符串的长度。 |
调用 sdslen 函数, 返回字符串的长度。 |
SETRANGE | 将对象转换成 raw 编码, 然后按raw 编码的方式执行此命令。 |
将对象转换成 raw 编码, 然后按raw 编码的方式执行此命令。 |
将字符串特定索引上的值设置为给定的字符。 |
GETRANGE | 拷贝对象所保存的整数值, 将这个拷贝转换成字符串值, 然后取出并返回字符串指定索引上的字符。 | 直接取出并返回字符串指定索引上的字符。 | 直接取出并返回字符串指定索引上的字符。 |
1.7.2、列表对象
列表对象的编码可以是ziplist或者linkedlist。
当列表对象可以同时满足以下两个条件时, 列表对象使用 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.7.3、哈希对象
哈希对象的编码可以是 ziplist
或者 hashtable
。
ziplist
编码的哈希对象使用压缩列表作为底层实现, 每当有新的键值对要加入到哈希对象时, 程序会先将保存了键的压缩列表节点推入到压缩列表表尾, 然后再将保存了值的压缩列表节点推入到压缩列表表尾, 因此:
- 保存了同一键值对的两个节点总是紧挨在一起, 保存键的节点在前, 保存值的节点在后;
- 先添加到哈希对象中的键值对会被放在压缩列表的表头方向, 而后来添加到哈希对象中的键值对会被放在压缩列表的表尾方向。
另一方面, hashtable
编码的哈希对象使用字典作为底层实现, 哈希对象中的每个键值对都使用一个字典键值对来保存:
- 字典的每个键都是一个字符串对象, 对象中保存了键值对的键;
- 字典的每个值都是一个字符串对象, 对象中保存了键值对的值。
当哈希对象可以同时满足以下两个条件时, 哈希对象使用 ziplist
编码:
- 哈希对象保存的所有键值对的键和值的字符串长度都小于
64
字节; - 哈希对象保存的键值对数量小于
512
个;
不能满足这两个条件的哈希对象需要使用 hashtable
编码。
哈希命令
命令 | 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.7.4、集合对象
集合对象的编码可以是 intset
或者 hashtable
。
当集合对象可以同时满足以下两个条件时, 对象使用 intset
编码:
- 集合对象保存的所有元素都是整数值;
- 集合对象保存的元素数量不超过
512
个;
不能满足这两个条件的集合对象需要使用 hashtable
编码。
集合命令的实现方法
命令 | 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.7.5、有序集合对象
有序集合的编码可以是 ziplist
或者 skiplist
。
ziplist
编码的有序集合对象使用压缩列表作为底层实现, 每个集合元素使用两个紧挨在一起的压缩列表节点来保存, 第一个节点保存元素的成员(member), 而第二个元素则保存元素的分值(score)。
压缩列表内的集合元素按分值从小到大进行排序, 分值较小的元素被放置在靠近表头的方向, 而分值较大的元素则被放置在靠近表尾的方向。
当有序集合对象可以同时满足以下两个条件时, 对象使用 ziplist
编码:
- 有序集合保存的元素数量小于
128
个; - 有序集合保存的所有元素成员的长度都小于
64
字节;
不能满足以上两个条件的有序集合对象将使用 skiplist
编码。
有序集合命令的实现方法
命令 | ziplist 编码的实现方法 |
zset 编码的实现方法 |
---|---|---|
ZADD | 调用 ziplistInsert 函数, 将成员和分值作为两个节点分别插入到压缩列表。 |
先调用 zslInsert 函数, 将新元素添加到跳跃表, 然后调用 dictAdd 函数, 将新元素关联到字典。 |
ZCARD | 调用 ziplistLen 函数, 获得压缩列表包含节点的数量, 将这个数量除以 2 得出集合元素的数量。 |
访问跳跃表数据结构的 length 属性, 直接返回集合元素的数量。 |
ZCOUNT | 遍历压缩列表, 统计分值在给定范围内的节点的数量。 | 遍历跳跃表, 统计分值在给定范围内的节点的数量。 |
ZRANGE | 从表头向表尾遍历压缩列表, 返回给定索引范围内的所有元素。 | 从表头向表尾遍历跳跃表, 返回给定索引范围内的所有元素。 |
ZREVRANGE | 从表尾向表头遍历压缩列表, 返回给定索引范围内的所有元素。 | 从表尾向表头遍历跳跃表, 返回给定索引范围内的所有元素。 |
ZRANK | 从表头向表尾遍历压缩列表, 查找给定的成员, 沿途记录经过节点的数量, 当找到给定成员之后, 途经节点的数量就是该成员所对应元素的排名。 | 从表头向表尾遍历跳跃表, 查找给定的成员, 沿途记录经过节点的数量, 当找到给定成员之后, 途经节点的数量就是该成员所对应元素的排名。 |
ZREVRANK | 从表尾向表头遍历压缩列表, 查找给定的成员, 沿途记录经过节点的数量, 当找到给定成员之后, 途经节点的数量就是该成员所对应元素的排名。 | 从表尾向表头遍历跳跃表, 查找给定的成员, 沿途记录经过节点的数量, 当找到给定成员之后, 途经节点的数量就是该成员所对应元素的排名。 |
ZREM | 遍历压缩列表, 删除所有包含给定成员的节点, 以及被删除成员节点旁边的分值节点。 | 遍历跳跃表, 删除所有包含了给定成员的跳跃表节点。 并在字典中解除被删除元素的成员和分值的关联。 |
ZSCORE | 遍历压缩列表, 查找包含了给定成员的节点, 然后取出成员节点旁边的分值节点保存的元素分值。 |
1.8、redis中的命令
redis用于操作键的命令分为两类,一类对任何键都可以操作,比如,DEL
,EXPIRE
,RENAME
,TYPE
等。
但是也有一些命令只能对特定对象操作
- SET 、 GET 、 APPEND 、 STRLEN 等命令只能对字符串键执行;
- HDEL 、 HSET 、 HGET 、 HLEN 等命令只能对哈希键执行;
- RPUSH 、 LPOP 、 LINSERT 、 LLEN 等命令只能对列表键执行;
- SADD 、 SPOP 、 SINTER 、 SCARD 等命令只能对集合键执行;
- ZADD 、 ZCARD 、 ZRANK 、 ZSCORE 等命令只能对有序集合键执行;
这里面就涉及到了redis的类型检查和命令多态,
- 在执行一个类型特定命令之前, 服务器会先检查输入数据库键的值对象是否为执行命令所需的类型, 如果是的话, 服务器就对键执行指定的命令;
- 否则, 服务器将拒绝执行命令, 并向客户端返回一个类型错误。
命令多台的体现比如 LLEN
- 如果列表对象的编码为
ziplist
, 那么说明列表对象的实现为压缩列表, 程序将使用ziplistLen
函数来返回列表的长度; - 如果列表对象的编码为
linkedlist
, 那么说明列表对象的实现为双端链表, 程序将使用listLength
函数来返回双端链表的长度;
1.9、redis的内存回收策略
C语言并没有类似于JVM那样的GC策略,所以,redis在自己的对象系统中构建了一个引用计数实现的内存回收机制,通过这个机制,程序跟踪对象的引用计数信息,进行内存回收。
规则如下
- 在创建一个新对象时, 引用计数的值会被初始化为
1
; - 当对象被一个新程序使用时, 它的引用计数值会被增一;
- 当对象不再被一个程序使用时, 它的引用计数值会被减一;
- 当对象的引用计数值变为
0
时, 对象所占用的内存会被释放。
1.10、对象共享策略
除了用于实现引用计数内存回收机制之外, 对象的引用计数属性还带有对象共享的作用。
举个例子, 假设键 A 创建了一个包含整数值 100
的字符串对象作为值对象.
如果这时键 B 也要创建一个同样保存了整数值 100
的字符串对象作为值对象, 那么服务器有以下两种做法:
- 为键 B 新创建一个包含整数值
100
的字符串对象; - 让键 A 和键 B 共享同一个字符串对象;
在 Redis 中, 让多个键共享同一个值对象需要执行以下两个步骤:
- 将数据库键的值指针指向一个现有的值对象;
- 将被共享的值对象的引用计数增一。
目前来说, Redis 会在初始化服务器时, 创建一万个字符串对象, 这些对象包含了从 0
到 9999
的所有整数值, 当服务器需要用到值为 0
到 9999
的字符串对象时, 服务器就会使用这些共享对象, 而不是新创建对象
1.11、对象的空转时间
除了前面介绍过的 type
、 encoding
、 ptr
和 refcount
四个属性之外, redisObject
结构包含的最后一个属性为 lru
属性, 该属性记录了对象最后一次被命令程序访问的时间。
typedef struct redisObject {
// ...
unsigned lru:22;
// ...
} robj;
redis> SET msg "hello world"
OK
# 等待一小段时间
redis> OBJECT IDLETIME msg
(integer) 20
# 等待一阵子
redis> OBJECT IDLETIME msg
(integer) 180
# 访问 msg 键的值
redis> GET msg
"hello world"
# 键处于活跃状态,空转时长为 0
redis> OBJECT IDLETIME msg
(integer) 0
2、单机数据库
2.1、数据库
Redis 是一个键值对(key-value pair)数据库服务器, 服务器中的每个数据库都由一个 redis.h/redisDb
结构表示, 其中, redisDb
结构的dict
字典保存了数据库中的所有键值对, 我们将这个字典称为键空间(key space)。
键空间和用户所见的数据库是直接对应的:
- 键空间的键也就是数据库的键, 每个键都是一个字符串对象。
- 键空间的值也就是数据库的值, 每个值可以是字符串对象、列表对象、哈希表对象、集合对象和有序集合对象在内的任意一种 Redis 对象。
添加一个新键值对到数据库, 实际上就是将一个新键值对添加到键空间字典里面, 其中键为字符串对象, 而值则为任意一种类型的 Redis 对象。
删除数据库中的一个键, 实际上就是在键空间里面删除键所对应的键值对对象。
对一个数据库键进行更新, 实际上就是对键空间里面键所对应的值对象进行更新, 根据值对象的类型不同, 更新的具体方法也会有所不同。
对一个数据库键进行取值, 实际上就是在键空间中取出键所对应的值对象, 根据值对象的类型不同, 具体的取值方法也会有所不同。
GET message
LRANGE message 0 -1
总结
- Redis 服务器的所有数据库都保存在
redisServer.db
数组中, 而数据库的数量则由redisServer.dbnum
属性保存。 - 客户端通过修改目标数据库指针, 让它指向
redisServer.db
数组中的不同元素来切换不同的数据库。 - 数据库主要由
dict
和expires
两个字典构成, 其中dict
字典负责保存键值对, 而expires
字典则负责保存键的过期时间。 - 因为数据库由字典构成, 所以对数据库的操作都是建立在字典操作之上的。
- 数据库的键总是一个字符串对象, 而值则可以是任意一种 Redis 对象类型, 包括字符串对象、哈希表对象、集合对象、列表对象和有序集合对象, 分别对应字符串键、哈希表键、集合键、列表键和有序集合键。
expires
字典的键指向数据库中的某个键, 而值则记录了数据库键的过期时间, 过期时间是一个以毫秒为单位的 UNIX 时间戳。- Redis 使用惰性删除和定期删除两种策略来删除过期的键: 惰性删除策略只在碰到过期键时才进行删除操作, 定期删除策略则每隔一段时间, 主动查找并删除过期键。
- 执行 SAVE 命令或者 BGSAVE 命令所产生的新 RDB 文件不会包含已经过期的键。
- 执行 BGREWRITEAOF 命令所产生的重写 AOF 文件不会包含已经过期的键。
- 当一个过期键被删除之后, 服务器会追加一条 DEL 命令到现有 AOF 文件的末尾, 显式地删除过期键。
- 当主服务器删除一个过期键之后, 它会向所有从服务器发送一条 DEL 命令, 显式地删除过期键。
- 从服务器即使发现过期键, 也不会自作主张地删除它, 而是等待主节点发来 DEL 命令, 这种统一、中心化的过期键删除策略可以保证主从服务器数据的一致性。
- 当 Redis 命令对数据库进行修改之后, 服务器会根据配置, 向客户端发送数据库通知。
2.2、RDB持久化
RDB包含的各个部分
db_version
长度为 4
字节, 它的值是一个字符串表示的整数, 这个整数记录了 RDB 文件的版本号, 比如 "0006"
就代表 RDB 文件的版本为第六版。 本章只介绍第六版 RDB 文件的结构。
RDB 文件的最开头是 REDIS
部分, 这个部分的长度为 5
字节, 保存着 "REDIS"
五个字符。 通过这五个字符, 程序可以在载入文件时, 快速检查所载入的文件是否 RDB 文件。
databases
部分包含着零个或任意多个数据库, 以及各个数据库中的键值对数据:
- 如果服务器的数据库状态为空(所有数据库都是空的), 那么这个部分也为空, 长度为
0
字节。 - 如果服务器的数据库状态为非空(有至少一个数据库非空), 那么这个部分也为非空, 根据数据库所保存键值对的数量、类型和内容不同, 这个部分的长度也会有所不同。
EOF
常量的长度为 1
字节, 这个常量标志着 RDB 文件正文内容的结束, 当读入程序遇到这个值的时候, 它知道所有数据库的所有键值对都已经载入完毕了
check_sum
是一个 8
字节长的无符号整数, 保存着一个校验和, 这个校验和是程序通过对 REDIS
、 db_version
、 databases
、 EOF
四个部分的内容进行计算得出的。 服务器在载入 RDB 文件时, 会将载入数据所计算出的校验和与 check_sum
所记录的校验和进行对比, 以此来检查 RDB 文件是否有出错或者损坏的情况出现。
重点说的是database这一部分,
一个 RDB 文件的 databases
部分可以保存任意多个非空数据库。
每个非空数据库在 RDB 文件中都可以保存为 SELECTDB
、 db_number
、 key_value_pairs
三个部分。
SELECTDB
常量的长度为 1
字节, 当读入程序遇到这个值的时候, 它知道接下来要读入的将是一个数据库号码。
db_number
保存着一个数据库号码, 根据号码的大小不同, 这个部分的长度可以是 1
字节、 2
字节或者 5
字节。 当程序读入 db_number
部分之后, 服务器会调用 SELECT 命令, 根据读入的数据库号码进行数据库切换, 使得之后读入的键值对可以载入到正确的数据库中。
key_value_pairs
部分保存了数据库中的所有键值对数据, 如果键值对带有过期时间, 那么过期时间也会和键值对保存在一起。 根据键值对的数量、类型、内容、以及是否有过期时间等条件的不同, key_value_pairs
部分的长度也会有所不同。
key_value_pairs 部分,部分都保存了一个或以上数量的键值对, 如果键值对带有过期时间的话, 那么键值对的过期时间也会被保存在内。
不带过期时间的键值对在 RDB 文件中对由 TYPE
、 key
、 value
三部分组成。
总结
- RDB 文件用于保存和还原 Redis 服务器所有数据库中的所有键值对数据。
- SAVE 命令由服务器进程直接执行保存操作,所以该命令会阻塞服务器。
- BGSAVE 命令由子进程执行保存操作,所以该命令不会阻塞服务器。
- 服务器状态中会保存所有用
save
选项设置的保存条件,当任意一个保存条件被满足时,服务器会自动执行 BGSAVE 命令。 - RDB 文件是一个经过压缩的二进制文件,由多个部分组成。
- 对于不同类型的键值对, RDB 文件会使用不同的方式来保存它们。
2.3、AOF持久化
AOF 持久化功能的实现可以分为命令追加(append)、文件写入、文件同步(sync)三个步骤。
当 AOF 持久化功能处于打开状态时, 服务器在执行完一个写命令之后, 会以协议格式将被执行的写命令追加到服务器状态的 aof_buf
缓冲区的末尾。
AOF 文件的写入与同步
Redis 的服务器进程就是一个事件循环(loop), 这个循环中的文件事件负责接收客户端的命令请求, 以及向客户端发送命令回复, 而时间事件则负责执行像 serverCron
函数这样需要定时运行的函数。
因为服务器在处理文件事件时可能会执行写命令, 使得一些内容被追加到 aof_buf
缓冲区里面, 所以在服务器每次结束一个事件循环之前, 它都会调用 flushAppendOnlyFile
函数, 考虑是否需要将 aof_buf
缓冲区中的内容写入和保存到 AOF 文件里面, 这个过程可以用以下伪代码表示:
def eventLoop():
while True:
# 处理文件事件,接收命令请求以及发送命令回复
# 处理命令请求时可能会有新内容被追加到 aof_buf 缓冲区中
processFileEvents()
# 处理时间事件
processTimeEvents()
# 考虑是否要将 aof_buf 中的内容写入和保存到 AOF 文件里面
flushAppendOnlyFile()
appendfsync 选项的值 |
flushAppendOnlyFile 函数的行为 |
---|---|
always |
将 aof_buf 缓冲区中的所有内容写入并同步到 AOF 文件。 |
everysec |
将 aof_buf 缓冲区中的所有内容写入到 AOF 文件, 如果上次同步 AOF 文件的时间距离现在超过一秒钟, 那么再次对 AOF 文件进行同步, 并且这个同步操作是由一个线程专门负责执行的。 |
no |
将 aof_buf 缓冲区中的所有内容写入到 AOF 文件, 但并不对 AOF 文件进行同步, 何时同步由操作系统来决定。 |
如果用户没有主动为 appendfsync
选项设置值, 那么 appendfsync
选项的默认值为 everysec
, 关于 appendfsync
选项的更多信息, 请参考 Redis 项目附带的示例配置文件 redis.conf
。
AOF总结
- AOF 文件通过保存所有修改数据库的写命令请求来记录服务器的数据库状态。
- AOF 文件中的所有命令都以 Redis 命令请求协议的格式保存。
- 命令请求会先保存到 AOF 缓冲区里面, 之后再定期写入并同步到 AOF 文件。
appendfsync
选项的不同值对 AOF 持久化功能的安全性、以及 Redis 服务器的性能有很大的影响。- 服务器只要载入并重新执行保存在 AOF 文件中的命令, 就可以还原数据库本来的状态。
- AOF 重写可以产生一个新的 AOF 文件, 这个新的 AOF 文件和原有的 AOF 文件所保存的数据库状态一样, 但体积更小。
- AOF 重写是一个有歧义的名字, 该功能是通过读取数据库中的键值对来实现的, 程序无须对现有 AOF 文件进行任何读入、分析或者写入操作。
- 在执行 BGREWRITEAOF 命令时, Redis 服务器会维护一个 AOF 重写缓冲区, 该缓冲区会在子进程创建新 AOF 文件的期间, 记录服务器执行的所有写命令。 当子进程完成创建新 AOF 文件的工作之后, 服务器会将重写缓冲区中的所有内容追加到新 AOF 文件的末尾, 使得新旧两个 AOF 文件所保存的数据库状态一致。 最后, 服务器用新的 AOF 文件替换旧的 AOF 文件, 以此来完成 AOF 文件重写操作。
2.4、事件
2.4.1、文件事件
Redis 基于 Reactor 模式开发了自己的网络事件处理器: 这个处理器被称为文件事件处理器(file event handler):
- 文件事件处理器使用 I/O 多路复用(multiplexing)程序来同时监听多个套接字, 并根据套接字目前执行的任务来为套接字关联不同的事件处理器。
- 当被监听的套接字准备好执行连接应答(accept)、读取(read)、写入(write)、关闭(close)等操作时, 与操作相对应的文件事件就会产生, 这时文件事件处理器就会调用套接字之前关联好的事件处理器来处理这些事件。
虽然文件事件处理器以单线程方式运行, 但通过使用 I/O 多路复用程序来监听多个套接字, 文件事件处理器既实现了高性能的网络通信模型, 又可以很好地与 Redis 服务器中其他同样以单线程方式运行的模块进行对接, 这保持了 Redis 内部单线程设计的简单性。
总结
- Redis 服务器是一个事件驱动程序, 服务器处理的事件分为时间事件和文件事件两类。
- 文件事件处理器是基于 Reactor 模式实现的网络通讯程序。
- 文件事件是对套接字操作的抽象: 每次套接字变得可应答(acceptable)、可写(writable)或者可读(readable)时, 相应的文件事件就会产生。
- 文件事件分为
AE_READABLE
事件(读事件)和AE_WRITABLE
事件(写事件)两类。 - 时间事件分为定时事件和周期性事件: 定时事件只在指定的时间达到一次, 而周期性事件则每隔一段时间到达一次。
- 服务器在一般情况下只执行
serverCron
函数一个时间事件, 并且这个事件是周期性事件。 - 文件事件和时间事件之间是合作关系, 服务器会轮流处理这两种事件, 并且处理事件的过程中也不会进行抢占。
- 时间事件的实际处理时间通常会比设定的到达时间晚一些。
2.5、客户端
- 服务器状态结构使用
clients
链表连接起多个客户端状态, 新添加的客户端状态会被放到链表的末尾。 - 客户端状态的
flags
属性使用不同标志来表示客户端的角色, 以及客户端当前所处的状态。 - 输入缓冲区记录了客户端发送的命令请求, 这个缓冲区的大小不能超过 1 GB 。
- 命令的参数和参数个数会被记录在客户端状态的
argv
和argc
属性里面, 而cmd
属性则记录了客户端要执行命令的实现函数。 - 客户端有固定大小缓冲区和可变大小缓冲区两种缓冲区可用, 其中固定大小缓冲区的最大大小为 16 KB , 而可变大小缓冲区的最大大小不能超过服务器设置的硬性限制值。
- 输出缓冲区限制值有两种, 如果输出缓冲区的大小超过了服务器设置的硬性限制, 那么客户端会被立即关闭; 除此之外, 如果客户端在一定时间内, 一直超过服务器设置的软性限制, 那么客户端也会被关闭。
- 当一个客户端通过网络连接连上服务器时, 服务器会为这个客户端创建相应的客户端状态。 网络连接关闭、 发送了不合协议格式的命令请求、 成为 CLIENT_KILL 命令的目标、 空转时间超时、 输出缓冲区的大小超出限制, 以上这些原因都会造成客户端被关闭。
- 处理 Lua 脚本的伪客户端在服务器初始化时创建, 这个客户端会一直存在, 直到服务器关闭。
- 载入 AOF 文件时使用的伪客户端在载入工作开始时动态创建, 载入工作完毕之后关闭。
2.6、服务端
一个命令请求从发送到获得回复的过程中, 客户端和服务器需要完成一系列操作
1. 客户端向服务器发送命令请求 `SET KEY VALUE` 。
2. 服务器接收并处理客户端发来的命令请求 `SET KEY VALUE` , 在数据库中进行设置操作, 并产生命令回复 `OK` 。
3. 服务器将命令回复 `OK` 发送给客户端。
4. 客户端接收服务器返回的命令回复 `OK` , 并将这个回复打印给用户观看。
- 一个命令请求从发送到完成主要包括以下步骤: 1. 客户端将命令请求发送给服务器; 2. 服务器读取命令请求,并分析出命令参数; 3. 命令执行器根据参数查找命令的实现函数,然后执行实现函数并得出命令回复; 4. 服务器将命令回复返回给客户端。
serverCron
函数默认每隔100
毫秒执行一次, 它的工作主要包括更新服务器状态信息, 处理服务器接收的SIGTERM
信号, 管理客户端资源和数据库状态, 检查并执行持久化操作, 等等。- 服务器从启动到能够处理客户端的命令请求需要执行以下步骤: 1. 初始化服务器状态; 2. 载入服务器配置; 3. 初始化服务器数据结构; 4. 还原数据库状态; 5. 执行事件循环。
3、集群数据库
3.1、复制
Redis 的复制功能分为同步(sync)和命令传播(command propagate)两个操作:
- 其中, 同步操作用于将从服务器的数据库状态更新至主服务器当前所处的数据库状态。
- 而命令传播操作则用于在主服务器的数据库状态被修改, 导致主从服务器的数据库状态出现不一致时, 让主从服务器的数据库重新回到一致状态。
同步功能
当客户端向从服务器发送 SLAVEOF 命令, 要求从服务器复制主服务器时, 从服务器首先需要执行同步操作, 也即是, 将从服务器的数据库状态更新至主服务器当前所处的数据库状态。
从服务器对主服务器的同步操作需要通过向主服务器发送 SYNC 命令来完成, 以下是 SYNC 命令的执行步骤:
- 从服务器向主服务器发送 SYNC 命令。
- 收到 SYNC 命令的主服务器执行 BGSAVE 命令, 在后台生成一个 RDB 文件, 并使用一个缓冲区记录从现在开始执行的所有写命令。
- 当主服务器的 BGSAVE 命令执行完毕时, 主服务器会将 BGSAVE 命令生成的 RDB 文件发送给从服务器, 从服务器接收并载入这个 RDB 文件, 将自己的数据库状态更新至主服务器执行 BGSAVE 命令时的数据库状态。
- 主服务器将记录在缓冲区里面的所有写命令发送给从服务器, 从服务器执行这些写命令, 将自己的数据库状态更新至主服务器数据库当前所处的状态。
命令传播
在同步操作执行完毕之后, 主从服务器两者的数据库将达到一致状态, 但这种一致并不是一成不变的 —— 每当主服务器执行客户端发送的写命令时, 主服务器的数据库就有可能会被修改, 并导致主从服务器状态不再一致。
为了让主从服务器再次回到一致状态, 主服务器需要对从服务器执行命令传播操作: 主服务器会将自己执行的写命令 —— 也即是造成主从服务器不一致的那条写命令 —— 发送给从服务器执行, 当从服务器执行了相同的写命令之后, 主从服务器将再次回到一致状态。
总结
- Redis 2.8 以前的复制功能不能高效地处理断线后重复制情况, 但 Redis 2.8 新添加的部分重同步功能可以解决这个问题。
- 部分重同步通过复制偏移量、复制积压缓冲区、服务器运行 ID 三个部分来实现。
- 在复制操作刚开始的时候, 从服务器会成为主服务器的客户端, 并通过向主服务器发送命令请求来执行复制步骤, 而在复制操作的后期, 主从服务器会互相成为对方的客户端。
- 主服务器通过向从服务器传播命令来更新从服务器的状态, 保持主从服务器一致, 而从服务器则通过向主服务器发送命令来进行心跳检测, 以及命令丢失检测。
3.2、Sentinel
启动一个 Sentinel 可以使用命令:
$ redis-sentinel /path/to/your/sentinel.conf
或者命令:
$ redis-server /path/to/your/sentinel.conf --sentinel
这两个命令的效果完全相同。
当一个 Sentinel 启动时, 它需要执行以下步骤:
- 初始化服务器。
- 将普通 Redis 服务器使用的代码替换成 Sentinel 专用代码。
- 初始化 Sentinel 状态。
- 根据给定的配置文件, 初始化 Sentinel 的监视主服务器列表。
- 创建连向主服务器的网络连接。
总结
- Sentinel 只是一个运行在特殊模式下的 Redis 服务器, 它使用了和普通模式不同的命令表, 所以 Sentinel 模式能够使用的命令和普通 Redis 服务器能够使用的命令不同。
- Sentinel 会读入用户指定的配置文件, 为每个要被监视的主服务器创建相应的实例结构, 并创建连向主服务器的命令连接和订阅连接, 其中命令连接用于向主服务器发送命令请求, 而订阅连接则用于接收指定频道的消息。
- Sentinel 通过向主服务器发送 INFO 命令来获得主服务器属下所有从服务器的地址信息, 并为这些从服务器创建相应的实例结构, 以及连向这些从服务器的命令连接和订阅连接。
- 在一般情况下, Sentinel 以每十秒一次的频率向被监视的主服务器和从服务器发送 INFO 命令, 当主服务器处于下线状态, 或者 Sentinel 正在对主服务器进行故障转移操作时, Sentinel 向从服务器发送 INFO 命令的频率会改为每秒一次。
- 对于监视同一个主服务器和从服务器的多个 Sentinel 来说, 它们会以每两秒一次的频率, 通过向被监视服务器的
__sentinel__:hello
频道发送消息来向其他 Sentinel 宣告自己的存在。 - 每个 Sentinel 也会从
__sentinel__:hello
频道中接收其他 Sentinel 发来的信息, 并根据这些信息为其他 Sentinel 创建相应的实例结构, 以及命令连接。 - Sentinel 只会与主服务器和从服务器创建命令连接和订阅连接, Sentinel 与 Sentinel 之间则只创建命令连接。
- Sentinel 以每秒一次的频率向实例(包括主服务器、从服务器、其他 Sentinel)发送 PING 命令, 并根据实例对 PING 命令的回复来判断实例是否在线: 当一个实例在指定的时长中连续向 Sentinel 发送无效回复时, Sentinel 会将这个实例判断为主观下线。
- 当 Sentinel 将一个主服务器判断为主观下线时, 它会向同样监视这个主服务器的其他 Sentinel 进行询问, 看它们是否同意这个主服务器已经进入主观下线状态。
- 当 Sentinel 收集到足够多的主观下线投票之后, 它会将主服务器判断为客观下线, 并发起一次针对主服务器的故障转移操作。
3.3、Redis集群
一个 Redis 集群通常由多个节点(node)组成, 在刚开始的时候, 每个节点都是相互独立的, 它们都处于一个只包含自己的集群当中, 要组建一个真正可工作的集群, 我们必须将各个独立的节点连接起来, 构成一个包含多个节点的集群。
连接各个节点的工作可以使用 CLUSTER MEET 命令来完成, 该命令的格式如下:
CLUSTER MEET <ip> <port>
向一个节点 node
发送 CLUSTER MEET 命令, 可以让 node
节点与 ip
和 port
所指定的节点进行握手(handshake), 当握手成功时, node
节点就会将 ip
和 port
所指定的节点添加到 node
节点当前所在的集群中。
节点启动
一个节点就是一个运行在集群模式下的 Redis 服务器, Redis 服务器在启动时会根据 cluster-enabled
配置选项的是否为 yes
来决定是否开启服务器的集群模式,。
集群模式下才会用到的数据, 节点将它们保存到了 cluster.h/clusterNode
结构, cluster.h/clusterLink
结构, 以及 cluster.h/clusterState
结构里面。
集群数据结构
clusterNode
结构保存了一个节点的当前状态, 比如节点的创建时间, 节点的名字, 节点当前的配置纪元, 节点的 IP 和地址, 等等。每个节点都会使用一个 clusterNode
结构来记录自己的状态, 并为集群中的所有其他节点(包括主节点和从节点)都创建一个相应的clusterNode
结构, 以此来记录其他节点的状态:
struct clusterNode {
// 创建节点的时间
mstime_t ctime;
// 节点的名字,由 40 个十六进制字符组成
// 例如 68eef66df23420a5862208ef5b1a7005b806f2ff
char name[REDIS_CLUSTER_NAMELEN];
// 节点标识
// 使用各种不同的标识值记录节点的角色(比如主节点或者从节点),
// 以及节点目前所处的状态(比如在线或者下线)。
int flags;
// 节点当前的配置纪元,用于实现故障转移
uint64_t configEpoch;
// 节点的 IP 地址
char ip[REDIS_IP_STR_LEN];
// 节点的端口号
int port;
// 保存连接节点所需的有关信息
clusterLink *link;
// ...
};
clusterNode
结构的 link
属性是一个 clusterLink
结构, 该结构保存了连接节点所需的有关信息, 比如套接字描述符, 输入缓冲区和输出缓冲区:
typedef struct clusterLink {
// 连接的创建时间
mstime_t ctime;
// TCP 套接字描述符
int fd;
// 输出缓冲区,保存着等待发送给其他节点的消息(message)。
sds sndbuf;
// 输入缓冲区,保存着从其他节点接收到的消息。
sds rcvbuf;
// 与这个连接相关联的节点,如果没有的话就为 NULL
struct clusterNode *node;
} clusterLink;
redisClient
结构和 clusterLink
结构的相同和不同之处,redisClient
结构和 clusterLink
结构都有自己的套接字描述符和输入、输出缓冲区, 这两个结构的区别在于, redisClient
结构中的套接字和缓冲区是用于连接客户端的, 而 clusterLink
结构中的套接字和缓冲区则是用于连接节点的。最后, 每个节点都保存着一个 clusterState
结构, 这个结构记录了在当前节点的视角下, 集群目前所处的状态 —— 比如集群是在线还是下线, 集群包含多少个节点, 集群当前的配置纪元, 诸如此类:
typedef struct clusterState {
// 指向当前节点的指针
clusterNode *myself;
// 集群当前的配置纪元,用于实现故障转移
uint64_t currentEpoch;
// 集群当前的状态:是在线还是下线
int state;
// 集群中至少处理着一个槽的节点的数量
int size;
// 集群节点名单(包括 myself 节点)
// 字典的键为节点的名字,字典的值为节点对应的 clusterNode 结构
dict *nodes;
// ...
} clusterState;
之后看一下CLUSTER MEET <IP> <PORT>
命令的实现过程
- 节点 A 会为节点 B 创建一个
clusterNode
结构, 并将该结构添加到自己的clusterState.nodes
字典里面。 - 之后, 节点 A 将根据 CLUSTER MEET 命令给定的 IP 地址和端口号, 向节点 B 发送一条
MEET
消息(message)。 - 如果一切顺利, 节点 B 将接收到节点 A 发送的
MEET
消息, 节点 B 会为节点 A 创建一个clusterNode
结构, 并将该结构添加到自己的clusterState.nodes
字典里面。 - 之后, 节点 B 将向节点 A 返回一条
PONG
消息。 - 如果一切顺利, 节点 A 将接收到节点 B 返回的
PONG
消息, 通过这条PONG
消息节点 A 可以知道节点 B 已经成功地接收到了自己发送的MEET
消息。 - 之后, 节点 A 将向节点 B 返回一条
PING
消息。 - 如果一切顺利, 节点 B 将接收到节点 A 返回的
PING
消息, 通过这条PING
消息节点 B 可以知道节点 A 已经成功地接收到了自己返回的PONG
消息, 握手完成。
总结
- 节点通过握手来将其他节点添加到自己所处的集群当中。
- 集群中的
16384
个槽可以分别指派给集群中的各个节点, 每个节点都会记录哪些槽指派给了自己, 而哪些槽又被指派给了其他节点。 - 节点在接到一个命令请求时, 会先检查这个命令请求要处理的键所在的槽是否由自己负责, 如果不是的话, 节点将向客户端返回一个
MOVED
错误,MOVED
错误携带的信息可以指引客户端转向至正在负责相关槽的节点。 - 对 Redis 集群的重新分片工作是由客户端执行的, 重新分片的关键是将属于某个槽的所有键值对从一个节点转移至另一个节点。
- 如果节点 A 正在迁移槽
i
至节点 B , 那么当节点 A 没能在自己的数据库中找到命令指定的数据库键时, 节点 A 会向客户端返回一个ASK
错误, 指引客户端到节点 B 继续查找指定的数据库键。 MOVED
错误表示槽的负责权已经从一个节点转移到了另一个节点, 而ASK
错误只是两个节点在迁移槽的过程中使用的一种临时措施。- 集群里的从节点用于复制主节点, 并在主节点下线时, 代替主节点继续处理命令请求。
- 集群中的节点通过发送和接收消息来进行通讯, 常见的消息包括
MEET
、PING
、PONG
、PUBLISH
、FAIL
五种。
4、Redis中的独立功能
4.1、发布与订阅
当一个客户端执行 SUBSCRIBE 命令, 订阅某个或某些频道的时候, 这个客户端与被订阅频道之间就建立起了一种订阅关系。
Redis 将所有频道的订阅关系都保存在服务器状态的 pubsub_channels
字典里面, 这个字典的键是某个被订阅的频道, 而键的值则是一个链表, 链表里面记录了所有订阅这个频道的客户端:
struct redisServer {
// ...
// 保存所有频道的订阅关系
dict *pubsub_channels;
// ...
};
订阅频道
每当客户端执行 SUBSCRIBE 命令, 订阅某个或某些频道的时候, 服务器都会将客户端与被订阅的频道在 pubsub_channels
字典中进行关联。
根据频道是否已经有其他订阅者, 关联操作分为两种情况执行:
- 如果频道已经有其他订阅者, 那么它在
pubsub_channels
字典中必然有相应的订阅者链表, 程序唯一要做的就是将客户端添加到订阅者链表的末尾。 -
如果频道还未有任何订阅者, 那么它必然不存在于
pubsub_channels
字典, 程序首先要在pubsub_channels
字典中为频道创建一个键, 并将这个键的值设置为空链表, 然后再将客户端添加到链表, 成为链表的第一个元素。退订频道
UNSUBSCRIBE 命令的行为和 SUBSCRIBE 命令的行为正好相反 —— 当一个客户端退订某个或某些频道的时候, 服务器将从 pubsub_channels
中解除客户端与被退订频道之间的关联:
- 程序会根据被退订频道的名字, 在
pubsub_channels
字典中找到频道对应的订阅者链表, 然后从订阅者链表中删除退订客户端的信息。 - 如果删除退订客户端之后, 频道的订阅者链表变成了空链表, 那么说明这个频道已经没有任何订阅者了, 程序将从
pubsub_channels
字典中删除频道对应的键。
总结
- 服务器状态在
pubsub_channels
字典保存了所有频道的订阅关系: SUBSCRIBE 命令负责将客户端和被订阅的频道关联到这个字典里面, 而 UNSUBSCRIBE 命令则负责解除客户端和被退订频道之间的关联。 - 服务器状态在
pubsub_patterns
链表保存了所有模式的订阅关系: PSUBSCRIBE 命令负责将客户端和被订阅的模式记录到这个链表中, 而UNSUBSCRIBE 命令则负责移除客户端和被退订模式在链表中的记录。 - PUBLISH 命令通过访问
pubsub_channels
字典来向频道的所有订阅者发送消息, 通过访问pubsub_patterns
链表来向所有匹配频道的模式的订阅者发送消息。 - PUBSUB 命令的三个子命令都是通过读取
pubsub_channels
字典和pubsub_patterns
链表中的信息来实现的。
4.2、事务
一个事务从开始到结束通常会经历以下三个阶段:
- 事务开始。
- 命令入队。
- 事务执行。
事务开始
redis> MULTI
OK
MULTI 命令可以将执行该命令的客户端从非事务状态切换至事务状态, 这一切换是通过在客户端状态的 flags
属性中打开 REDIS_MULTI
标识来完成的,开启事务。
命令入队
服务器会根据不同的命令执行不同的操作。
- 如果客户端发送的命令为 EXEC 、 DISCARD 、 WATCH 、 MULTI 四个命令的其中一个, 那么服务器立即执行这个命令。
- 与此相反, 如果客户端发送的命令是 EXEC 、 DISCARD 、 WATCH 、 MULTI 四个命令以外的其他命令, 那么服务器并不立即执行这个命令, 而是将这个命令放入一个事务队列里面, 然后向客户端返回
QUEUED
回复。
执行事务
当一个处于事务状态的客户端向服务器发送 EXEC 命令时, 这个 EXEC 命令将立即被服务器执行: 服务器会遍历这个客户端的事务队列, 执行队列中保存的所有命令, 最后将执行命令所得的结果全部返回给客户端。
举个例子
SET "name" "Practical Common Lisp"
GET "name"
SET "author" "Peter Seibel"
GET "author"
EXEC
1) OK
2) "Practical Common Lisp"
3) OK
4) "Peter Seibel"
总结
- 事务提供了一种将多个命令打包, 然后一次性、有序地执行的机制。
- 多个命令会被入队到事务队列中, 然后按先进先出(FIFO)的顺序执行。
- 事务在执行过程中不会被中断, 当事务队列中的所有命令都被执行完毕之后, 事务才会结束。
- 带有 WATCH 命令的事务会将客户端和被监视的键在数据库的
watched_keys
字典中进行关联, 当键被修改时, 程序会将所有监视被修改键的客户端的REDIS_DIRTY_CAS
标志打开。 - 只有在客户端的
REDIS_DIRTY_CAS
标志未被打开时, 服务器才会执行客户端提交的事务, 否则的话, 服务器将拒绝执行客户端提交的事务。 - Redis 的事务总是保证 ACID 中的原子性、一致性和隔离性, 当服务器运行在 AOF 持久化模式下, 并且
appendfsync
选项的值为always
时, 事务也具有耐久性
4.3、lua脚本
为了在 Redis 服务器中执行 Lua 脚本, Redis 在服务器内嵌了一个 Lua 环境(environment), 并对这个 Lua 环境进行了一系列修改, 从而确保这个 Lua 环境可以满足 Redis 服务器的需要。
Redis 服务器创建并修改 Lua 环境的整个过程由以下步骤组成:
- 创建一个基础的 Lua 环境, 之后的所有修改都是针对这个环境进行的。
- 载入多个函数库到 Lua 环境里面, 让 Lua 脚本可以使用这些函数库来进行数据操作。
- 创建全局表格
redis
, 这个表格包含了对 Redis 进行操作的函数, 比如用于在 Lua 脚本中执行 Redis 命令的redis.call
函数。 - 使用 Redis 自制的随机函数来替换 Lua 原有的带有副作用的随机函数, 从而避免在脚本中引入副作用。
- 创建排序辅助函数, Lua 环境使用这个辅佐函数来对一部分 Redis 命令的结果进行排序, 从而消除这些命令的不确定性。
- 创建
redis.pcall
函数的错误报告辅助函数, 这个函数可以提供更详细的出错信息。 - 对 Lua 环境里面的全局环境进行保护, 防止用户在执行 Lua 脚本的过程中, 将额外的全局变量添加到了 Lua 环境里面。
- 将完成修改的 Lua 环境保存到服务器状态的
lua
属性里面, 等待执行服务器传来的 Lua 脚本。
总结
重点回顾
- Redis 服务器在启动时, 会对内嵌的 Lua 环境执行一系列修改操作, 从而确保内嵌的 Lua 环境可以满足 Redis 在功能性、安全性等方面的需要。
- Redis 服务器专门使用一个伪客户端来执行 Lua 脚本中包含的 Redis 命令。
- Redis 使用脚本字典来保存所有被 EVAL 命令执行过, 或者被 SCRIPT_LOAD 命令载入过的 Lua 脚本, 这些脚本可以用于实现SCRIPT_EXISTS 命令, 以及实现脚本复制功能。
- EVAL 命令为客户端输入的脚本在 Lua 环境中定义一个函数, 并通过调用这个函数来执行脚本。
- EVALSHA 命令通过直接调用 Lua 环境中已定义的函数来执行脚本。
- SCRIPT_FLUSH 命令会清空服务器
lua_scripts
字典中保存的脚本, 并重置 Lua 环境。 - SCRIPT_EXISTS 命令接受一个或多个 SHA1 校验和为参数, 并通过检查
lua_scripts
字典来确认校验和对应的脚本是否存在。 - SCRIPT_LOAD 命令接受一个 Lua 脚本为参数, 为该脚本在 Lua 环境中创建函数, 并将脚本保存到
lua_scripts
字典中。 - 服务器在执行脚本之前, 会为 Lua 环境设置一个超时处理钩子, 当脚本出现超时运行情况时, 客户端可以通过向服务器发送SCRIPT_KILL 命令来让钩子停止正在执行的脚本, 或者发送 SHUTDOWN nosave 命令来让钩子关闭整个服务器。
- 主服务器复制 EVAL 、 SCRIPT_FLUSH 、 SCRIPT_LOAD 三个命令的方法和复制普通 Redis 命令一样 —— 只要将相同的命令传播给从服务器就可以了。
- 主服务器在复制 EVALSHA 命令时, 必须确保所有从服务器都已经载入了 EVALSHA 命令指定的 SHA1 校验和所对应的 Lua 脚本, 如果不能确保这一点的话, 主服务器会将 EVALSHA 命令转换成等效的 EVAL 命令, 并通过传播 EVAL 命令来获得相同的脚本执行效果。
4.4、排序命令
SORT 命令的最简单执行形式为:
SORT <key>
这个命令可以对一个包含数字值的键 key
进行排序。
redis> RPUSH numbers 3 1 2
(integer) 3
redis> SORT numbers
1) "1"
2) "2"
3) "3"
服务器执行的详细过程
- 创建一个和
numbers
列表长度相同的数组, 该数组的每个项都是一个redis.h/redisSortObject
结构, 如图 IMAGE_CREATE_ARRAY 所示。 - 遍历数组, 将各个数组项的
obj
指针分别指向numbers
列表的各个项, 构成obj
指针和列表项之间的一对一关系, 如图 IMAGE_POINT_OBJ 所示。 - 遍历数组, 将各个
obj
指针所指向的列表项转换成一个double
类型的浮点数, 并将这个浮点数保存在相应数组项的u.score
属性里面, 如图 IMAGE_SET_SCORE 所示。 - 根据数组项
u.score
属性的值, 对数组进行数字值排序, 排序后的数组项按u.score
属性的值从小到大排列, 如图 IMAGE_SORTED 所示。 - 遍历数组, 将各个数组项的
obj
指针所指向的列表项作为排序结果返回给客户端: 程序首先访问数组的索引0
, 返回u.score
值为1.0
的列表项"1"
; 然后访问数组的索引1
, 返回u.score
值为2.0
的列表项"2"
; 最后访问数组的索引2
, 返回u.score
值为3.0
的列表项"3"
。
总结
- SORT 命令通过将被排序键包含的元素载入到数组里面, 然后对数组进行排序来完成对键进行排序的工作。
- 在默认情况下, SORT 命令假设被排序键包含的都是数字值, 并且以数字值的方式来进行排序。
- 如果 SORT 命令使用了
ALPHA
选项, 那么 SORT 命令假设被排序键包含的都是字符串值, 并且以字符串的方式来进行排序。 - SORT 命令的排序操作由快速排序算法实现。
- SORT 命令会根据用户是否使用了
DESC
选项来决定是使用升序对比还是降序对比来比较被排序的元素, 升序对比会产生升序排序结果, 被排序的元素按值的大小从小到大排列, 降序对比会产生降序排序结果, 被排序的元素按值的大小从大到小排列。 - 当 SORT 命令使用了
BY
选项时, 命令使用其他键的值作为权重来进行排序操作。 - 当 SORT 命令使用了
LIMIT
选项时, 命令只保留排序结果集中LIMIT
选项指定的元素。 - 当 SORT 命令使用了
GET
选项时, 命令会根据排序结果集中的元素, 以及GET
选项给定的模式, 查找并返回其他键的值, 而不是返回被排序的元素。 - 当 SORT 命令使用了
STORE
选项时, 命令会将排序结果集保存在指定的键里面。 - 当 SORT 命令同时使用多个选项时, 命令先执行排序操作(可用的选项为
ALPHA
、ASC
或DESC
、BY
), 然后执行LIMIT
选项, 之后执行GET
选项, 再之后执行STORE
选项, 最后才将排序结果集返回给客户端。 - 除了
GET
选项之外, 调整选项的摆放位置不会影响 SORT 命令的排序结果。
4.5、慢查询日志
服务器状态中包含了几个和慢查询日志功能有关的属性:
struct redisServer {
// ...
// 下一条慢查询日志的 ID
long long slowlog_entry_id;
// 保存了所有慢查询日志的链表
list *slowlog;
// 服务器配置 slowlog-log-slower-than 选项的值
long long slowlog_log_slower_than;
// 服务器配置 slowlog-max-len 选项的值
unsigned long slowlog_max_len;
// ...
};
slowlog_entry_id
属性的初始值为 0
, 每当创建一条新的慢查询日志时, 这个属性的值就会用作新日志的 id
值, 之后程序会对这个属性的值增一。
比如说, 在创建第一条慢查询日志时, slowlog_entry_id
的值 0
会成为第一条慢查询日志的 ID , 而之后服务器会对这个属性的值增一; 当服务器再创建新的慢查询日志的时候, slowlog_entry_id
的值 1
就会成为第二条慢查询日志的 ID , 然后服务器再次对这个属性的值增一, 以此类推。
slowlog
链表保存了服务器中的所有慢查询日志, 链表中的每个节点都保存了一个 slowlogEntry
结构, 每个 slowlogEntry
结构代表一条慢查询日志:
typedef struct slowlogEntry {
// 唯一标识符
long long id;
// 命令执行时的时间,格式为 UNIX 时间戳
time_t time;
// 执行命令消耗的时间,以微秒为单位
long long duration;
// 命令与命令参数
robj **argv;
// 命令与命令参数的数量
int argc;
} slowlogEntry;
slowlog_entry_id
的值为6
, 表示服务器下条慢查询日志的id
值将为6
。slowlog
链表包含了id
为5
至1
的慢查询日志, 最新的5
号日志排在链表的表头, 而最旧的1
号日志排在链表的表尾, 这表明slowlog
链表是使用插入到表头的方式来添加新日志的。slowlog_log_slower_than
记录了服务器配置slowlog-log-slower-than
选项的值0
, 表示任何执行时间超过0
微秒的命令都会被慢查询日志记录。slowlog-max-len
属性记录了服务器配置slowlog-max-len
选项的值5
, 表示服务器最多储存五条慢查询日志。
总结
- Redis 的慢查询日志功能用于记录执行时间超过指定时长的命令。
- Redis 服务器将所有的慢查询日志保存在服务器状态的
slowlog
链表中, 每个链表节点都包含一个slowlogEntry
结构, 每个slowlogEntry
结构代表一条慢查询日志。 - 打印和删除慢查询日志可以通过遍历
slowlog
链表来完成。 slowlog
链表的长度就是服务器所保存慢查询日志的数量。- 新的慢查询日志会被添加到
slowlog
链表的表头, 如果日志的数量超过slowlog-max-len
选项的值, 那么多出来的日志会被删除。
5、监视器
通过执行 MONITOR 命令, 客户端可以将自己变为一个监视器, 实时地接收并打印出服务器当前处理的命令请求的相关信息:
redis> MONITOR
OK
1378822099.421623 [0 127.0.0.1:56604] "PING"
1378822105.089572 [0 127.0.0.1:56604] "SET" "msg" "hello world"
1378822109.036925 [0 127.0.0.1:56604] "SET" "number" "123"
1378822140.649496 [0 127.0.0.1:56604] "SADD" "fruits" "Apple" "Banana" "Cherry"
1378822154.117160 [0 127.0.0.1:56604] "EXPIRE" "msg" "10086"
1378822257.329412 [0 127.0.0.1:56604] "KEYS" "*"
1378822258.690131 [0 127.0.0.1:56604] "DBSIZE"
总结
- 客户端可以通过执行 MONITOR 命令, 将客户端转换成监视器, 接收并打印服务器处理的每个命令请求的相关信息。
- 当一个客户端从普通客户端变为监视器时, 该客户端的
REDIS_MONITOR
标识会被打开。 - 服务器将所有监视器都记录在
monitors
链表中。 - 每次处理命令请求时, 服务器都会遍历
monitors
链表, 将相关信息发送给监视器。
总结
感触很深,之前对redis的使用就是仅限于缓存,拦住了绝打部分直面数据库的请求,看完这本书后,从redis的底层数据结构,到我们接触的redis的五种对象,再到redis的持久化,复制,集群化等功能,加上redis的扩展功能,比如实现发布订阅,比如事务的支持,比如redis本身的一些监控如慢查询等,收益匪浅。