Redis从0开始系列(二)—单机数据库

129 阅读11分钟

一、数据库

1.1 数据库概念

Redis中通过数据库的物理模型存储数据:

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

dbnum属性的值由服务器配置的database选项决定,默认情况下,该选项的值为16,所以Redis服务器默认会创建16个数据库

NeatReader-1693986658337

客户端与Redis建立连接后会默认选择0号数据库,不过可以随时使用SELECT命令更换数据库。

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

1.2 数据库的作用

Redis实例默认建立了16个db,由于不支持自主进行数据库命名所以以dbX的方式命名。默认数据库数量可以修改配置文件的database值来设定。对于db正确的理解应为“命名空间”,多个应用程序不应使用同一个Redis不同库,而一个应用程序对应一个Redis实例,不同的数据库可用于存储不同环境的数据。

因此,数据库是为了应对当只有单机实例时,多个应用使用同一个实例应进行环境隔离

而在集群的情况下不支持使用select命令来切换db,因为Redis集群模式下只有一个db0。

二、键淘汰策略

2.1 什么是淘汰策略

当我们设置了一个带有超时时间的Key,例如:

redis>expire key_Test 60
1
redis>ttl key_Test
58

当时间过了60S之后,该key的有效期已经过期,正常来说当我们第80S再次访问应该访问不到。

但是,这个Key是什么时候被物理删除的呢?是第60S的时候Redis的定时器准时帮我们删除的?还是是第70S的时候Redis的定时任务帮我们删除的?又或者是是当我们访问的时候Redis发现这个Key已经过期了,第80S的时候删除的?

其实Redis的淘汰策略就存在这三个问题之中:

  • 定时删除:在设置键的过期时间的同时,创建一个定时器(timer),让定时器在键的过期时间来临时,立即执行对键的删除操作。
  • 定期删除:每隔一段时间,程序就对数据库进行一次检查,删除里面的过期键。至于要删除多少过期键,以及要检查多少个数据库,则由算法决定。
  • 惰性删除:放任键过期不管,但是每次从键空间中获取键时,都检查取得的键是否过期,如果过期的话,就删除该键;如果没有过期,就返回该键。

2.2 定时删除

定时删除是每次有设置过期键的时候就启动一个定时器,定时器一直轮询查看该Key是否过期,一但发现过期就直接删除。毫无疑问,该方式对内存及其友好,因为过期的Key会基本上立马删除。但是对CPU也及其不友好,假如我们设置了一个长达一年的过期时间,CPU就需要一直轮询一年查看是否过期,一旦数据量过大,CPU的使用率肯定会飙升。

2.3 定期删除

定期删除使用折中策略,比如每隔1S,就抽查一批Key查看其是否过期。这种方式能够避免定时删除中的不停轮询消耗CPU的问题。

2.4 惰性删除

当Key被访问时,会先查看该Key是否过期,如果过期的话就返回空,并且此时再将此Key物理删除。毫无疑问的是该方式对CPU时及其友好的,因为基本上不会存在耗费CPU的操作,同时对内存也是及其不友好的。如果一批Key长时间一直未被访问,那么会一直占用内存空间,极其浪费空间存储效率。

NeatReader-1693988103430

2.5 常用的淘汰策略

Redis使用的淘汰策略是定期删除+惰性删除,通过配合使用这两种删除策略,服务器可以很好地在合理使用CPU时间和避免浪费内存空间之间取得平衡。

三、RDB持久化

什么是持久化?持久化就是当系统关机或宕机之后,再次启动能够恢复到上一次关机之前的状态。Redis的持久化机制有两种:RDB与AOF。

3.1 RDB命令

我们1.1中讲到,Redis中数据都存在于数据库,而想要做到持久化,只需要将数据库中的所有数据保存下来即可。当Redis再次启动时,只需要将数据重新写入数据库即可完成持久化机制。因此,也可以将RDB简单的理解为存储快照。

RDB命令有两种:SAVEBGSAVE,两者的区别是阻塞和非阻塞的

SAVE命令会阻塞Redis服务器进程,直到RDB文件创建完毕为止,在服务器进程阻塞期间,服务器不能处理任何命令请求。因此我们生产中一般不会使用此命令。

和SAVE命令直接阻塞服务器进程的做法不同,BGSAVE命令会派生出一个子进程,然后由子进程负责创建RDB文件,服务器进程(父进程)继续处理命令请求。

def BGSAVE():
    # 创建子进程
    pid = fork()
    if pid == 0:
        # 子进程负责创建RDB文件
        rdbSave()
        # 完成之后向父进程发送信号
        signal_parent()
    elif pid > 0:
        # 父进程继续处理命令请求,并通过轮询等待子进程的信号
        handle_request_and_wait_signal()
    else:
        # 处理出错情况
        handle_fork_error()

此外,Redis服务器会优先使用AOF的方式来回滚数据,这是因为AOF的数据更新频率比RDB快。只有当AOF关闭时才会使用RDB。

3.2 RDB文件结构

本章内容将讲述RDB的文件结构,看一下数据是怎么存储在RDB文件中的。

RDB文件主要有以下五个部分:

NeatReader-1693992048912

  • REDIS,一个常量字符串,用于标识该文件为RDB文件。
  • db_version长度为4字节,它的值是一个字符串表示的整数,这个整数记录了RDB文件的版本号,比如"0006"就代表RDB文件的版本为第六版。
  • databases,用于存储数据库中的数据。
  • EOD,也为常量,标识数据库部分已经结束。
  • check_sum是一个8字节长的无符号整数,保存着一个校验和,这个校验和是程序通过对REDIS、db_version、databases、EOF四个部分的内容进行计算得出的。服务器在载入RDB文件时,会将载入数据所计算出的校验和与check_sum所记录的校验和进行对比,以此来检查RDB文件是否有出错或者损坏的情况出现。

3.2.1 databases

databases中可以保存多个数据库,每个数据库的格式为:

NeatReader-1693992577078

其中:

  • SELECTDB常量的长度为1字节,当读入程序遇到这个值的时候,它知道接下来要读入的将是一个数据库号码。
  • db_number用于标识该数据库的编号。
  • key_value_pairs部分保存了数据库中的所有键值对数据,如果键值对带有过期时间,那么过期时间也会和键值对保存在一起。

3.2.2 key_value_pairs

key_value_pairs保存了多个键值对,即我们存入Redis的数据。

NeatReader-1693992677256

其中:

  • TYPE记录了value的类型,长度为1字节,一般是以下几种的一种:

REDIS_RDB_TYPE_STRING

REDIS_RDB_TYPE_LIST

REDIS_RDB_TYPE_SET

REDIS_RDB_TYPE_ZSET

REDIS_RDB_TYPE_HASH

......

  • key是一个字符串对象,即我们存入的key
  • value为具体的值

带有过期时间的键值对:

NeatReader-1693992805154

其中

  • EXPIRETIME_MS常量的长度为1字节,它告知读入程序,接下来要读入的将是一个以毫秒为单位的过期时间。
  • ms为8位的UNIX时间戳,标识该key的过期时间。

四、AOF持久化

我们与Redis的交互都是通过命令的方式实现的,试想一下,我们除了可以保存数据库的状态,是不是也可以保存所有执行过的命令呢?我们只需要在启动时执行所有执行过的命令,最终数据库的状态也可以达到关机之前的状态。

Redis 服务在启动之后会陷入一个巨大的 while 循环,不停地执行 processEvents 方法处理文件事件时间事件 ,每次循环中执行完上述的两次事件之后,都会执行AOF操作。一次完整的循环。即文件时间+时间时间+AOF操作称之为一次时间循环。

4.1 AOF实现

Redis会开辟一块缓冲区,在每有新的修改类型命令执行时,会同时将该命令写入该缓冲区。此后,每当开始执行AOF操作时,会将该缓冲区的命令通过操作系统写入到硬盘上的AOF文件中。

其中写入硬盘的时机有三种模式:

  • always,每次事件循环都会将新的命令写入到磁盘上的AOF文件中。该操作效率最慢,因为需要等待将数据写入磁盘中,但是安全度最高。
  • everysec,会判断上次写入操作距今是否已经超过1S,是的话再继续写入。换句话说,每间隔至少一秒钟写入一次磁盘。
  • no,不主动写入磁盘,等待操作系统择机写入。该操作效率最高,但安全性较低。

4.2 AOF读取

因为AOF文件中已经包含了所有要执行的命令,因此只需要执行文件中所有的命令即可恢复原来的数据库状态。

Redis的命令只能在客户端执行,因此Redis服务器会创建一个不带网络连接的伪客户端,由该客户端执行这些所有的AOF命令,服务端像正常的服务器一样接受这些命令即可。

4.3 AOF重写

4.3.1 重写实现

随着服务器的运行时长累加,AOF文件的大小肯定会快速膨胀,因此我们需要对AOF文件进行优化重写。

例如:

redis> RPUSH list "A" "B"          // ["A", "B"]
(integer) 2
redis> RPUSH list "C"                        // ["A", "B", "C"]
(integer) 3
redis> RPUSH list "D" "E"            // ["A", "B", "C", "D", "E"]
(integer) 5
redis> LPOP list                     // ["B", "C", "D", "E"]
"A"
redis> LPOP list                     // ["C", "D", "E"]
"B"
redis> RPUSH list "F" "G"            // ["C", "D", "E", "F", "G"]
(integer) 5

经过以上六条命令,数据库中最终只剩下 ["C", "D", "E", "F", "G"],正常情况下如果不对文件进行重写,那么AOF文件中肯定是有6条命令的。但是很明显,这些命令是可以进行优化的,我们可以简单的优化为:

RPUSH list "C" "D" "E" "F" "G"

那么这个优化的过程就是对AOF重写的过程。

然而,Redis并没有对AOF文件进行读取、分析、修改的操作,而是采用了直接读取数据库数据的操作。

例如上述的情况,Redis只需要读取数据库中的 ["C", "D", "E", "F", "G"],并根据类型重新拼装成 RPUSH list "C" "D" "E" "F" "G"这条命令即可。

这是因为,直接读取数据库的操作远比对AOF文件命令进行分析简单的多,因为可能两条对相同Key操作的命令相差很远,此外计算也会比较耗时。

4.3.2 后台重写

我们之前提到,RDB因为阻塞性质的原因,可以通过fork子进程的方式解决此问题。

同样的,AOF也可以通过子进程的形式在后台进行重写。

但是此时会带来一个问题:

子进程在重写期间,主进程还会对数据进行处理,如果不解决此问题,就会造成新的数据丢失的情况。

NeatReader-1694002036753

如果不对K2 K3 K4进行补偿处理,则会造成数据丢失。

因此Redis创建了一个AOF重写缓冲区,在子进程开始执行重写操作时,服务器进程把新写入的命令存入此AOF重写缓冲区,以达到保存数据的效果。等待子进程重写完成之后,再将此缓冲区内的所有命令添加到子进程重新的AOF文件尾部,从而达到了保存新数据的效果。

NeatReader-1694002213746

Q:既然已经有了AOF重写的功能,那一开始的AOF是不是就没必要了?