仅针对 Redis 3.0 版本及之前,新版本会有变更
一、数据结构与对象
1、简单动态字符串
Redis 是用 C 语言开发的一个开源的高性能键值对(key-value)数据库,但是没有直接使用 C 语言传统的字符串表示(以空字符结尾的字符数组),而是自己构建了一种名为简单动态字符串(simple dynamic string, SDS)的抽象类型,并将SDS用作Redis的默认字符串表示。
这样是因为 Redis 的 key 全部都是字符串,会频繁对字符串进行各种操作。
Redis 的包含字符串的键值对在底层都是 SDS 实现的,但是 C 字符串也会在一些无需对字符串值进行修改的地方使用,比如打印日志。
1.1 SDS 的定义
struct sdshdr {
// 记录 buf 数组中已使用字节的数量
// 等于 SDS 所保存字符串的长度
int len;
// 记录 buf 数组中未使用字节的数量
int free;
// 字节数组,用于保存字符串
char buf[];
};
SDS 遵循 C 字符串以空字符结尾的管理,但保存空字符的 1 字节不计算在 SDS 的 len 属性里。
1.2 SDS 较 C 字符串的优点
① Redis 将获取字符串长度所需的时间复杂度从 O(N) 降低到 O(1),确保了获取字符串长度的工作不会成为 Redis 的性能瓶颈;
② C 语言在拼接字符串时,需要保证已经给原字符串分配足够多的内存,否则会产生缓冲区溢出;而 SDS 在拼接字符串之前会先判断 free 是否够存储下需要拼接的字符串,不够会进行扩容源字符串,避免了缓冲区溢出;
③ 减少修改字符串时带来的内存重分配次数;
④ SDS 的 API 都是二进制安全的:所有 SDS API 都是以处理二进制的方式来处理 buf 数组里的数据,不仅能存储文本数据,还可以保存任意格式的二进制数据,而且在写入时是什么样,读取时就是什么样,这也是 buf 属性被称为字节数组的原因;
⑤ SDS 还可以使用部分 C 字符串函数。
按 C 字符串来说,每次拼接与缩短都会对这个 C 字符串进行一次内存重分配操作:
· 如果拼接之前忘记扩展底层数组的空间大小,会造成缓冲区溢出
· 如果缩短字符之后,忘记释放字符串不再使用的空间,会造成内存泄露
SDS 为了避免这种情况,实现了空间与分配和惰性空间释放两种优化策略:
- 空间预分配:当对 SDS 字符串拼接时,会为 SDS 分配所需要的空间,还会为 SDS 分配额外的未使用空间,用 free 记录。
- 惰性空间释放:当需要缩短 SDS 时,程序不会直接回收多余的字节,而是使用 free 属性记录起来,等待将来使用,Redis 也提供了真正释放 SDS 的未使用空间的方法。
1)其中额外分配的未使用空间数量公式为:
- 如果进行修改后,SDS 的长度将小于 1 MB ,那么程序分配和 len 同样大小的未使用空间,即:扩容后的 len 和 free 相等
- 如果修改后,SDS 长度大于 1 MB 那么程序会分配 1 MB 的未使用空间,即:修改后的 len 为 10 MB ,那么 free 为 1 MB ,SDS 的 buf 实际长度为 10 MB + 1 MB + 1 byte
通过空间预分配策略,Redis 可以减少连续执行字符串扩容时所需的内存重分配次数,连续扩容 N 次所需内存重分配次数从必定 N 次到最多 N 次;
2、链表
// 链表节点结构
typedef struct listNode {
// 前置节点
struct listNode *prev;
// 后置节点
struct listNode *next;
// 节点的值
void *value;
} listNode;
其中 prev 指向前一个 listNode ,next 指向下一个 listNode ,value是链表的值,为双端链表,图示:
// Redis 的链表结构
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 的链表和 JAVA 中的 LinkedList 相似,有以下特征:
- 双端:每个链表节点都有 prev 和 next 指针,获取前置节点和后直接点的时间复杂度都是
O(1)。
- 无环:表头节点的 prev 指针和表尾节点的 next 都指向 null,以 null 为终点。
- 带表头指针和表尾指针:获取链表表头和表尾的节点时间复杂度为 O(1)。
- 带链表长度计数器:获取链表中的节点数量时间复杂度为 O(1)。
- 多态:链表节点使用 void* 指针来保存节点值, 并且可以通过 list 结构的 dup 、 free、
match 三个属性为节点值设置类型特定函数, 所以链表可以用于保存各种不同类型的值。
3、字典
Redis 的字典使用哈希表作为底层实现,一个哈希表里有多个哈希表节点,每个哈希表节点保存了字段中的一个键值对。
3.1 哈希表
typedef struct dictht {
// 哈希表数组
dictEntry **table;
// 哈希表大小
unsigned long size;
// 哈希表大小掩码,用于计算索引值
// 总是等于 size - 1
unsigned long sizemask;
// 该哈希表已有键值对节点的数量
unsigned long used;
} dictht;
空哈希表结构如下:
3.2 哈希表节点
typedef struct dictEntry {
// 键
void *key;
// 值
union {
void *val;
uint64_t u64;
int64_t s64;
} v;
// 指向下个哈希表节点,形成链表
struct dictEntry *next;
} dictEntry;
next 属性是指向另一个哈希表节点的指针,这样可以解决健冲突,如下所示:
3.3 字典实现
/* 字典结构 */
typedef struct dict {
// 类型特定函数,指向 dictType 指针
// 每个 dictType 结构保存了一些用于操作特定类型键值对的函数
// Redis 会为用途不同的字典设置不同的类型特定函数
dictType *type;
// 私有数据,保存了需要传给那些类型特定函数的可选参数
void *privdata;
// 哈希表
// 包含两个 dictht 的数组,一般情况下,字典只使用 ht[0] 哈希表
// ht[1] 哈希表只会在对 ht[0] 哈希表进行 rehash 时使用
dictht ht[2];
// rehash 索引,记录了 rehash 目前的进度
// 当 rehash 不在进行时,值为 -1
int rehashidx; /* rehashing not in progress if rehashidx == -1 */
} 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 *key1, const void *key2);
// 销毁键的函数
void (*keyDestructor)(void *privdata, void *key);
// 销毁值的函数
void (*valDestructor)(void *privdata, void *obj);
} dictType;
如下所示为一个普通状态下的字典:
如下所示为一个包含两个键值对,且其中 k2 和 k1 冲突的 hash表:
解决冲突的方法:因为 dictEntry 节点组成的链表是单向链表,所以为了速度考虑,程序使用头插法,插入时的复杂度为 O(1)。
3.4 哈希算法
# 1、使用字典设置的哈希函数,计算键 key 的哈希值
hash = dict->type->hashFunction(key);
# 2、 使用哈希表的 sizemask 属性和哈希值,计算出索引值
# 根据情况不同, ht[x] 可以是 ht[0] 或者 ht[1]
index = hash & dict->ht[x].sizemask;
其中 hashFunction 使用 MurmurHash2 算法来计算键的 hash 值,优点在于,即使键输入是有规律的,算法仍能给出很好的随机分部向,并且算法的计算速度也非常快。
3.5 rehash
随着操作不断进行,哈希表保存的键值对会逐渐增多或减少,为了让哈希表的负载因子(load factor)维持在一个合理的范围之内,所需会对哈希表进行扩容或收缩,扩容和收缩哈希表的过程,就称为 rehash。
rehash 步骤如下:
- 给字典的 ht[1] 申请存储空间,大小取决于要进行的操作,以及 ht[0] 当前键值对的数量
(ht[0].used):
- 如果执行的是扩展操作,那么 ht[1] 的大小为第一个大等于 ht[0].used * 2 的
2^n (2的 n 次方)。例如 used = 30, 则 ht[1] 的大小是 64。 - 如果执行的是收缩操作,那么 ht[1] 的值是第一个大于等于 ht[0].used 的 2^n。
例:used = 30,则 ht[1] 的大小是 32。
- 将保存在 ht[0] 中的所有键值对 rehash 到 ht[1] 上, rehash 是指重新计算键的哈希值和索引值,然后将键值对(dictEntry)放到 ht[1] 哈希表的重新计算后的位置上
- 当 ht[0] 包含的键值对都迁移到了 ht[1] 上之后(ht[0] 变为空表),释放 ht[0] ,将
ht[1] 设置成 ht[0] ,并在 ht[1]新创建一个空哈希表,为下一次 rehash 做准备
负载因子 = 哈希表已保存节点数量 / 哈希表大小
load_factor = ht[0].used / ht[0].size
扩容条件:
- 服务器目前没有在执行 BGSAVE 或者 BGREWRITEAOF 命令,并且哈希表的负载因子大于等于 1
- 服务器目前正在执行 BGSAVE 或者 BGREWRITEAOF 命令,并且哈希表的负载因子大等于 5
收缩条件:
负载因子小于0.1
3.5.1 渐进式rehash
rehash 的动作并不是一次性完成的,而是分多次、渐进式的完成的。
这样保证如果键值对成千上万个时,一次性 rehash 到 ht[1] 的话,庞大的计算量可能会导致服务器在一段时间内停止服务。
优点是:将 rehash 键值对所需的计算工作均摊到对字典的每个增删改查操作上,避免了集中式 rehash 带来的庞大计算量。
步骤:
- 为 ht[1] 分配空间,让字典同时持有 ht[0] 和 ht[1] 两个哈希表
- 在字典中维持一个索引计数器变量 rehashidx ,并将它的值设置为 0 ,标识 rehash 工作
开始 - 在 rehash 期间,每次对字典执行增删改查操作时,程序除了执行指定的操作以外,还会顺带将 ht[0] 哈希表在 rehashidx 索引上的所有键值对 rehash 到 ht[1], 该索引上的 rehash 工作完成之后,程序将 rehashidx 属性加 1
- 随着字典操作的不断执行,最终在某个时间点, ht[0] 的所有键值对会被 rehash 到
ht[1],这时,程序会把 rehashidx 设置为 -1, 表示 rehash 操作完成
变化过程如下图:
渐进式 rehash 时的哈希表操作:
查询:先在 ht[0] 里查找,没找到在 ht[1] 继续查找
增加:直接增加到 ht[1] 里,保证 ht[0] 里的键值对只增不减
删除和修改: 先在 ht[0] 里查找,没找到在 ht[1] 继续查找,找到之后进行删除或修改
4、跳跃表
跳跃表(skiplist)是一种有序数据结构,它通过在每个节点中维持多个指向其他节点的指针,从而达到快速访问节点的目的。
跳跃表查询时间复杂度平均 O(logN) 最坏 O(N) ,还可以通过顺序性操作来处理节点。
在 Redis 中,有两个地方用到了跳跃表,一是有序集合(sorted Set),另一个是在集群节点中用作内部数据结构。
跳表和 B+Tree 的区别
跳跃表以空间来换取时间,而且层高具有不稳定性,但是,跳表使用概率均衡技术而不是强制均衡技术,理论上插入和删除速度比传统上的平衡树高效,而且实现比 B+Tree 简单。
B+Tree 磁盘预读性好,一般来说 B+Tree 的磁盘 IO 次数会比跳跃表少,因为 B+Tree 树深较稳定。
4.1 跳跃表实现
Redis 的跳跃表是由 redis.h/zskiplistNode 和 redis.h/zskiplist 两个结构定义,其中 zskiplistNode 标识跳跃表节点,而 zskiplist 结构用于保存跳跃表节点的相关信息,如节点的数量,以及指向表头表尾节点的指针等。
跳跃表有以下特征:
- 每个跳跃表节点的层高是 1 至 32 之间的随机数
- 在统一跳跃表中,多个节点可以包含相同的分值,但每个节点的成员对象必须是唯一的
- 节点按照分值排序,当分值相同时,节点按照成员对象的大小进行排序
// 跳跃表节点
typedef struct zskiplistNode {
// 后退指针
struct zskiplistNode *backward;
// 分值
double score;
// 成员对象
robj *obj;
// 层
struct zskiplistLevel {
// 前进指针
struct zskiplistNode *forward;
// 跨度,用这个属性能很快的算出指定成员在跳跃表中的排名
unsigned int span;
} level[];
} zskiplistNode;
// 跳跃表
typedef struct zskiplist {
// 表头节点和表尾节点
struct zskiplistNode *header, *tail;
// 表中节点的数量(表头节点不计算在内)
unsigned long length;
// 表中层数最大的节点的层数(表头节点的层数不计算在内)
int level;
} zskiplist;
5、整数集合
整数集合 (intset)是集合键(Set)的底层实现之一:当一个集合只包含整数值元素,并且这个集合的元素数量不多时, Redis 就会使用整数集合作为集合键的底层实现。
5.1 整数集合实现
整数集合 (intset)是 Redis 用于保存整数值的集合抽象数据结构,它可以保存类型为 int16_t、int32_t 和 int64_t 的整数,并且保证集合中不会出现重复元素。
每个 intset.h/intset 结构标识一个整数集合:
typedef struct intset {
// 编码方式
// 1、INTSET_ENC_INT16 ,contents 保存 int16_t 类型数组
// 范围:-32,768 ~ 32,768
// 2、INTSET_ENC_INT32 ,contents 保存 int32_t 类型数组
// 范围:-2,147,483,648 ~ 2,147,483,648
// 3、INTSET_ENC_INT64 ,contents 保存 int64_t 类型数组
// 范围:-9,223,372,036,854,775,808 ~ 9,223,372,036,854,775,808
uint32_t encoding;
// 集合包含的元素数量
uint32_t length;
// 保存元素的数组
int8_t contents[];
} intset;
contents 数组是整数集合的底层实现:整数集合的每个元素都是 contents 数组的一个数组项(item),每个项在数组中按值的大小从小到大有序排序,并且数组中不包含任何重复项。
5.2 升级
每当我们要将一个新元素添加到整数集合里面,并且新元素的类型比整数集合现在所有元素的类型都要长时,就会进行升级操作(upgrade),然后才能将新元素添加到整数集合里。
升级整数集合并添加新元素共分为三步进行:
- 根据新元素的类型,和现有元素个数 + 1(+1来自于新添加的元素),扩展整数集合底层数组的空间大小
- 将底层数组现有所有的元素都从后往前转换成与新元素相同的类型,并将类型转换后的元素放置在正确的位置上,而且在放置元素的过程中,需要继续维持底层数组的有序性不变
- 将新元素添加到底层数组里
因为每次向整数集合添加新元素都可能引起升级,而每次升级都需要对底层数组中已有的所有元素进行类型转换,所以向整数集合添加新元素的时间复杂度为 O(N)
因为引发升级的新元素的长度总是比整数集合现有所有元素的长度都大,所以这个新元素要么大于所有元素,要么小于所有元素,存放位置要么是 0 要么是 -1。
5.3 升级的好处
整数集合的升级策略有两个好处:提升整数集合的灵活性和尽可能地节约内存
- 提升灵活性: 我们可以随意将 int16_t、int32_t、或者 int_64_t 类型的整数添加到集合中,而不必担心出现类型错误;
- 节约内存:只在需要的时候才升级成 int64_t 类型,尽可能节约内存。
5.4 整数集合 intset 不支持降级操作
6、压缩列表
压缩列表(ziplist)本质是一个字节数组,常见的应用有:list、zset、hash。(3.0 之后 list 键已经不直接用 ziplist 做底层实现了,取而代之的是 quicklist)
当一个列表键只包含少量列表项、并且每个列表项要么就是小整数值,要么就是长度比较短的字符串,那么 Redis 就会使用压缩列表做底层实现
6.1 压缩列表的构成
压缩列表是 Redis 为节约内存而开发,由一系列特殊编码的连续内存块组成的顺序型(sequential)数据结构,使用字节数组标识一个压缩列表,字节数组按逻辑划分为多个字段,如图所示:
属性说明如下:
zlbytes:记录整个压缩列表占用的内存字节数:在堆压缩列表进行内存重分配,或者计算 zlend 的位置时使用。
zltail:记录压缩列表表尾节点距离压缩列表的起始地址有多少字节:通过这个偏移量,程序无需遍历整个压缩列表 就可以确定表尾节点的地址。
zllen:记录了压缩列表包含的节点数量:当这个属性的值小于 UINT16_MAX(65535)时,这个属性的值就是压缩列表包含节点的数量;当这个值等于 UINT16_MAX 时,节点的真实数量需要遍历整个压缩列表才能得出。
entryX:压缩列表的各个节点,节点的长度由节点保存的内容决定。
zlend:特殊值 0xFF(十进制 255),用于标记压缩列表的末端
6.2 压缩列表节点的构成
previous_entry_length:
以字节为单位,记录压缩列表中前一个节点的长度,有了这个长度,程序就可以通过指针运算,根据当前节点的起始地址来计算出前一个节点的起始地址,压缩列表从表尾向表头遍历操作就是通过这一原理实现。
previous_entry_length 属性的长度可以是 1 字节或者 5 字节:
· 如果前一节点的长度小于 254 字节 那么该属性的长度是 1 字节,前节点的长度就保存在
这一字节里。
· 如果前一节点的长度大等于 254 自己,那么该属性的长度是 5 字节,其中属性的第一字节
就会被设置成 0xFE(十进制值 254),而之后的四个字节保存的是前一字节的长度。
encoding:
记录了节点的 content 属性保存数据的类型以及长度。
content:
负责保存字节的值,节点值可以是一个字节数组或者整数,值的类型和长度由字节的 encoding 属性决定。
优点:
比链表的好处主要是:链表不仅有前后指针,而且还是一个个 StringObject 组成,省了不少字节的内存,Redis 是基于内存的,所以 Redis 使用此方法节省内存。
缺点:
1、因为 ziplist 是紧凑存储,没有冗余空间,所以插入新元素就需要扩展内存,过程中可能需要重新分配空间,并将需要偏移的内容一次性拷贝到新地址,如果数据量太大,重新分配内存和拷贝数据会有很大的消耗。
2、连锁更新问题:前面说到 ziplist 使用 previous_entry_length 存储前一个字节的长度,会根据前字节长度而改变 previous_entry_length 的所占字节,如果因为改变了前字节的长度, previous_entry_length 从 1 字节变成 5 字节,并且这个 entry 本身的长度也在 250 ~ 253 字节之间,那么如果后续的压缩表节点也是这种条件,会不断的对压缩列表执行空间重分配操作,造成 “连锁更新” 的情况。但是此情况发生的几率很低。
7、对象
Redis 并没有直接使用上述数据结构来实现键值对数据库,而是基于这些数据结构创建了一个对象系统,做到针对不同的使用场景,为对象设置多种不同的数据结构实现,从而优化对象在不同场景下的使用效率。
Redis 中的每个对象都由一个 redisObject 结构表示, 该结构中和保存数据有关的三个属性分别是 type 属性、 encoding 属性和 ptr 属性。
typedef struct redisObject {
// 类型
// REDIS_STRING 字符串对象
// REDIS_LIST 列表对象
// REDIS_HASH 哈希对象
// REDIS_SET 集合对象
// REDIS_ZSET 有序集合对象
unsigned type:4;
// 编码 ptr 指针指向的对象的数据结构由 encoding 决定,
// 通过 encoding 来设定对象使用的编码,而不是为特定类型的对象关联
// 一种固定的编码,极大的提升了 Redis 的灵活性与效率
unsigned encoding:4;
// 指向底层实现数据结构的指针
void *ptr;
// ...
} robj;
7.1 字符串对象
字符串对象的编码可以是 int 、 raw 或者 embstr。
· 如果一个字符串对象里保存的是整数型,并且这个整数值可以用 long 类型来表示,那么该
字符串对象的编码会被设置为 int。
· 如果保存的是字符串,并且字符串长度大于 39 字节(3.0 之前,之后会变为 44 字节),
那么字符串会使用 SDS 来保存这个值,并将对象编码设置为 raw 。
· 如果保存的是字符串,并且字符串长度小等于 39 字节,那么会使用 embstr 来保存这个
值,并将对象编码设置为 embstr 。
· 如果保存的是 double 类型,会用 raw 或者 embstr 来保存,并且会在执行 incr 等特定
命令时,把值转换为浮点数,执行命令之后再转成字符串放到对象里。
int 编码的字符串对象和 embstr 编码的字符串对象在条件满足的情况下, 会被转换为 raw 编码的字符串对象:
int 类型的被追加字符串之后,会变为 raw。
而 embstr 是没有修改方法的,是只读的,所以对 embstr 类型修改时,会先转换为 raw 再修改。
embstr 编码的字符串对象用来保存短字符串值有以下好处:
- raw 编码会调用两次内存分配函数来分别创建 redisObject 结构和 sdshdr 结构, 而 embstr 编码则通过调用一次内存分配函数来分配一块连续的空间, 空间中依次包含 redisObject 和 sdshdr 两个结构
- 释放内存时,raw 需要调用两次内存释放函数,而 embstr 只需要调用一次
- embstr 类型的数据都在一块连续的内存里,能够更好的利用缓存带来的优势
至于为什么字符串长度大于 39 字节的原因是:从 2.4 版本开始, redis 使用了 jemalloc 内存分配器,jemalloc 会分配 8、16、32、64 等字节的内存,embstr 最小为 16 + 8 + 1 = 25,当字符小等于 39 时,就会默认分配 64 字节。
16 为 redisObject 的字节数,1 为 '\0'(空字符串结尾),8 为 sds 除了 buf 外的属性占用的字节数,而 redis 没有给 embstr 字符串分配空间的操作,所以总占用字节数大于 64 就会转换为 raw 。
但是从 3.2 版本之后,SDS 有了变化,字符长度大于 44 时会转化为 raw,还是说总占用字节数大于 64 就会转换为 raw。
7.2 列表对象
列表对象的编码可以是 ziplist 或者 linkedlist 。
当列表对象同时满足以下两个条件时,列表对象使用 ziplist 编码:
· 保存的所有元素长度都小于 64 字节;
· 列表对象保存的元素数量小于 512 个。
以上两个条件的上限值是可以修改的,具体见配置文件list-max-ziplist-value 选项和 list-max-ziplist-entries 选项。
redis> RPUSH numbers 1 "three" 5
(integer) 3
ziplist 编码的列表对象:
linkedList 编码的列表对象:
7.3 哈希对象
哈希对象的编码可以是 ziplist 或者 hashtable 。
当哈希对象同时满足以下两个条件时,哈希对象使用 ziplist 编码:
· 保存的所有键值对的字符串长度都小于 64 字节;
· 列表对象保存的键值对数量小于 512 个。
以上两个条件的上限值是可以修改的,具体见配置文件 hash-max-ziplist-value 选项和 hash-max-ziplist-entries 选项。
如果执行 hset 命令,那么服务器会创建一个列表对象作为 profile 键的值
redis> HSET profile name "Tom"
(integer) 1
redis> HSET profile age 25
(integer) 1
redis> HSET profile career "Programmer"
(integer) 1
ziplist 编码的哈希对象:
hashtable 编码的哈希对象,字典的每个键值都是一个 StringObject,如下图也只是抽象的样子,具体参照字典结构的底层实现:
7.4 集合对象
集合对象的编码可以是 intset 或者 hashtable。
当集合对象同时满足以下两个条件时,集合对象使用 intset 编码:
· 保存的所有元素都是整数值;
· 保存的元素数量数量小于 512 个。
以上两个条件的上限值是可以修改的,具体见配置文件 set-max-intset-entries 选项。
如果执行下面的语句,会创建 intset 编码集合对象
redis> SADD numbers 1 3 5
(integer) 3
如果执行下面的语句,会创建 hashtable 编码集合对象,字典的每个键都是一个字符串对象,每个值都是 null
redis> SADD fruits "apple" "banana" "cherry"
(integer) 3
7.5 有序集合对象
有序集合对象的编码可以是 ziplist 或者 skiplist 。
当有序集合对象同时满足以下两个条件时,有序集合对象使用 ziplist 编码:
· 有序集合保存的原数数量小于 128 个;
· 保存的所有元素成员的长度都小于 64 字节。
以上两个条件的上限值是可以修改的,具体见配置文件 zset-max-ziplist-entries 选项。
如果执行下面的语句,那么服务器会创建一个有序集合对象作为 price 键的值
redis> ZADD price 8.5 apple 5.0 banana 6.0 cherry
(integer) 3
如果 price 键的值使用的是 ziplist 编码,那么这个值对象存储方式和 hash 存储键值对类似,成员在前分数在后,将会是如下所示:
skiplist 编码的有序集合对象使用 zset 结构作为底层实现,一个 zset 结构同时包含一个字典和一个跳跃表:
typedef struct zset {
zskiplist *zsl;
dict *dict;
} zset;
zset 结构中的 zsl 跳跃表按分支从小到大保存了所有集合元素,每个跳跃表节点都保存了一个集合元素:跳跃表节点的 object 属性保存了元素的成员,而 score 保存了元素的分值。通过跳跃表,程序可以对有序集合进行范围性操作和排行计算,比如 ZRANK 、ZRANGE 等命令就是基于跳跃表 API 实现的。
zset 结构中的 dict 字典为有序集合创建了一个从成员到分值的映射,字典中的每个键值对都保存了一个集合元素:字典的键保存了元素的成员,值保存了分数。通过这个字典,程序可以用 O(1) 复杂度查找给定成员的分值, ZSCORE 等命令就是基于这一特性实现。
值得一提的是:虽然 zset 结构同时使用跳跃表和字典来保存有序集合元素,但这两种数据结构都会通过指针来共享相同元素的成员和分值,所以不会产生任何重复成员或分值,也不会因此浪费额外内存。
下图就是 zset 结构的样子,下图为了展示方便,在字典和跳跃表中重复展示了成员和分值:
7.6 内存回收
C 语言不具备自动的内存回收的功能,所以 Redis 在自己的对象系统中构建了一个引用计数(reference counting)技术使用的回收机制,通过这一机制,程序可以通过跟踪对象的引用技术信息,在适当的时候自动释放对象并进行内存回收。
typedef struct redisObject {
// ...
// 引用计数
int refcount;
// ...
} robj;
7.7 对象共享
除了用于实现引用计数内存回收机制之外,对象的引用计数属性还带有对象共享的作用。
举个例子:如果创建了 key=a ,value=9999 的字符串对象,然后又创建了 key=b, value=9999 的对象,那么服务器有两种做法:
- 为 key=b 新创建一个包含整数值 100 的字符串对象;
- 让 a 和 b 共享一个字符串对象
两种方式很明显第二种更节约内存。
在 Redis 中,让多个键共享同一个值对象需要执行以下两个步骤:
- 将数据库键的值指针指向一个现有的值对象;
- 将被共享的值对象的引用计数增 1 。
目前来说 Redis 会在初始化服务器时,创建一万个字符串对象,这些对象包含了从 0 到 9999 的所有整数值,当服务器需要这个范围内的字符串对象时,服务器就会使用这些共享对象,而不是新创建对象。创建共享字符串对象的数量可以通过修改 redis.h/REDIS_SHARED_INTEGERS 常量来修改。
另外,这些共享对象不单单只有字符串键可以使用,那些在数据结构中嵌套了字符串对象的对象(linkedlist 编码的列表对象、hashtable 编码的哈希对象、hashtable 编码的集合对象、以及 zset 编码的有序集合对象)都可以使用这些共享对象。
为什么 Redis 不共享包含字符串的对象?
当服务器考虑将一个共享对象设置为键的值对象时,程序需要先检查给定的共享对象和键想创建的目标对象是否完全相同,只有在共享对象和目标独享完全相同的情况下,程序才会将共享对象用作键的值对象,而一个共享对象的值越复杂,验证共享对象和目标对象是否相同所需的复杂度就会越高,消耗 CPU 的时间也会越多。
· 如果共享对象是保存整数值的字符串对象,那么验证操作的复杂度是 O(1);
· 如果共享对象是保存字符串的字符串对象,那么验证操作的复杂度为 O(N);
· 如果共享对象是包含了多个值(或者对象的)对象,比如列表对象或者哈希对象,那么验证
的复杂度将是 O(N^2)。
因此,尽管共享更复杂的对象可以节约更多的内存,但受到 CPU 时间的限制, Redis 只对包含整数值的字符串对象进行共享。
7.8 对象的空旋时长
除了前面介绍过的 type、 encoding、 ptr 和 refcount 四个属性外,redisObject 还包含一个属性为 lru 属性,该属性记录了对象最后一次被命令程序访问的时间:
typedef struct redisObject {
// ...
unsigned lru:22;
// ...
} robj;
OBJECT IDLETIME 命令可以打印出给定键的空转时长,这一空转时长就是通过将当前时间减去键的值对象的 lru 时间计算得出的,而且该命令在访问键对象的时候,不会修改对象的 lru 属性。
lru 属性除了可以用来执行 OBJECT IDLETIME 命令以外,键的空转时长还有另外一项作用,如果服务打开了 maxmemory 选项,并且服务器用于回收内存的算法为 volatile-lru 或者 allkeys-lru ,那么当服务器占用的内存超过了 maxmemory 选项所设置的上限值时,空转时长较高的那部分键会优先被服务器释放,从而回收内存。
二、单机数据库的实现
8、数据库
8.1 设置键的生存时间或过期时间
8.1.1 设置过期时间
- EXPIRE 命令用于将键 key 的生存时间设置为 ttl 秒。
- PEXPIRE 命令用于将键 key 的生存时间设置为 ttl 秒。
- EXPIREAT 命令用于将键 key 的过期时间设置为 timestamp 秒
- PEXPIREAT 命令用于将键 key 设置为 timestamp 毫秒
虽然有多种不同单位和不同形式的设置命令,但实际上所有命令都是转换成 PEXPIREAT 命令的形式来实现的。
8.1.2 保存过期时间
redisDb 结构的 expires 字典保存了数据库中所有键的过期时间,我们称这个字典为过期字典:
-
- 过期字典的键是一个指针,这个指针指向键对象,即 key 指向的 redisObject 。
- 过期字典的值是一个 long 类型的整数,这个整数保存了键所指向的数据库键的过期时间 —— 一个毫秒精度的 UNIX 时间戳。
8.1.3 移除过期时间
PERSIST 命令可以移出一个键的过期时间。
PERSIST 就是 PEXPIREAT 命令的反操作:PERSIST 在过期字典中招到给定的键,然后解除键和值(过期时间)在过期字典中的关联。
8.2 过期键删除策略
有三种不同的过期删除策略:
-
- 定时删除:在设置键的过期时间的同时创建一个定时器,让定时器在键的过期时间来临时,立即执行对键的删除操作
- 惰性删除:放任键过期不管,但是每次从键空间取键时,都检查是否已过期,如果过期就删除
- 定期删除:每隔一段时间,程序就对数据库进行一次检查,删除里面的过期键。至于删除多少过期键,以及检查多少数据库,由算法决定。
8.2.1 定时删除
定时删除是对内存最友好的,通过使用定时器,定时删除策略可以保证及时释放内存,但是需要占用 CPU 时间,要让服务器创建大量的定时器,从而实现定时删除策略,在现阶段来说是不现实的。
8.2.2 惰性删除
惰性删除不会在删除其它无关的过期键上花费 CPU 时间,但有可能会有大部分无用的过期键占用着内存。
8.2.3 定期删除
定期删除是两种策略的一个整合和折中,但难点是确定删除执行的时长和频率。
8.3 Redis 的过期键删除策略
Redis 实际使用的是惰性删除和定期删除两种策略。
惰性删除策略的实现由 db.c/expireIfNeeded 函数实现,在每次执行对键的操作时,都会调用该函数来判断是否过期,然后进行合适的动作。
过期键的定期删除策略由 redis.c/activeExpireCycle 函数实现,每当 Redis 的服务器周期性操作 redis.c/serverCron 函数执行时, activeExpireCycle 函数就会被调用,他在规定时间内分多次遍历数据库,从 expires 字典中随机检查一部分键的过期时间并删除其中的过期键。
activeExpireCycle 函数的主要工作可以拆分为:
-
- 从一定数量的数据库中取出一定数量的随机键进行检查,并删除其中的过期键。
- 全局变量 current_db 会记录当前 activeExpireCycle 函数检查的进度,也就是遍历到几号库了,然后在下一次函数 activeExpireCycle 调用时,接着上一次次的进度进行处理。
- 当所有数据库都被检查一遍的话,函数会将 current_db 变量重置为 0,然后开始新一轮检查。
8.4 AOF、RDB 和复制功能对过期键的处理
8.4.1 生成 RDB 文件
在执行 SAVE 或 BGSAVE 命令创建的新 RDB 文件时,程序会对数据库的过期键进行检查,已过期的不会被保存到新创建的 RDB 文件中。
8.4.2 载入 RDB 文件
启动 Redis 时,如果服务器开起了 RDB 功能,那么服务器会对 RDB 文件进行载入:
-
- 如果服务器以主服务器模式运行,那么载入时,会对文件中保存的键进行检查,如果过期会被忽略
- 如果服务器以从服务器模式运行,那么载入时,不论是否过期都会被载入到数据库中,不过会因为主服务器进行数据同步的时候,从服务器的数据库被情况。
8.4.3 AOF 文件写入
服务器以 AOF 持久化模式运行时,如果数据库中的某个键已经过期,但是还没有被惰性删除或定时删除,呢么 AOF 文件不会因为这个过期键而产生影响。
当过期键被惰性删除或者定时删除之后,程序回向 AOF 文件追加( append )一条 DEL 命令,来显式的记录该键被删除。
8.4.4 AOF 重写
重写的时候会对数据库中的键进行检查,已过期的不会被保存到重写后的 AOF 文件中。
8.4.5 复制
当服务器运行在复制模式下,从服务器的过期删除动作由主服务器控制:
-
- 主服务器在删除一个过期键后,会显式地向所有从服务器发送一个 DEL 命令,告诉从服务器删除这个过期键。
- 从服务器在执行客户端发送的读命令时,即使碰到过期键,也不会删除过期键,而是像没过期的键一样来处理。
- 从服务器收到主服务器的 DEL 命令后,才会删除过期键。
9、RDB 持久化
把服务器中的非空数据库以及它们的键值对统称为数据库状态。
RDB 持久化可以把内存中的数据库状态用二进制方式保存到磁盘里。
RDB 持久化既可以手动执行,也可以根据服务器配置选项定期执行。
9.1 RDB 文件的创建与载入
有两个 Redis 命令可以生成 RDB 文件,一个是 SAVE,另一个是 BGSAVE。
如果配置了 save 配置,达到条件执行的是 BGSAVE 命令,如果进行 shutdown 命令,即关闭 Redis 时,会先执行 SAVE 命令。
SAVE 命令直接阻塞服务器进程,直到 RDB 文件创建完毕,在服务器进程阻塞期间,服务器不能处理任何命令请求。
BGSAVE 会派生出一个子进程,然后由子进程创建 RDB 文件,父进程继续处理命令请求。
RDB 文件的载入实在服务器启动时自动执行的,并且在载入期间,会一直处于阻塞状态。
因为 AOF 文件的更新频率通常比 RDB 文件的更新频率高,所以如果服务器开启了 AOF 持久化,那么服务器会优先使用 AOF 文件来还原数据库状态。
在执行 BGSAVE 命令的过程中, Redis 仍然可以继续处理客户端的请求命令,但是在 BGSAVE 执行期间,服务器处理 SAVE 、BGSAVE 、BGREWRITEAOF 三个命令的方式会和平时有所不同:
-
- BGSAVE 命令会被拒绝,防止产生竞争条件;
- BGSAVE 命令也会被拒绝,防止产生竞争条件;
- 如果 BGREWRITEAOF 命令正在执行,那么客户端发送的 BGSAVE 命令会被服务器拒绝;
- 如果 BGSAVE 命令在执行,那么客户端发送的 BGSAVE 命令会延迟到 BGSAVE 执行完毕后执行。
因为 BGREWRITEAOF 和 BGSAVE 两个命令的实际工作都由子进程执行,所以再操作方面并没有什么冲突的地方,不能同时执行是从性能方面考虑——并发两个子进程,并且两个子进程都同时执行大量的磁盘写入操作,并不是一个好主意。
9.2 RDB 文件结构
- RDB 文件的开头是 REDIS 部分,这个长度为 5 字节,保存着 "REDIS" 五个字符,通过五个字符,程序可以在载入文件时,快速检查所载入的恩建是否 RDB 文件。注意:RDB 文件保存的是二进制数据,而不是 C 字符串,所以没有 '\0' 结尾;
- db_version 长度为 4 字节,是一个字符串表示的整数,记录了 RDB 文件的版本号,一般情况下高版本产生的 RDB 文件,低版本无法载入;
- database 部分包含着零个或者任意多个数据库,以及各个数据库中的键值对数据;
- EOF 常量的数据库长度为 1 字节,标志着 RDB 文件正文内容的结束,当读入程序遇到这个值时,就知道已经载入完毕了;
- check_sum 是一个 8 字节的无符号整数,保存着一个校验和,是通过对 REDIS 、db_version、databases、EOF 四个部分计算得出的,可以通过将载入数据计算出的校验和与 chekc_sum 所记录的校验和进行对比,以此来检查 RDB 文件是否有出错或者损坏的情况出现。
9.2.1 databases 部分
- SELECTDB 常量的长度为 1 字节,当读入程序遇到这个值时,它知道接下来要读入的将是一个数据库号码;
- db_number 保存的是数据库号码,这个部分的长度可以是1、2、5 字节。当程序读入 db_number 之后,服务器会调用 SELECT 命令,进行数据库切换,保证载入到正确的数据库中。
9.2.2 key_value_pairs 部分
- EXPIRETIME_MS 常量的长度是 1 字节,告知读入程序,接下来要读入的将是一个毫秒为单位的过期时间
- ms 是一个 8 字节长的带符号整数,记录着一个以毫秒为单位的 UNIX 时间戳
10、AOF 持久化
10.1 AOF 持久化的实现
AOF 持久化功能可分为命令追加(append)、文件写入、文件同步(sync)三个步骤。
10.1.1 命令追加
当 AOF 处于开启时,服务器在每次执行完一个写命令之后,会以协议格式将被执行的写命令追加到服务器状态的 aof_buffer 缓冲区的末尾。
10.1.2 文件写入与同步
Redis 的服务进程就是一个事件循环,会循环处理文件事件与时间事件,处理完时间事件之后,会判断是否将 aof_buffer 中的内容写入到 AOF 文件中,这个过程用以下伪代码表示:
def eventLoop():
while True:
# 处理文件事件,接收命令请求以及发送命令回复
# 处理命令请求时可能会有新内容被追加到 aof_buf 缓冲区中
processFileEvents()
# 处理时间事件
processTimeEvents()
# 考虑是否要将 aof_buf 中的内容写入和保存到 AOF 文件里面
flushAppendOnlyFile()
flushAppendOnlyFile 函数的行为由服务器配置的 appendfsync 选项的值来决定, 各个不同值产生的行为如下 所示:
策略 | 说明 |
---|---|
appendfsync always | 每次修改都进行同步,数据完整性较好 |
appendfsync everysec | 每秒同步,每秒记录数据,异步操作。不会影响主线程,如果一秒宕机,有数据丢失(默认) |
appendfsync no | 将 aof_buf 缓冲区中的所有内容写入到 AOF 文件, 但并不对 AOF 文件进行同步, 何时同步由操作系统来决定。不会影响主线程 |
文件的写入与同步介绍
为了提高文件的写入效率, 在现代操作系统中, 当用户调用 write 函数, 将一些数据写入到文件的时候, 操作系统通常会将写入数据暂时保存在一个内存缓冲区里面, 等到缓冲区的空间被填满、或者超过了指定的时限之后, 才真正地将缓冲区中的数据写入到磁盘里面。
这种做法虽然提高了效率, 但也为写入数据带来了安全问题, 因为如果计算机发生停机, 那么保存在内存缓冲区里面的写入数据将会丢失。
为此, 系统提供了 fsync 和 fdatasync 两个同步函数, 它们可以强制让操作系统立即将缓冲区中的数据写入到硬盘里面, 从而确保写入数据的安全性。
10.2 AOF 文件的载入与数据还原
首先 select 0,然后读取执行所有写命令,如下图:
10.2 AOF 重写
因为 AOF 持久化会随着服务器运行时间的流逝,AOF 文件变得越来越大,所以 Redis 提供了重写的功能,生成新的 AOF 文件,新旧 AOF 文件保存的数据库状态相同,但体积会变小。
使用 BGREWRITEAOF 命令进行后台重写。实现方法:
- 使用子进程对目前服务器进程的数据副本进行 AOF 重写,
- 然后再 AOF 重写期间,服务器进程继续处理命令,这时候写命令不仅会把命令加到 AOF 缓冲区,还会加到 AOF重写缓冲区
- 对数据副本进行备份完之后,向父进程发送一个信号
- 父进程收到信号之后,会调用一个信号处理函数执行以下操作
-
- 把重写缓冲区中的所有内容追加写入到 AOF 文件中,这时数据就是完整的
- 对新 AOF 文件进行改名,原子地覆盖现有的 AOF 文件,结束 AOF 重写
11、事件
Redis 服务器是一个事件驱动程序,服务器需要处理以下两类事件:
- 文件事件(file event):Redis 服务器通过套接字与客户端(或者其他 Redis 服务器)进行连接,而文件事件就是服务器对套接字操作的抽象。服务器与客户端(或者其他服务器)的通信会产生响应的文件事件,而服务器则通过监听并处理这些事件完成一系列网络通信操作。
- 时间事件(time event):Redis 服务器中的一些操作需要在给定的时间点执行,而时间时间就是对服务器这类定时操作的抽象。
11.1 文件事件
Redis 基于 Reactor 模式开发了自己的网络事件处理器:这个处理器被称为文件事件处理器(file event handler):
- 文件事件处理器使用 I/O 多路复用程序来同时监听多个套接字,并且根据套接字目前执行的任务来为套接字关联不同的事件处理器。
- 当被监听的套接字准备好执行连接应答(accept)、读取(read)、写入(write)、关闭(close)等操作时,与操作相对应的文件事件就会产生,这时文件事件处理器就会调用套接字之前关联好的事件处理器来处理这些事件。
通过使用 I/O多路复用程序来监听多个套接字,既实现了高性能的网络通信模型,又可以很好的与 Redis 服务器中其他以单线程方式运行的模块 进行对接,保持了 Redis 内部单线程设计的简单性。
11.1.1 文件事件处理器的构成
文件事件处理器由四个部分构成,分别是:套接字、I/O 多路复用程序、文件时间分派器和事件处理器。
在每一个套接字准备好执行连接应答(accept)、写入、读取、关闭等操作时,就会产生出一个文件事件。
因为是 I/O 多路复用程序,并且会有多个套接字,所以文件事件可能同时生成多个,I/O 多路复用程序总是会把所有产生事件的套接字放到一个队列里,按顺序将上一个套接字里的事件通过文件事件分派器交给具体的事件处理器处理完之后才会执行下一个套接字的事件。
事件处理器就是一个个函数,定义了某个事件发生时,服务器应该执行的动作。
11.1.2 I/O 多路复用程序的实现
Redis 的多路复用程序的所有功能都是通过包装常见的 select、epoll、evport、和 kqueue 这些 I/O 多路复用函数库来实现的,每个多路复用函数库在 Redis 源码中都对应一个单独的文件,程序会在编译时自动选择系统中性能最高的 I/O 多路复用函数库来作为多路复用程序的底层实现。
11.1.3 事件的类型
I/O 多路复用程序可以监听多个套接字的 ae.h/AE_READABLE 事件和 ae.h/AE_WRITABLE 事件,这两类时间和套接字操作之间的对应关系如下:
- 当套接字变得可读时(客户端对套接字执行 write 操作,或者执行 close 操作),或者有新的可应答(accept)的套接字出现时(客户端对服务器的监听套接字执行 connect 操作),套接字产生 AE_READABLE 事件。
- 当套接字变得可写时(客户端对套接字执行 read 操作),客户端产生 AE_WRITABLE 事件。
如果一个套接字可读又可写,会先读套接字,再写套接字。
11.1.4 文件事件处理器
- 连接应答处理器
当 Redis 启动初始化时,程序就会将连接应答处理器和服务器监听套接字的 AE_READABLE 事件关联起来,当客户端用 sys/socket.h/connect 函数连接服务器监听套接字时,套接字就会产生 AE_READABLE 事件,引发连接应答处理器执行,并执行相应的套接字应答操作。
- 命令请求处理器
当客户端通过连接应答处理器成功连接到服务器之后,服务器会将客户端套接字的 AE_READABLE 事件关联起来,在客户端连接服务器的整个过程中,服务器会一直为客户端套接字的 AE_READABLE 事件关联命令请求处理器。
当客户端向服务端发送命令请求时,套接字会生成 AE_READABLE 事件,引发命令请求处理器执行,并执行相应的套接字读入操作。
- 命令回复处理器
当服务器有命令回复需要传送给客户端的时候,服务器会将客户端套接字的 AE_WRITABLE 事件和命令回复处理器关联起来,然后当客户端准备好接收服务器传回的命令回复时,就会产生 AE_WRITABLE 事件,引发命令回复处理器执行,并执行响应的套接字写入操作,当回复发送完毕之后,服务器就会解除命令回复处理器与 AE_WRITABLE 事件的关联。
11.2 时间事件
Redis 的时间事件分为两类:
定时事件:让一段程序在指定的时间之后执行一次。比如说在当前时间的 30 毫秒后执行一次。
周期性时间:让一段程序隔一段时间执行一次。比如说程序每隔 30 秒执行一次。
11.2.1 实现
服务器将所有时间事件放在一个链表里,每次时间执行器运行时,它就遍历整个链表,查找所有已到达的时间事件,并调用响应的事件处理器。
这个链表每次是按照头插的方式往链表里加入,正常模式下 Redis 只使用 serverCron 一个时间事件,而在 benchmark 模式下,服务器也只使用了两个时间事件。
11.2.2 serverCron 函数
持续运行的 Redis 服务器需要定期对自身的资源和状态进行检查和调整,从而确保服务器可以长期、稳定运行,这些定期操作就是由 redis.c/serverCron 函数负责执行,主要工作包括:
- 更新服务器的各类统计信息,比如时间、内存占用(内存淘汰就需要用到)、数据库占用情况等
- 清理数据库中的过期键值对
- 关闭和清理连接失效的客户端
- 尝试进行 AOF 或 RDB 持久化操作
- 如果服务器是主服务器,那么对从服务器进行定期同步
- 如果处于集群模式,对集群进行定期同步和连接测试
在 Redis 2.6 版本,服务器规定 serverCron 每秒运行 10 次、
从 Redis 2.8 开始,用户可以通过修改 hz 选项来调整 serverCron 的每秒执行次数。
11.3 事件的调度与执行
伪代码:
def aeProcessEvents():
# 获取到达时间离当前时间最接近的时间事件
time_event = aeSearchNearestTimer()
# 计算最接近的时间事件距离到达还有多少毫秒
remaind_ms = time_event.when - unix_ts_now()
# 如果事件已到达,那么remaind_ms 的值可能为负数,将它设定为0
if remaind_ms < 0:
remaind_ms = 0
# 根据remaind_ms 的值,创建timeval 结构
timeval = create_timeval_with_ms(remaind_ms)
# 阻塞并等待文件事件产生,最大阻塞时间由传入的timeval 结构决定
# 如果remaind_ms 的值为0 ,那么aeApiPoll 调用之后马上返回,不阻塞
aeApiPoll(timeval)
# 处理所有已产生的文件事件,虚构的
# 在实际中,处理已产生文件事件的代码是直接写在aeProcessEvents函数里面 的,这里为了方便讲述
processFileEvents()
# 处理所有已到达的时间事件,虚构的
processTimeEvents()
- aeApiPoll 函数的最大阻塞时间由到达时间最接近当前时间的时间事件来决定,这个方法不仅可以避免服务器对时间事件进行频繁的轮训,也可以确保 aeAipPoll 函数不会阻塞太长时间。
- 因为文件事件是随机出现的,如果等待并处理完一次文件事件之后,仍未有时间事件到达,那么服务器将再次等待并处理文件事件。随着文件事件的不断执行,时间会逐渐向时间事件所设置的时间逼近,并最终来到到达时间,这时服务器就可以开始处理到达的时间事件了。
- 对于文件和时间事件的处理都是同步、有序、原子地执行的,服务器不会中断事件处理,也不会对事件进行抢占,因此不管是文件事件还是时间事件的处理器,它们都会尽可能的减少程序的阻塞时间,并在需要时主动让出执行权,从而降低造成事件饥饿的可能性。比如说,在命令恢复处理器将一个命令回复写入到客户端套接字时,如果写入字节数超过了一个预设常量的话,命令回复处理器就会主动用 break 跳出写入循环,将余下的数据留到下次再写;另外时间事件也会将非常耗时的持久化操作放到子线程或者子进程执行。
- 因为时间事件在文件事件之后执行,并且事件之间不会进行抢占,所以时间事件的实际处理时间通常会比设定的时间稍晚一些。