Redis 服务器
上一节已经说过 redis 客户端在 redis 服务器的一些属性,并且客户端在跟服务端交互时这些属性起到的作用。
那这一节站在服务器的角度,看看在一次命令请求中,服务器中各个组件是怎么交互的,服务器又是怎么启动起来的。
命令请求的执行过程
一个命令请求从发送到获得回复的过程中, 客户端和服务器需要完成一系列操作,举个简单的例子
那么从客户端发送 set name john 命令到获得回复 OK
期间, 客户端和服务器共需要执行以下操作:
- 客户端向服务器发送命令请求 set name john 。
- 服务器接收并处理客户端发来的命令请求 set name john , 在数据库中进行设置操作, 并产生命令回复
OK
。 - 服务器将命令回复
OK
发送给客户端。 - 客户端接收服务器返回的命令回复
OK
, 并将这个回复打印给用户观看。
那接下来针对上述这些步骤,探索服务器到底做了什么。
发送命令请求
当用户在客户端中输入一个命令请求时, 客户端会将这个命令请求转换成协议格式, 然后通过连接到服务器的套接字, 将协议格式的命令请求发送给服务器。
读取命令请求
当客户端与服务器之间的连接套接字因为客户端的写入而变得可读时, 服务器将调用命令请求处理器来执行以下操作:
- 读取套接字中协议格式的命令请求, 并将其保存到客户端状态的输入缓冲区里面。
- 对输入缓冲区中的命令请求进行分析, 提取出命令请求中包含的命令参数, 以及命令参数的个数, 然后分别将参数和参数个数保存到客户端状态的
argv
属性和argc
属性里面。 - 调用命令执行器, 执行客户端指定的命令。
这个步骤已经在上一节提到过,所以这里不做补充。
命令执行器,查找命令实现
命令执行器要做的第一件事就是根据客户端状态的 argv[0]
参数, 在命令表(command table)中查找参数所指定的命令, 并将找到的命令保存到客户端状态的 cmd
属性里面。
命令表是一个字典, 字典的键是一个个命令名字,比如 "set"
、 "get"
、 "del"
,等等。而字典的值则是一个个 redisCommand
结构, 每个 redisCommand
结构记录了一个 Redis 命令的实现信息:
属性名 | 类型 | 作用 |
---|---|---|
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 |
服务器执行这个命令所耗费的总时长。 |
sflags
属性的标识:
标识 | 意义 | 带有这个标识的命令 |
---|---|---|
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 |
命令表在内存中的结构为:
- SET 命令的名字为
"set"
, 实现函数为setCommand
; 命令的参数个数为-3
, 表示命令接受三个或以上数量的参数; 命令的标识为"wm"
, 表示 SET 命令是一个写入命令, 并且在执行这个命令之前, 服务器应该对占用内存状况进行检查, 因为这个命令可能会占用大量内存。 - GET 命令的名字为
"get"
, 实现函数为getCommand
函数; 命令的参数个数为2
, 表示命令只接受两个参数; 命令的标识为"r"
, 表示这是一个只读命令。
命令执行器根据客户端状态的 argv[0]
参数,从命令表中找到命令,拿到命令对应的redisCommand结构,并把它指向客户端状态的cmd属性:
这里客户端状态 argv[0] 属性不区分大小写。
命令执行器,执行预备操作
到目前为止, 服务器已经将执行命令所需的命令实现函数(保存在客户端状态的 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 四个命令, 其他命令都会被放进事务队列中。
- 如果服务器打开了监视器功能, 那么服务器会将要执行的命令和参数等信息发送给监视器。
当完成了以上预备操作之后, 服务器就可以开始真正执行命令了。
命令执行器,调用命令的实现函数
前面的操作中, 服务器已经将要执行命令的实现保存到了客户端状态的 cmd
属性里面, 并将命令的参数和参数个数分别保存到了客户端状态的 argv
属性和 argc
属性里面, 当服务器决定要执行命令时, 它只要执行以下语句就可以了:
// client 是指向客户端状态的指针 client->cmd->proc(client);
因为执行命令所需的实际参数都已经保存到客户端状态的 argv
属性里面了, 所以命令的实现函数只需要一个指向客户端状态的指针作为参数即可。
被调用的命令实现函数会执行指定的操作, 并产生相应的命令回复, 这些回复会被保存在客户端状态的输出缓冲区里面(buf
属性和 reply
属性), 之后实现函数还会为客户端的套接字关联命令回复处理器, 这个处理器负责将命令回复返回给客户端。
命令执行器,执行后续工作
在执行完实现函数之后, 服务器还需要执行一些后续工作:
- 如果服务器开启了慢查询日志功能, 那么慢查询日志模块会检查是否需要为刚刚执行完的命令请求添加一条新的慢查询日志。
- 根据刚刚执行命令所耗费的时长, 更新被执行命令的
redisCommand
结构的milliseconds
属性, 并将命令的redisCommand
结构的calls
计数器的值增一。 - 如果服务器开启了 AOF 持久化功能, 那么 AOF 持久化模块会将刚刚执行的命令请求写入到 AOF 缓冲区里面。
- 如果有其他从服务器正在复制当前这个服务器, 那么服务器会将刚刚执行的命令传播给所有从服务器。
当以上操作都执行完了之后, 服务器对于当前命令的执行到此就告一段落了, 之后服务器就可以继续从文件事件处理器中取出并处理下一个命令请求了。
将命令回复发送给客户端
命令实现函数会将命令回复保存到客户端的输出缓冲区里面, 并为客户端的套接字关联命令回复处理器, 当客户端套接字变为可写状态时, 服务器就会执行命令回复处理器, 将保存在客户端输出缓冲区中的命令回复发送给客户端。
当命令回复发送完毕之后, 回复处理器会清空客户端状态的输出缓冲区, 为处理下一个命令请求做好准备。
客户端接受并打印命令回复
当客户端接收到协议格式的命令回复之后, 它会将这些回复转换成人类可读的格式, 并打印给用户观看(假设我们使用的是 Redis 自带的 redis-cli
客户端)
初始化服务器
从启动 Redis 服务器, 到服务器可以接受外来客户端的网络连接这段时间, Redis 需要执行一系列初始化操作。
整个初始化过程可以分为以下六个步骤:
- 初始化服务器全局状态。
- 载入配置文件。
- 创建 daemon 进程。
- 初始化服务器功能模块。
- 载入数据。
- 开始事件循环。
以下各个小节将介绍 Redis 服务器初始化的各个步骤。
初始化服务器全局状态
redis.h/redisServer
结构记录了和服务器相关的所有数据, 这个结构主要包含以下信息:
- 服务器中的所有数据库。
- 命令表:在执行命令时,根据字符来查找相应命令的实现函数。
- 事件状态。
- 服务器的网络连接信息:套接字地址、端口,以及套接字描述符。
- 所有已连接客户端的信息。
- Lua 脚本的运行环境及相关选项。
- 实现订阅与发布(pub/sub)功能所需的数据结构。
- 日志(log)和慢查询日志(slowlog)的选项和相关信息。
- 数据持久化(AOF 和 RDB)的配置和状态。
- 服务器配置选项:比如要创建多少个数据库,是否将服务器进程作为 daemon 进程来运行,最大连接多少个客户端,压缩结构(zip structure)的实体数量,等等。
- 统计信息:比如键有多少次命令、不命中,服务器的运行时间,内存占用,等等。
在这一步, 程序创建一个 redisServer
结构的实例变量 server
用作服务器的全局状态, 并将 server
的各个属性初始化为默认值。
当 server
变量的初始化完成之后, 程序进入服务器初始化的下一步: 读入配置文件。
载入配置文件
在初始化服务器的上一步中, 程序为 server
变量(也即是服务器状态)的各个属性设置了默认值, 但这些默认值有时候并不是最合适的:
- 用户可能想使用 AOF 持久化,而不是默认的 RDB 持久化。
- 用户可能想用其他端口来运行 Redis ,以避免端口冲突。
- 用户可能不想使用默认的 16 个数据库,而是分配更多或更少数量的数据库。
- 用户可能想对默认的内存限制措施和回收策略做调整。
为了让使用者按自己的要求配置服务器, Redis 允许用户在运行服务器时, 提供相应的配置文件(config file)或者显式的选项(option), Redis 在初始化完 server
变量之后, 会读入配置文件和选项, 然后根据这些配置来对 server
变量的属性值做相应的修改:
-
如果单纯执行
redis-server
命令,那么服务器以默认的配置来运行 Redis 。 -
另一方面, 如果给 Redis 服务器送入一个配置文件, 那么 Redis 将按配置文件的设置来更新服务器的状态。
-
除此之外, 还可以显式地给服务器传入选项, 直接修改服务器配置。
-
当然, 同时使用配置文件和显式选项也是可以的, 如果文件和选项有冲突的地方, 那么优先使用选项所指定的配置值。
创建 daemon 进程
Redis 默认以 daemon 进程的方式运行。
当服务器初始化进行到这一步时, 程序将创建 daemon 进程来运行 Redis , 并创建相应的 pid 文件。
初始化服务器功能模块
在这一步, 初始化程序完成两件事:
- 为
server
变量的数据结构子属性分配内存。 - 初始化这些数据结构。
在这一步, 程序完成的主要动作如下:
- 初始化 Redis 进程的信号功能。
- 初始化日志功能。
- 初始化客户端功能。
- 初始化共享对象。
- 初始化事件功能。
- 初始化数据库。
- 初始化网络连接。
- 初始化订阅与发布功能。
- 初始化各个统计变量。
- 关联服务器常规操作(cron job)到时间事件,关联客户端应答处理器到文件事件。
- 如果 AOF 功能已打开,那么打开或创建 AOF 文件。
- 设置内存限制。
- 初始化 Lua 脚本环境。
- 初始化慢查询功能。
- 初始化后台操作线程。
完成这一步之后, 服务器打印出 Redis 的 ASCII LOGO 、服务器版本等信息, 表示所有功能模块已经就绪, 可以等待被使用了。我本机 redis-server 启动后的日志:
载入数据
在这一步, 程序需要将持久化在 RDB 或者 AOF 文件里的数据, 载入到服务器进程里面。
如果服务器有启用 AOF 功能的话, 那么使用 AOF 文件来还原数据; 否则, 程序使用 RDB 文件来还原数据。
开始事件循环
到了这一步, 服务器的初始化已经完成, 程序打开事件循环, 开始接受客户端连接。
参考文献
Redis设计与实现
http://www.redis.cn/commands/client-list.html