深入了解Redis之客户端C-服务器S设计与实现

我们知道Redis是一个典型的C/S设计程序,一个服务器可以与多个客户端建立连接。通过I/O多路复用技术实现的文件事件处理器,Redis服务器使用单线程单进程的方式来处理命令请求,并与多个客户端进行网络通信。

所以,对于Redis我们分为这两部分来了解,

  • Redis服务器维护和管理客户端状态的方法
  • Redis服务器的运行机制

客户端

对于每个与服务器进行连接的客户端,服务器都为这些客户端建立了相应的 redisClient 结构(客户端状态),这个结构保存了客户端当前的状态信息。

typedef struct redisClent{

  // ...
  // 客户端的套接字描述符
  int fd;
  // 客户端的名字
  robj *name;
  // 客户端的标识值(flag)
  int flags;
  // 输入缓冲区
  sqs querybuf;
  // 命令与命令参数
  robj **argv;
  // 命令参数个数
  int argc;
  //命令的实现函数
  struct redisCommand *cmd;
  //输出缓冲区
  char buf[REDIS_REPLY_CHUNK_BYTES];
  //
  int bufpos;
  // 身份认证相关属性
  int authenticated;
  //
  time_t ctime;
  time_t lastinteraction;
  time_t obuf_soft_limit_reached_time;

  // ...

} redisClient

Redis服务器状态结构的clients属性是一个链表,这个链表保存了所有与服务器连接的客户端的状态结构(redisClient),对客户端执行批量操作或者查找某个指定的客户端,都可以通过遍历 clients 链表来完成

struct redisServer {
  //...

  // 一个链表,保存了所有客户端状态
  list *clients;

  //...
}

如图,是一个与三个客户端进行连接的服务器。

clients 链表

客户端类型

按照处理的命令请求的来源,客户端可以分为以下两种:

  • 伪客户端

    处理来自AOF文件或者Lua脚本的命令请求。目前Redis会在两个地方用到伪客户端,

    • 用于载入 AOF 文件并还原数据库状态
    • 用于执行Lua脚本中包含的Redis命令
  • 普通客户端

    处理来自网络的普通命令请求。

两者的主要区别是,在客户端状态结构(redisClient)中的 fd 属性,伪客户端的该属性值为-1,普通客户端的该属性的值是普通客户端的套接字的描述符。

客户端状态属性

客户端状态属性主要可以分为两类:

  • 比较通用的属性

    • 套接字描述,根据客户端端类型的不同,该属性的值可以是-1 或者是大于-1 的整数。
    • 名字,默认情况下,一个连接到服务器的客户端是没有名字的,使用 CLIENT setname 可以为客户端设置一个名字。
    • 标识,记录了客户端的角色(role)如,主服务器,从服务器,低版本的从服务器,专门处理Lua脚本的伪客户端;以及客户端目前所处的状态,如客户端正在执行MONITOR命令,客户端正在被BRPOPBLPOP等命令阻塞,客户端正在执行事务等等。
    • 输入缓冲区,用于保存客户端发送的命令请求。
    • 命令与命令参数,保存命令参数以及命令参数个数。
    • 命令的实现函数,保存命令的实现函数。
    • 输出缓冲区,保存执行命令所得的命令回复内容。
    • 身份验证,用于记录客户端是否通过了身份验证。
    • 时间,记录创建客户端的时间、客户端与服务器最后一次交互的时间、输出缓冲区第一次到达软性限制的时间等等。
  • 和特定功能相关的属性

    • db属性(数据库操作时需要)
    • dictid属性(数据库操作时需要)
    • mstate属性(执行事务时需要)
    • watched_keys属性(执行WATCH命令时需要)

客户端的创建与关闭

创建

如果客户端是通过网络连接与服务器进行连接的普通客户端,在客户端使用connect函数连接到服务器时,服务器就会调用连接事件处理器,为客户端创建相应的客户端状态,并将这个新的客户端状态添加到服务器状态结构 clients 链表的末尾。

对于Lua脚本的伪客户端,服务器会在初始化时,创建负责执行Lua脚本中包含的Redis命令的伪客户端,并将这个伪客户端关联在服务器状态结构的 lua_client 属性中。且 lua_client 伪客户端会在服务器运行的整个生命周期中会一直存在,只有在服务器关闭时,这个伪客户端才会关闭。

对于用户载入 AOF 文件还原数据库的伪客户端,会在服务器载入AOF文件时创建,用于执行AOF文件包含的Redis命令,在完成载入操作之后,这个伪客户端就会关闭。

关闭

对于一个普通客户端可以有多种原因而被关闭,

  • 客户端进程推出或者被杀死
  • 客户单向服务器端发送了不符合协议格式的命令请求
  • 客户端成为了 CLIENT KILL 命令的目标
  • 客户端空转时间 大于了 timeout 的设置
  • 客户端发送的命令请求的大小超过了输入缓冲区的大小限制(默认 1GB
  • 发送给客户端的命令回复的大小超过了输出缓冲区的限制大小

服务器

Redis 服务器负责与多个客户端建立网络连接,处理客户端发送的命令请求,在数据库中保存客户端执行命令所产生的数据,并通过资源管理来维持服务器自身的运转。

一条请求命令的执行过程

一条命令请求从发送到获得回复的过程中,客户端和服务器需要完成一系列操作。,例如,执行 redis> SET KEY VALUE 这条命令的执行过程如下:

  • 1)客户端向服务器发送命令请求 SET KEY VALUE
  • 2)服务器接收并处理客户端发送过来的命令请求 SET KEY VALUE,在数据库中进行设置操作,并返回命令回复 OK
  • 3)服务器将命令回复OK发送给客户端
  • 4)客户端接收服务器返回的命令回复OK,并将这个回复打印给用户

发送命令请求

客户端接收并发送命令请求的过程
如图所示,用户在客户端中键入一个命令请求时,客户端会将这个命令请求转换成协议格式,然后通过连接到服务器的套接字,将协议格式的命令发送服务器。

示例,假设用户在客户端键入了命令:

SET KEY VALUE

那么客户端会将这个命令转换成协议格式

*3\r\n$3\r\nSET\r\n$3\r\nKEY\r\n$5\r\nVALUE\r\n

然后将这段协议内容发送给服务器。

服务器读取命令请求

当客户端与服务器之间的连接套接字因为客户端的写入而变得可读时,服务器将调用命令请求出来器来执行一下操作。

  • 1)读取套接字中协议格式的命令请求,并将其保存到客户端状态的输入缓冲区里。
  • 2)对输入缓冲区中的命令请求进行分析,提取出命令请求中包含的命令参数,以及参数的个数,然后分别保存到客户端状态的argv属性和argc属性中。
  • 3)调用命令执行器,执行客户端指定的命令。

示例,

客户端状态中的命令请求

服务器将命令请求保存到客户端状态的输入缓冲区。之后,分析程序将对输入缓冲区的协议进行分析,并将分析结果保存到客户端状态的argv属性和argc属性里。

客户端状态的argv属性和argc属性

执行命令

分析程序将分析结果保存到客户端状态的argv属性和argc属性里之后,服务器将通过调用命令执行器来完成执行命令所需的余下步骤。

  • 命令执行器:查找命令实现

    根据客户端状态的 argv[0] 参数,在命令表(command table) 中查找参数所指定的命令,并将找到的命令保存到客户端状态的cmd属性中。

  • 命令执行器:执行预备操作

    到目前为止,服务器已经将执行命令所需的命令实现函数(cmd属性)、参数(argv属性)、参数个数(argc属性)都收集齐了。但在真正执行命令前,还需要进行一些预备操作,从而确保命令能够正确的、顺序被执行。这些操作有,

    • 检查实现函数是否为空
    • 检查参数个数满足当前实现函数的要求
    • 检查客户端是否已经通过了身份验证
    • 如果服务器打开了 maxmemory 功能,那么检查服务器的内从占用情况,并在有需要的情况下回收内存
    • 如果服务器上一次执行 BGSAVE 命令出错,且打开了 stop-writes-on-bgsave-error 功能,且服务器当前要执行的是一个写命令,则拒绝执行这个命令,并返回错误。
    • 检查服务器是否正处于某种状态(正在用subscribe命令订阅频道、正在用psubscribe命令订阅模式、正在进行数据载入、正在阻塞、正在执行事务),如果处于某种状态之下,则需要分别当前命令是否可以在该状态下被执行。
    • 如果服务器打开了监视功能,那么服务器会将要执行的命令和参数等信息,发送给监视器。

    当完成以上操作之后,服务器就可以开始真正执行命令了。

  • 命令执行器:调用命令的实现函数

    因为之前服务器已经将要执行的命令的实现保存到了客户端状态的cmd属性、参数和个数分别保存到了argv属性和argc属性,所以当服务器决定要执行命令时,是需要执行就可以了。client->cmd->proc(client);实现函数执行完操作之后,会产生相应的命令回复,这些回复会保存在客户端状态的输出缓冲区里,之后实现函数还会为客户端的套接字关联命令回复处理器,这个处理器负责将命令回复返回给客户端。

  • 命令执行器:执行后续操作

    在执行完函数之后,服务器还需要执行一些后续工作,

    • 如果服务器开启了慢日志功能,那么检查刚执行的命令是否符合条件
    • 更新命令的redisCommand结构中milliseconds属性(执行命令耗费时长),和calls属性(被调用次数)
    • 如果服务器开启AOF持久化功能,将刚执行的命令传播给所有从服务器

    到此,服务器对于当前命令的执行就告一段落了。

将命令回复发送给客户端

前面的命令实现函数会将命令回复保存到客户端的输出缓冲区里面,并为客户端的套接字关联命令回复处理器,当客户端套接字变成可写状态时,服务器就会执行命令回复处理器,将保存在输出缓冲区中的命令回复发送给客户端。

当命令回复发送完毕之后,回复处理器会清空客户端状态的输出缓冲区,为处理下一个命令请求做好准备。

客户端接收并打印命令回复

当客户端接收到协议格式的命令回复之后,它会将这些回复转换成人类可读的格式,并打印给用户。

客户端接收并打印显示命令回复的过程

serverCron 函数

redis服务器中的serverCron函数负责管理服务器的资源,并保持服务器自身的良好运转,其默认每隔 100 ms 执行一次。

serverCron函数需要做的事情,

  • 更新服务器的时间缓存(用于获得当前时间时)
  • 更新LRU时钟(用于计算键的空转事件)
  • 更新服务器每秒执行命令次数
  • 更新服务器内存峰值记录
  • 处理SIGTERM信号
  • 管理客户端资源
  • 管理数据库资源
  • 执行被延迟的BGREWRITEAOF
  • 检查持久化操作的运行状态
  • AOF缓冲区中的内容写入AOF文件
  • 关闭异步客户端
  • 增加cronloops计数器值

初始化服务器

一个redis服务器从启动到能够接受客户端的命令请求,需要经过一些列的初始化和设置过程,如

  • 初始化服务器状态结构
  • 载入配置选项
  • 初始化服务器数据结构
  • 还原数据库状态
  • 执行事件循环

初始化服务器状态结构

服务器状态结构指的就是 redisServer 结构,初始化服务器的第一步就是创建一个 struct redisServer类型的实例变量 server 作为服务器的状态,并为结构中的各个属性设置默认值。

具体初始化工作由 redis.c/initServerConfig 函数完成,initServerConfig 函数完成的主要工作是,

  • 设置服务器的运行ID
  • 设置服务器的默认运行频率
  • 设置服务器的默认配置文件路径
  • 设置服务器的运行架构
  • 设置服务器的默认端口号
  • 设置服务器的默认RDB持久化条件和AOF持久化条件
  • 创建命令表

initServerConfig 函数执行完毕之后,服务器就可以进入初始化的第二阶段–载入配置选项

载入配置选项

服务器在用 initServerConfig 函数初始化完 server 变量之后,就会载入用户给指定的配置参数和配置文件,并根据用户设定的配置,对 server 变量相关属性进行修改:

例如,

$ redis-server --port 10086

那么我们就会修改了服务器默认的运行端口号。

$ redis-server redis.conf

按照配置文件中的配置,修改 server 变量属性。

服务器在载入用户指定的配置选项,并对server状态进行更新之后,服务器就可以进入初始化的第三个阶段–初始化服务器数据结构。

初始化服务器数据结构

在之前执行 initServerConfig 函数初始化 server 状态,程序只创建了命令表的一个数据结构,不过了命令表之外,服务器状态还包含其他数据结构。

  • server.client 链表,记录了所有与服务器相连的客户端的状态结构,链表的每个节点都包含了一个redisClient结构实例。
  • server.db 数组,包含了服务器的所有数据库。
  • server.pubsub_channels 字典,保存频道订阅信息。
  • server.pubsub_patterns 链表,保存模式订阅信息。
  • server.lua,保存执行lua脚本的Lua环境。
  • server.slowlog ,保存慢查询日志。

这些数据结构是通过 iniServer 函数来实现,为这些数据结构分配内存,并设置或关联初始化值。

iniServer 函数除了初始化这些数据结构之外,还会进行一些其他非常重要的设置操作,包括:

  • 为服务器是指进程信号处理器
  • 创建共享对象:这些对象包括redis服务器经常用到的一些值(如,包含“OK”回复的字符串对象,包含“ERR”回复的字符串对象,等等),服务器通过重用这些对象来避免反复重复的创建相同的对象。
  • 打开服务器的监听端口,并为监听套接字关联连接应答事件处理器,等待服务器正式运行时接收客户端连接。
  • serverCron 函数创建时间事件,等待服务器正式运行时实行 serverCron 函数。
  • 如果AOF持久化功能已经打开,那么打开现有的AOF文件,如果AOF文件不存在,那么创建并打开一个新的AOF文件,为AOF文件写入做好准备。
  • 初始化后台I/O模块(bio),为将来的I/O操作做好准备。

iniServer 函数执行完毕之后,服务器会用 ASCII字符在日志中打印出Redis的图标,以及Redis的版本信息。

root@5254004e45d0:/srv/rorapps/redis/redis-2.8.15# src/redis-server
[16445] 16 Sep 09:54:32.684 # Warning: no config file specified, using the default config. In order to specify a config file use src/redis-server /path/to/redis.conf
[16445] 16 Sep 09:54:32.686 * Increased maximum number of open files to 10032 (it was originally set to 1024).
                _._                                                  
           _.-``__ ''-._                                             
      _.-``    `.  `_.  ''-._           Redis 2.8.15 (00000000/0) 64 bit
  .-`` .-```.  ```\/    _.,_ ''-._                                   
 (    '      ,       .-`  | `,    )     Running in stand alone mode
 |`-._`-...-` __...-.``-._|'` _.-'|     Port: 6379
 |    `-._   `._    /     _.-'    |     PID: 16445
  `-._    `-._  `-./  _.-'    _.-'                                   
 |`-._`-._    `-.__.-'    _.-'_.-'|                                  
 |    `-._`-._        _.-'_.-'    |           http://redis.io        
  `-._    `-._`-.__.-'_.-'    _.-'                                   
 |`-._`-._    `-.__.-'    _.-'_.-'|                                  
 |    `-._`-._        _.-'_.-'    |                                  
  `-._    `-._`-.__.-'_.-'    _.-'                                   
      `-._    `-.__.-'    _.-'                                       
          `-._        _.-'                                           
              `-.__.-'                                               

[16445] 16 Sep 09:54:32.688 # Server started, Redis version 2.8.15
[16445] 16 Sep 09:54:32.688 # WARNING overcommit_memory is set to 0! Background save may fail under low memory condition. To fix this issue add 'vm.overcommit_memory = 1' to /etc/sysctl.conf and then reboot or run the command 'sysctl vm.overcommit_memory=1' for this to take effect.
[16445] 16 Sep 09:54:32.688 * The server is now ready to accept connections on port 6379

还原数据库状态

在完成对 server 变量的初始化之后,服务器需要载入 RDB 文件或者 AOF 文件,并根据文件记录的内容来还原服务器的数据库状态。

  • 如果服务器采用的是 AOF方式的持久化方式,那么服务器将使用AOF文件来还原数据库状态。
  • 相反的如果服务器没有采用AOF方式的持久化方式,那么服务器使用RDB文件来还原服务器数据库状态。

当服务器完成数据库的还原操作之后,服务器将在日志中打印出载入文件并还原数据库状态所耗费的时长

[5244] 21 Nov 22:43:49.084 * DB loaded from disk: 0.067 seconds

执行事件循环

初始化的最后一步,服务器将打印以下日志,

[5244] 21 Nov 22:43:49.084 * The server is now ready to accept connections on port  6379

并开始执行服务器的时间循环(loop

至此,服务器的初始化操作就圆满完成。服务器从现在开始就可以接受客户端的连接请求,并处理客户端发来的命令请求了。。。