第二部分 单机数据库的实现
[toc]
1. 数据库
本章说明服务器保存数据库的方法,客户端切换数据库的方法,数据库保存键值对的方法,还有针对数据库的增删改查等操作的实现方法等等.
(1). 服务器中的数据库
这里说的数据库是指一个Redis程序中的多个命名空间,它们相互无关,所以认为是不同的库.
Redis服务器将所有数据库都保存在redis.h/redisServer结构的db数组中,db数组中的每一个元素都是一个redis.h/redisDb结构.每一个redisDb结构代表一个数据库.
struct redisServer{
// ......
// db数组
redisDb *db;
// 服务器的数据库数量
int dbnum;
// .....
};
dbnum的值默认为16,可以通过配置更改.
(2). 切换数据库
初始化完成后,默认使用的数据库是0号数据库,可以使用SELECT命令切换数据库.
redis> SET msg "hello"
OK
redis> GET msg
"hello"
redis> SELECT 1
OK
redis[1]> GET msg
(nil)
在服务器内部,客户端状态redisClient结构(每一个Redis客户端连接服务端,就有一个该结构的实例)的db属性记录了客户端当前的目标数据库.也就是说这个结构用于服务端在对客户端发出的命令进行响应时,选择哪一个数据库进行响应.
typedef struct redisClient{
// .....
// 记录客户端正在使用的数据库,指向之前db数组中的一个元素
redis *db;
// .....
} redisClient;
Redis没有返回数据库的命令,也就是说,除了Redis命令行中会有号码外,不能得知当前使用的是哪一个数据库,因此在其他语言中通过接口使用Redis数据库,要谨防多次切换数据库后忘记当前使用的是哪一个数据库.
安全起见,最好先执行一个SELECT进行切换.
(3). 数据库键空间
Redis是一个键值对数据库服务器,服务器中的每一个数据库都由一个redis.h/redisDb表示.其中的dict是一个字典,存储了这个数据库所有的键值对,被称为键空间.
typedef struct redisDb{
// .....
// 数据库键空间,是一个字典,保存了数据库中所有的键值对
dict *dict;
// .....
} redisDb;
同字典一样,键空间的键都是string对象,值可以使任意的redisObject.
对数据库的操作,比如新建一个字符串键,都是通过对键空间这个字典的操作来实现的.增删改查都是如此.
(4). 设置键的生存时间
通过EXPIRE命令或者PEXPIRE命令,可以以秒或者毫秒的精度为数据库中的一个键设置生存时间.经过指定的一段时间后,服务器就会自动删除生存时间为0的键.
SETEX命令可以在声明字符串的时候就设置生存时间,后面分布式锁会用到.只能用于字符串.
和EXPIRE命令或者PEXPIRE命令类似,还可以使用EXPIREAT命令或者PEXPIREAT命令设置键的死亡时刻,传入一个UNIX时间戳.到达这个时间戳,这个对象就被清理.
TTl和PTTL命令接受一个带有生存时间或者死亡时刻的键,返回不同时间精度的剩余存活时长.
实际上底层都是通过PEXPIREAT命令实现的
1). 保存过期时间
redisDB结构的expires字典保存了数据库中所有键的过期时间,我们也成这个键为过期字典.
typedef struct redisDb{
// .....
// 数据库键空间,是一个字典,保存了数据库中所有的键值对
dict *dict;
// 过期字典
dict expires;
// .....
} redisDb;
设置一个键的过期时间实际上就是在过期字典中添加一个string和string的键值对,只不过值存储的是long long类型的时间.
那么很明显,移除过期时间就是将过期字典中的一个键值对移除.返回过期时间就是相应的查询操作.
2). 过期键的判定
使用过期字典,可以通过以下方式检查一个键是否过期:
- 检查这个键是否存在于过期字典,如果存在那么取得过期时间戳,如果不存在那么一定没过期
- 将过期时间戳与当前的UNIX时间戳进行比较,如果是过去的时间戳,那么这个键过期了.
(5). 过期键删除策略
三种不同的删除策略:
- 定时删除:设置一个计时器,计时器通知在键的过期时间来立即执行删除操作
- 惰性删除:获取键时,判断是否过期,如果过期,删除
- 定期删除:每隔一段时间检查过期键,并删除
1). 定时删除
定时删除策略对内存是最友好的,因为能最及时的删除过期键,但对CPU时间非常不友好,因为有些时间CPU非常紧张二内存还很充裕.此时如果进行了删除键,会对CPU压力非常大,影响响应时间和吞吐量.
此外,定时器需要用到Redis服务器中的时间事件,实现方式为无序链表,查找时间事件的时间复杂度为O(n),并不高效.
所以定时删除策略并不是很现实.
2). 惰性删除策略
惰性删除对CPU时间是非常友好的,保证过期键的操作只在非做不可的情况下执行,并且仅仅删除当前处理的键.不会在其他键上花费CPU时间.
但是这种策略对内存是十分不友好的,这个键若是不再被使用就会一直待在内存中.甚至可以看做是一种内存泄露.对于运行状态十分依赖内存的Redis服务器来说,并不是一个好消息.
3). 定期删除
定期删除策略是前两种策略的一个折中.既能考虑到内存,又对CPU一定程度上的友好.
难点在于确定删除操作执行的时长和频率,如果太过频繁,或者执行时间太长,会退化成定时删除策略.如果删除操作执行的太少,或者执行的时间太短,就会出现和惰性删除一样的问题,浪费内存.
(6). Redis的过期键删除策略
Redis实际上使用的是惰性删除和定期删除两种策略.两种策略配合使用,取得平衡.
1). 惰性删除策略的实现
所有读写数据库的Redis命令在执行前都会调用expireIfNeeded函数对输入键进行检查,
- 如果输入键过期,那么将输入键删除
- 如果输入键未过期,那么不做动作
expireIfNeeded函数像一个过滤器,能过过滤掉所有过期的键.相应的所有的命令也都必须能够同时处理键存在和键不存在两种情况.
2). 定期删除则略的实现
过期键的定期删除策略由activeExpireCycle函数实现.
每当Redis的服务器周期性操作serverCron函数执行时,activeExpireCycle函数就会被调用,它在规定时间内,分多次扫描服务器中的各个数据库,从数据库的expires字典(过期字典)中随机检查一部分键的过期时间,删除掉其中过期的.
工作模式:
- 每次函数被调用时,都从一部分数据库的过期字典中再找出一部分键进行判断过期,并进行删除操作
- 全局变量current_db记录当前函数检查的进度,下一次被调用时,接着这个进度进行检查
- 随着这个函数的多次调用,服务器中的所有数据库都会被检查一遍,然后记录清零,从头开始
(7). AOF,RDB和复制功能对过期键的处理
AOF和RDB是Redis的两种持久化方案
1). 生成RDB文件
SAVE或者BGSAVE命令会创建一个新的RDB文件,程序会抛弃数据库中过期的键,过期的键不会被保存到新的RDB文件中.
2). 载入RDB文件
启动Redis服务器时,如果开启了RDB功能,那么服务器将对RDB文件进行载入,初始化服务器:
- 如果服务器是==主服务器==,程序会对文件中保存的键再进行一次检查,过期键会被忽略
- 如果服务器是==从服务器==,不论键是否过期,都会被载入到数据库中.不过主服务器在进行数据同步时,从服务器会被清空,所以也不影响.
3). AOF文件写入
当服务器以AOF持久化运行是,如果某个键已经过期但是还没被删除,AOF文件不会有任何处理.但==当这个键被删除时,程序会在AOF文件追加一条DEL命令显式的记录这个键被删除了==.
4). AOF重写
在执行AOF重写过程中,程序会对键进行检查,所有过期的键(而不是被删除)不会被保存到新的AOF文件中.
5). 复制
当服务器子在复制模式下运行时,服务器的过期键删除动作由主服务器控制:
- 主服务器删除一个键后,会向所有从服务器发送一个DEL命令,通知从服务器删除这个键保持同步.
- 从服务器不会考虑键的过期情况,只有主服务器发来DEL命令时,才进行相应的操作
当一个过期键在主服务器中存在时,也应该同时存在于从服务器.
(8). 数据库通知
数据库通知功能可以让客户端通过订阅某个给定的频道,来获取数据库中键的变化,以及数据库中命令的执行情况.(大致意思就是,客户端可以监听服务器对某个键的所有操作和变化,服务器发回一些列消息说明这个键执行了什么命令,也就是通知).
"这个键执行了什么命令"这种通知被称为键空间通知,除此之外,还有键空间时间通知,关注的是"某个命令被什么键执行了"
1). 发送通知
发送通知的功能由以下函数实现:
/**
* type参数是当前要发送通知的类型,服务器根据这个参数确是否发送这条通知
* event是事件名称
* key是产生事件的键
* dbid是产生事件的数据库号码
* 下面的三个参数构建了通知的内容
*/
void notifyKeyspaceEvent(int type, char *event, robj *key, int dbid);
当一个Redis命令需要发送数据库通知的时候,就会调用这个函数,并通过参数传递相应的信息.
2). 发送通知的实现
- 如果给定的通知是服务器不允许发送的,那么直接返回
- 检测服务器是否允许发送==键空间==通知,如果允许,程序会构件并发送时间通知
- 函数检测服务器是否云溪发送==键事件==通知,如果允许,构件并发送
2. RDB持久化
全称Redis Database,实际指磁盘上的Redis数据库.
我们将服务器中的非空数据库以及它们的键值对统称为数据库状态.
因为Redis是内存数据库,如果不想办法将存储在内存中的数据保存到磁盘中,那么一旦服务器进程退出,数据就消失了.为了解决这个问题,Redis提供了RDB持久化功能,这个功能可以==将Redis在内存中的数据库状态保存到磁盘中==,避免数据意外丢失.
RDB持久化功能生成的RDB文件是一个经过压缩的二进制文件.
(1). RDB文件的创建与载入
有两个命令可以生成RDB文件,分别是SAVE命令和BGSAVE命令.
SAVE命令会阻塞Redis服务器进程,直到RDB文件创建完成为止.,期间服务器不能处理任何请求.
BGSAVE命令会派生出一个子进程,由子进程负责创建RDB文件,服务器进程继续处理命令请求.
这两个命令都是通过调用rdbSave函数完成生成RDB文件的功能的.
伪代码如下:
def SAVE():
# 生成RDB文件
rdbSave()
def BGSAVE():
# 创建子进程
# 这个方法会将当前执行的方法复制一份
# 在子进程中返回的pid为0,父进程中为正数
# 返回负数则说明创建失败
pid = fork()
if pid == 0:
# 子进程中生成RDB文件
rdbSave()
# 完成之后向父进程发送信号
signal_parent()
elif pid > 0:
# 父进程中继续处理命令请求,并轮询等待子进程的完成信号
handle_request_and_wait_signal()
else:
# 如果创建失败,进行相应的处理
handle_fork_error()
和生成RDB文件不同,Redis只在服务器启动时进行,所以并没有专门用于载入RDB文件的命令,只要Redis检测到RDB文件,那么在启动服务器时就会进行载入.
因为AOF文件的更新频率通常都会比RDB文件高,所以服务器优先使用AOF来进行持久化,也就是说如果服务器开启了AOF功能,那么服务器有限使用AOF文件进行数据的恢复,只有当没有开启AOF功能时,才会使用RDB文件恢复数据.
1). SAVE命令执行是的服务器状态
阻塞,可以看做的串行化的.
2). BGSAVE命令执行是的服务器状态
服器可以在执行BGSAVE的通知接受请求并执行命令,但是在这期间处理SAVE,BGSAVE,BGREWRITEAOF这些命令会和平时不同.
- SAVE和BGSAVE命令会被拒绝
- BGREWRITEAOF命令会被延迟到BGSAVE命令执行完再进行(如果BGREWRITEAOF正在执行,BGSAVE会被拒绝),这是处于性能上的考虑
3). RDB文件载入是的服务器状态
载入期间,会阻塞服务器进程,直到载入完成.
(2). 自动间隔性保存
因为BGSAVE命令可以在子线程中执行,不阻塞服务器,所以Redis允许用户设置save配置,让服务器每隔一段时间自动执行一次BGSAVE命令.
用户可以设置多个保存条件,当其中之一满足时,就会执行BGSAVE命令.
样例如下:
// 服务器在900秒之内对数据库至少进行了1次修改
save 900 1
save 300 10
save 60 10000
1). 设置保存条件
上面显示的就是默认的自动间隔性保存配置.
启动服务器时,Redis会根据这个配置,设置服务器状态redisServer结构的saveparams属性;
struct redisServer{
// .....
// 记录了保存条件的数组
struct saveparam *saveparams;
// .....
};
saveparams是一个数组,每一个元素都是saveparam结构,其内部保存了一个saveparams选项设置的保存条件:
struct saveparam{
// 秒数
time_t seconds;
// 修改数
int changes;
};
2). dirty计数器和lastsave属性
除了saveparams数组之外,服务器还维护者一个dirty计数器和lastsave属性:
- dirty计数器记录上次(BG)SAVE之后服务器对数据库状态的修改次数
- lastsave属性记录了上次(BG)SAVE执行的UNIX时间戳
struct redisServer{
// .....
// 修改计数器
long long dirty;
// 上次执行保存的时间
time_t lastsave;
// .....
};
3). 检查保存条件是否满足
Redis的服务器周期性操作函数serverCron默认每100毫秒就执行一次,它的其中一项工作就是检查save选项所保存的条件是否被满足.
和saveparams数组中的条件进行比较.计算出距上次save的时间,如果dirty数大于cahnges,==并且==时间大于所设置的时间,就进行BGSAVE操作.
(3). RDB文件结构
一个完整的RDB文件所包含的各个部分:
-
REDIS:
- RDB文件的最开头部分摩擦和你共度为5字节,就记录了REDIS五个字符,相当于Java中class文件中的魔数,用于文件类型的确认.
-
db_version:
- 长度为4字节,它记录了一个字符串表示的整数,用于表示RDB文件的版本号,从而确定解析方式的不同.相当于Java中的class文件的版本号.
-
databases:
- 包含0个或者多个数据库,以及各个数据库中的键值对数据
-
EOF:
- 长度为1字节,用来标记databases部分的结束
-
check_sum:
- 是一个8字节长的无符号整数,是Redis程序根据前面四个部分的内容进行计算得出的一个校验和.通过对比这个数字和当前程序计算得出的结构,可以得知这个RDB文件是否出错或者被损坏.
1). databases部分
一个RDB文件的databases部分可以保存任意多个数据库,如果其中一部分数据库为空,那么会跳过这个库,直接保存下一个库的内容.
每一个非空数据库在RDB文件中都可以保存为==SELECTDB, db_number, key_value_pairs==三部分
SELECTDB | db_number | key_value_pairs |
---|---|---|
常量,长度为1字节,让读入程序知道接下来读入的将是一个数据库号码. | 保存所记录的数据库的号码.根据号码的不同,长度可以是1字节,2字节或者5字节.读入这个值之后,服务器也会进行相应的切换库操作,保证后面的数据进入正确的库. | 这部分保存了数据库中的所有键值对数据,包括过期时间 |
2). key_value_pairs部分
每一个key_value_pairs部分都保存了一个或以上数量的键值对.
==带过期时间的==key_value_pairs前面才会有这两个属性,不带过期时间的之后后三个属性
EXPIRETIME_MS | ms | TYPE | key | value |
---|---|---|---|---|
常量,1字节,告诉程序接下来读取一个毫秒为单位的过期时间 | 8字节长的带符号整数,记录了一个UNIX时间戳 | 是众多常量中的一个,表示这个键值对的对象类型或者底层编码,服务器根据这个字段决定如何读入和解释value的数据 | 一个字符串对象,表示键 | 根据不同的类型或者编码,有不同的格式和长度 |
3). value的编码
value属性中每种编码的结构都不同
1>. string对象
如果是整数,那么直接存储
len | string |
---|---|
字符串长度 | 字符串内容 |
如果是raw编码,说明是一个字符串,长度大于20字节会被压缩.
不被压缩的会保存长度和内容
被压缩之后会保存标记,压缩后长度,压缩前长度和压缩后的内容
REDIS_RDB_ENC_LZF | compressed_len | origin_len | compressed+string |
---|---|---|---|
标记为,表示是否经过LZF算法压缩 | 压缩后长度 | 压缩前长度 | 压缩后内容 |
2>. list对象
双端链表编码:
list_length | item 1 | item 2 | ... | item n |
---|---|---|---|---|
列表长度 | 列表项,是字符串对象 |
3>. set对象
字典编码:
set_size | elem 1 | elem 2 | ... | elem n |
---|---|---|---|---|
集合中元素数 | 元素,是字符串对象 |
4>. hash对象
字典编码:
hash_size | key_value_pair 1 | key_value_pair 2 | ... | key_value_pair n |
---|---|---|---|---|
哈希表的键值对数量 | 一个键值对 |
key_value_pair的结构:
key 1 | value 1 | key 2 | value 2 | ... | key n | value n |
---|---|---|---|---|---|---|
第一个key,是string对象 | 第一个value,是字符串对象 |
5>. zset对象
跳表编码:
sorted+set_size | element 1 | element 2 | ... | element n |
---|---|---|---|---|
有序集和的元素数量 | 一个键值对 |
element的结构:
member 1 | score 1 | member 2 | score 2 | ... | member n | score n |
---|---|---|---|---|---|---|
第一个成员,是string对象 | 第一个分值,是double类型的浮点数 |
6>. 其他编码实现
其他编码方式(整数编码的集合,压缩列表编码的列表,哈希表或者有序集合),都是将数据部分转化为字符串进行存储,因为这些数据底层都是字节数组.
3. AOF持久化
全称是Append Only File,也就是可追加的一个文件.
通过保存Redis服务器所执行的==写命令==来记录数据库状态.
被写入AOF文件的所有命令都是以==Redis的命令请求协议REST==格式保存的.
(1). AOF持久化的体现
AOF持久化功能的实现可以分为命令追加,文件写入和文件同步三个步骤.
1). 命令追加
当开启AOF功能的Redis服务器执行完一个写命令之后,会以REST的格式将这个命令写入服务器状态的aof_buf缓冲区.
struct redisServer{
// .....
// AOF缓冲区
sds aof_buf;
// .....
};
2). AOF文件的写入和同步
这里写入是指将缓冲中的数据写入到内存中的AOF文件中,同步则相当于保存到磁盘中.
Redis的服务器进程是一个事件循环,其中的文件事件负责接受命令请求,而时间时间则执行serverCron这样的定时执行的函数.
因为每一个文件事件都有可能执行写命令,所以服务器每次结束一个时间循环之前,都会调用flushAppendOnlyFile()函数考虑是否将缓冲中的数据追加到AOF文件中.
事件循环如下伪代码:
def eventLoop():
while True:
# 进行文件事件
processFileEvents()
# 时间事件
processTimeEvents()
# 判断是否进行写入和同步
flushAppendOnlyFile()
flushAppendOnlyFile()函数的行为有服务器配置的appendfsync选项的值决定.
appendfsync的值 | flushAppendOnlyFile函数的行为 |
---|---|
always(每命令) | 将缓冲区中的内容写入并同步到AOF文件中 |
everysec(每秒,==默认配置==) | 先进行写入,如果距离上次同步超过一秒,那么接着在一个专门的线程中进行同步操作 |
no(从不) | 进行写入操作,不进行同步,由操作系统来决定同步的时机 |
- always配置是三个配置中最慢的,但是最安全,因为最多会丢失一个命令数据
- everysec最多丢失一秒的数据
- no配置会丢失上次操作系统自动同步到宕机为止所有的数据,并且单次同步时间最长(数据量大),但是效率最高
(2). AOF文件的载入与数据还原
Redis服务器在启动时如果开启了AOF功能,并且检测到AOF文件,那么就会进行AOF文件的载入和数据还原.
详细步骤如下:
- 创建一个==不带网络连接的伪客户端==(其实是在服务端内部的,所以叫伪客户端,用于处理命令,其效果和普通的Redis客户端使用网络传递命令是一样的)
- 从AOF文件中分析并读取出一条命令
- 使用伪客户端执行这条命令
- 重复2和3,直到AOF文件被全部读取完毕
(3). AOF重写
因为AOF文件是追加写入的,所以随着服务器运行时间的流逝,这个文件会越来越大,不加以控制会造成越来越多的影响.
为了解决AOF文件体积膨胀的问题,Redis提供了AOF文件重写功能.Redis将通过这个功能创建出一个新的AOF文件代替旧的,膨胀了的AOF文件.新旧==两个AOF文件功能上完全一致,但是新的AOF文件不会包含任何的冗余命令==,所以体积会小很多.
1). AOF文件重写的实现
这个功能通过去读现有的数据库状态来实现.==Redis会将每一个键值对用一条写命令代替==(例如:一个命令插入一整个list,而不是一个列表项一个列表项插入).
2). AOF后台重写
上面介绍的AOF文件重写功能虽然很好,但是这个函数会进行大量的写入操作,所以调用这个函数的线程将被长时间阻塞.由于Redis使用单线程来处理请求,所以就可能导致Redis服务器的停滞.所以==Redis决定将AOF重写程序放在子进程中进行,从而不影响服务器进程处理命令,另一方面使用子进程可以在不使用锁的情况下保证数据的安全==.
在AOF重写进行中,服务器进程还要继续处理新的命令请求,就会改变数据库状态,就可能在AOF重写完成后,和现有的数据库状态不一致.为了解决这个问题,==Redis设置了AOF重写缓冲区,在AOF重写的过程中所有的AOF追加部分将同时被写入AOF缓冲和AOF重写缓冲==.这样就保证了现有的AOF文件是正常更新的,并且从创建子进程开始,所有的命令都会被加以记录.
当AOF重写完成后,子进程会向父进程发送一个信号.父进程接收到之后(这里因为执行了信号处理函数,是会==阻塞==父进程的)会==将AOF重写缓冲中的内容写入新的AOF文件==中,追加完成之后,这个新的AOF文件就和当前的数据库状态一致了.然后==使用新的AOF文件原子性覆盖旧的文件==.然后就完成了整个AOF重写的全过程
4. 事件
Redis服务器是一个事件驱动程序,服务器需要处理以下两类事件:
- 文件事件:Redis服务器通过套接字(socket)与客户端进行连接,文件事件就是服务器对套接字操作的抽象.服务器和客户端或者其他服务器的通信就会产生文件事件.服务器通过监听并处理这些文件事件来完成一些列网络通信操作.
- 时间事件:服务器对定时操作的抽象.
(1). 文件事件
Redis基于Reactor模式开发了自己的网络时间处理器:被称为==文件事件处理器==:
- 使用I/O多路复用程序来同时监听多个套接字,并根据每个套接字执行的任务来关联不同的事件处理器.
- 被监听的套接字准备好执行一系列操作时,与操作队形的文件事件就会产生,这是文件事件处理器就会调用与套接字相关联的事件处理器去处理这些事件.
1). 文件事件处理器的构成
文件事件处理器由四个部分组成,分别是:套接字,I/O多路复用程序,文件事件分派器,多个事件处理器.
[图片上传失败...(image-d2434c-1590395205133)]
I/O多路复用程序负责监听多个套接字,并向文件事件分派器传送那些产生了事件的套接字.==它总会把所有产生事件的套接字都放到一个队列里面,然后通过这个队列,以有序,同步,每次一个套接字的方式向文件事件分派器传送套接字==.当一个套接字产生的事件被处理完之后,I/O多路复用程序才会继续床送下一个套接字.
文件事件分派器接收I/O多路复用程序传来的套接字,根据套接字产生的事件类型,调用相应的事件处理器.这些处理器就是一个个不同的函数,它们定义了事件发生时,服务器应该进行的动作.
2). I/O多路复用程序的实现
程序会在编译时通过宏定义自动选择系统中性能最高的I/O多路复用函数库来作为Redis的I/O多路复用程序的底层实现.
3). 事件类型
- 当套接字可读时,产生AE_READABLE事件
- 当套接字可写时,产生AE_WRITABLE事件
如果一个套架子同事产生这两种事件,也就是说可读又可写,服务器优先读套接字,然后才写.
4). 文件事件的处理器
Redis为文件事件编写了多个处理器,分别用于实现不用的网络通信需求:
1>. 连接应答处理器
用于对 连接服务器监听套接字的客户端 进行应答.也就是用于处理客户端连接服务端的请求
2>. 命令请求处理器
负责从套接字中读入客户端发送的命令请求内容.
3>. 命令回复处理器
负责将服务器执行命令后得到的命令通过套接字回复给客户端.
4>. 一次完整的客户端与服务端连接时间的实例
[图片上传失败...(image-ef7412-1590395205133)]
(2). 时间事件
Redis的时间事件分为以下两类:
- 定时时间:让一段程序在==指定的时间之后==执行一次
- 周期性时间:让一段程序==每隔一段时间==就执行一次
一个时间事件由一下三个属性组成:
- id:服务器会为时间事件创建全局唯一的ID,新的事件的ID比旧的事件的ID大
- when:毫秒精度的一个UNIX时间戳,记录了时间事件应该发生的时间戳
- timeProc:时间事件处理器,一个函数.当时间事件到达时,服务器会调用相应的处理器来处理时间
==一个时间事件是不是周期性的取决于时间事件处理器的返回值==:
- 返回AE_NOMORE,则表明以后不再重复这个时间事件
- 否则会根据时间事件处理器返回的值对when属性进行更改,从而得到下一次执行的时间
1). 实现
服务器将所有时间事件都放在一个==无序链表==(这里的无序是指不按when属性排序)中.==每当时间事件执行器运行时,它就遍历这个链表,调用其中所有到达时间的时间事件的处理器.==
一般情况下,服务器只会使用一个时间事件(serverCron),benchmark模式下也只有两个,所以不影响性能.
(3). 事件的调度与执行
服务器中同事存在着文件事件和时间事件,所以服务器西部队这两种事件进行调度,决定优先级和分配的时间等等.
大致的调度方式如下面的伪代码:
def main():
# 初始化服务器
init_server()
# 在服务器未被关闭的情况下一直循环
while server_is_not_shutdown():
# 处理事件
# 其中要先等待文件事件产生,然后在进行处理
# 然后在轮询时间事件是否到达,到达进行处理
aeProcessEvents()
clean_server()
因为时间事件的处理是在文件事件处理之后进行的,所以时间事件的实际处理时间通常会比设定的时间晚一些到达.
5. 客户端
Redis服务器是定性的一对多服务器程序:一个服务器可以与多个客户端建立网络连接,每个客户端可以向服务器发送命令请求,二服务器接受并处理客户端发送的命令请求,并向各个客户端返回命令回复.
对于每个与服务器进行连接的客户端,服务器都为这些客户端建立了相应的redisClient结构,其中的内容包括:客户端的套接字描述符, 名字, 标志值, 正在使用的数据库指针和号码, 当期那要执行的命令,参数以及需要调用的函数指针, 输入缓冲区和输出缓冲区, 复制状态信息, 事务状态, 发布与订阅相关数据结构, 身份验证标志, 创建时间, 最后一次通信时间等等....
Redis服务器状态中有一个链表存储了链接到这台服务器的所有客户端的信息
struct redisServer{
// .....
// 一个链表,存储了所连接的所有客户端
list *clients;
// .....
};
(1). 客户端属性
客户端属性一般分为两类:
- 一类是比较通用的属性,这种属性很少与特定的功能相关,比如名字,创建时间等等
- 另外一类是和特定功能相关的属性,如db属性和操作数据库相关
typedef struct redisClient{
// .....
// 套接字描述符
// 若为-1,则是伪客户端,处理的命令请求来自于AOF文件或者Lua脚本,不需要套接字连接
// 若为大于-1的整数,则是普通客户端,客户端通过这个整数代表的套接字与服务端进行通信
int fd;
// 客户端名字
// 默认情况下没有名字,为NULL
// 如果客户端通过CLIENT setname命令可以为当前正在使用的客户端设置名字,这时会指向一个string对象
robj *name;
// 标志
// 记录了客户端的角色以及目前所处的状态
// 标记可以使单个标记也可以是多个标记的二进制或,即不同的位负责不同的状态
// 可表示的信息有:主从复制中连接的是主或者从服务器,是否低于2.8版本,是否是处理Luau脚本的伪客户端等等
int falgs;
// 输入缓冲区
// 用于保存客户端发送的命令请求
// 格式就是REST协议定义的,Redis中所有的网络传输内容都是REST格式的
// 这个缓冲区的大小会根据输入内容动态的缩小或者扩大,但不超过1GB
sds querybuf;
// 命令和命令参数
// argc是argv数组的长度
// argv数组是string对象数组,每一个桶存储了命令中的一个单词
// 类似: | set | name | Benjamin |
int argc;
robj **argv;
// 命令的实现函数
// 这个指针是从命令表中找到的,命令表示一个字典,key为命令的名称,value为命令所对应的函数的封装结构体
// redisCommand结构体保存了命令的实现函数,标志,参数个数,总执行次数和总消耗时长等统计信息
// cmd指针会在找到这个结构体之后指向这里
// 然后就可以通过cmd指向的结构体和argv参数列表执行命令
struct redisCommand *cmd;
// 输出缓冲区
// 有三个输出缓冲
// 一个用于保存ok,数值等简短的字符串值
// 另一个用于返回信息,大小默认为16K
// 当16K不够用时,使用双端链表实现的可变大小缓冲
int bufpos;
char buf[16*1024];
list *reply;
// 身份验证
// 用于记录客户端是否通过了身份验证
// 0为未通过,1为通过
// 未通过验证的客户端,除了AUTH命令(身份验证命令)之外的所有命令都会被服务器拒绝执行
// 这个属性仅仅在服务器开启了身份验证功能时使用,如果没有启用,默认为0,服务端不拒绝命令
int authenticated;
// 时间
// 创建客户端时间
time_t ctime;
// 最后一次互动时间,可以用来计算空转时间
time_t lastinteration;
// 输出缓冲区第一次达到软性限制的时间
time_t obuf_soft_limit_reached_time;
// .....
};
(2). 客户端的创建与关闭
服务器通过不同的方式创建和关闭不同类型的客户端
1). 创建普通客户端
如果客户端是==通过网络连接与服务器进行连接==的普通客户端,那么客户端使用connect函数连接到服务器时,服务器调用连接事件处理器,为客户端创建相应的客户端状态,并添加至clients链表的末尾.
2). 关闭普通客户端
一个普通客户端可以因为多种原因被关闭:
- 客户端进程退出或者被杀死,网络连接关闭,从而客户端关闭
- 发送了不符合REST协议的请求
- 成为了CLIENT KILL命令的目标
- 空转时间超出设定值
- 命令请求大小超过输入缓冲区大小1 GB
- 发给客户端的回复命令请求超过了输出缓冲区的限制大小(硬件限制的大小和软性限制大小,有属性会记录第一次达到软性限制大小的时间,如果一致超过软性限制并且超出了服务器这顶的时长,就会关闭)
3). Lua脚本的伪客户端
服务器会在初始化时创建 负责执行Lua脚本中的Redis命令 的伪客户端.
会被关联在redisServer的lua_client属性中
struct redisServer{
// .....
redisClient *lua_client;
// .....
}
这个伪客户端会一直保存到服务器关闭
4). AOF文件的伪客户端
服务器载入AOF文件时,会创建 用于执行AOF文件包含的Redis命令 的伪客户端,载入完成之后关闭这个伪客户端.
6. 服务器
Redis服务器负责与多个客户端建立网络连接,处理多个客户端发送的命令请求,在数据库中保存客户端执行命令所产生的数据,并通过资源管理来维持服务器自身的运转.
(1). 命令请求的执行过程
- 客户端向服务端发送命令请求
- 服务器接受并处理客户端发来的命令请求,在数据库中进行相应的操作,并产生回复信息
- 服务端将命令回复发送给客户端
- 客户端接受服务端返回的命令回复,并将这个回复打印给用户看
1). 发送命令请求
Redis服务器的命令请求来自于Redis客户端,当用户在客户端键入一个命令请求时,客户端会将这个命令转化为REST协议格式,然后通过与服务器连接的套接字将转化出来的数据发送给服务器.
2). 读取命令请求
- 服务器读取套接字中REST协议格式的命令请求,保存到对应客户端结构体的输入缓冲中去
- 对输入缓冲中的数据进行分析,提取出命令中包含的参数,以及参数个数.保存到客户端结构体的参数字段
- 调用命令执行器执行指定的命令
3). 命令执行器(1):查找命令实现
命令执行器要做的第一件事是从命令表中查找参数数组0下标处,也就是命令名对应的函数,并将其保存到cmd字段
4). 命令执行器(2):执行预备操作
此时,服务器已经拥有了命令实现函数,命令的参数,命令的个数.但是还需要执行一些准备工作
- 检查客户端状态的cmd指针是否为null,为null说明未找到相应的函数,会返回一个错误
- 检查函数的参数数量是否和命令中的参数数量一样,如果不一样返回一个错误
- 检查客户端是否通过了身份验证
- 如果正在执行事务,那么只会执行控制事务的命令,其他命令会被放在事务队列中
5). 命令执行器(3):调用命令的实现函数
执行以下语句:
// client是指向客户端状态的指针,其中就包含了参数
client->cmd->proc(client)
被调用的命令实现函数会执行制定的操作,并产生相应的命令回复,保存在客户端状态的输出缓存中.
然后为这个客户端的套接字关联命令回复处理器,这个处理器负责将命令回复返回给客户端
6). 命令执行器(4):执行后续工作
- 如果服务器开启了慢查询日志功能,那么慢查询日志模块会检查是否需要为刚刚执行的命令添加一条慢查询日志
- 更新命令执行函数的总调用时长和调用次数
- 如果开启了AOF功能,把这条命令写入AOF缓冲区
- 如果有其他的服务器正在复制这个服务器,那么会把这个命令传播给所有从服务器
7). 将命令回复给客户端
在实现函数中已经将命令回复处理器和客户端套接字关联,这时==当客户端套接字状态变为可写时==,命令回复处理器就会==将保存在输出缓冲区的命令回复发送给客户端==.
发送完毕之后回复处理器就会==清空输出缓冲区==.
8). 客户端接受并打印命令回复
客户端收到的是REST协议格式的命令回复,将它转化为人可读的格式后,打印到屏幕上给用户看.
(2). serverCron函数
Redis服务中的serverCron函数默认每100毫秒执行一次,这个函数负责==管理服务器的资源,保存服务器的良好运转==.
下面展示了对redisServer中属性的维护:
struct redisServer{
// .....
// 系统时钟缓存
// serverCron函数中每次会更新这两个缓存,也就是说,Redis服务器中的时间是100毫秒更新一次的.
// 一般都是用这个时间缓存作为系统时间
// 但是对于为键设置过期时间,添加慢查询日志这种需要高精度时间的功能
// Redis还是会使用系统调用去获取最新的系统时间的.
// 保存了秒级精度的系统当前UNIX时间戳
time_t unixtime;
// 保存了毫秒级别的时间戳
time_t mstime;
// LRU时钟缓存
// 服务器状态中的lruclock和之前的两个属性一样,都是服务器时间缓存的一种
// 这个属性用于计算一个数据库键的空转时间.
// 服务器会使用lruclock减去robj的lru属性(最后一次被命令访问的时间).
// 该属性在serverCron函数中每10秒更新一次.
unsigned lruclock:22;
// 每秒命令执行次数
// 这个属性会被serverCron函数中的trackOperationsPerSecond函数每100毫秒更新一次
// 会以抽样计算的方式,估算并记录服务器在最近一秒钟处理的命令请求数量
long long ops_sec_last_sample_time;
// 记录服务器的内存峰值大小
// 每次serverCron执行时,程序都会查看当前使用的内存数量,判断并更新这个峰值属性
size_t stat_peak_memory;
// 关闭服务器的标识,1为关闭服务器,0不做动作
// 启动服务器时,Redis会为服务器进程的SIGNTERM信号关联处理器
// 每当服务器接到这个信号时,就会打印日志并设置服务器的shurdown_asap标识为1
// 然后serverCron函数运行时就会检查这个标识
// 如果为1,就做关闭服务器的准备(RDB持久化),然后关闭服务器
int shutdown_asap;
// BGREWRITEAOF延迟标记
// 之前提到过执行BGSAVE时遇到BGREWRITEAOF是会被延迟的
// 延迟就会将这个标记更新为1
// 在每一次serverCron中都会对这个标记进行检查
// 如果为1,那么进行BGREWRITEAOF(还是会检查BGSAVE和BGREWRITEAOF是都在执行,如果是,再次延迟)
int aof_rewrite_scheduled;
// 记录BGSAVE和BGREWRITEAOF的运行情况
// 在serverCron执行过程中,会检查这两个值,只要有一个不为-1
// 就会执行wait3函数,检查子进程是否有发信号到服务器进程
// 如果有,那么执行RDB或者AOF的后续操作(文件的替换)
// 如果都为-1,那么会检查是否有被延迟的BGREWRITEAOF
// 检查服务器的自动保存条件是否满足,如果满足,进行BGSAVE
// 检查AOF重写条件是否满足,如果满足并且没有其他的持久化操作,进行BGREWRITEAOF
// 执行BGSAVE的子进程的id,如果没在执行,为-1
pid_t rdb_chile_pid;
// 记录执行BGREWRITEAOF的子进程的id,如果没在执行,为-1
pid_t aof_chile_pid;
// serverCron函数的计数器
// 每执行一次,计数器加一
int cronloops;
// .....
};
其他的管理:
- 管理客户端资源:调用clientsCron函数,这个函数会对客户端进行连接超时检查和缓冲区大小检查(如果过大,重建)
- 管理数据库资源:也就是删除其中的过期键,并在有需要时对字典进行收缩
- AOF写入:每次serverCron都会对AOF写入的条件进行判断,如果满足进行响应的操作(写入和同步)
- 关闭异步客户端:客户端的输出缓存超出范围,会被关闭
(3). 初始化服务器
一个Redis服务器从启动到准备好接收客户端的命令请求,要经历一系列的初始化的设置
1). 初始化服务器状态结构
创建一个struct redisServer类型的实例变量,为结构中的各个属性设置初始值(服务器的运行id, 默认配置文件路径,默认服务器频率,服务器运行架构(机器的字长),默认端口号).
2). 载入配置选项
载入配置文件,根据其中的内容来修改服务器的默认配置
3). 初始化服务器数据结构
服务器状态还有其他数据结构的属性,如果server.clients链表,server.db数组等等.
在这里才配置的原因是,这些属性都需要根据配置来正确的初始化(避免重复调整).
除此之外,还会进行:为服务器设置进程信号处理器,创建共享对象,打开监听窗口,为serverCron函数创建时间事件,如果AOF功能打开,那么打开现有的AOF文件(不存在则新建),打印出Redis的开始问候语.
4). 还原数据库状态
- 如果服务器启用了,那么使用AOF文件来还原数据库状态.
- 如果没有启用,那么使用RDB文件来进行还原
5). 执行事件循环
打印出日志,来时执行服务器的事件循环.