Redis单机功能及其原理

上一张介绍了redis的数据类型和底层实现,本章将会着重介绍redis的单机数据库的底层细节,包括客户端,服务端,数据过期,持久化,过期键等实现细节。

数据库

redis服务端一般有16个数据库,默认情况下都在0号数据库操作,我们也可以通过命令来实现数据库的切换。

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

不同数据库间的数据都是隔离的,那么redis底层是如何实现这个功能的呢?首先我们要引入redis服务端最重要的一个结构redisServer。所有的相关信息都在这个结构上,由于字段太多,我们先引入我们关心的字段即可,剩下的之后再慢慢介绍。

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

服务器会在初始化的时候根据dbnum属性来决定创建多少个数据库,而dbnum的值又来源于配置文件,默认16.

还有个重要的结构,是客户端redisClinet,这个结构会在每个客户端连接上的时候创建,每个连接都有一个单独的redisClient结构

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

img

如果客户端执行命令select 3,那么指针就指向db[3]的位置,就是这么的简单。

后续你还会发现,redis所有的命令执行,都依赖于数据结构里的数据变更。

每个数据库的结构里又保存着我们上一章提到的字典,这样又串联起来了(没看过快滚回去看上一章,嘻嘻)

typedef struct redisDb {
    //数据库里的字典
    dict *dict;
} redisDb;

img

对数据库的增删改查,本质上就是通过客户端指向的数据库,查找里面的字典,匹配对应的key,取出对应的value。

键过期

redis是如何保存过期时间又是如何让key过期的?接下来就从数据结构的角度,来看看具体的原理。

首先redis设置过期时间有四个方式

EXPIRE <key> <ttl> 命令用于将键key的生存时间设置为ttl秒。
PEXPIRE <key> <ttl>命令用于将键key的生存时间设置为tt1毫秒。
EXPIREAT <key> <timestamp> 命令用于将键key的过期时间设置为timestamp
PEXPIREAT <key> <timestamp> 命令用于将键key的过期时间设置为timestamp所指定的毫秒数时间戳。

但实际上在redis的服务端,只有一种方式,就是采用PEXPIREAT这个命令,另外三个命令都会被计算,转化成PEXPIREAT来执行。

typedef struct redisDb {
    //数据库里的字典
    dict *dict;
    //过期时间的字典
    dict *expires;
} redisDb;

这个过期字典和键值对字典一模一样,唯一不一样的就是过期字典的值是Long类型存的是时间戳。这里就能体现复用了键对象来节省空间的思想了。

img

如果需要查看,移除或者修改过期时间,那么和键值对的增删改查是一致的。

过期策略

如果一个键过期了,它在什么时候会被删除呢?一般会有三种可能,定时删除,定期删除,惰性删除

定时删除

定时删除是对内存最友好的,但是对cpu不友好。键只要一过期,我们就立马去删除key,释放内存空间。但是这意味着cpu需要不断的去遍历所有的key,来判断对方有没有过期。影响到了我们正常的请求处理。

惰性删除

惰性删除对内存不友好,但是对cpu很友好。只有到key被访问了,我们才会去先判断key是否过期,过期就直接删除key,并返回nil。但是如果有key长时间没有被访问到,内存就会一直被占用

定期删除

定期删除,整合了上面两者的缺点。每隔一段时间,我们就对一些随机key进行检查,删除里面过期的key。

只要设定好频率和执行时间,就能很好的管理key,但是难点就在于频率和执行时间的设定上。

综合下来,redis最终采用的是惰性删除+定期删除的方式搭配使用

我们都知道redis的主从复制,是啊采用全量同步+增量同步的方式,增量同步类似于AOF。数据过期的时候,主库会执行一条del命令,这个命令也会记录在主库的AOF中,同时会将该命令传递给从库,让从库也达到数据过期的效果。

所以从库不会主动执行定期删除等策略

RDB

RDB是持久化的一种方式,我们可以将redis数据库的数据保存为RDB文件,服务器在启动的时候初始化中,又会将RDB文件恢复成redis的缓存数据。

可执行的命令有SAVE和BGSAVE两个命令。

SAVE就是主线程同步保存,在保存期间不接受外界的命令。

BGSAVE会fork一个子进程来保存RDB,在这个期间能够正常接受外界命令,这时候如果有修改的语句,会采用copy on write的方式来进行修改。保存好的新的RDB后,会将新RDB替换旧的RDB。如果期间还有SAVE,或BGSAVE命令执行,会被拒绝,如果有BGREWRITEAOF命令,会在BGSAVE执行结束后再执行

img

为什么redis内存不宜超过16G?

因为数据太多,在RDB的时候子进程需要复制的页表就太多,这个时间会阻塞主进程的。

RDB的触发规则?

我们可以通过配置来决定RDB什么时候触发持久化。

save 600 1 #900s内至少修改1次
save 200 10 #300s内至少修改10次
save 10 100 #60s内至少修改1000

rdb触发条件底层实现

struct redisServer {
    //修改计数器
    long long dirty;
    //上一次保存的时间
    time_t lastsave;
};

服务器每一次修改都会让dirty加一,然后serverCron每隔100ms会有个周期任务来检查这些时间间隔,修改数量会不会匹配的上我们配置的规则。配置规则在程序启动的时候也会被加载进我们的结构中。

struct redisServer{
    // 保存条件配置的数组
    struct saveparam *saveparams;
}
struct saveparam{
    // 秒数
    time_t seconds;
    // 修改次数
    int changes;
}

img

RDB的恢复?

在程序启动的时候就会判断是否开启rdb是否开启aof,来决定恢复方式。这些都是自动的。

img

AOF

AOF (Append Only File) ,顾名思义,就是一条条追加的日志。由于RDB是一段时间保存一次,那在两次保存中间的修改,就会被丢失,因此还提供了AOF的功能。

AOF保存的是每条被执行的命令。比如我们执行以下命令

redis> SET msg "hello"
OK
redis> SADD fruits "apple" "banana" "cherry"
(integer)3
redis> RPUSH numbers 128 256 512 
(integer)3

对应aof文件保存的是这样的

*3\r\n$3\r\nSET\r\n$3\r\nmsg\r\n$5\r\nhello\r\n
*5\r\n$4\r\nSADD\r\n$6\r\nfruits\r\n$5\r\napple\r\n$6\r\nbanana\r\n$6\r\ncherry\r\n
*5\r\n$5\r\nRPUSH\r\n$7\r\nnumbers\r\n$3\r\n128\r\n$3\r\n256\r\n$3\r\n512\r\n

持久化的实现

AOF 持久化功能的实现可以分为**命令追加(append)、文件写入、文件同步(sync)**三个步骤。

命令追加
struct redisServer{
    // AOF缓冲区
    sds aof_buf;
}

aof有个缓冲区,里面记录的是所有修改的命令。新的修改命令会被追加在缓冲区的末尾。

AOF 文件的写入与同步

redis的主进程就是一个无限的循环,里面会处理文件事件,时间事件等。这些事件都有可能会造成写操作。所以一个循环的末尾,它都会调用 flushAppendOnlyFile 函数,考虑是否需要将 aof_buf 缓冲区的内容写入和同步到 AOF 文件里,这个过程可以用伪代码表示

def evenLoop () :
 
    while True :
 
        # 处理文件事件,接受命令请求以及发送命令回复
        # 处理请求时可能会有新内容被追加到 aof_buf 缓冲区
        processFileEvents ()
 
        # 处理时间事件
        processTimeEvents ()
        
        # 考虑是否要将 aof_buf 中的内容写入和保存到 AOF 文件里
        flushAppendOnlyFile ()

而具体是否同步是根据一个配置来appendfsync 来决定的

always:每次都将 aof_buf 缓冲区中的所有内容写入并同步到 AOF 文件

everysec:先将 aof_buf 缓冲区中的所有内容写入到 AOF 文件,如果上次同步 AOF 文件的时间距离现在超过一秒钟,那么再次对 AOF 文件进行同步,并且这个操作是由一个线程专门负责执行的

no:将 aof_buf 缓冲区中的所有内容写入到 AOF 文件,但并不对 AOF 文件进行同步,何时同步由操作系统来决定

上面三种操作都会将文件写入,但是是否同步就根据选项不一致,有不同的效果。具体的规则实际上和mysql的刷盘策略很像。都写到pagecache,是否刷盘可以自己配置,也可以根据操作系统自己的判断。

mysql redis kafka都有这样相关的刷盘配置,可以了解下

AOF数据恢复

AOF 持久化默认是关闭的,通过将 redis.conf 中将 appendonly no,修改为appendonly yes来开启AOF 持久化功能,如果服务器开始了 AOF 持久化功能,服务器会优先使用 AOF 文件来还原数据库状态。只有在 AOF 持久化功能处于关闭状态时,服务器才会使用 RDB 文件来还原数据库状态。如图:

img

如果开启了AOF,那么服务器会创建一个伪客户端来执行里面的命令。

步骤1:从 AOF 文件中分析并读取出一条写命令。

步骤2:使用伪客户端执行被读出的写命令。

步骤3:一直执行步骤1和步骤2,直到 AOF 文件中的所有写命令都被处理完毕为止。

AOF 重写

因为 AOF 持久化是通过保存被执行的写命令来记录数据库状态的,所以随着服务器运行时间的流逝,AOF 文件中的内容会越来越多,文件的体积也会越来越大,如果不加以控制的话,体积过大的 AOF 文件很可能对 Redis 服务器、甚至整个宿主计算机造成影响,并且 AOF 文件的体积越大,使用 AOF 文件来进行数据还原所需的时间就越长,这时候就需要对AOF文件进行重写。

重写的效果类似这样:

img

我们可以通过调用BGREWRITEAOF命令来后台重写AOF文件,也可以通过配置文件,程序在达到对应的规则自动调用BGREWRITEAOF来重写文件

# AOF文件比上次文件增长超过多少百分比则触发重写
auto- aof - rewr ite- percentage 100
# AOF文件体积最小多大以上才触发重写
auto- aof- rewrite - min-size 64mb

AOF的重写流程:

  1. fork一个子进程进行AOF重写,重写的原理和RDB类似,把现有的数据转成新增命令保存。
  2. 父进程正常接收客户端命令,所有新的写操作会把命令输入缓冲管道(也就是父子进程通信的管道)
  3. 子进程写完AOF后,再追加管理里的新命令。
  4. 重写结束,新的AOF文件替换旧的AOF文件。

RDB+AOF

4.0版本引入了RDB+AOF的功能,整合了RDB的占用内存小的优点,和AOF实时性的优点。

aof-use-rdb-preamble yes

这样bgrewriteaof的功能就变了,会把aof文件重写成rdb的格式,然后后续的追加仍然是aof。变成了RDB和aof的一个结合。

img

客户端

每个连接redis服务器的客户端,都会有一个redisClient结构

typedef struct redisClient {
    int fd;//套接字描述符
    sds querybuf;//输入缓冲区
    robj **argv;//命令的参数
    int argc;//命令参数的长度
    struct redisCommand *cmd;//对应命令表的命令
    list *reply;//输出缓冲区
    // ...
} redisClient;

fd属性记录了客户端正在使用的套接字描述符

querybuf 因为是动态字符串,所以输入缓冲区的大小会根据输入内容动态地缩小或者扩大,但它的最大大小不能超过1GB,否则服务器将关闭这个客户端。

argv属性是一个数组,数组中的每个项都是一个字符串对象,其中argv[0]是要执行的命令,而之后的其他项则是传给命令的参数。

argc属性则负责记录argv数组的长度。

redisCommand:通过命令表获得,当程序在命令表中成功找到argv[0]所对应的redisCommand结构时,它会将客户端状态的cmd指针指向这个结构

reply 输出缓冲区

服务端

我们来看看一个客户端发起命令到服务端执行的全流程。

redis > set key value

1)客户端向服务器发送命令请求SET KEY VALUE。

2)服务器接收并处理客户端发来的命令请求SET KEY VALUE,在数据库中进行设置操作,并产生命令回复OK。

3)服务器将命令回复OK发送给客户端。

4)客户端接收服务器返回的命令回复OK,并将这个回复打印给用户观看。

img

以上是最显而易见的内容,那么这个过程深入分析包含了哪些内容呢?

整个执行完成并返回主要包括三部分:

1) 建立连接阶段,响应了socket的建立,并且创建了client对象;

2) 处理阶段,从socket读取数据到输入缓冲区,然后解析并获得命令,执行命令并将返回值存储到输出缓冲区中;

3)数据返回阶段,将返回值从输出缓冲区写到socket中,返回给客户端,最后关闭client。

img

发送命令请求

举个例子,假设用户在客户端键人了命令:

SET KEY VALUE 

那么客户端根据RESP协议会将这个命令转换成协议:

*3\r \n$3 \r\nSET\r \n$3\ r \nKEY\r\n$5 \r\nVALUE\r\n

然后将这段协议内容发送给服务器。

读取命令请求

当客户端与服务器之间的连接套接字因为客户端的写入而变得可读时,服务器将调用命令请求处理器来执行以下操作:

1)读取套接字中协议格式的命令请求,并将其保存到客户端状态的输入缓冲区里面。

img

2)对输入缓冲区中的命令请求进行分析,提取出命令请求中包含的命令参数,以及命令参数的个数,然后分别将参数和参数个数保存到客户端状态的argv属性和argc属性里面。

img

3)调用命令执行器,执行客户端指定的命令。

服务端命令执行

1)查找命令

命令执行器根据客户端状态的argv[0]参数,在命令表中查找参数所指定的命令,并将找到的命令保存到客户端状态的cmd属性里面。

命令表是一个字典,字典的键是一个个命令名字,比如"set"、“get”、"del"等等;而字典的值则是一个个redisCommand结构,每个redisCommand结构记录了一个Redis命令的实现信息.

img

2)执行准备

在获得执行所需的:命令,对应的函数、参数、参数个数后,服务器需要进行一些预处理操作,主要包括:

① 命令校验:检查客户端状态的cmd指针是否指向NULL。

② 参数校验:根据客户端cmd属性指向的redisCommand结构的arity属性,检查命令请求所给定的参数个数是否正确。

③ 权限校验:检查客户端是否已经通过了身份验证,未通过身份验证的客户端只能执行AUTH命令。

④ 内存检测:如果服务器打开了maxmemory功能,那么在执行命令之前,先检查服务器的内存占用情况,并在有需要时进行内存回收,从而使得接下来的命令可以顺利执行

⑤ 其他校验…

3)调用命令实现函数

如下图所示,服务器将要执行命令的实现保存到了客户端状态的cmd属性里面,并将命令的参数和参数个数分别保存到了客户端状态的argv属性和argv属性里面,当服务器执行命令时,只需要一个指向客户端状态的指针作为参数,调用实际执行函数。

4)执行后续操作

如在Redis.config里面有相关配置,则后续操作包含:慢日志记录、redisCommand结构属性更新、AOF持久化记录、主从复制命令传播等。

服务端将命令回复发送客户端

命令实现函数会将命令回复保存到客户端的输出缓冲区里面,并为客户端的套接字关联命令回复处理器,当客户端套接字变为可写状态时,服务器就会执行命令回复处理器,将保存在客户端输出缓冲区中的命令回复发送给客户端。发送完毕后,回复处理器会清空客户端状态的输出缓冲区,为下一个命令请求做好准备。

客户端接收并打印命令回复

当客户端接收到协议格式的命令回复之后,它会将这些回复转换成人类可读的格式,并打印给用户观看。

redis的版本升级

4.0

4.0新增了混合持久化

lazy free Redis 4.0 新添加了UNLINK命令, 这个命令是DEL命令的异步版本, 它可以将删除指定键的操作放在后台线程里面执行, 从而尽可能地避免服务器阻塞:

redis> UNLINK fruits
(integer) 1

​ 因为一些历史原因, 执行同步删除操作的DEL命令将会继续保留。此外, Redis 4.0 中的 FLUSHDB和 FLUSHALL这两个命令都新添加了ASYNC选项, 带有这个选项的数据库删除操作将在后台线程进行:

redis> FLUSHDB ASYNC
OK
redis> FLUSHALL ASYNC
OK

​ 还有,执行 rename oldkey newkey时,如果newkey已经存在,Redis会先删除已经存在的newkey,这也会引发上面提到的删除大key问题。如果想让Redis在这种场景下也使用lazyfree的方式来删除,可以按如下配置:

lazyfree-lazy-server-del yes

所以从4.0起redis就有一些多线程的行为了,后面还会更加优化

5.0

增加了stream类型,能更好的实现消息队列功能

6.0

增加了多线程IO。

redis 6.0 以前线程执行模式,如下操作在一个线程中执行完成

img

可以通过如下参数配置多线程模型:

io-threads 4  // 这里说 有三个IO 线程,还有一个线程是main线程,main线程负责IO读写和命令执行操作

​ 默认情况下,如上配置,有三个IO线程, 这三个IO线程只会执行 IO中的write 操作,也就是说,read 和 命令执行 都由main线程执行。最后多线程将数据写回到客户端。

img

开启了如下参数:

io-threads-do-reads yes // 将支持IO线程执行 读写任务。

img

官方建议:至少4核的机器才开启IO多线程,并且除非真的遇到了性能瓶颈,否则不建议开启此配置 ,且配置的线程数少于机器总线程数,如果有4核建议开启2,3个线程,如果有8核建议开6线程。 线程并不是越多越好,多于8个线程意义不大。

流程简述如下:

1、主线程负责接收建立连接请求,获取 socket 放入全局等待读处理队列

2、主线程处理完读事件之后,通过 RR(Round Robin) 将这些连接分配给这些 IO 线程

3、主线程阻塞等待 IO 线程读取 socket 完毕

4、主线程通过单线程的方式执行请求命令,请求数据读取并解析完成,但并不执行

5、主线程阻塞等待 IO 线程将数据回写 socket 完毕

6、解除绑定,清空等待队列

img

从上面的实现机制可以看出,Redis的多线程部分只是用来处理网络数据的读写和协议解析,执行命令仍然是单线程顺序执行。所以我们不需要去考虑控制 key、lua、事务,LPUSH/LPOP 等等的并发及线程安全问题。

文件事件

redis的文件事件,以及相关的多路复用,会专门写一篇来讲解

img

参考

《redis设计与实现》

redis命令的执行过程

Redis4.0、5.0、6.0、7.0特性整理

  • 0
    点赞
  • 2
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值