Redis知识点整理(二)——单机数据库实现
本文紧接着上一篇的内容,简要讲述Redis单机数据库的实现原理。
1、数据库的结构
Redis服务器的数据库信息由redisServer结构定义:
struct redisServer {
// ...
// 服务器的数据库数量,由服务器配置的database选项决定,一般为16
int dbnum;
// 一个数组,保存着服务器中的所有数据库
redisDb *db;
// AOF 缓冲区
sds aof_buf
};
Redis服务器默认会创建16个数据库,可以在客户端通过SELECT命令执行数据库切换。
每个数据库都由一个redisDb结构表示,这里重点关注dict和expires两种属性:
typedef struct redisDb {
// ...
// 数据库键空间,保存着数据库中的所有键值对
dict *dict;
// 过期字典,保存着键的过期时间
dict *expires;
// ...
} redisDb;
1.1、键空间
redisDb结构的dict字典保存了数据库中的所有键值对,称为键空间:
在数据库中对键值对的增删改查操作就是基于键空间字典进行的,例如增加键值对就是在键空间字典里新增一项,键为字符串对象,值为任意一种Redis对象。
1.2、过期字典
expires字典保存了所有键的过期时间,称为过期字典,其键为键空间中的某个键对象,而值是一个long long类型的整数,保存过期时间的毫秒级UNIX时间戳。程序根据检查给定键是否存在于过期字典、过期字典的值是否小于当前UNIX时间戳判断一个键是否过期。
过期键的删除策略分为三种:
(1)定时删除:设置键的过期时间的同时设置定时器,过期时间一到就立即删除,对CPU最不友好但是对内存最友好;
(2)惰性删除:程序只有在取出键的时候才进行过期检查,对CPU最友好但是对内存最不友好;很多过期键例如日志等很容易堆积在数据库中;
(3)定期删除:每隔一段时间随机检查数据库中的过期字典,并删除过期键,是上述两种方法的折衷;
实践中,Redis结合了惰性删除和定期删除两种策略,其中惰性删除由expireIfNeeded函数实现,定期删除由activeExpireCycle实现。
在主从服务器集群模式下,从服务器即便发现了过期键也不会自作主张地删除,而是等到主服务器发现过期键、向从服务器发送DEL message命令时才进行删除。以保持主从一致性。
2、持久化
由于Redis是内存数据库,如果服务器进程退出,数据库状态也会消失,因此可以通过持久化将数据库状态保存到磁盘中,称为持久化。Redis持久化分为RDB持久化和AOF持久化。如果服务器同时开启了AOF和RDB持久化,会优先使用AOF文件进行还原。
2.1、RDB持久化
RDB持久化又称为快照持久化,将数据库中的键值对保存到RDB文件中。可以通过SAVE指令、BGSAVE指令、自动间隔性保存三种方式触发RDB持久化:
(1)SAVE指令:会阻塞Redis服务器进程,直到RDB文件创建完为止,服务器不能处理任何其他命令请求;
(2)BGSAVE指令:fork一个子进程负责创建RDB文件,父进程处理命令请求;根据《Redis实战》,随着Redis占用内存的提升,BGSAVE创建子进程会导致系统停顿的时间也会更长;
(3)自动间隔性保存:设置服务器配置的save选项,Redis服务器的操作函数serverCron会周期性检查save选项的条件是否满足,如果满足就执行BGSAVE。
RDB文件结构如下:
db_version 长度为 4 字节, 它的值记录了 RDB 文件的版本号;
databases 部分包含数据库以及键值对数据;
EOF 常量的长度为 1 字节, 这个常量标志着 RDB 文件正文内容的结束;
check_sum 是一个 8 字节长的无符号整数, 保存着一个校验和, 用于检查 RDB 文件是否有出错或者损坏的情况出现。
服务器载入RDB文件的期间会一直处于阻塞状态直到完成为止。
另外需要注意的是,生成RDB文件时,已过期的键不会被保存到RDB文件中;载入RDB文件时,主服务器会对过期键进行检查,过期的键不会载入到服务器中。
2.2、AOF持久化
AOF持久化保存Redis服务器执行的写命令记录数据库状态。其实现包括命令追加、文件写入、文件同步三步:
(1)命令追加:当 AOF 持久化功能处于打开状态时, 服务器在执行完一个写命令之后, 会以协议格式将被执行的写命令追加到服务器状态的 aof_buf 缓冲区的末尾;
(2)文件写入:在服务器每次结束一个事件循环之前, 它都会调用 flushAppendOnlyFile 函数, 考虑是否需要将 aof_buf 缓冲区中的内容写入和保存到 AOF 文件里面;
为了提高文件的写入效率, 在现代操作系统中,当用户调用 write 函数,将一些数据写入到文件的时候, 操作系统通常会将写入数据暂时保存在一个内存缓冲区里面, 等到缓冲区的空间被填满、或者超过了指定的时限之后,才真正地将缓冲区中的数据写入到磁盘里面。这种做法虽然提高了效率,但也为写入数据带来了安全问题,因为如果计算机发生停机,那么保存在内存缓冲区里面的写入数据将会丢失。
(3)文件同步: flushAppendOnlyFile 函数基于服务器配置的appendfsync选项的值决定是否将写入到内存缓冲区的内容立即同步到硬盘中的AOF文件中。
系统提供了 fsync 和 fdatasync 两个同步函数,它们可以强制让操作系统立即将缓冲区中的数据写入到硬盘里面,从而确保写入数据的安全性。
然而,随着服务器的运行,AOF文件的体积可能会越来越大,因此需要使用AOF重写功能,BGREWRITEAOF命令创建一个子进程,子进程创建一个新的AOF文件,在新的AOF文件中使用一条指令代替原有AOF文件中重复的多条指令,最后用新的AOF文件原子地覆盖原有AOF文件。
在子进程进行重写时,Redis会将这段时间执行的写命令也发送给AOF重写缓冲区。子进程完成AOF重写后,会将AOF重写缓冲区中的内容追加到新的AOF文件末尾。这样能够避免重写过程中对服务器的数据库状态的修改没有体现在AOF文件中。
AOF载入时,将会创建一个不带网络连接的伪客户端,执行AOF文件中的每条写命令,服务器响应相应的命令,从而实现数据库状态的还原。
3、事件
Redis服务器是单线程的事件驱动程序,会轮流处理两类事件:文件事件和时间事件。
3.1、文件事件
文件事件是对套接字操作的抽象, 每当一个套接字准备好执行连接应答(accept)、写入、读取、关闭等操作时, 就会产生一个文件事件。 因为一个服务器通常会连接多个套接字, 所以多个文件事件有可能会并发地出现。
Redis 基于 Reactor 模式开发了自己的网络事件处理器,称为文件事件处理器(file event handler);文件事件处理器使用 I/O 多路复用(multiplexing)程序来同时监听多个套接字, 并根据套接字目前执行的任务来为套接字关联不同的事件处理器:
(1)I/O 多路复用程序:负责监听多个套接字, 并向文件事件分派器传送那些产生了事件的套接字。
尽管多个文件事件可能会并发地出现, 但 I/O 多路复用程序总是会将所有产生事件的套接字都入队到一个队列里面, 然后通过这个队列, 以有序(sequentially)、同步(synchronously)、每次一个套接字的方式向文件事件分派器传送套接字: 当上一个套接字产生的事件被处理完毕之后(该套接字为事件所关联的事件处理器执行完毕), I/O 多路复用程序才会继续向文件事件分派器传送下一个套接字;
Redis 的 I/O 多路复用程序的所有功能都是通过包装常见的 select 、 epoll 、 evport 和 kqueue 这些 I/O 多路复用函数库来实现的;
(2)文件事件分派器:接收 I/O 多路复用程序传来的套接字, 并根据套接字产生的事件的类型, 调用相应的事件处理器;
(3)事件处理器:服务器会为执行不同任务的套接字关联不同的事件处理器, 这些处理器是一个个函数, 它们定义了某个事件发生时, 服务器应该执行的动作。
操作 | 文件事件类型 | 事件处理器 |
---|---|---|
客户端调用connect函数连接服务器监听套接字 | 监听套接字产生AE_READABLE事件 | 连接应答处理器 |
客户端向服务器发生命令请求 | 客户端套接字产生AE_READABLE事件 | 命令请求处理器 |
服务器发送命令回复 | 客户端套接字产生AE_WRITABLE事件 | 命令回复处理器 |
主从服务器进行复制操作 | 产生AE_READABLE AE_WRITABLE事件 | 复制处理器 |
虽然文件事件处理器以单线程方式运行, 但通过使用 I/O 多路复用程序来监听多个套接字, 文件事件处理器既实现了高性能的网络通信模型, 又可以很好地与 Redis 服务器中其他同样以单线程方式运行的模块进行对接, 这保持了 Redis 内部单线程设计的简单性。
3.2、时间事件
时间事件分为定时事件和周期性事件。服务器会将所有时间事件都放在一个无序链表中,当时间事件执行器运行时,遍历链表,查找已到达的时间事件并调用响应的事件处理器。
例如,Redis服务器的serverCron函数就是一个周期性时间事件,其工作包括清理过期键值对、进行AOF或RDB持久化、更新服务器的统计信息、对从服务器进行定期同步等。
4、服务器与客户端
服务器状态结构redisServer使用clients链表连接多个客户端状态redisClient结构;新添加的客户端状态会插到链表尾部。
redisClient结构如下:
typedef struct redisClient {
//...
// 客户端正在使用的套接字描述符
int fd;
// 客户端的名字
robj *name;
// 客户端的标志:主服务器/从服务器/其他状态
int flags;
// 输入缓冲区
sds querybuf;
// 命令参数的数组和长度
robj **argv;
int argc;
// argv[0] 所对应的 redisCommand 结构
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;
下面以用户输入SET KEY VALUE命令说明命令请求的执行过程:
(1)用户在客户端输入命令
SET KEY VALUE
客户端将这个命令转换为协议,
*3\r\n$3\r\nSET\r\n$3\r\nKEY\r\n$5\r\nVALUE\r\n
并将协议内容发送给服务器;
(2)客户端的写入,使得客户端与服务器之间的连接套接字产生AE_READABLE事件,服务器调用命令请求处理器执行操作;
(3)命令请求处理器将协议格式的命令请求,保存在该客户端所对应的客户端状态redisClient结构的querybuf属性中:
(4)命令请求处理器对命令请求的内容进行分析, 并将得出的命令参数以及命令参数的个数分别保存到客户端状态redisClient结构的 argv 属性和 argc 属性:
argv 属性是一个数组, 其中 argv[0] 是要执行的命令, 而之后的其他项则是传给命令的参数;argc 属性则负责记录 argv 数组的长度。
(5)命令请求处理器调用命令执行器完成剩余步骤。
(6)命令执行器根据客户端状态的 argv[0] 参数, 在命令表(command table)中查找参数所指定的命令, 并将找到的命令保存到客户端状态的 cmd 属性里面。
命令表是一个字典, 字典的键保存了命令的名字, 值是命令所对应的redisCommand 结构,该结构保存了命令的实现函数、 命令的标志、 命令应该给定的参数个数、 命令的总执行次数和总消耗时长等统计信息。
在修改后,当前客户端状态已经包含了执行命令所需要的的所有内容:
(7)命令执行器执行预备操作,例如:
检查客户端状态的 cmd 指针是否指向 NULL ,如果是的话, 那么说明用户输入的命令名字找不到相应的命令实现, 服务器不再执行后续步骤, 并向客户端返回一个错误;
根据客户端 cmd 属性指向的 redisCommand 结构的 arity 属性,检查命令请求所给定的参数个数是否正确, 当参数个数不正确时, 不再执行后续步骤, 直接向客户端返回一个错误;
检查客户端是否已经通过了身份验证, 未通过身份验证的客户端只能执行 AUTH 命令, 如果未通过身份验证的客户端试图执行除 AUTH命令之外的其他命令, 那么服务器将向客户端返回一个错误。
(8)命令执行器调用命令的实现函数:
// client 是指向客户端状态的指针
client->cmd->proc(client);
(9)调用函数后,会产生一个 “+OK\r\n” 回复, 这个回复会被保存到客户端状态的输出缓冲区 buf 属性里面;
(10)命令执行器执行其他后续操作,例如如果服务器开启了 AOF 持久化功能, 那么 AOF 持久化模块会将刚刚执行的命令请求写入到 AOF 缓冲区里面;
(11)当客户端套接字变为可写状态时,服务器就会执行命令回复处理器, 将保存在客户端输出缓冲区中的命令回复 "+OK\r\n"发送给客户端。当命令回复发送完毕之后, 回复处理器会清空客户端状态的输出缓冲区, 为处理下一个命令请求做好准备。
(12)当客户端接收到协议格式的命令回复之后, 它会将这些回复转换成人类可读的格式,并打印给用户观看。