Redis设计与实现(三)——单机数据库实现

Posted by 皮皮潘 on 12-15,2021

数据库

数据库结构

Redis将所有数据库都保存在服务器状态redisServer结构的db数组中,数据库的具体存储都在redisDb结构中,也即每一个redisDb结构代表一个数据库,Redis默认会创建16个数据库,并通过db字段存储对应的指针,另外在Redis内部,客户端状态redisClient结构的db属性记录了客户端当前的目标数据库,这个属性是一个指向redisDb结构的指针,通过SELECT语句来切换数据库的具体实现就是简单地修改redisClient.db属性对应的指针

由于Redis本身是一个键值对的数据库,因此在redisDb结构中使用dict字典保存了数据库中的所有键值对,这个字典也被称为键空间,也即redisDb结构的核心如下:

struct redisDb {
    // 数据库键空间,保存着数据库中的所有键值对
    dict *dict;
    
    // 保存着键的过期时间
    dict *expires;
}

过期删除实现

通过EXPIRE或者PEXPIRE命令,可以以秒或者毫秒精度为数据库中的某个键设置生存时间,经过指定的时间后服务器就会自动删除生存时间为0的键,该功能的具体实现如下:

  1. 过期时间的保存:redisDb结构使用一个名为expires的字典保存着所有键的过期时间,其存的值是一个long long类型的整数,该整数保存了具体的过期时间——一个毫秒精度的UNIX时间戳

  2. 过期键的判定:

    • 检查给定键是否存在于过期字典中
    • 如果存在则检查当前UNIX时间戳是否大于键的过期时间
  3. 过期键删除策略:惰性删除与定期删除的结合

    • 惰性删除:所有读写数据库的Redis命令都会在执行之前调用expireIfNeeded函数对于输入键进行检查,如果对应的键已经过期则将其删除
    • 定期删除:在时间事件触发的时候(serverCron函数每隔100ms执行一次),会调用对应的activeExpireCycle函数去分多次遍历服务器中的各个数据库(通过一个全局变量记录上次遍历到了哪个数据库),然后从数据库的expires字典中随机检查一部分键的过期时间,并删除其中的过期键
    • 在后文中会提到时间事件和文件事件是串行的,因此惰性删除和定期删除不会有并发冲突的情况发生

AOF和RDB

RDB持久化可以将Redis在内存中的数据库状态(包括所有的键值)保存到一个RDB文件中,RDB持久化既可以手动执行(执行SAVE命令时会同步地创建一个新的RDB文件,而执行BGSAVE时Redis会通过开一个子进程异步地创建一个新的RDB文件),也可以根据服务器配置选项定期执行,其配置规则如下:在一段时间内,如果操作超过一定阈值则执行BGSAVE,Redis默认配置三条规则,分别是:save 900 1;save 300 10;save 60 10000

AOF持久化是通过保存Redis服务器所执行的写命令再加上回放来记录数据库状态的(RDB则是记录了整个数据库的状态),Redis默认不开启AOF持久化功能。AOF持久化功能的实现可以分为命令追加、文件写入以及文件同步三个步骤:

  • 命令追加:Redis在执行完一个写命令之后,会以协议格式将执行的写命令追加都redisServer结构中的aof_buf缓冲区的末尾
  • 文件写入与同步:在Redis每次你结束一个事件循环之前,它都会调用flushAppendOnlyFile函数来考虑是否将缓冲区中的内容写入和保存到AOF文件里面,其中其实不管怎么样都会将内容写入AOF文件,但是是否同步则取决于appendfsync选项:
appendfsync行为
always每次都同步
everysec每隔1秒钟同步一次
no何时同步由OS自行决定

事件

Redis服务器本质上是一个基于多路复用+事件驱动的服务端(类似Netty的Reactor模型,但是其Worker只有一个),它主要处理两类事件:1. 文件事件:也即Socket上的事件,包括:连接、写入、读取,也就是Selector那一套 2. 时间事件:也即定时操作

文件事件

文件事件主要有三个核心事件,这三个核心时间分别通过三个应答处理器实现:

  • 连接应答处理器
  • 命令请求处理器
  • 命令回复处理器

时间事件

一个时间事件主要由以下三个属性组成:1. id:服务器为时间事件创建的全局唯一ID,新事件的id比旧事件的id大;2. when:毫秒精度的UNIX时间戳,记录了时间事件的需要处理的时间点 3. timeProc:时间事件处理器(一个函数)

Redis将所有时间事件都放在redisDb的一个无序链表中(time_events字段),当每次事件循环调用到processTimeEvents函数时,它会遍历整个链表,查找所有应该执行的时间事件并调用对应的事件处理器,由于Redis目前只是用serverCron一个时间事件,因此Redis几乎是将无序链表退化成一个指针来使用的,并不影响事件执行的性能

在serverCron中主要执行以下任务:

  1. 更新服务器时间缓存:对于精度要求不高的时间计算,可以使用服务器时间缓存,从而减少系统调用
  2. 更新LRU时钟:Redis通过redisServer的lruclock字段以及每个redisObject的lru字段来计算一个数据库键的空转时间,同样也是为了减少系统调用
  3. 更新服务器每秒执行命令次数
  4. 更新服务其内存峰值记录
  5. 检查SIGTERM信号的处理结果:信号本身是由Redis在启动时注册的信号处理器处理的,serverCron主要对服务器状态的shutdown_asap属性进行检查,并根据该值决定是否关闭服务器
  6. 管理客户端资源:调用clientCron函数,去释放超时的客户端,重新创建客户端输入缓冲区,关闭异常的客户端等
  7. 管理数据库资源:调用databaseCron函数,去删除过期键,对字典收缩等

事件的调度与执行

对应的逻辑和Netty的EventLoop的逻辑非常相似,具体的伪代码如下:

def aeProcessEvents():
    time_event = aeSearchNearestTimer()
    
    remained_ms = time_event.when - unix_ts_now()
    
    // 阻塞并等待文件事件产生,最大阻塞时间由传入remaind_msu而定
    aeApiPoll(remaind_ms)
    
    processFileEvents()
    
    processTimeEvents()

由于需要先等待FileEvents处理完,所以TimeEvent会有一定的延时

将aeProcessEvents函数置于一个循环里面,加上初始化和清理函数,这就构成了Redis服务器的主函数:

def main():
    init_server()
    
    while server_is_not_shutdown():
        aeProcessEvents()
        
    clean_server()

客户端状态

对于每一个与服务器进行连接的客户端,Redis都会为它们建立相应的redisClient结构又来存客户端状态,这个结构保存了客户端当前的状态信息,以及执行相关功能时需要的数据结构。在Redis的服务端状态redisServer结构中,通过一个类型为链表的clients属性来保存所有与服务器连接的客户端的状态结构redisClient,对于客户端执行批量操作,或者查找某个执行的客户端,都可以通过遍历clients链表实现

在客户端状态中,核心属性如下:

  1. 套接字描述符:通过socketFd用来与客户端实现交互
  2. 标志:记录了客户端的角色以及客户端目前所处的状态
  3. 输入缓冲区:在Redis将客户端发送的命令请求保存到客户端状态的querybuf属性(输入缓冲区)之后,Redis会将对命令请求的内容进行分析,并将得出的命令参数以及命令参数的个数分别保存到redisClient结构中的argv属性和argc属性,最后Redis将根据argv[0]的值在命令表中查找命令所对应的命令实现函数并将redisClient结构中的cmd属性指向对应的函数,这里需要注意的是,输入缓冲区时使用SDS的数据结构进行存储的,因此会动态缩小或扩大,但是最大大小不能超过1GB,否则Redis会关闭这个客户端
  4. 输出缓冲区:执行命令所得到的命令回复会被保存在客户端状态的输出缓冲区里面,然后得到对应的客户端可写事件发生时,再通过命令回复处理器回复给客户端,每个redisClient有两个输出缓冲区,一个是大小固定为16K的缓冲区,一个使用链表连接SDS的可变大小缓冲区
  5. 身份验证:使用authenticated属性用于记录客户端是否通过了身份验证,当对应的值为0且开启了身份验证功能时,除了AUTH命令之外,客户端发送的所有其他命令都会被服务器拒绝执行
  6. 时间相关属性:创建时间ctime;最后一次互动时间lastinteraction

除了普通客户端之外,Redis会在初始化时创建负责执行Lua脚本中包含的Redis命令的伪客户端,并将这个伪客户端关联在服务器状态结构的lua_client属性中,该为客户端的生命周期与Redis的生命周期相同

服务端状态

Redis使用redisServer这个结构保存了服务器的所有状态,该结构核心属性包括:1. 数据库集合——db属性 2. 客户端状态集合—— clients属性 3. 用于执行Lua脚本的Lua环境——lua属性 4. 慢查询日志——slowlog属性

初始化服务器的步骤如下:

  1. 初始化服务器状态结构
  2. 载入配置选项
  3. 初始化服务器数据结构
  4. 还原数据库状态
  5. 执行事件循环