【读书笔记】《Redis设计与实现(第二版)》:Part2 单机数据库的实现 (一)

3 篇文章 0 订阅
1 篇文章 0 订阅

本文是接着上一篇文章【读书笔记】《Redis设计与实现(第二版)》:Part 1数据结构与对象继续对书中内容的学习和总结,主要与redis中数据库的实现和提供的相关操作有关。

9. 数据库

9.1 服务器中的数据库

redis服务器将所有数据库都保存在服务器状态redis.h/redisServer结构的db数组中,db数组的每个项都是一个redis.h/redsiDb结构,每个redisDb结构代表一个数据库:

struct redisServer{
    // ...
    // 一个数组,保存着服务器中的所有数据库
    redisDb *db;
    // 服务器的数据库数量
    int dbnum; 
    // ...
}

9.2 切换数据库

每个redis客户端都有自己的目标数据库,每当客户端执行数据库写命令或者读命令时,目标数据库就会成为这些命令的操作对象。
默认情况下,redis客户端的目标数据为0号数据库,但客户端可以通过执行SELECT命令来切换目标数据库。

redis > SET msg "hello world"
OK
redis > SELECT 2
OK
redis[2] > GET msg
(nil)
redis[2] > SET msg "another world"
OK
redis[2] > GET msg
"another world"

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

typedef struct redisClient{
    // ...
    // 记录客户端当前正在使用的数据库
    redisDb *db;
    
    // ...
}redisClient;

redisClient.db指针指向redisServer.db数组的其中一个元素,而被指向的元素就是客户端的目标数据库。SELECT指令可以修改redisClient.db指向的数据库

9.3 数据库键空间

redis是一个键值对数据库服务器,服务器中的每个数据库都由一个redis.h/redisDb结构表示,其中,redisDb结构的dict字典保存了数据库中的所有键值对,我们将这个称为键空间。

typedef struct redisDb{
    // ...
    
    // 数据库键空间,保存着数据库中的所有键值对
    dict *dict;

    // ...
}redisDb;

键空间和用户所见的数据库是直接对应的:

  • 键空间的键也就是数据库的键,每个键都是一个字符串对象;
  • 键空间的值也就是数据库的值,每个值可以是字符串对象、列表对象、哈希表对象、集合对象和有序集合对象的任意一种redis对象。

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-YvBTZwc7-1593872107458)(./part2_img/键空间.png)]

数据库的操作
  1. 添加新键,例如:
redis> SET date "2013.12.1"
OK
  1. 删除键,例如:
redis> DEL book
(integer) 1
  1. 更新键,例如:
redis> SET message "blah blah"
OK

// 往已有的哈希表上添加新的键值对
redis> HSET book page 320
(integer) 1
  1. 对键取值,例如:
redis> GET message
"hello world"

// 查找alphabet键所有列的值
redis> LRANGE alphabet 0 -1
1) "a"
2) "b"
3) "c"
  1. 其他键空间操作,例如:
FLUSHDB //清空整个数据库
RANDOMKEY //随机返回数据库中某个键
DBSIZE //返回数据库键的数量
等

9.4 设置键的生存时间或过期时间

通过EXPIRE命令或者PEXPIRE命令,客户端可以以秒或者毫秒精度为数据库中的某个键设置生存时间(Time To Live,TTL),在经过指定的秒数或者毫秒数之后,服务器就会自动删除生存时间为0的键。

redis> SET key value
OK

redis> EXPIRE  key 5
(integer) 1

redis> GET key //5秒之内
"value"

redis> GET key //5秒之后
(nil)

客户端可以通过EXPIREAT命令或PEXPIREAT命令,以秒或者毫秒精度给数据库中的某个键设置过期时间。过期时间是一个UNIX时间戳,当键的过期时间来临时,服务器会自动从数据库中删除这个键:

redis> SET key value
OK

redis> EXPIREAT key 1377257300
(integer) 1

redis> GET key
value

redis> TIME
1)"1377257303"
2)"230656"

redis> GET key
(nil)

TTL命令和PTTL命令接受一个带有生存时间或者过期时间的键,返回这个键的剩余生存时间,也就是,返回距离这个键被服务器自动删除还有多长时间。

redis> SET key value
OK

redis> EXPIRE key 1000
(integer) 1

redis> TTL key
(integer) 997
保存过期时间

redisDB结构的expires字典保存了数据库中所有键的过期时间,我们称这个字典为过期字典:

  • 过期字典的键是一个指针,这个指针指向键空间中的某个键对象
  • 过期字典的值是一个long long类型的整数,这个整数保存了键所指向的数据库键的过期时间——一个毫秒精度的UNIX时间戳。
typedef struct redisDb{
    // ...
    
    // 过期字典,保存着键的过期时间
    dict *expires;

    // ...
}redisDb;

移除过期时间

PERSIST命令可以移除一个键的过期时间:

redis> PEXPRIREAT message 1391234400000
(integer) 1

redis> TTL message
(integer) 13893281

redis> PERSIST message
(integer) 1

redis> TTL message
(integer) -1

9.5 过期键删除策略

如果一个键过期了,那么它什么时候会被删除呢?

三种不同的删除策略:

  • 定时删除:在设置键的过期时间的同时,创建一个定时器,让定时间在键的过期时间来临时,立即执行对键的删除操作。
  • 惰性删除:放任键过期不管,但是每次从键空间中获取键时,都检查取得的键是否过期,如果过期的话,就删除该键;如何没有过期,就返回该键。
  • 定期删除:每隔一段时间,程序就对数据库进行一次检查,删除里面的过期键。
过期删除策略定时删除惰性删除定期删除
优点对内存优化:使用定时器,保证过期键尽快删除,释放占用的内存对CPU时间友好:不会在删除其他无关的过期键上花费任何CPU时间一种折中的方式:通过限制删除操作执行的时长和频率来减少对CPU时间的影响,同时也减少了过期键积压而带来的内存浪费
问题对CPU时间不友好:在过期键比较多的情况,删除过期键可能会占用相当一部分CPU时间,影响服务器响应时间和吞吐量;
redis中的时间事件通过无序链表管理,查找时间复杂度为O(n),不能高效处理大量时间事件
对内存不友好:会造成过期键大量积压在数据库中,占据内存空间难点是确定删除操作执行的时长和频率

9.6 Redis的过期键删除策略

Redis服务器实际使用的是惰性删除和定期删除两种策略:通过配合使用这两种删除策略,服务器可以很好地合理使用CPU时间和避免浪费内存空间之间取得平衡。
过期键的惰性删除策略由db.c/expireIfNeeded函数实现,所有读写数据库的Redis命令在执行之前都会调用expireIfNeeded函数对输入键进行检查:

  • 如果输入键已经过期,那么将输入键从数据库中删除
  • 如果输入键未过期,那么expireIfNeeded不做动作
    另外还要考虑键存在和不存在的情况:

定期删除策略由redis.c/activeExpireCycle函数实现,每当Redis的服务器周期性操作redis.c/serverCron函数执行时,activeExpireCycle函数就会被调用,它在规定的时间内,分多次遍历服务器中的各个数据库,从数据库的expires字典中随机检查一部分键的过期时间,并删除其中的过期键。

9.7 AOF、RDB和复制功能对过期键的处理

  1. 生成RDB文件
    在执行SAVE命令或者BGSAVE命令创建一个新的RDB文件时,程序会对数据库中的键进行检查,已过期的键不会被保存到新创建的RDB文件中。

  2. 载入RDB文件
    在启动Redis服务器时,如果服务器开启了RDB功能,那么服务器将对RDB文件进行载入:
    1). 服务器:未过期的键载入,过期键会被忽略
    2). 从服务器:保存所有键,不论是否过期,都会被载入到数据库中。

  3. AOF文件写入
    当服务器以AOF持久化模式运行时,如果数据库中的某个键已经过期,但它还没有被惰性删除或者定期删除,那么AOF文件不会因为这个过期键而产生任何影响。
    当过期键被删除,程序会向AOF文件追加一条DEL命令,来显式地记录该键已被删除。

  4. AOF重写

    • AOF持久化是通过保存被执行的写命令来记录数据库状态的,所以AOF文件的大小随着时间的流逝一定会越来越大;影响包括但不限于:对于Redis服务器,计算机的存储压力;AOF还原出数据库状态的时间增加;
    • 为了解决AOF文件体积膨胀的问题,Redis提供了AOF重写功能:Redis服务器可以创建一个新的AOF文件来替代现有的AOF文件,新旧两个文件所保存的数据库状态是相同的,但是新的AOF文件不会包含任何浪费空间的冗余命令,通常体积会较旧AOF文件小很多。

    已过期的键不会被保存到重写后的AOF文件中。

  5. 复制
    当服务器在复制模式下时,从服务器的过期键删除动作有 主服务器控制:

  • 主服务器在删除一个过期键之后,会显式地向所有从服务器发送DEL命令,告知从服务器删除这个过期键
  • 从服务器在执行客户端发送的读命令时,即使碰到过期键也不会将过期键删除,而是继续像处理未过期的键一样来处理过期键
  • 从服务器只有在接到主服务器发来的DEL命令之后,才会删除过期键。

9.8 数据库通知

数据库通知是Redis 2.8版本新增加的功能,这个功能可以让客户端通过订阅给定的频道或者模式,来获知数据库中键的变化,以及数据库中命令的执行情况。

  • 键空间通知:关注“某个键执行了什么命令”
  • 键事件通知:关注“某个命令被什么键执行了”

发送数据库通知的功能是由notify.c/notifyKeysapceEvent函数实现的:

void notifyKeyspaceEvent(int type, char *event, robj *key, int dbid);

该函数执行以下操作:

  1. 检查给定的通知类型是否是服务器允许发送的通知类型,不是则结束
  2. 如果给定的通知是允许发送的,检测服务器是否允许发送键空间通知,允许则构建并发送通知
  3. 如果给定的通知是允许发送的,检测服务器是否允许发送键事件通知,允许则构建并发送通知

10. RDB持久化

10.1 RDB文件的创建与载入

RDB持久化功能所生成的RDB文件是一个经过压缩的二进制文件,通过该文件可以还远生成RDB文件时的数据库状态。Redis保存和载入RDB事务方法:SAVE命令和BGSAVE命令的实现方式。

  • SAVE命令会阻塞Redis服务器进程,直到RDB文件创建完毕为止,为服务器进程阻塞期间,服务器不能处理任何命令请求
  • BGSAVE会派生出一个子进程,然后由子进程负责RDB文件,服务器进程(父进程)继续处理命令请求

和使用SAVE命令或者BGSAVE命令创建RDB文件不同,RDB文件的载入工作在服务器启动时自动执行的,所以Redis并没有专门用于载入RDB文件的命令,只要Redis服务器在启动时检测到RDB文件存在,它就自动载入RDB文件。

AOF文件的更新频率通常比RDB文件的更新频率高,所以:

  • 如果服务器开启AOF持久化功能,那么服务器会优先使用AOF文件来还原数据库状态。
  • 只有在AOF持久化功能处于关闭状态时,服务器才会使用RDB文件来还原数据库状态。

10.2 自动间隔性保存

  1. 当redis服务器启动时,用户可以通过指定配置文件或者传入启动参数的方式设置save选项,如果用户没有主动设置save选项,那么服务器会为save选项设置默认条件:

    save 900 1
    save 300 10
    save 60 10000
    // save <xxx> <yyy>
    // 服务器在xxx秒内对数据库进行了至少yyy次修改,服务器就会执行*BGSAVE*命令
    

    在redisServer结构体中,saveparams属性会保存根据save选项所设置的保存条件

    struct saveparam{
        // 秒数
        time_t seconds;
        // 修改数
        int changes;
    };
    

    [外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-ygpyegy7-1593872107461)(./part2_img/服务器状态中的保存条件.png)]

  2. dirty计数器和lastsave属性
    除了saveparams数组之外,服务器状态还维持着一个dirty计数器,以及一个lastsave属性:

  • dirty计数器记录了距离上一次成功执行SAVE 或者BGSAVE命令之后,服务器对数据库状态(服务器中的所有数据库)进行了多少次修改;
  • lastsave属性是一个UNIX时间戳,记录了服务器上一次成功执行SAVE命令或者BGSAVE命令的时间

10.3 RDB文件结构

  • db_version:长4个字节,一个字符串表示的整数,记录RDB文件的版本号;
  • databases:包含两个或任意多个数据库,以及各个数据库中的键值对数据;
  • EOF:1个字节,标志着RDB文件正文内容的结束;
  • check_sum:8字节的无符号整数,保存一个校验和。
databases部分

一个databases部分可以保存任意多个非空数据库,如下图中的databases 0和databases 3

每个databases的内部结构:

1). SELECTDB:当读入程序遇到这个值的时候,它知道接下来要读入的将是一个数据库号码
2). db_number:保存一个数据库号码
3). key_value_pairs保存数据库中所有键值对数据
其中这个部分中存在两个结构:带过期时间的键值对和不带过期时间的键值对


每个键值对的value部分都保存了一个值对象,每个值对象的类型都由与之对应的TYPE记录,根据类型的不同,value部分的结构和结构也会有所不同,这里不再深入探讨。

10.4 分析RDB文件

使用od命令来分析redis服务器产生的RDB文件,该命令可以用给定的格式转存(dump)并打印输入文件。给定-c参数可以以ASCII编码的方式打印输入文件,给定-x参数可以以十六进制的方式打印输入文件,具体可以参考od命令的文档。

11. AOF持久化

除了RDB持久化功能之外,Redis还提供了AOF持久化功能。与RDB持久化通过保存数据库中的键值对来记录数据库状态不同,AOF持久化通过保存Redis服务器所执行的写命令来记录数据库状态。
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-omr2jxwG-1593872107466)(part2_img/AOF持久化.png)]

11.1. AOF持久化的实现

AOF持久化功能的实现跨越分为命令追加、文件写入、文件同步三个步骤。

  1. 命令追加
    当AOF持久化功能处于打开状态时,服务器在执行完一个写命令之后,会以协议格式将被执行的写命令追加到服务器状态的aof_buf缓冲区的末尾:

    struct redisServer{
        // ...
    
        // AOF缓冲区
        sds aof_buf;
    
        // ...
    
    };
    

    举个例子,如果客户端向服务器发送以下命令:

    redis> SET KEY VALUE
    OK
    

    那么服务器在执行这个SET命令之后,会将以下协议内容追加到aof_buf缓冲区的末尾:

    *\r\n$3\r\nSET\r\n$3\r\nKEY\r\n$5\r\nVALUE\r\n
    
  2. Redis的服务器进程就是一个事件循环(loop),这个循环中的文件事件负责接收客户端的命令请求,以及向客户端发送命令回复,而时间事件则负责执行像serverCron函数这样需要定时运行的函数。
    服务器在处理文件事件时可能会执行写命令,使得一些内容被追加到aof_buf缓冲区里面,所以在服务器每次结束一个事件循环之前,它都会调用flushAppenOnlyFile函数,考虑是否将aof_buf缓冲区中的内容写入和保存到aof文件里。
    flushAppendOnlyFile函数的行为由服务器配置的appendsync选项的值来决定,各个不同值产生的行为如下表所示:

    appendfsync选项的值flushAppendOnlyFile函数的行为
    always将aof_buf缓冲区中的所有内容写入并同步到AOF文件中,同步的频率是每个事件循环一次
    everysec将aof_buf缓冲区中的所有内容写入到AOF文件中,如果上次同步AOF文件的时间距离现在超过1s,那么在此对AOF文件进行同步,并且这个同步操作是由一个线程专门负责执行的
    no将aof_buf缓冲区中的所有内容写入到AOF文件,但并不对AOF文件进行同步,何时同步有操作系统来决定

11.2. AOF文件的载入与数据还原

  1. 创建一个redis客户端
  2. 从AOF文件中分析并读取出一条写命令
  3. 客户端执行写命令
  4. 循环执行2、3直到AOF文件中的所有写命令都被处理完毕为止。

11.3. AOF重写

因为AOF持久化是通过保存被执行的写命令来记录数据库状态的,所以苏浙服务器运行时间的流逝,AOF文件中的内容会越来越多,文件的体积也会越来越大,对服务器的性能造成影响。为了解决AOF文件体积膨胀的问题,Redis提供AOF文件重写功能。Redis通过创建一个新的AOF文件来代替现有的AOF文件,新旧两个AOF文件所保存的数据库状态相同,但新的AOF文件体积要小得多。

为了减少保存某个键所需的命令的数量,服务器库通过读取这个键的值,然后再使用写入命令,插入键的所有值。这样写入的命令可以减少为一条。

AOF后台重写

在进行AOF重写的过程,重写程序会创建一个新AOF文件,执行大量的写入操作。所以为了防止调用这个程序造成长时间的进程阻塞,Redis会将AOF重写程序放到子进程里执行,这样做可以:

  • 子进程进行AOF重写期间,服务器进程(父进程)可以继续处理命令请求;
  • 子进程带有服务器进程的数据副本,使用子进程而不是线程,可以避免使用锁的情况下,保证数据的安全性。

在子进程进行AOF重写期间,服务器还需要继续处理命令请求,而新的命令会对现有的数据库状态进行修改,从而使得服务器当前的数据库状态和重写后的AOF文件所保存的数据库状态不一致。例如当子进程开始文件重写时,数据库中只有k1一个键,但是当子进程完成AOF文件重写之后,服务器进程的数据库中已经新设置了k2,k3,k4三个键,因此重写后的AOF文件和服务器当前的数据库状态并不一致。

为了解决这种数据不一致问题,Redis服务器设置了一个AOF重写缓冲区,这个缓冲区在服务器创建子进程之后开始使用,当Redis服务器执行完一个写命令之后,它会同时将这个写命令发送给AOF缓冲区和AOF重写缓冲区。

这样一来保证:

  • AOF缓冲区的内容会定期被写入和同步到AOF文件,对现有AOF文件的处理工作会如常进行;
  • 从创建子进程开始,服务器执行的所有写命令都会被记录到AOF重写缓冲区里面。

当子进程完成AOF重写工作之后,它会向父进程发送一个信号,父进程在接到该信号之后,会调用一个信号处理函数,并执行以下工作:
1). 将AOF重写缓冲区中的所有内容写入到新AOF文件中;
2). 对新的AOF文件进行改名,原子覆盖现有的AOF文件。

这个信号处理函数执行完毕后,父进程可以继续像往常一样接受命令请求。在整个AOF后台重写过程中,只有信号处理函数执行时会对服务器进程(父进程)造成阻塞。其他时候,AOF后台重写都不会阻塞父进程,降低AOF重写对服务器性能的影响。

12. 事件

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

  • 文件事件:Redis服务器疼痛感套接字与客户端进行连接,而文件事件就是服务器对套接字操作的抽象。服务器与客户端的通信会产生相应的文件事件,而服务器则通过监听并处理这些事件来完成一系列网络通信操作。
  • 时间事件:Redis服务器中的一些操作需要在给定的时间执行,而时间事件就是服务器对这类定时操作的抽象。

12.1. 文件事件

Redis基于Reactor模式开发了自己的网络事件处理器:这个处理器被称为文件事件处理器:

  • 文件事件处理器使用I/O多路复用程序来同时监听多个套接字,并根据套接字目前执行的任务来为套接字关联不同的事件处理器
  • 当被监听的套接字准备好执行连接应答、读取、写入、关闭等操作时,与操作相对应的文件事件就会产生,这时文件事件处理器就会调用套接字之前关联好的事件处理器来处理这些事件。

文件事件处理器以单线程方式运行,但通过使用I/O多路复用程序来监听多个套接字,文件事件处理器既实现了高性能的网络通信模型,又可以很好地与Redis服务器中其他同样以单线程方式运行的模块进行对接。

  1. 文件事件处理器
    文件事件处理器的四个组成部分:套接字、I/O多路复用抽象、文件事件分派器以及事件处理器。

    一个服务器通常会连接多个套接字,所以多个文件事件有可能会并发地出现。I/O多路复用程序负责监听多个套接字,并向文件事件分派器传送那些产生了事件的套接字。尽管多个文件事件可能会并发地出现,但I/O多路复用程序总是会将所有产生事件的套接字都放到一个队列里面,然后通过这个队列。以有序、同步、每次一个套接字的方式向文件事件分派器传送套接字。当上一个套接字产生的事件被处理完毕之后,I/O多路复用程序才会继续向文件事件分派器传送下一个套接字。

  2. I/O多路复用程序的实现
    Redis的I/O多路复用程序的所有功能都是通过包装常见的select、epoll、evport和kqueue这些I/O多路复用函数库来实现的,每个I/O多路复用函数库在Redis源码中都对应一个单独的文件。I/O多路复用程序的实现源码中用#include宏定义了相应的规则,程序会在编译时自动选择系统中性能最高的I/O多路复用函数库作为Redis的I/O多路复用程序作为底层实现。

  3. 事件类型

    I/O多路复用程序可以监听多个套接字的AE_READABLE事件和AE_WRITABLE事件,这两个事件和套接字操作之间的对应关系如下:

    • 套接字可读时或者有新的可应答(acceptable)套接字时,产生AE_READABLE事件
    • 套接字变得可写时,产生AE_WRITABLE事件
  4. 文件事件的处理器

    Redis为文件事件编写了多个处理器,这些事件处理器分别用于实现不同的网络通信需求。

    1). 连接应答处理器:用于对连接服务器监听套接字的客户端进行应答,具体实现为对socket.h/accept函数的包装;
    2). 命令请求处理器:负责从套接字中读入客户端发送的命令请求内容,具体实现为unistd.h/read函数的包装;
    3). 命令回复处理器:负责将服务器执行命令得到的命令回复通过套接字返回给客户端,具体实现为unistd.h/write函数的包装。

12.2. 时间事件

Redis的时间事件分为以下两类:

  • 定时时间:让一段程序在指定的时间之后执行一次
  • 周期性时间:让一段程序每隔指定时间就执行一次

一个时间事件主要由以下三个属性组成:

  • id:服务器为时间事件创建的全局唯一ID。ID号按从小到大的顺序递增,新事件的ID比旧事件的ID号要大;
  • when:毫秒精度的UNIX时间戳,记录了时间事件的到达时间
  • timeProc:时间事件处理器,一个函数。当时间事件达到时,服务器就会调用相应的处理器来处理事件。

一个时间事件是定时事件还是周期事件取决于时间事件处理器的返回值,返回AR_NOMORE则为定时事件,该事件在到达一次之后就会被删除,之后不再到达;否则为周期事件,当周期事件到达后,会根据返回值,对when属性进行更新,让这个时间在一段时间之后再次到达。

12.3. 事件的调度与执行

因为服务器中同时存在文件事件和时间事件两种事件类型,所以服务器必须对这两种事件进行调度,决定何时应该处理文件事件,何时又应该处理时间事件,以及花多少时间来处理它们等等。

事件的调度和执行规则:

  1. 文件事件的I/O多路复用程序的最大阻塞时间有到达时间最接近当前时间的时间事件决定,这样既可以避免服务器对时间事件进行频繁的轮询,也可以确保阻塞时间过长;
  2. 因为文件事件是随机出现的,如果等待并处理完一次文件事件后,仍未有 任何时间事件到达,那么服务器将再次等待并处理文件事件。随着文件事件的不断执行,时间会逐渐向时间事件所设置的到达时间逼近,并最终到达;
  3. 对文件事件和时间事件的处理都是同步、有序、原子地执行的,服务器不会中途中断事件处理,也不会对事件进行抢占。因此,对于文件事件和时间事件,它们需要尽可能地减少程序的阻塞时间,并在有需要时主动让出执行权;
  4. 因为时间事件在文件事件之后执行,并且事件之间不会抢占,所以时间事件的实际处理事件通常会稍晚一些。

待续…

如果觉得还不错,关注公众号获取更多优质文章 ~

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值