0. redisServer 数据结构
1 | struct redisServer { |
1. 命令请求的执行过程
1.1 发送命令请求
Redis 服务器的命令请求来自 Redis 客户端,当用户在客户端中键入一个命令请求时,客户端会将这个命令请求转换成协议格式,然后通过连接到服务器的套接字,将协议格式的命令请求发送给服务器。
1.2 读取命令请求
当客户端与服务器之间的连接套接字因为客户端的写入而变得可读时,服务器将调用命令请求处理器来执行以下操作:
- 读取套接字中协议格式的命令请求,并将其保存到客户端状态的输入缓冲区里面。
- 对输入缓冲区中的命令请求进行分析,提取出命令请求中包含的命令参数,以及命令参数的个数,然后分别将参数和参数个数保存到客户端状态的
argv
属性和argc
属性里面。 - 调用命令执行器,执行客户端指定的命令。
1.3 命令执行器(1):查找命令实现
命令执行器要做的第一件事就是根据客户端状态的 argv[0]
参数,在命令表(command table)中查找参数所指定的命令,并将找到的命令保存到客户端状态的 cmd
属性里面。
命令表是一个字典,字典的键是一个个命令名字,比如 "set"
、"get"
、"del"
,等等;而字典的值则是一个个 redisCommand
结构,每个 redisCommand
结构记录了一个 Redis 命令的实现信息:
1 | struct redisCommand { |
属性名 | 类型 | 作用 |
---|---|---|
name |
char * |
命令的名字,比如 "set" 。 |
proc |
redisCommandProc * |
函数指针,指向命令的实现函数,比如 setCommand 。 redisCommandProc 类型的定义为 typedef void redisCommandProc(redisClient *c); 。 |
arity |
int |
命令参数的个数,用于检查命令请求的格式是否正确。如果这个值为负数 -N ,那么表示参数的数量大于等于 N 。 注意命令的名字本身也是一个参数,比如说 SET msg"hello world" 命令的参数是 "SET" 、"msg" 、"hello world" ,而不仅仅是 "msg" 和 "hello world" 。 |
sflags |
char * |
字符串形式的标识值,这个值记录了命令的属性,比如这个命令是写命令还是读命令,这个命令是否允许在载入数据时使用,这个命令是否允许在 Lua 脚本中使用,等等。 |
flags |
int |
对 sflags 标识进行分析得出的二进制标识,由程序自动生成。服务器对命令标识进行检查时使用的都是 flags 属性而不是 sflags 属性,因为对二进制标识的检查可以方便地通过 & 、^ 、~ 等操作来完成。 |
calls |
long long |
服务器总共执行了多少次这个命令。 |
milliseconds |
long long |
服务器执行这个命令所耗费的总时长。 |
flags
属性可以使用的标识符及意义:
标识 | 意义 | 带有这个标识的命令 |
---|---|---|
w |
这是一个写入命令,可能会修改数据库。 | SET、RPUSH、DEL,等等。 |
r |
这是一个只读命令,不会修改数据库。 | GET、STRLEN、EXISTS,等等。 |
m |
这个命令可能会占用大量内存,执行之前需要先检查服务器的内存使用情况,如果内存紧缺的话就禁止执行这个命令。 | SET、APPEND、RPUSH、 LPUSH、SADD、SINTERSTORE,等等。 |
a |
这是一个管理命令。 | SAVE、BGSAVE、SHUTDOWN,等等。 |
p |
这是一个发布与订阅功能方面的命令。 | PUBLISH、SUBSCRIBE、PUBSUB,等等。 |
s |
这个命令不可以在 Lua 脚本中使用。 | BRPOP、BLPOP、BRPOPLPUSH、SPOP,等等。 |
R |
这是一个随机命令,对于相同的数据集和相同的参数,命令返回的结果可能不同。 | SPOP、SRANDMEMBER、SSCAN、RANDOMKEY,等等。 |
S |
当在 Lua 脚本中使用这个命令时,对这个命令的输出结果进行一次排序,使得命令的结果有序。 | SINTER、SUNION、SDIFF、SMEMBERS、KEYS,等等。 |
l |
这个命令可以在服务器载入数据的过程中使用。 | INFO、SHUTDOWN、PUBLISH,等等。 |
t |
这是一个允许从服务器在带有过期数据时使用的命令。 | SLAVEOF、PING、INFO,等等。 |
M |
这个命令在监视器(monitor)模式下不会自动被传播(propagate)。 | EXEC |
1.4 命令执行器(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
四个命令, 其他命令都会被放进事务队列中。 - 如果服务器打开了监视器功能, 那么服务器会将要执行的命令和参数等信息发送给监视器。
当完成了以上预备操作之后, 服务器就可以开始真正执行命令了。
1.5 命令执行器(3):调用命令的实现函数
在前面的操作中,服务器已经将要执行命令的实现保存到了客户端状态的 cmd
属性里面,并将命令的参数和参数个数分别保存到了客户端状态的 argv
属性和 argc
属性里面,当服务器决定要执行命令时,它只要执行以下语句就可以了:
1 | // client 是指向客户端状态的指针 |
因为执行命令所需的实际参数都已经保存到客户端状态的 argv
属性里面了,所以命令的实现函数只需要一个指向客户端状态的指针作为参数即可。
被调用的命令实现函数会执行指定的操作,并产生相应的命令回复,这些回复会被保存在客户端状态的输出缓冲区里面(buf
属性和 reply
属性),之后实现函数还会为客户端的套接字关联命令回复处理器,这个处理器负责将命令回复返回给客户端。
1.6 命令执行器(4):执行后续工作
在执行完实现函数之后,服务器还需要执行一些后续工作:
- 如果服务器开启了慢查询日志功能,那么慢查询日志模块会检查是否需要为刚刚执行完的命令请求添加一条新的慢查询日志。
- 根据刚刚执行命令所耗费的时长,更新被执行命令的
redisCommand
结构的milliseconds
属性,并将命令的redisCommand
结构的calls
计数器的值增一。 - 如果服务器开启了 AOF 持久化功能,那么 AOF 持久化模块会将刚刚执行的命令请求写入到 AOF 缓冲区里面。
- 如果有其他从服务器正在复制当前这个服务器,那么服务器会将刚刚执行的命令传播给所有从服务器。
当以上操作都执行完了之后,服务器对于当前命令的执行到此就告一段落了,之后服务器就可以继续从文件事件处理器中取出并处理下一个命令请求了。
1.7 将命令回复发送给客户端
命令实现函数会将命令回复保存到客户端的输出缓冲区里面,并为客户端的套接字关联命令回复处理器,当客户端套接字变为可写状态时,服务器就会执行命令回复处理器,将保存在客户端输出缓冲区中的命令回复发送给客户端。
当命令回复发送完毕之后,回复处理器会清空客户端状态的输出缓冲区,为处理下一个命令请求做好准备。
1.8 客户端接收并打印命令回复
当客户端接收到协议格式的命令回复之后,它会将这些回复转换成人类可读的格式,并打印给用户观看。
2. serverCron 函数
2.1 更新服务器时间缓存
Redis 服务器中有不少功能需要获取系统的当前时间,而每次获取系统的当前时间都需要执行一次系统调用,为了减少系统调用的执行次数,服务器状态中的 unixtime
属性和 mstime
属性被用作当前时间的缓存。
由于 serverCron
函数默认会以每 100 毫秒一次的频率更新 unixtime
属性和 mstime
属性,所以这两个属性记录的时间精确度并不高:
- 服务器只会在打印日志、更新服务器的 LRU 时钟、决定是否执行持久化任务、计算服务器上线时间(uptime)这类对事件精确度要求不高的功能上 “使用 unixtime 属性和 mstime 属性”。
- 对于为键设置过期时间、添加慢查询日志这种需要高精度时间的功能来说,服务器还是会再次执行系统调用,从而获得最准确的系统当前时间。
2.2 更新 LRU 时钟
服务器状态中的 lruclock
属性保存了服务器的 LRU 时钟,也是服务器时间缓存的一种:
1 | struct redisServer{ |
每个 Redis 对象都会有一个 lru
属性,保存了对象最后一次被命令访问的时间:
1 | typedef struct redisObject { |
当服务器要计算一个数据库键的空转时间(也即是数据库键对应的值对象的空转时间),程序会用服务器的 lruclock
属性记录的时间减去对象的 lru
属性记录的时间,得出的计算结果就是这个对象的空转时间。
serverCron
函数默认以每 10 秒一次的频率更新 lruclock
属性的值,因为这个时钟不是实时的,所以根据这个属性计算出来的 LRU 时间实际上只是一个模糊的估算值。
lruclock
时钟的当前值可以通过 INFO server
命令的 lru_clock
域查看。
2.3 更新服务器每秒执行命令次数
serverCron
函数中的
trackOperationsPerSecond函数会以每 100ms 一次的频率执行,这个函数的功能是以抽样计算的方式,估算并记录服务器在最近一秒钟处理的命令请求数量。可以通过
INFO stats命令的
instantaneous_ops_per_sec ` 域查看。
2.4 更新服务器内存峰值记录
服务器状态中的 stat_peak_memory
属性记录了服务器的内存峰值大小:
1 | struct redisServer{ |
每次 serverCron
函数执行时,程序都会查看服务器当前使用的内存数量,并与 stat_peak_memory
保存的数值进行比较,如果当前使用的内存数量比 stat_peak_memory
属性记录的值要大,那么程序就将当前使用的内存数量记录到 stat_peak_memory
属性里面。
INFO memory
命令的 used_memory_peak
和 used_memory_peak_human
两个域分别以两种格式记录了服务器的内存峰值:
1 | redis>INFO memory |
2.5 处理SIGTERM信号
在启动服务器时,Redis 会为服务器进程的 SIGTERM
信号关联处理器 sigtermHandler
函数,这个信号处理器负责在服务器接到 SIGTERM
信号时,打开服务器状态的 shutdown_asap
标识:
1 | // SIGTERM信号的处理器 |
每次 serverCron
函数运行时,程序都会对服务器状态的 shutdown_asap
属性进行检查,并根据属性的值决定是否关闭服务器:
1 | struct redisServer{ |
服务器在关闭自身之前会进行 RDB 持久化操作,这也是服务器拦截 SIGTERM
信号的原因,如果服务器一接到 SIGTERM
信号就立即关闭,那么它就没办法执行持久化操作了。
2.6 管理客户端资源
serverCron
函数每次执行都会调用 clientsCron
函数,clientsCron
函数会对一定数量的客户端进行以下两种检查:
- 如果客户端和服务器之间的连接已经超时,那么程序释放这个客户端。
- 如果客户端在上一次执行命令请求后,输入缓冲区的大小超过了一定的长度,那么程序会释放客户端当前的输入缓冲区,并重新创建一个默认大小的输入缓冲区,从而防止客户端的输入缓冲区耗费了过多的内存。
2.7 管理数据库资源
serverCron
函数每次执行都会调用 databaseCron
函数,这个函数会对服务器中的一部分数据库进行检查,删除其中的过期键,并在有需要时,对字典进行收缩操作。
2.8 执行被延迟的 BGREWRITEAOF
服务器在执行 BGSAVE
期间,如果客户端向服务器请求了 BGREWRITEAOF
命令,那么服务器会将 BGREWRITEAOF
命令延迟到 BGSAVE
执行完毕之后执行。
服务器的 aof_rewrite_scheduled
标识记录了服务器是否延迟了 BGREWRITEAOF
命令:
1 | struct redisServer{ |
每次 serverCron
函数执行时,函数都会检查 BGSAVE
命令或者 BGREWRITEAOF
命令是否正在执行,如果这两个命令都没在执行,并且 aof_rewrite_scheduled
属性的值为 1,那么服务器就会执行之前被推延的 BGREWRITEAOF
命令。
2.9 检查持久化操作的运行状态
服务器状态用 rdb_child_pid
和 aof_child_pid
属性记录了执行 BGSAVE
命令和 BGREWRITEAOF
命令的子进程的 ID,这两个属性可以用于检查这两个命令是否正在执行:
1 | struct redisServer{ |
serverCron
函数每次执行都会检查 rdb_child_pid
和 aof_child_pid
两个属性的值,只要其中一个属性的值不为 -1,程序会执行一次 wait3
函数,检查子进程是否有信号发来服务器进程:
- 如果有信号到达,那么表示新的 RDB 文件已经生成完毕(对于
BGSAVE
命令来说)或者 AOF 文件已经重写完毕(对于BGREWRITEAOF
命令来说),服务器需要进行相应命令的后续操作,比如用新的 RDB 文件替换现有的 RDB 文件,或者用重写后的 AOF 文件替换现有的 AOF 文件。 - 如果没有信号到达,那么表示持久化操作未完成,程序不做动作。
另一方面,如果 rdb_child_pid
和 aof_child_pid
两个属性的值都为 -1,那么表示服务器没有在进行持久化操作,在这种情况下,程序执行以下三个检查:
- 查看是否有
BGREWRITEAOF
被延迟了,如果有的话,那么开始一次新的BGREWRITEAOF
操作。 - 检查服务器自动保存条件是否已经被满足,如果条件满足,并且服务器没有在执行其他持久化操作,那么服务器开始一次新的
BGSAVE
操作(因为条件 1 可能会引发一次BGREWRITEAOF
,所以在这个检查中,程序会再次确认服务器是否已经在执行持久化操作了)。 - 检查服务器设置的 AOF 重写条件是否满足,如果条件满足,并且服务器没有在执行其他持久化操作,那么服务器将开始一次新的
BGREWRITEAOF
操作(因为条件 1 和条件 2 都可能会引起新的持久化操作,所以在这个检查中,我们要再次确认服务器是否已经在执行持久化操作了)。
2.10 将 AOF 缓冲区中的内容写入 AOF 文件
如果服务器开启了 AOF 持久化功能,并且 AOF 缓冲区里面还有待写入的数据,那么 serverCron
函数会调用相应的程序,将 AOF 缓冲区中的内容写入到 AOF 文件里面。
2.11 关闭异步客户端
服务器会关闭那些输出缓冲区大小超出限制的客户端。
2.12 增加 cronloops 计数器的值
服务器状态的 cronloops
属性记录了 serverCron
函数执行的次数:
1 | struct redisServer { |
cronloops
属性目前在服务器中的唯一作用,就是在复制模块中实现 “每执行 serverCron 函数 N 次就执行一次指定代码” 的功能。
3. 初始化服务器
3.1 初始化服务器状态结构
初始化服务器的第一步就是创建一个 struct redisServer
类型的实例变量 server 作为服务器的状态,并为结构中的各个属性设置默认值。初始化 server 变量的工作由 redis.c/initServerConfig
函数完成。
initServerConfig
函数中,大部分是对 server 的属性设置默认值,还有一部分是调用 populateCommandTable
函数对 Redis 的命令表初始化。全局变量 redisCommandTable
是 redisCommand
类型的数组,保存 Redis 支持的所有命令。server.commands
是一个 dict
,保存命令名到 redisCommand
的映射。populateCommandTable
函数会遍历全局 redisCommandTable
表,把每条命令插入到 server.commands
中,根据每个命令的属性设置其 flags
。以下是这个函数的部分代码:
1 | void initServerConfig(void) { |
以下是 initServerConfig
函数完成的主要工作:
- 设置服务器的运行 ID。
- 设置服务器的默认运行频率。
- 设置服务器的默认配置文件路径。
- 设置服务器的运行架构。
- 设置服务器的默认端口号。
- 设置服务器的默认 RDB 持久化条件和 AOF 持久化条件。
- 初始化服务器的 LRU 时钟。
- 创建命令表。
initServerConfig
函数设置的服务器状态属性基本都是一些整数、浮点数、或者字符串属性,除了命令表之外,initServerConfig
函数没有创建服务器状态的其他数据结构,数据库、慢查询日志、Lua 环境、共享对象这些数据结构在之后的步骤才会被创建出来。
3.2 载入配置选项
在启动服务器时,用户可以通过给定配置参数或者指定配置文件来修改服务器的默认配置。服务器在用 initServerConfig
函数初始化完 server 变量之后,就会开始载入用户给定的配置参数和配置文件,并根据用户设定的配置,对 server 变量相关属性的值进行修改。
- 如果用户为这些属性的相应选项指定了新的值,那么服务器就使用用户指定的值来更新相应的属性。
- 如果用户没有为属性的相应选项设置新的值,那么服务器就沿用之前
initServerConfig
函数为属性设置的默认值。
3.3 初始化服务器数据结构
在之前执行 initServerConfig
函数初始化 server 状态时,程序只创建了命令表一个数据结构,不过除了命令表之外,服务器状态还包含其他数据结构,比如:
server.clients
链表,这个链表记录了所有与服务器相连的客户端的状态结构,链表的每一个节点都包含一个redisClient
结构实例。server.db
数组,数组中包含了服务器的所有数据库。- 用于保存频道订阅信息的
server.pubsub_channels
字典,以及用于保存模式订阅信息的server.pubsub_patterns
链表。 - 用于执行 Lua 脚本的 Lua 环境
server.lua
。 - 用于保存慢查询日志的
server.slowlog
属性。
当初始化服务器进行到这一步,服务器将调用initServer函数,为以上提到的数据结构分配内存,并在有需要时,为这些数据结构设置或关联初始化值。
服务器到现在才初始化数据结构的原因在于,服务器必须先载入用户指定的配置选项,然后才能正确地对数据结构进行初始化。如果在执行 initServerConfig
函数时就对数据结构进行初始化,那么一旦用户通过配置选项修改了和数据结构有关的服务器状态属性,服务器就要重新调整和修改已创建的数据结构。为了避免出现这种麻烦的情况,服务器选择了将 server 状态的初始化分为两步进行,initServerConfig
函数主要负责初始化一般属性,而 initServer
函数主要负责初始化数据结构。
除了初始化数据结构之外,initServer
还进行了一些非常重要的设置操作:
- 为服务器设置进程信号处理器。
- 创建共享对象,这些对象包含 Redis 服务器经常用到的一些值,服务器通过重用这些共享对象来避免反复创建相同的对象。
- 打开服务器的监听端口,并为监听套接字关联连接应答事件处理器,等待服务器正式运行时接受客户端的连接。
- 为
serverCron
函数创建时间事件,等待服务器正式运行时执行serverCron
函数。 - 如果 AOF 持久化功能已经打开,那么打开现有的 AOF 文件,如果 AOF 文件不存在,那么创建并打开一个新的 AOF 文件,为 AOF 写入做好准备。
- 初始化服务器后台 I/O 模块(bio),为将来的 I/O 操作做准备。
当 initServer
函数执行完毕之后,服务器将用 ASCII 字符在日志中打印出 Redis 的图标,以及 Redis 的版本号信息。
3.4 还原数据库状态
在完成了对服务器状态 server 变量的初始化之后,服务器需要载入 RDB 文件或者 AOF 文件,并根据文件记录的内容来还原服务器的数据库状态。
根据服务器是否启用了 AOF 持久化功能,服务器载入数据时所使用的目标文件会有所不同:
- 如果服务器启用了 AOF 持久化功能,那么服务器使用 AOF 文件来还原数据库状态;
- 相反地,如果服务器没有启用 AOF 持久化功能,那么服务器使用 RDB 文件来还原数据库状态。
当服务器完成数据库状态还原工作之后,服务器将在日志中打印出载入文件并还原数据库状态所耗费的时长:
1 | [5244] 21 Nov 22:43:49.084 * DB loaded from disk: 0.068 seconds |
3.5 执行事件循环
在初始化的最后一步,服务器将打印出以下日志:
1 | [5244] 21 Nov 22:43:49.084 * The server is now ready to accept connections on port 6379 |
并开始执行服务器的事件循环。
至此,服务器的初始化工作圆满完成,服务器现在开始可以接受客户端的连接请求,并处理客户端发来的命令请求了。