这里总结一下我使用Redis
的一些心得,主要是参考了Redis设计与实现
和 Redis开发与运维
这两本书。
一. Redis
对象
1.1 简单动态字符串 SDS
struct sdshdr {
// 记录 buf 数组中已使用字节的数量
// 等于 SDS 所保存字符串的长度
int len;
// 记录 buf 数组中未使用字节的数量
int free;
// 字节数组,用于保存字符串
char buf[];
};
-
len
: 字符串的字符长度。利用
len
这个属性,很容易知道字符串的长度;一个中文字符算3
个长度。 -
free
: 剩余空间的大小。 -
buf[]
: 保存字符串数据。- 为了兼容
c
自带的字符串处理库,SDS
也会再字符串最后添加\0
这个字符,但是它不计算在len
里面。 - 因此
buf[]
数组长度就是len + free + 1
。
- 为了兼容
SDS
与 C
字符串相比较,有以下优点:
- 更容易获取字符串长度,
C
字符串需要遍历才知道字符串长度。 - 因为不是通过
\0
判断字符串结尾,因此SDS
可以储存任意二进制数据,而C
字符串只能储存文本字符,否则可能因为\0
字符导致字符串提前终止。 - 动态扩展,
SDS
可以通过动态扩展防止缓冲区溢出,而C
字符串需要使用者自己计算使用大小。
SDS
的动态扩展
当
SDS
进行字符串追加操作时,会先检查当前SDS
的剩余空间(即free
的值)能否容纳追加的数据,如果不能,那么就需要进行扩展了,规则如下:
- 计算追加之后字符串的大小
len
,如果len
小于1MB
,那么free
的值就等于len
;- 如果
len
大于等于1MB
,那么free
的值就等于1MB
;buf[]
数组大小就是len + free + 1
,注意这里是追加之后字符串大小len
。SDS
通过空间预分配策略,Redis
可以减少连续执行字符串增长操作所需的内存重分配次数。
当减少
SDS
字符,例如清空SDS
,并不立即使用内存重分配来回收缩短后多出来的字节,而是使用free
属性将这些字节的数量记录起来,并等待将来使用。当然SDS
提供了特定的方法来释放多余占有空间。
1.2 链表 list
typedef struct listNode {
// 前置节点
struct listNode *prev;
// 后置节点
struct listNode *next;
// 节点的值
void *value;
} listNode;
一个典型的双向链表的节点,
prev
和next
分别是指向前一个和后一个节点的指针,value
用来储存节点数据。
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;
-
head
和tail
: 分别表示链表头和尾,用来从头或者从尾遍历链表。 -
len
: 记录链表的长度,不然就需要通过遍历链表才知道长度了。 -
dup
,free
和match
: 都是操作链表节点的函数。
特别注意
redis
的链表是一个无环双向链表,即链表头head
节点的prev
是null
,链表尾tail
节点的next
是null
。
1.3 字典
就是哈希表,关于哈希表相关原理请看数据结构_哈希表。
1.3.1 数据结构
typedef struct dictEntry {
// 键
void *key;
// 值
union {
void *val;
uint64_t u64;
int64_t s64;
} v;
// 指向下个哈希表节点,形成链表
struct dictEntry *next;
} dictEntry;
-
key
: 哈希表键值对中的键。 -
v
: 哈希表键值对中的值,是一个联合体结构。类型可以是有符号整数类型
int64_t
, 无符号整数类型uint64_t
和任一类型指针void *
。 -
next
: 下一个键值对的指针,形成一个链表,使用链地址法解决哈希冲突。
typedef struct dictht {
// 哈希表数组
dictEntry **table;
// 哈希表大小
unsigned long size;
// 哈希表大小掩码,用于计算索引值
// 总是等于 size - 1
unsigned long sizemask;
// 该哈希表已有节点的数量
unsigned long used;
} dictht;
-
table
: 哈希表数组,相当于dictEntry[]
,用来记录哈希表数据。 -
size
: 就是哈希表数组的大小注意为了加快哈希速度,这个
size
值必须是2
的幂数,方便使用&
位运算取余。 -
sizemask
: 值就是size - 1
。使用hash & sizemask
来计算hash
相对于size
的余数。 -
used
: 表示哈希表已有节点的数量。
typedef struct dict {
// 类型特定函数
dictType *type;
// 私有数据
void *privdata;
// 哈希表
dictht ht[2];
// rehash 索引
// 当 rehash 不在进行时,值为 -1
int rehashidx; /* rehashing not in progress if rehashidx == -1 */
} dict;
-
type
和privdata
是用来针对不同类型的键值对,为创建多态字典而设置的。-
type
属性是一个指向dictType
结构的指针,每个dictType
结构保存了一簇用于操作特定类型键值对的函数,Redis
会为用途不同的字典设置不同的类型特定函数。 -
privdata
属性则保存了需要传给那些类型特定函数的可选参数。
-
-
ht[2]
和rehashidx
用来实现带有渐进式rehash
功能的哈希表。
1.3.2 渐进式 rehash
我们知道链地址的哈希表,当数据越多,那么发生哈希冲突就越多,那么每个节点的链就越长,这个时候哈希表的效率就变低了,因为要遍历链来查找想要的key
。
这个时候必须进行重新哈希 rehash
, 即扩大哈希表数组大小,再将哈希表中数据进行重新分配。
- 但是这里有个问题,当哈希表在
rehash
,是没有办法对外提供功能的,只能等到rehash
完成才能使用;- 如果哈希表中的数据非常多,那么
rehash
时间可能会很长。
那么如何在 rehash
情况下,也能继续使用哈希表的功能呢?
就要用的渐进式
rehash
功能了。
dict
中有两个dictht
,分为两种情况:
- 正常情况下
哈希表
ht[0]
中包含字典所有的数据,对外提供哈希表的功能;ht[1]
中没有任何数据,是一个空的dictht
。 - 发生
rehash
时- 先创建指定大小的
dictht
赋值给ht[1]
。 - 再通过
rehashidx
逐渐将ht[0]
中的数据迁移到ht[1]
,具体在rehashidx
中讲解。 - 在这个时候,字典同时使用
ht[0]
和ht[1]
两个哈希表,所以对字典的删除(delete
),查找(find
),更新(update
)等,会在两个哈希表上进行;但是对字典添加操作,只会在ht[1]
上进行,保证了ht[0]
中的键值对数量会只减不增,并随着rehash
操作的执行而最终变成空表。 -
rehash
完成之后,再将ht[1]
赋值给ht[0]
,并将ht[1]
再设置成空的dictht
。
- 先创建指定大小的
使用 rehashidx
来进行渐进式 rehash
:
- 正常情况下,
rehashidx
的值就是-1
。 - 渐进式
rehash
时候- 先将
rehashidx
的值设置为0
; - 在
rehash
进行期间,每次对字典执行添加、删除、查找或者更新操作时,除了执行指定的操作以外,还会顺带将ht[0]
哈希表在rehashidx
索引上的所有键值对rehash
到ht[1]
,当rehash
工作完成之后,程序将rehashidx
属性的值增一。 - 随着字典操作的不断执行,最终在某个时间点上,
ht[0]
的所有键值对都会被rehash
至ht[1]
,这时程序将rehashidx
属性的值设为-1
,表示rehash
操作已完成。
- 先将
触发rehash
的情况:
- 扩展操作
当以下条件中的任意一个被满足时,程序会自动开始对哈希表执行扩展操作:
- 服务器目前没有在执行
BGSAVE
命令或者BGREWRITEAOF
命令,并且哈希表的负载因子大于等于1
; - 服务器目前正在执行
BGSAVE
命令或者BGREWRITEAOF
命令,并且哈希表的负载因子大于等于5
;
- 服务器目前没有在执行
- 收缩操作
当哈希表的负载因子小于
0.1
时,程序自动开始对哈希表执行收缩操作。
哈希表的负载因子的计算公式:
# 负载因子 = 哈希表已保存节点数量 / 哈希表大小
load_factor = ht[0].used / ht[0].size
1.4 跳跃表
typedef struct zskiplistNode {
// 后退指针
struct zskiplistNode *backward;
// 分值
double score;
// 成员对象
robj *obj;
// 层
struct zskiplistLevel {
// 前进指针
struct zskiplistNode *forward;
// 跨度
unsigned int span;
} level[];
} zskiplistNode;
-
backward
: 后退指针用于从表尾向表头方向访问节点,跟可以一次跳过多个节点的前进指针不同,因为每个节点只有一个后退指针,所以每次只能后退至前一个节点。
-
score
: 节点的分值,一个double
类型的浮点数,跳跃表中的所有节点都按分值从小到大来排序。 -
obj
: 节点成员对象的指针,在同一个跳跃表中,各个节点保存的成员对象必须是唯一的。 -
level[]
: 节点的层。- 跳跃表节点的
level
数组可以包含多个元素,每个元素都包含一个指向其他节点的指针,程序可以通过这些层来加快访问其他节点的速度,一般来说,层的数量越多,访问其他节点的速度就越快。 - 每次创建一个新跳跃表节点的时候,程序都根据幂次定律,随机生成一个介于
1
和32
之间的值作为level
数组的大小,这个大小就是层的“高度”。 -
forward
表示前进指针,span
表示跨度,用于记录两个节点之间的距离。
- 跳跃表节点的
1.5 整数集合
typedef struct intset {
// 编码方式
uint32_t encoding;
// 集合包含的元素数量
uint32_t length;
// 保存元素的数组
int8_t contents[];
} intset;
-
encoding
: 编码方式,表示这个整数集合存储元素的格式。有三种格式,分别是:
-
INTSET_ENC_INT16
: 表示是int16_t
类型的整数集合,存储数的范围就是-32768 ~ 32767
。 -
INTSET_ENC_INT32
: 表示是int32_t
类型的整数集合,存储数的范围就是-2,147,483,648 ~ 2,147,483,647
。 -
INTSET_ENC_INT64
: 表示是int64_t
类型的整数集合,存储数的范围就是--9,223,372,036,854,775,808 ~ 9,223,372,036,854,775,807
。
-
-
length
: 表示整数集合中元素个数。 -
contents[]
: 储存整数集合的数据。- 因为要储存整数集合中所有元素,所以按照整数集合最大的数范围,来确定整数集合的编码方式。
- 如果向整数集合添加了一个超过当前整数集合编码方式范围的值,例如向
INTSET_ENC_INT16
中添加一个40000
,那么整数集合就会升级,将原来的值都变成INTSET_ENC_INT32
大小储存。 - 但是整数集合不会降级,即使将超过
INTSET_ENC_INT16
范围的值删除了,整数集合也不会降级成INTSET_ENC_INT16
。
1.6 压缩列表
+---------+--------+-------+--------+-------+
| zlbytes | zltail | zllen | entryX | zlend |
+---------+--------+-------+--------+-------+
属性 | 类型 | 长度 | 用途 |
---|---|---|---|
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 ),用于标记压缩列表的末端。 |
压缩列表节点的结构如下:
+-----------------------+----------+---------+
| previous_entry_length | encoding | content |
+-----------------------+----------+---------+
-
previous_entry_length
: 记录了压缩列表中前一个节点的长度。长度可以是
1
字节或者5
字节:- 如果前一节点的长度小于
254
,那么previous_entry_length
就是一个字节,存储前一节点的长度。 - 如果前一节点的长度大于等于
254
字节,那么previous_entry_length
就是五个字节,第一个字节数固定是0xFE
(254
),之后的四个字节则用于保存前一节点的长度。 - 通过
previous_entry_length
就可以知道前一个节点的位置。
- 如果前一节点的长度小于
-
encoding
: 记录保存数据的类型以及长度。- 最高位
11
开头,encoding
就1
个字节,表示整数编码。 - 最高位
00
开头,encoding
就1
个字节,表示字节数组编码。 - 最高位
01
开头,encoding
就2
个字节,表示字节数组编码。 - 最高位
10
开头,encoding
就5
个字节,表示字节数组编码。
- 最高位
content
: 存储节点数据。
根据 encoding
不同,content
储存数据格式不同:
编码 | 编码长度 | content 属性保存的值 |
---|---|---|
00bbbbbb | 1 字节 | 长度小于等于 63 字节的字节数组。 |
01bbbbbb xxxxxxxx | 2 字节 | 长度小于等于 16383 字节的字节数组。 |
10__ aaaaaaaa bbbbbbbb cccccccc dddddddd | 5 字节 | 长度小于等于 4294967295 的字节数组。 |
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 属性。 |
1.7 Redis
对象
typedef struct redisObject {
// 类型
unsigned type:4;
// 编码
unsigned encoding:4;
// 指向底层实现数据结构的指针
void *ptr;
// 引用计数
int refcount;
unsigned lru:22;
// ...
} robj;
-
type
: 表示对象的类型。- 分别有字符串对象(
string
),列表对象(list
),哈希对象(hash
),集合对象(set
)和有序集合对象(zset
)。 - 可以通过
type
命令获取对象的类型。
- 分别有字符串对象(
-
encoding
: 对象底层的编码方式。- 为了节约内存,
redis
为每种类型提供了两种以上的编码方式。 - 通过通过
object encoding
命令获取对象的编码方式。
- 为了节约内存,
-
ptr
: 对象数据的指针。 -
refcount
: 对象的引用计数,通过它来进行内存回收。- 当对象的引用计数值变为
0
时,对象所占用的内存会被释放。 - 通过通过
object refcount
命令获取对象当前的引用计数。
- 当对象的引用计数值变为
-
lru
: 记录了对象最后一次被命令程序访问的时间。- 用来计算键的空转时长,空转时长就是当前时间减去键的值对象的
lru
时间的差值。 - 通过通过
object idletime
命令获取对象的空转时长。
- 用来计算键的空转时长,空转时长就是当前时间减去键的值对象的
类型 | 编码方式 | 对象 |
---|---|---|
string | REDIS_ENCODING_INT(int) | 使用整数值实现的字符串对象。 |
string | REDIS_ENCODING_EMBSTR(embstr) | 使用 embstr 编码的简单动态字符串实现的字符串对象。 |
string | REDIS_ENCODING_RAW(raw) | 使用简单动态字符串实现的字符串对象。 |
list | REDIS_ENCODING_ZIPLIST(ziplist) | 使用压缩列表实现的列表对象。 |
list | REDIS_ENCODING_LINKEDLIST(linkedlist) | 使用双端链表实现的列表对象。 |
hash | REDIS_ENCODING_ZIPLIST(ziplist) | 使用压缩列表实现的哈希对象。 |
hash | REDIS_ENCODING_HT(hashtable) | 使用字典实现的哈希对象。 |
set | REDIS_ENCODING_INTSET(intset) | 使用整数集合实现的集合对象。 |
set | REDIS_ENCODING_HT(hashtable) | 使用字典实现的集合对象。 |
zset | REDIS_ENCODING_ZIPLIST(ziplist) | 使用压缩列表实现的有序集合对象。 |
zset | REDIS_ENCODING_SKIPLIST(skiplist) | 使用跳跃表和字典实现的有序集合对象。 |
1.7.1 字符串对象
字符串对象有三种编码方式:
-
int
: 字符串对象保存的是整数值。浮点型不能用
int
数据结构保存。 -
raw
: 字符串对象保存的是长度大于39
字节的字符串值。 -
embstr
: 字符串对象保存的是长度小于等于39
字节的字符串值。- 与
raw
编码一样,都使用redisObject
结构和sdshdr
结构来表示字符串对象。 - 但是
raw
编码会调用两次内存分配函数来分别创建redisObject
结构和sdshdr
结构。 - 而
embstr
编码则通过调用一次内存分配函数来分配一块连续的空间,空间中依次包含redisObject
和sdshdr
两个结构。
- 与
编码的转换:
- 对于
int
编码的字符串对象来说,如果我们向对象执行了一些命令,使得这个对象保存的不再是整数值,而是一个字符串值,那么字符串对象的编码将从int
变为raw
。- 当我们对
embstr
编码的字符串对象执行任何修改命令时,程序会先将对象的编码从embstr
转换成raw
,然后再执行修改命令;因为这个原因,embstr
编码的字符串对象在执行修改命令之后,总会变成一个raw
编码的字符串对象。
1.7.2 列表对象
列表对象有ziplist
和 linkedlist
两种编码方式:
- 当列表对象可以同时满足以下两个条件时,列表对象使用
ziplist
编码:- 列表对象保存的所有字符串元素的长度都小于
list-max-ziplist-value
(默认是64
) 字节。 - 列表对象保存的元素数量小于
list-max-ziplist-entrie
(默认是512
) 个。
- 列表对象保存的所有字符串元素的长度都小于
- 不能满足这两个条件的列表对象需要使用
linkedlist
编码。
注意在现在版本下,使用
quicklist
代替了ziplist
。
1.7.3 哈希对象
哈希对象有ziplist
和 hashtable
两种编码方式:
- 当哈希对象可以同时满足以下两个条件时,哈希对象使用
ziplist
编码:- 哈希对象保存的所有键值对的键和值的字符串长度都小于
hash-max-ziplist-value
(默认是64
) 字节; - 哈希对象保存的键值对数量小于
hash-max-ziplist-entries
(默认是512
) 个;
- 哈希对象保存的所有键值对的键和值的字符串长度都小于
- 不能满足这两个条件的哈希对象需要使用
hashtable
编码。
1.7.4 集合对象
集合对象的编码可以是 intset
或者 hashtable
:
- 当集合对象可以同时满足以下两个条件时,对象使用
intset
编码:- 集合对象保存的所有元素都是整数值。
- 集合对象保存的元素数量不超过
set-max-intset-entries
(默认值512
) 个。
- 不能满足这两个条件的集合对象需要使用
hashtable
编码。
1.7.5 有序集合对象
有序集合的编码可以是 ziplist
或者 skiplist
:
- 当有序集合对象可以同时满足以下两个条件时,对象使用
ziplist
编码:- 有序集合保存的元素数量小于
zset-max-ziplist-entries
(默认值128
) 个; - 有序集合保存的所有元素成员的长度都小于
zset-max-ziplist-value
((默认值64
) 字节;
- 有序集合保存的元素数量小于
- 不能满足以上两个条件的有序集合对象将使用
skiplist
编码。
二. 数据库功能
2.1 数据库
typedef struct redisServer {
// ...
// 一个数组,保存服务器中所有数据库
redisDb *db;
// 服务器数据库数量
int dbnum;
// ...
} redisServer;
-
db
: 服务器中所有数据库,相当于redisDb[]
。虽然
Redis
服务器支持多个数据库,但是使用多个数据库,因为redis
是单线程,多个数据库操作都是在同一个线程执行的。 -
dbnum
: 服务器数据库数量。
typedef struct redisDb {
// ...
// 数据库键空间,保存着数据库中的所有键值对
dict *dict;
dict *expires;
// ...
} redisDb;
redisDb
结构的dict
字典保存了数据库中的所有键值对,我们将这个字典称为键空间(key space
),键空间和用户所见的数据库是直接对应的:
- 键空间的键也就是数据库的键,每个键都是一个字符串对象。
- 键空间的值也就是数据库的值,每个值可以是字符串对象、列表对象、哈希表对象、集合对象和有序集合对象在内的任意一种
Redis
对象。
redisDb
结构的expires
字典保存了数据库中所有过期键的时间:
expires
字典中键值对的键是一个指针,指向dict
字典中的一个键。expires
字典中键值对的值是一个long
类型整数,表示这个键的过期时间,一个精确到毫秒的UNIX
时间戳。
过期键的删除策略:
-
惰性删除
: 所有读写数据库的命令执行之前,都会对输入键进行检查。- 如果输入键已经过期,那么就删除。
- 如果输入键没有过期,或者没有过期时间,那么不做任何操作。
-
定期删除
: 定期遍历一部分数据库,从数据库的expires
字典中随机检查一部分键的过期时间,删除其中已过期的键。个人觉得这里可以使用过期桶的方式,将过期键按照过期时间放到不同的过期时间桶,这样就避免查找过期键的时间,直接删除过期时间桶中所有过期键。
RDB
,AOF
和复制过程中,对过期键的处理:
- 生成
RDB
时,会检查键的过期时间,已经过期的键不会保存到RDB
文件中。 - 载入
RDB
文件时,也会检查键的过期时间,已经过期的键直接被忽略。 -
AOF
写入时,如果某个键被惰性或者定期删除了,会直接在AOF
文件中插入一个DEL
命令。 -
AOF
重写时,会检查键的过期时间,已经过期的键不会保存到重写后的AOF
文件中。 - 复制时,因为只有主服务器才能使用写命令,从服务器只能使用读命令,因此即使从服务器发现了键过期,它也不会删除这个键;只有当主服务器发现这个键过期,删除它,并会向从服务器发送一条
DEL
命令。
2.2 RDB
持久化
Redis
可以通过 save
或者 bgsave
命令,生成RDB
文件,来实现持久化。
-
save
命令当
save
命令执行时,Redis
服务器会被阻塞,客户端发送的所有命令请求都会被拒绝。 -
bgsave
命令-
Redis
进程执行fork
操作创建子进程,RDB
持久化过程由子进程负责,完成后自动结束;阻塞只发生在fork
阶段,一般时间很短。 -
Redis
服务器在bgsave
期间可以继续处理客户端命令请求,但是save
,bgsave
和bgrewriteaof
这三个命令会有不同: - 会拒绝执行
save
和bgsave
命令;bgrewriteaof
指令会延迟到bgsave
完成之后执行。
-
2.2.1 自动保存
用户可以通过配置项 save
设置触发 bgsave
命令。
例如
save 900 1
save 300 100
save 60 1000
即 900
秒内至少一次修改,300
秒内至少100
次修改,60
秒内至少 1000
次修改,只有满足其中一个条件就会触发bgsave
命令。
实现原理就是
typedef struct redisServer {
// ...
// 记录自动保存的条件
struct saveparam *saveparams;
// 上次保存后,服务器修改次数。
long long dirty;
// 上次保存时间
time_t lastsave;
// ...
} redisServer;
typedef struct saveparam {
// 间隔时间
time_t seconds;
// 修改次数
int changes;
} saveparam;
redisServer
中有一个saveparams
数组记录着所有自动保存的条件。redisServer
中dirty
和lastsave
记录上次保存后,修改次数和保存时间,那么就可以判断是否满足自动保存条件。
2.2.2 fork
操作
fork
使用了写时复制机制(copy-on-write
):
- 父子进程会共享相同的物理内存页,当父进程处理写请求时会把要修改的页创建副本,不会改变共享内存快照数据。
- 子进程在fork操作过程中共享整个父进程内存快照。
- 这样子进程不会复制整个父进程内存,只需要复制物理内存页就行了。
2.3 AOF
持久化
AOF
(append only file
)持久化,以独立日志的方式记录每次写命令,重启时再重新执行AOF
文件中的命令达到恢复数据的目的。
AOF
的主要作用是解决了数据持久化的实时性,目前已经是Redis
持久化的主流方式。
AOF
持久化功能的实现可以分为命令追加(append
)、文件写入、文件同步(sync
)三个步骤。
2.3.1 命令追加
struct redisServer {
// ...
// AOF 缓冲区
sds aof_buf;
// ...
};
当
AOF
持久化功能处于打开状态时,服务器在执行完一个写命令之后,会以协议格式将被执行的写命令追加到服务器状态的aof_buf
缓冲区的末尾。
2.3.2 文件写入
Redis
的服务器进程就是一个事件循环(loop
),这个循环中的文件事件负责接收客户端的命令请求,以及向客户端发送命令回复。
因为服务器在处理文件事件时可能会执行写命令,使得一些内容被追加到 aof_buf
缓冲区里面,所以在服务器每次结束一个事件循环之前,都会考虑是否需要将 aof_buf
缓冲区中的内容写入和保存到 AOF
文件里面。
2.3.3 文件同步
Redis提供了多种AOF
缓冲区同步文件策略,由参数appendfsync
控制,可选择值如下:
可配置值 | 描述 |
---|---|
always | 将 aof_buf 缓冲区中的所有内容写入并同步到 AOF 文件。 |
everysec | 将 aof_buf 缓冲区中的所有内容写入到 AOF 文件,如果上次同步 AOF 文件的时间距离现在超过一秒钟,那么再次对 AOF 文件进行同步,并且这个同步操作是由一个线程专门负责执行的。 |
no | 将 aof_buf 缓冲区中的所有内容写入到 AOF 文件,但并不对 AOF 文件进行同步,何时同步由操作系统来决定。 |
2.3.4 重写机制
随着命令不断写入AOF
,文件会越来越大,为了解决这个问题,Redis
引入AOF
重写机制压缩文件体积。
AOF
文件重写就是把Redis
进程内的数据转化为写命令同步到新AOF
文件的过程。
可以通过 auto-aof-rewrite-min-size
和 auto-aof-rewrite-percentage
配置项自动触发bgrewriteaof
命令。
auto-aof-rewrite-min-size
表示运行AOF
重写时文件最小体积,默认
为64MB
。auto-aof-rewrite-percentage
代表当前AOF
文件空间(aof_current_size
)和上一次重写后AOF
文件空间(aof_base_size
)的比值。- 自动触发的条件就是
aof_current_size > auto-aof-rewrite-minsize
且(aof_current_size - aof_base_size)/aof_base_size>=auto-aof-rewritepercentage
。- 其中
aof_current_size
和aof_base_size
可以在info Persistence
统计信息中查看。
AOF
重写流程:
- 父进程执行
fork
创建子进程。 - 父进程在子进程重写时间内,会将处理的写命令不但写入
aof_buf
中,还会写入一个AOF
重写缓冲区中。 - 子进程根据内存快照,按照命令合并规则写入到新的
AOF
文件。 - 当子进程完成新
AOF
文件写入后,会发送信号给父进程。 - 父进程把
AOF
重写缓冲区的数据写入到新的AOF
文件。 - 最后原子性用新
AOF
文件替换老文件,完成AOF
重写。
2.3.5 AOF
文件载入
-
AOF
持久化开启且AOF
文件存在时,优先加载AOF
文件。 -
AOF
关闭或者AOF
文件不存在时,加载RDB
文件。 - 加载
AOF/RDB
文件成功后,Redis
启动成功。 -
AOF/RDB
文件存在错误时,Redis
启动失败并打印错误信息。
载入AOF
文件过程如下:
- 创建一个不带网络连接的伪客户端。
- 读取
AOF
文件中一条命令,在伪客户端中执行。 - 直到
AOF
文件中所有命令都在伪客户端中执行完成。
2.4 复制
2.4.1 旧版复制
分为两部分:
- 同步
sync
: 将从服务器数据库状态更新到主服务器数据库状态。 - 命令传播 : 主服务器数据库状态改变时,会发送命令给从服务器,让主从服务器数据库状态重回一致。
2.4.1.1 同步 sync
客户端向从服务器发送 slaveof {masterHost} {masterPort}
命令,根据masterHost
和 masterPort
与主服务器建立连接,然后执行同步 sync
操作:
- 从服务器向主服务器发送
sync
命令。 - 主服务器收到
sync
命令,就会执行bgsave
命令,生成一个RDB
文件,并使用一个缓存区记录从现在开始执行的所有写命令。 - 当主服务器的
RDB
文件生成后,会将这个文件发送给从服务器,这样从服务器根据RDB
文件,将自己的数据库状态更新到主服务器执行bgsave
命令时的状态。 - 最后主服务器再将记录这段时间内写命令的缓存区数据发送给从服务器。
2.4.1.2 命令传播
主服务器会将执行的写命令(会导致主从服务器数据库状态不一致),发送给从服务器,让主从服务器数据库状态回归一致。
2.4.1.3 旧版复制的缺陷
- 当主从服务器连接偶尔断链,再重连后,也会发送
sync
命令,同步主从服务器数据库状态。- 即使主从服务器数据库差距很小,但还是必须使用
sync
命令,否则没办法同步数据库状态。 - 而
bgsave
是非常消耗服务器cpu
资源,发送RDB
文件又会消耗服务器网络资源。
- 即使主从服务器数据库差距很小,但还是必须使用
- 命令传播时,发送给从服务器的写命令,可能因为网络原因丢失了。但是主服务器却不知道从服务器是否完成了这个写命令,导致主从服务器数据库状态不一致。
这个才是比较致命的,旧版的复制其实没办法使用的,因为主从服务器数据可能就是不一样的。
2.4.2 新版复制
新版复制使用 psync
命令代替 sync
命令,来执行同步操作,psync
命令有完整同步和部分同步:
-
完整同步
: 和sync
命令一样,需要主服务器生成RDB
文件然后进行同步。 -
部分同步
: 主服务器将从服务器状态到当前状态之间的写命令都发送给从服务器,这样从服务器执行这些写命令,就可以将自己的状态同步到主服务器的状态。
2.4.2.1 实现部分同步
主要靠三个属性:复制偏移量,复制积压缓存区和服务器运行ID(即runID
)。
-
复制偏移量
主从服务器都会维护一个复制偏移量
- 每次主服务器向从服务器发送
N
个字节的写命令时,就会将自己的复制偏移增加N
。 - 从服务器收到主服务器
N
个字节的写命令,执行完成后,也会将自己的复制偏移增加N
。
- 每次主服务器向从服务器发送
-
复制积压缓存区
复制积压缓存区是由主服务器维护的固定长度的先进先出队列,默认大小就是
1MB
。- 当主服务器进行命令传播时,不仅会将写命令发送给所有从服务器,还会将写命令放入复制积压缓存区中。
-
服务器运行ID
- 不管是主服务器还是从服务器,都会有自己的
runID
。 -
runID
在服务器启动时自动生成,由40
个随机十六进制字符组成,每次服务器重启,runID
都会不一样。
- 不管是主服务器还是从服务器,都会有自己的
当从服务器进行同步时,会发送 psync
, runid
是上次复制主服务器运行ID
,offset
是当前从服务器的复制偏移量。
主服务器收到psync
请求,会进行如下情况:
- 如果
runid
不是自己的运行ID
,或者复制偏移量offset
不在复制积压缓存区中,那么返回+FULLRESYNC
, 让从服务器进行完整同步。 - 如果
runid
是自己的运行ID
且复制偏移量offset
在复制积压缓存区中,那么回复+CONTINUE
,让客户端进行部分同步,随后会将从服务器缺失的写命令发送过来。
2.4.2.2 心跳检测
从服务器会以一定频率(默认是1
秒),向主服务器发送命令
REPLCONF ACK
主要是实现以下三个功能:
- 使用心跳检测主从服务器网络连接状态。
- 实现
min-slaves
的功能。Redis
有min-slaves-to-write
和min-slaves-max-lag
选项,防止主服务器在不安全情况下执行写命令。这些都是需要通过心跳,主服务器才知道有多少可用的从服务器。 - 检测命令转播写命令的丢失
因为从服务器会上报自己当前的复制偏移量
offset
,主服务器就可以将缺失写命令发送给从服务器。
2.5 发布与订阅
2.5.1 数据格式
struct redisServer {
// ...
// 保存所有频道的订阅关系
dict *pubsub_channels;
// 保存所有模式订阅关系
list *pubsub_patterns;
// ...
};
struct pubsubPattern {
// 订阅的客户端
redisClient *redisClient;
// 订阅模式
robj *pattern;
}
-
pubsub_channels
: 使用一个字典记录所有频道的订阅关系- 字典键值对的键就是频道名。
*字典键值对的值是一个链表,链表记录所有订阅这个频道的客户端。
- 字典键值对的键就是频道名。
-
pubsub_patterns
: 使用一个链表记录所有模式订阅关系。链表中的节点类型都是
pubsubPattern
,保存模式订阅的模式和对应客户端。
2.5.2 发送消息
当客户端执行 publish
命令,向频道channel
发送 message
消息时,服务器会执行如下操作:
- 根据频道名
channel
,从pubsub_channels
中获取订阅这个频道所有的客户端,然后向它们发送message
消息。 - 遍历
pubsub_patterns
链表,如果发现模式与频道名channel
匹配,那么就向它客户端发message
消息。
2.5.3 查看订阅消息
-
pubsub channels
: 用于返回服务器当前被订阅的所有频道名。其实就是字典
pubsub_channels
的键的集合。 -
pubsub numsub
: 返回频道名对应的订阅数。其实就是字典
pubsub_channels
的键对应值链表类型的长度。 -
pubsub numpat
: 返回当前服务器模式订阅的数量。其实就是链表
pubsub_patterns
的长度。
2.6 事务
Redis
通过 MULTI
,EXEC
,DISCARD
和WATCH
命令来实现事务。
注意任何在事务里可以完成的事, 在脚本里面也能完成。 并且一般来说, 使用脚本要来得更简单,并且速度更快。
事务的实现经历以下三个阶段:事务开始,命令入队和事务执行。
2.6.1 事务开始
通过 MULTI
命令就开启事务。
2.6.2 命令入队
当一个客户端处于非事务状态时,这个客户端发送的命令会立即被服务器执行。
与此不同的是,当一个客户端切换到事务状态之后,服务器会根据这个客户端发来的不同命令执行不同的操作:
- 如果客户端发送的命令为
EXEC
、DISCARD
、WATCH
、MULTI
四个命令的其中一个,那么服务器立即执行这个命令。
与此相反,如果客户端发送的命令是EXEC
、DISCARD
、WATCH
、MULTI
四个命令以外的其他命令,那么服务器并不立即执行这个命令,而是将这个命令放入一个事务队列里面,然后向客户端返回QUEUED
回复。
2.6.3 事务执行
当一个处于事务状态的客户端向服务器发送 EXEC
命令时,这个 EXEC
命令将立即被服务器执行:服务器会遍历这个客户端的事务队列,执行队列中保存的所有命令,最后将执行命令所得的结果全部返回给客户端。
当然你也可以使用 DISCARD
丢弃这个事务,会清空事务队列。
但是需要注意
EXEC
和DISCARD
命令必须是在事务状态下才能执行,非事务状态下,会直接报错。
2.6.4 WATCH
命令实现
WATCH
命令是一个乐观锁,它可以在 EXEC
命令执行之前,监控任意数量的数据库键,并在EXEC
命令执行时,判断这些被监控的数据库键值是否被修改过,如果是那么EXEC
命令将拒绝执行,直接报错。
WATCH
命令实现原理就是:
typedef struct redisDb {
// ...
// 正在被 WATCH 监控的键
dict *watched_keys;
// ...
} redisDb;
使用一个字典watched_keys
记录WATCH
监控的键,字典键值对的键是监控键,键值对的值是一个链表,记录WATCH
监控键所有客户端。
所有对数据库修改命令,都会检查修改的键是否在watched_keys
中,如果在,那么就设置这个键对应的所有监控客户端事务安全性已经被破坏了。
2.6.5 事务中的错误
使用事务时可能会遇上以下两种错误:
- 事务在执行
EXEC
之前,入队的命令可能会出错。比如说,命令可能会产生语法错误(参数数量错误,参数名错误,等等),或者其他更严重的错误,比如内存不足(如果服务器使用
maxmemory
设置了最大内存限制的话)。 - 命令可能在
EXEC
之后失败。举个例子,事务中的命令可能处理了错误类型的键,比如将列表命令用在了字符串键上面等等。
对于第一种错误,事务会拒绝执行;但是对于第二中错误,成功的命令会正常执行,失败的命令就失败,而且 redis
不支持事务的回滚。