Redis设计与实现-单机数据库的实现-服务器
服务器
命令请求的执行流程
一个命令请求从发送到获得回复的过程中, 客户端和服务器需要完成一系列操作。
举个例子, 如果我们使用客户端执行以下命令:
redis> SET KEY VALUE
OK
那么从客户端发送 SET KEY VALUE 命令到获得回复 OK 期间, 客户端和服务器共需要执行以下操作:
-
客户端向服务器发送命令请求 SET KEY VALUE 。
-
服务器接收并处理客户端发来的命令请求 SET KEY VALUE , 在数据库中进行设置操作, 并产生命令回复 OK 。
- 客户端将命令转化为协议格式并发送命令到套接字
- 服务器从套接字读取保存到缓冲区
- 服务器分析缓冲区的命令,进行拆分保存到客户端状态的 argv 属性(参数数组)和 argc 属性(参数个数)里面。
- 用argv[0]去命令表中查找对应命令
- 以查找命令集中的属性与指针指向函数和参数进行命令执行
- 将返回值输出
-
服务器将命令回复 OK 发送给客户端。
-
客户端接收服务器返回的命令回复 OK , 并将这个回复打印给用户观看。
发送命令请求
Redis 服务器的命令请求来自 Redis 客户端, 当用户在客户端中键入一个命令请求时, 客户端会将这个命令请求转换成协议格式, 然后通过连接到服务器的套接字, 将协议格式的命令请求发送给服务器。
举个例子, 假设客户端执行命令:
SET KEY VALUE
那么客户端会将这个命令转换成协议:
*3\r\n$3\r\nSET\r\n$3\r\nKEY\r\n$5\r\nVALUE\r\n
然后将这段协议内容发送给服务器。
读取命令请求
当客户端与服务器之间的连接套接字因为客户端的写入而变得可读时, 服务器将调用命令请求处理器来执行以下操作:
-
读取套接字中协议格式的命令请求, 并将其保存到客户端状态的输入缓冲区里面。
-
对输入缓冲区中的命令请求进行分析, 提取出命令请求中包含的命令参数, 以及命令参数的个数, 然后分别将参数和参数个数保存到客户端状态的 argv 属性和 argc 属性里面。
-
调用命令执行器, 执行客户端指定的命令。 继续用上一个小节的 SET 命令为例子, 下图展示了程序将命令请求保存到客户端状态的输入缓冲区之后, 客户端状态的样子。
之后, 分析程序将对输入缓冲区中的协议, 并将得出的分析结果保存到客户端状态的 argv 属性和 argc 属性里面, 如图 。
命令执行器(1):查找命令实现
命令执行器要做的第一件事就是根据客户端状态的 argv[0] 参数, 在命令表(command table)中查找参数所指定的命令, 并将找到的命令保存到客户端状态的 cmd 属性里面。
命令表是一个字典, 字典的键是一个个命令名字,比如 “set” 、 “get” 、 “del” ,等等; 而字典的值则是一个个 redisCommand 结构, 每个 redisCommand 结构记录了一个 Redis 命令的实现信息。 redisCommand 结构的主要属性
下表sflags 属性可以使用的标识值, 以及这些标识的意义。
以 SET 命令和 GET 命令作为例子, 展示了 redisCommand 结构:
-
SET 命令的名字为 “set” , 实现函数为 setCommand ; 命令的参数个数为 -3 , 表示命令接受三个或以上数量的参数; 命令的标识为 “wm” , 表示 SET 命令是一个写入命令, 并且在执行这个命令之前, 服务器应该对占用内存状况进行检查, 因为这个命令可能会占用大量内存。
-
GET 命令的名字为 “get” , 实现函数为 getCommand 函数; 命令的参数个数为 2 , 表示命令只接受两个参数; 命令的标识为 “r” , 表示这是一个只读命令。
继续之前 SET 命令的例子, 当程序 argv[0] 作为输入, 在命令表中进行查找时, 命令表将返回 “set” 键所对应的 redisCommand 结构, 客户端状态的 cmd 指针会指向这个 redisCommand 结构, 如图
命令名字的大小写不影响命令表的查找结果
因为命令表使用的是大小写无关的查找算法, 无论输入的命令名字是大写、小写或者混合大小写, 只要命令的名字是正确的, 就能找到相应的 redisCommand 结构。
比如说, 无论用户输入的命令名字是 “SET” 、 “set” 、 “SeT” 又或者 “sEt” , 命令表返回的都是同一个 redisCommand 结构。
这也是 Redis 客户端可以发送不同大小写的命令, 并且获得相同执行结果的原因
命令执行器(2):执行预备操作
到目前为止, 服务器已经将执行命令所需的命令实现函数(保存在客户端状态的 cmd 属性)、参数(保存在客户端状态的 argv 属性)、参数个数(保存在客户端状态的 argc 属性)都收集齐了, 但是在真正执行命令之前, 程序还需要进行一些预备操作, 从而确保命令可以正确、顺利地被执行, 这些操作包括:
-
检查客户端状态的 cmd 指针是否指向 NULL , 如果是的话, 那么说明用户输入的命令名字找不到相应的命令实现, 服务器不再执行后续步骤, 并向客户端返回一个错误。
-
根据客户端 cmd 属性指向的 redisCommand 结构的 arity 属性, 检查命令请求所给定的参数个数是否正确, 当参数个数不正确时, 不再执行后续步骤, 直接向客户端返回一个错误。 比如说, 如果 redisCommand 结构的 arity 属性的值为 -3 , 那么用户输入的命令参数个数必须大于等于 3 个才行。
-
检查客户端是否已经通过了身份验证, 未通过身份验证的客户端只能执行 AUTH 命令, 如果未通过身份验证的客户端试图执行除 AUTH 命令之外的其他命令, 那么服务器将向客户端返回一个错误。
-
如果服务器打开了 maxmemory 功能, 那么在执行命令之前, 先检查服务器的内存占用情况, 并在有需要时进行内存回收, 从而使得接下来的命令可以顺利执行。 如果内存回收失败, 那么不再执行后续步骤, 向客户端返回一个错误。
-
如果服务器上一次执行 BGSAVE 命令时出错, 并且服务器打开了 stop-writes-on-bgsave-error 功能, 而且服务器即将要执行的命令是一个写命令, 那么服务器将拒绝执行这个命令, 并向客户端返回一个错误。
-
如果客户端当前正在用 SUBSCRIBE 命令订阅频道, 或者正在用 PSUBSCRIBE 命令订阅模式, 那么服务器只会执行客户端发来的 SUBSCRIBE 、 PSUBSCRIBE 、 UNSUBSCRIBE 、 PUNSUBSCRIBE 四个命令, 其他别的命令都会被服务器拒绝。
-
如果服务器正在进行数据载入, 那么客户端发送的命令必须带有 l 标识(比如 INFO 、 SHUTDOWN 、 PUBLISH ,等等)才会被服务器执行, 其他别的命令都会被服务器拒绝。
-
如果服务器因为执行 Lua 脚本而超时并进入阻塞状态, 那么服务器只会执行客户端发来的 SHUTDOWN nosave 命令和 SCRIPT KILL 命令, 其他别的命令都会被服务器拒绝。
-
如果客户端正在执行事务, 那么服务器只会执行客户端发来的 EXEC 、 DISCARD 、 MULTI 、 WATCH 四个命令, 其他命令都会被放进事务队列中。
-
如果服务器打开了监视器功能, 那么服务器会将要执行的命令和参数等信息发送给监视器。 当完成了以上预备操作之后, 服务器就可以开始真正执行命令了。
注意 以上只列出了服务器在单机模式下执行命令时的检查操作, 当服务器在复制或者集群模式下执行命令时, 预备操作还会更多一些。
命令执行器(3):调用命令的实现函数
在前面的操作中, 服务器已经将要执行命令的实现保存到了客户端状态的 cmd 属性里面, 并将命令的参数和参数个数分别保存到了客户端状态的 argv 属性和 argc 属性里面, 当服务器决定要执行命令时, 它只要执行以下语句就可以了:
// client 是指向客户端状态的指针
client->cmd->proc(client);
因为执行命令所需的实际参数都已经保存到客户端状态的 argv 属性里面了, 所以命令的实现函数只需要一个指向客户端状态的指针作为参数即可。
继续以之前的 SET 命令为例子, 下图展示了客户端包含了命令实现、参数和参数个数的样子。
对于这个例子来说, 执行语句:
client->cmd->proc(client);
等于执行语句:
setCommand(client);
被调用的命令实现函数会执行指定的操作, 并产生相应的命令回复, 这些回复会被保存在客户端状态的输出缓冲区里面(buf 属性和 reply 属性), 之后实现函数还会为客户端的套接字关联命令回复处理器, 这个处理器负责将命令回复返回给客户端。
命令执行器(4):执行后续工作
在执行完实现函数之后, 服务器还需要执行一些后续工作:
-
如果服务器开启了慢查询日志功能, 那么慢查询日志模块会检查是否需要为刚刚执行完的命令请求添加一条新的慢查询日志。
-
根据刚刚执行命令所耗费的时长, 更新被执行命令的 redisCommand 结构的 milliseconds 属性, 并将命令的 redisCommand 结构的 calls 计数器的值增一。
-
如果服务器开启了 AOF 持久化功能, 那么 AOF 持久化模块会将刚刚执行的命令请求写入到 AOF 缓冲区里面。
-
如果有其他从服务器正在复制当前这个服务器, 那么服务器会将刚刚执行的命令传播给所有从服务器。
-
当以上操作都执行完了之后, 服务器对于当前命令的执行到此就告一段落了, 之后服务器就可以继续从文件事件处理器中取出并处理下一个命令请求了。
将命令回复发送给客户端
前面说过, 命令实现函数会将命令回复保存到客户端的输出缓冲区里面, 并为客户端的套接字关联命令回复处理器, 当客户端套接字变为可写状态时, 服务器就会执行命令回复处理器, 将保存在客户端输出缓冲区中的命令回复发送给客户端。
当命令回复发送完毕之后, 回复处理器会清空客户端状态的输出缓冲区, 为处理下一个命令请求做好准备。
serverCron函数
Redis服务器中的serverCron函数每隔100毫秒就执行一次,这个函数负责管理服务器的资源,并保存服务器自身的良好运转
更新服务器时间缓存
Redis服务器总有许多功能需要获取系统的当前时间,为了减少系统调用的次数,服务器状态也中的unixtime属性和mstime属性被用作当前时间的缓存
struct redisServer{
//...
//保存了秒级精度的系统当前UNIX时间戳
time_t unixtime;
//保存了毫秒级精度的系统当前UNIX时间戳
long long mstime;
//...
}
serverCron每隔100毫秒就更新一次unixtime属性和mstime属性,所以这两个属性的精确度并不高
-
对于像打印日志,更新服务器的LRU时钟,决定是否执行持久化任务,计算服务器上线时间这类对时间精确度要求不高的功能上,使用unixtime和mstime。
-
对于像给键设置过期时间,添加慢查询日志这种需要高精度时间的功能来说,服务器会选择去进行系统调取,获得最准确的系统当前时间。
更新LRU时钟
服务器状态中的lruclock属性保存了服务器的LRU时钟,这个属性和上面介绍的两种时间属性一样都是服务器时间缓存的一种
struct redisServer{
//...
//默认每十秒更新一次的时间缓存
//用于计算键的空转时间
unsigned lruclock:22;
//...
}
每个Redis对象都有一个lru属性,这个lru属性保存对象最后一次被命令访问的时间
typedef struct redisObject{
//...
unsigned lruclock:22;
//...
}robj;
当服务器需要知道一个键的空转时间的时候,就会用服务器的lruclock属性去减对应对象的lru属性,得到的差值就是空转时间(由于lruclock每隔十秒更新一次,所以得到的只是应该模糊的估算值)。
更新服务器每秒执行的命令次数
serverCron函数中的trackoperationPerSecond函数已每100毫秒一次的频率执行,这个函数以抽样计算的方式估算并记录服务器在最近一秒钟处理的命令请求数量
INFO status命令的instantaneous_ops_per_sec域可以查看这个值
trackoperationPerSecond函数与redisServer结构中的四个以ops_sec_开头的属性有关
struct redisServer{
//...
//上一次进行服务器每秒执行命令数量抽样的时间
long long ops_sec_last_sample_time;
//上一次进行服务器每秒执行命令数量抽样时,服务器已执行命令的数量
long long ops_sec_last_sample_ops;
//环形数组,每个元素记录一次服务器每秒执行命令数量抽样结果,估算服务器在最近一秒钟处理的命令请求数量(数组长度默认为16,100毫秒更新一次)
long long ops_sec_samples[REDIS_OPS_SEC_SAMPLE];
//ops_sec_samples数组的索引值,每次抽样后值增1,等于16时重置为0
int ops_sec_idx;
}
trackoperationPerSecond函数每次运行,都会用当前时间减去上次抽样的时间(ops_sec_last_sample_time)相减得到一个间隔时间,然后当前已经执行命令数量与上次执行命令数量(ops_sec_last_sample_ops)相减的到这个间隔时间内执行的命令数量,用这两个数计算出平均每毫秒执行的命令数量,将这个值乘以1000得到每秒执行命令的数量。这个估计值会被计入环形数组。
在INFO status命令中的值是取环形数组中所有数的平均值。
更新服务器内存峰值记录
struct redisServer{
//...
size_t stat_peak_memory;
//...
}
stat_peak_memory属性记录服务器的内存峰值大小
每次执行serverCron函数,程序都会查看服务器当前使用的内存数量,并与stat_peak_memory进行比较,保存较大的那个数。
处理SIGTERM信号
在启动服务器是,Redis会为服务器进程的SIGTERM信号关联处理器sigtermHandler函数,这个信号处理器负责在服务器接到SIGTERM信号时,打开服务器状态的shutdown_asap标识。
每次serverCron函数执行时,都会检查服务器状态的shutdown_asap标识,并由此决定要不要关闭服务器
struct redisServer{
//...
// 关闭服务器的标识,1为关,0为不关。
int shutdwon_asap;
//...
}
服务器在关闭自身之前会进行RDB持久化操作,这也是SIGTERM信号的原因,如果服务器接到信号就立刻关闭,就没办法执行持久化了。
管理客户端资源
serverCron函数每次执行都会调用clientsCron函数,clientsCron函数会对一定数量的客户端做以下工作
- 如果客户端与服务器之间的连接已经超时(长时间未互动),那么程序会释放这个客户端
- 如果客户端在上一次执行命令请求以后,输入缓冲区超过一定长度,那么程序会释放客户端当前的输入缓冲区,并重新创建一个默认大小的输入缓冲区,防止客户端的输入缓冲区耗费过多内存
管理数据库资源
serverCron函数每次都会调用databasesCron函数,该函数会对一部分数据库进行检查,删除其中的过期键,并在有需要时,对字典执行收缩操作。
执行被延迟的BGREWRITEAOF
若客户端发送BGREWRITEAOF命令时,正在执行BGSAVE命令,则BGREWRITEAOF命令会被延迟到BGSAVE命令之后。
若有BGREWRITEAOF命令来时被延迟,那么redisServer结构中的aof_rewrite_scheduled属性会被置1
每次serverCron函数执行时都会检测BGREWRITEAOF命令,BGSAVE命令是不是正在执行,若没在执行且aof_rewrite_scheduled属性为1,则会执行被延迟的BGREWRITEAOF命令
检测持久化操作的运行状态
服务器状态使用rdb_child_pid和aof_child_pid两个属性记录BGSAVE命令和BGWRITEAOF命令的子进程的ID,若这两个命令没有在执行,那么对应属性的值为-1,这两个属性用于检测BGSAVE命令和BGWRITEAOF命令是否正在执行。
在执行serverCron函数时,程序会检查这两个值
- 若有值不为-1程序执行wait3函数,检查是否有持久化工作完成,是否有信号发来服务器
- 如果有信号到达,那么表示新的RDB文件以及生成完毕或者有AOF重写完毕,服务器会执行后续工作,比如用新的文件代替旧的文件
- 若无信号到达,说明没有持久化工作完成,程序不做动作
- 若两个值都为-1,说明没有持久化工作在进行,此时,程序会做一下三个检查
- 查看是否有BGREWRITEAOF命令被延迟了,若有,就开始一次新的重写
- 检查自动保存条件是否满足,若满足,执行BGAVE命令,进行一次新的持久化操作
- 检查AOF重写条件是否满足,满足就执行BGREWRITEAOF命令,进行重写
将AOF缓冲区的内容写入AOF文件
若开启了AOF持久化功能,并且在AOF缓冲区中有待写入的数据,那么serverCron函数会调用相应的程序,将AOF缓冲区中的内容写入到AOF文件中
关闭异步客户端
关闭输入缓冲区超出大小限制的客户端
增加cronloops计数器的值
服务器状态(redisServer)的cronloops属性记录了serverCron函数执行的次数,每执行一次serverCron函数,这个值就会加1。 这个值的作用在于没执行N次serverCron函数就执行某个操作时使用。
初始化服务器
Redis服务器的初始化要经历多个步骤,如初始化服务器状态,接受用户指定服务器配置,创建相应的数据结构和网络连接等。
初始化服务器状态结构
服务器第一步会创建一个struct redisServer类型的实例变量server作为服务器的状态
初始化server的工作由redis.s/initServerConfig函数完成,它为各个属性设置默认值,工作内容如下:
- 设置服务器的运行ID
- 设置服务器的默认运行频率
- 设置服务器的默认配置文件路径
- 设置服务器的运行架构
- 设置服务器的默认端口号
- 设置服务器的默认RDB持久化条件和AOF持久化条件
- 初始化服务器的LRU时钟
- 创建命令表 initServerConfig函数设置的服务器状态属性基本都是一些整数和浮点数,或者字符串。
initServerConfig函数没有创建服务器状态的其他数据结构,数据库,慢查询日志,Lua环境,共享对象,这些数据结构在之后才会被创建。
载入配置选项
在启动时,用户可以使用指定的配置文件(conf文件),通过给定的配置参数或者指定配置文件来修改服务器的默认配置。
在加载配置文件时,若用户定义的配置不一样,会修改默认的数值。
初始化服务器数据结构
initServerConfig函数初始化server状态时,程序只创建了命令表一个数据结构,不过除了命令表之外,服务器状态还包含其他的数据结构,如客户端链表、数据库数组、保存频道订阅信息的字典、保存模式订阅信息的链表、执行Lua脚本的Lua环境,保存慢查询日志的server.slowlog属性。
到这一步时,服务器会调用initServer函数,为以上提到的数据结构分配内存,并在有需要时为其设置关联初始化值,最后为这些数据结构进行初始化的原因在于,需要先加载配置中和数据结构有关的服务器状态属性。如果后加载这些配置,一旦修改了这些配置,就需要重新调整和修改已创建的数据结构,所以初始化分了两步工作:
- 先让initServerConfig函数初始化一些属性
- initServer根据加载属性中与数据结构有关的属性来初始化数据结构
initServer还会进行一些其他设置操作
- 为服务器设置进程信号处理器
- 创建共享对象(回复使用的字符串对象“OK”,“ERR”;1-10000的字符串对象等)
- 打开服务器的监听端口,监听套接字,保证与客户端的连接
- 为serverCron创建时间事件
- 若AOF文件持久化功能打开。那么打开现有的AOF文件,如果AOF文件不存在,那么打开一个新的AOF文件
- 初始化后台I/O模块
initServer函数执行完以后,服务器将用ASCII字符在日志中打印Redis的图标和版本号信息
还原数据库状态
在完成对服务器状态server变量的初始化后,服务器需要载入RDB文件或者AOF文件,还原数据库状态。
若开启了AOF持久化功能就使用AOF文件来还原,未开启就使用RDB文件来还原
执行时间循环
初始化最后一步,打印完成日志,并开始服务器的事件循环,初始化工作完成,开始接受客户端连接请求,并处理客户端的命令
总结
服务端处理命令步骤
从客户端发送SET KEY VALUE命令到获得回复OK期间,客户端和服务器共需要执行以下操作:
-
客户端向服务器发送命令请求SET KEY VALUE. 客户端会将这个命令请求转换成协议格式,然后通过连接到服务器的套接字,将协议格式的命令请求发送给服务器.
-
服务器接收并处理客户端发来的命令请求SET KEY VALUE,在数据库中进行设置操作,并产生命令回复OK.
当客户端与服务器之间的连接套接字因为客户端的写入而变得可读时,服务器将调用命令请求处理器来执行以下操作:
1)读取套接字中协议格式的命令请求,并将其保存到客户端状态的输入缓冲区里面.
2)对输入缓冲区中的命令请求进行分析,提取出命令请求中包含的命令参数,以及命令参数的个数,然后分别将参数和参数个数保存到客户端状态的argv属性和argc属性里面.
3)调用命令执行器,执行客户端指定的命令. [1]在命令表(command table)中查找参数所指定的命令. [2]
-
服务器将命令回复OK发送给客户端.
-
客户端接收服务器返回的命令回复OK,并将这个回复打印给用户观看.
一个命令请求从发送到完成主要包括以下步骤:
-
客户端将命令请求发送给服务器;
-
服务器读取命令请求,并分析出命令参数;
-
命令执行器根据参数查找命令的实现函数,然后执行实现函数并得出命令回复;
-
服务器将命令回复返回给客户端.
serverCron函数默认每隔100毫秒执行一次,它的工作主要包括更新服务器状态信息,处理服务器接收的SIGTERM信号,管理客户端资源和数据库状态,检查并执行持久化操作等等.
服务器从启动到能够处理客户端的命令请求需要执行以下步骤:
-
初始化服务器状态
-
载入服务器配置
-
初始化服务器数据结构
-
还原数据库状态
-
如果服务器启用了AOF持久化功能,那么服务器使用AOF文件来还原数据库状态.
-
相反地,如果服务器没有启用AOF持久化功能,那么服务器使用RDB文件来还原数据库状态
-
-
执行事件循环
转载自:https://juejin.cn/post/7072632394045456421