Redis学习笔记二:单机数据库的实现

1. 数据库

  1. 服务器中的数据库
    Redis服务器将所有数据库都保存在服务器状态redis.h/redisServer结构的db数组中,db数组的每个项都是一个redis.h/redisDb结构,每个redisDb结构代表一个数据库:
struct redisServer {
	// ...
    redisDb *db; /* 一个数组,保存着服务器中的所有数据库 */
    // ...
    int dbnum; /* 创建多少个数据库,由服务器配置的database选项决定,默认为16个数据库,0-15 */
};
  1. 切换数据库
    每个redis客户端都有自己的目标和数据库,每当客户端执行数据库写命令或者数据库读命令的时候,目标数据库就会成为这些命令的操作对象。默认情况Redis客户端的目标数据库为0号库,但是客户端可以通过执行select命令来切换目标数据库。
127.0.0.1:6379> get msg
"liushijie"
127.0.0.1:6379> select 2
OK
127.0.0.1:6379[2]> get msg
(nil)

在服务器内部,客户端redisClient结构的db属性记录了客户端当前的目标数据库,这个属性是一个指向redisDb结构的指针:

typedef struct redisClient {
	// ...
    // 当前数据库的指针
    redisDb *db;
    // ...
} redisClient;
  1. 数据库键空间
    服务器中的数据库由redis.h/redisDb结构表示,其中redisDb结构的dict字典保存了数据库中的所以键值对,这个字典叫键空间(key space):
typedef struct redisDb {
    dict *dict;                 /* The keyspace for this DB */
    dict *expires;              /* Timeout of keys with a timeout set */
    dict *blocking_keys;        /* Keys with clients waiting for data (BLPOP) */
    dict *ready_keys;           /* Blocked keys that received a PUSH */
    dict *watched_keys;         /* WATCHED keys for MULTI/EXEC CAS */
    struct evictionPoolEntry *eviction_pool;    /* Eviction pool of keys */
    int id;                     /* Database ID */
    long long avg_ttl;          /* Average TTL, just for stats */
} redisDb;

增删改查方法实现略过,下面列出了读写键空间时的维护操作:
+ 在读取一个键(读写操作都要读取)服务器会根据键是否存在来更新服务器的键空间命中次数或键空间不命中次数,这两个值可以在info stats命令的keyspace_hits属性和keyspace_misses属性中查看;
+ 读取一个键会跟新键的LRU;
+ 如果在读取一个键时发现该键已经过期,那么服务器会先删除这个过期键,然后才执行余下的其他操作;
+ 如果有客户端使用watch命令监视了某个键,那么服务器在对被监视的键进行修改之后,会将这个键标记为dirty,从而让事务程序注意到这个键已经被修改过;
+ 服务器每次修改一个键后,都会对dirty键计数器的值增1,这个计数器会触发服务器的持久化以及复制操作;
+ 如果服务器开启了数据库通知服务功能,那么在对键进行修改之后,服务器将按配置发送相应的数据库通知。

  1. 设置键的TTL和过期时间
    操作命令
    通过expirepexpire可以以秒或者毫秒精度为键设置TTL;
    通过expireatpexpireat设置过期时间;
    使用ttlpttl接受一个带有ttl或过期时间的键,返回这个键的剩余时间;
    当剩余时间为0的时候,服务器会自动删除;
    命令persist可以一个键的过期时间,解除键和值在过期字典中的关联。
    保存过期时间
    redisDb结构的expires字典保存了数据库中所有键的过期时间,这个字典叫过期字典。数据结构如下:
typedef struct redisDb {
	// ...
    dict *expires;              /* Timeout of keys with a timeout set */
	// ...
} redisDb;
  1. 过期键删除策略

    • 定时删除
      设置过期时间的同时,创建一个定时器,在达到过期时间立即进行删除操作。
      优点:对内存最友好,及时释放过期键所占用的内存;
      缺点:对CPU时间是最不友好的,定时器会占用相当一部分CPU时间,在内存不紧张但是CPU时间非常紧张的情况下,会对服务器的相应时间和吞吐量造成影响。
    • 惰性删除
      每次从键空间获取键时,都检查取得的键是否过期,过期则删除该键,否则返回该键。
      优点:对CPU最友好,只有取出键时才对键进行过期检查,这个策略不会在删除其他无关的过期键上花费任何CPU时间;
      缺点:对内存不友好:如果一个键已经过期,而这个键又保留在数据库中,它占得内存就不会释放。
    • 定期删除
      是前两种策略的一种整合和折中。每个一段时间,程序就对数据库进行一次检查,删除里面的过期键。至于要删除多少过期键,以及要检查多少个数据库,由算法决定。
    • 小结
      定时删除和定期删除属于主动删除策略,惰性删除属于被动删除策略。
  2. AOF、RDB和复制功能对过期键的处理

    • 生成RDB文件
      执行save或者bgsave创建一个新RDB文件时,程序会对数据库中的键进行检查,过期的键不会被保存到新创建的RDB文件中。
    • 载入RDB文件
      • 服务器是主服务器,在载入RDB文件时,会忽略过期的键。
      • 服务器是从服务器,在载入RDB文件时,所有的键都会被载入RDB。
    • AOF文件写入
      服务器以AOF持久化模式运行时,如果数据库中的某个键已经过期,但是还没有被惰性删除或者定期删除,那么AOF文件不会因为这个过期键产生任何影响。当被惰性删除或定期删除之后,程序会向AOF文件追加一个DEL命令,来显示地记录该键已被删除。
    • AOF重写
      与生成RDB文件类似,执行AOF重写的过程中,已过期的键不会被保存到重写后的AOF文件中。
    • 复制
      当服务器运行在复制模式下时,从服务器的过期键删除动作由主服务器控制:
      • 主服务器删除一个过期键后,会显示地向所有从服务器发送一个DEL命令,告知从服务器删除该键。
      • 从服务器在执行客户端发送的读命令时,即使碰到过期键也不会将过期键删除,而是像未过期一样处理。
      • 从服务器只有在接到主服务器发来的DEL命令之后,才会删除过期键。
  3. 数据库通知
    是2.8版本新增功能,这个功能可以让客户通过订阅给定的频道或者模式,来获知数据库中键的变化,以及数据库中命令的执行情况。
    订阅通知:

127.0.0.1:6379> SUBSCRIBE __keyspace@0__:msg
Reading messages... (press Ctrl-C to quit)
1) "subscribe"
2) "__keyspace@0__:msg"
3) (integer) 1
1) "message"
2) "__keyspace@0__:msg"
3) "set"

发送通知:

127.0.0.1:6379> PUBLISH __keyspace@0__:msg set
(integer) 1

2. RDB持久化

Redis提供RDB持久化功能,这个功能可以将Redis在内存中的数据库状态保存到磁盘里,避免数据意外丢失。

  • RDB文件的创建与载入
    生成RDB文件命令有两个:savebgsave,前者同步阻塞,后者异步非阻塞(新fork子进程)
    。RDB文件的载入工作是在服务器启动时检测是否存在RDB文件,存在则自动载入RDB文件,在载入期间,Redis一直处于阻塞状态,直到载入工作完成。
  • 自动间隔性保存
    Redis允许用户通过设置服务器配置的save选项,让服务器每隔一段时间自动执行一次bgsave命令。
    设置保存条件(默认):
    save 900 1 # 服务器在900秒内,对数据库进行了至少1次修改
    save 300 10 # ...
    save 60 10000 # ...
    当Redis启动时,用户可以通过指定配置文件或者传入启动参数的方式设置save选项,如果没有主动设置,则使用上面的默认条件。save选项保存在redisServer结构的saveparams属性。
    在Redis内部有dirty计数器和lastsave属性分别记录着距离上一次成功执行save(bgsave)命令进行多少次修改和上一次执行save(bgsave)命令的时间。
    内部还有一个周期性操作函数serverCron默认每隔100ms执行一次。它用于对正在运行的服务器进行维护,其中一项是检查save选项设置的条件是否满足,如果满足则执行bgsave命令。
  • RDB文件结构
    完整的RDB文件所包含的各个部分:
    |REDIS|db_version|databases|EOF|check_sum|
    REDIS: 占用5字节,用来判断是否是RDB文件,相当于class文件的魔数;
    db_version: 占用4字节,用来记录数据库的版本号;
    databases: 包含着数据库的键值对数据,可以为空;
    EOF: 标志正文结束;
    check_sum: 校验和,验证RDB文件是否完整。
    分析RDB文件命令:
reids> save // 生成RDB文件
localhost> locate rdb.dump // 定位到文件,博主的在/var/lib/redis这个目录下
localhost> od -c rdb.dump // od分析

注:详细的RDB文件结构分析强烈建议看书去了解,书中详细的解释了数据库在RDB文件是怎么存储的。

  • 字符串压缩
    Redis默认开始字符串压缩,使用LZF算法
    如果字符串编码为REDIS_ENCODING_RAW并且字符串长度大于20字节,那么这个字节会被压缩之后再保存。
    如果想减少CPU的消耗,可以在redis.conf中找到rdbcompression yesyes修改为no,这样做带来的弊端是生成的RDB文件可能过大。

3. AOF持久化

AOF(Append Only File)持久化是通过保存Redis服务器执行的写命令来记录数据库的状态。

  1. AOF持久化的实现
    AOF是使用SDS结构作为缓冲区的,这个在前一篇文章有提过。
struct redisServer {
	// ...
    sds aof_buf;
    // ...
}

AOF持久化分为三个步骤:命令追加(append)文件写入文件同步(sync)

  • 命令追加:服务器执行一个写命令后,会以协议格式将命令追加到服务器状态的aof_bug的缓冲区的末尾。
  • 写入与同步:Redis服务器进程是一个事件循环,每次事件结束循环之前都会调换用flushAppendOnlyFile函数都会将aof_buf中的内容写入和保存到AOF文件里。flushAppendOnlyOnlyFile反映到配置项中就是appendfsync选项。
    always:将aof_buf缓冲区中的所有内容写入并同步到AOF文件;
    everysec:1秒同步1次,是默认值;
    no:OS自行决定何时同步。
  1. AOF的载入和数据还原
    创建一个伪客户端,读取AOF文件,依次一条条的执行命令。
  2. AOF重写
    目的是为了避免存储过多的冗余命令。核心原理是用一条命令去记录键值对,代替之前记录这个键值对的多个命令,重写函数为aof_write,这个函数会进行大量的写入操作,调用这个函数的线程将会被长时间阻塞,因为Redis服务器是单线程处理命令请求的,所以作为应该使用异步的方式执行AOF重写。使用了子进程执行重写,不使用线程的原因是是,子进程带有服务器进程的数据副本,可以在避免锁的情况下,保证数据库的安全性。以上就是bgrewriteaof的实现原理。

4. 事件

Redis服务器是一个事件驱动程序,服务器需要处理两类事件:

  • 文件事件:
    Redis服务器通过套接字与客户端和其他Redis服务器连接,文件事件就是服务器对套接字操作的抽象。服务器与客户端和其他Redis服务器的通信会产生相应的文件事件,服务器通过监听并处理这些事件完成一系列网络通信操作。
    Redis基于Reactor模式开发了自己的网络事件处理器,这个处理器被称为文件事件处理器。文件时间处理器时候用I/O多路复用来同时监听多个套接字,并根据目前执行的任务为套接字关联不同的事件处理器。当被监听的套接字准备好执行连接应答、读取、写入、关闭等操作时,与操作相对应的文件事件就会产生,这时文件事件处理器就会调用套接字之前关联好的事件处理器来处理这些时间。仔细理解Reactor模式的话,以上会比较容易理解。

  • 时间事件:
    对定时操作的抽象。分为定时时间和周期性时间两类。一个时间属性有idwhen
    timeProc三个属性组成。

5. 客户端

Redis服务器可以通过I/O多路复用与多个客户端进行网络通信。

客户端属性

结构redisClient保存了客户端的当前状态信息,包括:

  • 客户端的套接
    根据客户端类型的不同, fd 的值可以是 -1 或者大于 -1 的整数:
    • 伪客户端的fd为 -1,伪客户端处理的命令请求来源于 AOF 文件或 Lua 脚本,这种客户端不需要套接字
    • 普通客户端的fd大于-1,合法的套接字描述符是必然大于 -1 的整数
  • 客户端名字
    默认情况客户端是没有名字的,通过CLIENT list可以查看当前的客户端连接信息,其中的name是空白的。使用CLIENT setname命令可以为客户单设置名字
  • 客户端的flag
    记录了客户端的角色和目前所处的状态
  • 客户端正在使用的数据库的指针,以及该数据库的号码
  • 客户端当前要执行的命令、命令的参数、命令参数的个数,以及指向命令实现函数的指针
    将客户端发送的命令解析到客户端状态的argv数组属性和argc数组长度长度属性
  • 客户端的输入缓存区和输出缓存区
    输入缓冲区:用于保存客户端发送的命令请求
    输出缓冲区:保存执行命令所得的命令回复
  • 客户端的复制状态信息,以及进行复制所需的数据结构
  • 客户端执行BRPOPBLPOP等列表阻塞命令时用到的数据结构
  • 客户端的事务状态,以及执行WATCH命令时用到的数据结构
  • 客户端执行发布与订阅功能时用到的数据结构
  • 客户端的身份验证与标志
    用于记录客户端是否通过了验证,未通过为0,通过为1。
  • 客户端的创建时间,客户端和服务器最后一次通信的时间,以及客户端的输出缓冲区超出软性限制的时间

客户端的创建与关闭

之前有提过客户端分为普通客户端伪客户端两类。

  • 创建和关闭普通客户端
    创建:客户端使用connect函数连接到服务器,服务器会为客户端创建相应的客户端状态,并添加到clients链表的末尾
    关闭:普通客户端关闭有多种原因,比如客户端进程退出或被杀死、向服务器发送了不符合协议格式的命令请求等
  • 伪客户端
    Lua脚本的伪客户端:服务器初始化时会创建负责执行Lua脚本中包含的 Redis 命令的伪客户端,lua_client 伪客户端在服务器运行的整个生命周期一直存在,只有服务器被关闭,这个客户端才被关闭
    AOF文件的伪客户端:服务器在载入 AOF 文件时,会创建用于执行 AOF 文件包含的 Redis 命令的伪客户端,并在载入完成后,关闭这个伪客户端

6. 服务器

命令请求的执行过程

一条命令请求需要如下操作:

  1. 客户端向服务器发送命令请求
  2. 服务器接收并处理客户但发来的命令请求,对数据库进行数据读写,并产生响应内容
  3. 服务器返回响应内容
  4. 客户端接收服务器返回的内容并显示

发送命令请求
客户端会将命令请求转换成协议格式,通过套接字发送给服务器

读取命令请求
服务器调用命令请求处理器执行以下操作:

  1. 保存命令请求到客户端状态的输入缓冲区里
  2. 对输入缓冲区中的命令请求进行分享,提取其中包含的命令参数、命令参数的个数,分表保存到argvargc
  3. 调用命令执行器,执行客户端命令

命令执行器
分为:查找命令实现、执行预备操作(数据校验、身份验证、上下文环境等)、调用命令的实现函数、执行后续操作四大步骤。
命令执行器使用,大小写无关算法,所以命令不区分大小写。

将命令响应内容发送给客户端
当客户端套接字变为可写状态时,服务器会执行命令回复处理器,将保存在客户端缓存区的命令响应内容发送给客户端,并清空客户端的输出缓冲区,为处理下一个命令请求做好准备。

客户端接收并显示响应内容
解析协议,打印响应内容。

serverCron 函数

这个函数默认 100 ms执行一次,负责管理服务器的资源,并保持服务器自身的良好运转。

  • 更新服务器时间缓存
    为了减少系统调用,服务器状态中的提供了unixtimemstime属性作为时间缓存。因为 100 ms 一次的频率更新,这个时间并不精确。主要用在打印日志、更新服务器 LRU 时钟、决定是否执行持久化任务、计算服务器上线时间这类对时间精确度不高的功能上。
    对于为键甚至过期时间、添加慢查询日志这种需要高精度时间的功能,服务器还是会再次执行系统调用获取精确时间。

  • 更新 LRU 时钟
    服务器状态lrulock属性保存了服务器的 LRU 时钟,也是服务器时间缓存的一种。默认每 10 s更新一次。每个redisObject都有一个lru属性,保存了对象的最后一次被访问的时间。空转时间 = lrulock - lru。

  • 更新服务器美妙执行命令次数
    serverCron函数中的trackOperationPerSecond函数会以每 100 ms 一次的频率执行,功能是通过抽样计算的方式估算并记录最近一秒处理的命令请求数量。可以通过INFO status命令的instantantaneous域查看。

  • 更新服务器内存峰值记录
    服务器状态中的stat_peak_memory属性记录了服务器的内存峰值大小。

  • 处理 SIGTERM 信号
    每次serverCron运行时,程序都会对shutdown_asp属性检查,根据属性的值决定是否关闭服务器。这个属性由SIGTERM信号关联处理器sigtermHandler函数处理。

  • 管理客户端资源
    serverCron每次执行都会调用````clientsCron```函数。释放超时客户端和重建客户端的输入缓冲区。

  • 管理数据库资源
    serverCron每次执行都会调用databaseCron函数。检查部分数据库:删除其中的过期键、收缩字典。

  • 执行被延迟的 BGREWRITEAOF
    在服务器执行BGSAVE命令期间,如果客户端发来的BGREWRITEAOF命令,服务器会将BGREWRITEAOF命令的执行时间延迟到BGSAVE执行完毕之后。

  • 检查持久化操作的运行状态
    检查BGSAVEBGREWRITEAOF命令是否正在执行。

  • 将 AOF 缓存区中的内容写入 AOF 文件
    如果开启了 AOF 持久化功能,并且 AOF 中有待写入数据,serverCron函数会调用相应的程序,将 AOF 缓冲区内容写到 AOF 文件里面。

  • 关闭客户端
    服务器会关闭那些输出缓冲区大小超出限制的客户端。

  • 增加 cronloops 计数器的值
    cronloops属性记录了serverCron函数的执行次数。

初始化服务器

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

  • 初始化化服务器状态结构
    初始化工作由redis.c/initServerConfig函数完成,主要工作:
    设置服务器的运行ID、设置服务器的默认运行频率、默认配置文件路径、运行架构、默认端口号、默认 RDB 持久化条件和 AOF 持久化条件、初始化服务器的 LRU 时钟、创建命令表。

  • 载入配置选项
    用户可以通过给定的配置参数或者配置文件修改服务器的默认配置。

  • 初始化服务器数据结构
    服务器将调用initServer函数,为之前创建的数据结构分配内存、设置或关联初始化值。
    initServer还进行了一些非常重要的设置操作,包括:

    • 为服务器设置进程信号处理器
    • 创建共享对象
    • 打开服务器的监听端口
    • serverCron函数创建时间事件,等待服务器正式触发
    • 为 AOF 写入做好准备
    • 为将来的 I/O 操作做好准备

    执行完毕后,服务器江永ASCII字符在日志中打印 Redis 的图标和版本信息。

  • 还原数据库状态
    在完成对象的初始化后,服务器将载入 RDB 或 AOF 文件,并根据文件记录的内容还原数据库状态。

  • 执行时间循环
    初始化的最后一步,打印接收请求的日志,开始执行服务器的时间循环,开始接收并处理客户端的连接请求。

原文地址:https://www.cnblogs.com/liushijie/p/5092764.html