Redis 服务端实例为 redisServer,一个 redisServer 可以有多个数据库,放在 db 字段里。
一个服务端实例可以有多个数据库,redisServer 中有一个 dbnum 字段,来决定应该创建几个数据库,这个字段的值由服务器的配置决定,一般为 16。
数据库为 redisDb 类型。
每个 Redis 客户端连接都会在 Redis 服务器的内部维护一个客户端状态 redisClient,上面有一个 db 字段,是一个指针,指向当前客户端连接选中的数据库。使用 SELECT 指令可以切换客户端连接当前对应的数据库。
redisDb 类型里面有一个 dict 字段,为字典类型,存储数据库中所有的键值对,我们将这个字典称为键空间。dict 字段里面的键都是字符串对象,值是任意的 Redis 对象。
如下指令可以设置过期时间:
EXPIRE [key] [TTL]:在 TTL 秒后键 key 过期PEXPIRE [key] [TTL]:在 TTL 毫秒后键 key 过期EXPIREAT [key] [timestamp]:在 timestamp 这个时间键 key 过期。timestamp 时一个 UNIX 秒级时间戳PEXPIREAT [key] [timestamp]:在 timestamp 这个时间键 key 过期。timestamp 时一个 UNIX 毫秒级时间戳前三个指令都是基于最后一个指令的:
使用 TTL 和 PTTL 指令查看一个键距离过期还有多少秒/毫秒。
使用 PERSIST 指令可以移除一个键的过期时间。
在 redisDb 中有一个 expires 字段,字典类型,称为过期字典。过期字典的键是一个指针,指向键空间中的某个键对象。值是一个 long long 类型的毫秒级 UNIX 时间戳,保存了过期时间。
Redis 通过如下的步骤判断一个键是否过期:
Redis 有如下的删除策略:
惰性删除:每次从键空间获取键时,检查取得的键是否过期,过期就删除键。
定期删除
Redis 定期执行定期删除的任务。
定期删除的任务维护 current_db,表示当前检查的进度。每次任务执行时,只检查一定数量的数据库,可能不会检查完全部数据库。
检查某个数据库时,会从数据库中的过期字典里随机选取一定数量键,检查过期时间。
每检查完一个键是否过期后,会判断一下当前任务已经执行的时间是否到达了上限,超过上限就立刻停止,确保定期删除的任务不执行太久。
RDB 持久化可以将 Redis 内数据库的当前状态持久化保存到一个 RDB 文件中。
进行 RDB 持久化时,Redis 会阻塞当前线程或是 fork 出一个子进程来进行 RDB 持久化。对于后者,由于 fork 的 cow 机制,子进程其实是会持有一个数据库的快照。然后会使用一些编码方式来把当前数据库的数据保存到一个 RDB 文件中。
进行 RDB 持久化时会扫描整个数据库,于是 Redis 此时就可以趁机把过期的键删掉了,同时过期的键不写到 RDB 文件中。
Redis 会先把 RDB 文件写入到一个临时文件,写入完再执行 fsync 落盘,然后会使用 rename 将这个临时文件重命名成最终的 RDB 文件的名称(dump.rdb)。最后的这个重命名操作可以做到,如果 dump.rdb 已经存在,则替换掉原有文件,并且这个过程是原子化的,这样可以保证如果本次数据库的快照保存到一般突然被终止,那么下一次 Redis 启动时不会读到脏文件,而是读到的上次的数据库快照。
触发 RDB 持久化的方式有如下几种:
使用 SAVE 指令。该指令会堵塞 Redis 服务器进程,堵塞期间服务器不能处理。
使用 BGSAVE 指令。Redis 会派生出一个子进程,然后由子进程负责创建 RDB 文件,服务器进程继续处理命令请求。
同时轮询判断子进程是否完成(注册一个时间事件,事件中使用 waitpid(WNOHANG))
执行 BGSAVE 期间,Redis 会拒绝掉对 SAVE 指令的处理。
自动触发
用户可以自行设置 save 选项,如果没有设置,Redis 会使用默认值:
Unknown123save 900 1 save 300 10 save 60 10000
Redis 在加载时会读取 save 选项,将其保存到 redisServer 的 saveparams 字段中。
同时,redisServer 还维护如下字段:
dirty:记录距离上一次成功执行 SAVE 命令或者 BGSAVE 命令之后,服务器对数据库(所有的数据库)进行了多少次的修改(包括写入,删除,更新等操作)lastsave:上一次成功执行 SAVE 或是 BGSAVE 的时间Redis 的时间事件中,有一个事件就是自动进行 RDB 持久化的。该事件会遍历所有的 saveparams,如果存在一个 saveparams,使得数据库状态的修改次数超过了 changes,并且距离上一次保存事件超过了 seconds,那么就执行 RDB 持久化。
在 Redis 最开始启动时,会判断是否存在 RDB 文件,然后自动加载。载入期间 Redis 会处于阻塞状态。
载入 RDB 文件时,会根据 Redis 服务器的主从状态进行不同的操作:
REDIS:5 字节,存储的就是 REDIS 这五个字母。db_version:4 字节,是一个字符串表示的整数,表示 RDB 文件的版本号,如 "0006"。EOF:1 字节,为一个常量,表示 databases 字段的结束。check_sum:8 字节,一个无符号整数,对前 4 个部分的内容进行计算得出的。databases:该部分保存非空数据库的数据SELECTDB:1 字节,一个常量,表示一个数据库数据的开始db_number:表示数据库号码,根据号码大小的不同,这个部分可以是 1 字节,2 字节或是 5 字节key_values_pairs:保存数据库中所有的键值对数据键值对数据有两种:
EXPIRETIME_MS:1 字节,一个常量,表示后面会跟着一个带过期时间的键值对。ms:8 字节长的有符号整数,表示的是一个毫秒级的 UNIX 时间戳,表示键值对的过期时间。TYPE:1 字节,表示 value 的类型。key:一个字符串对象,占用大小不定value:存储的类型和占用大小不定其中不同类型和编码的对象,对应的 TYPE 值如下:
| 类型 | 编码 | TYPE |
|---|---|---|
| REDIS_STRING | —— | REDIS_RDB_TYPE_STRING |
| REDIS_LIST | REDIS_ENCODING_LINKEDLIST | REDIS_RDB_TYPE_LIST |
| REDIS_HASH | REDIS_ENCODING_HT | REDIS_RDB_TYPE_HASH |
| REDIS_SET | REDIS_ENCODING_HT | REDIS_RDB_TYPE_SET |
| REDIS_SET | REDIS_ENCODING_INTSET | REDIS_RDB_TYPE_SET_INTSET |
| REDIS_ZET | REDIS_ENCODING_SKIPLIST | REDIS_RDB_TYPE_ZSET |
| REDIS_LIST | REDIS_ENCODING_ZIPLIST | REDIS_RDB_TYPE_LIST_ZIPLIST |
| REDIS_HASH | REDIS_ENCODING_ZIPLIST | REDIS_RDB_TYPE_HASH_ZIPLIST |
| REDIS_ZSET | REDIS_ENCODING_ZIPLIST | REDIS_RDB_TYPE_ZSET_ZIPLIST |
RDB 文件的具体编码方式貌似不是很重要,这里就不写了
AOF 是另一种持久化方式。默认是不开启的,需要修改配置文件来开启。
redisServer 中存在一个 aof_buf 字段,为 aof 的缓冲区。
Redis 每次事件循环,会先从各个连接中读出一批指令,然后执行,执行完毕之后会把这些指令本身(字符串)放到 aof_buf 中。
在事件循环的最后,会将 aof_buf 的内容追加到 AOF 文件里面。追加写入这个成本是比较小的,一般只是单纯地将用户态缓冲区的数据拷贝到内核态的文件缓存中。成本较高的是后续的落盘。AOF 提供多种持久化策略,可以通过服务器配置的 appendfsync 选项来设置:
| appendfsync | 行为 |
|---|---|
| always | 每次 aof_buf 追加到文件末尾之后都执行 fsync。这个策略对性能影响最大,但是如果服务故障的话,最多丢失最近一个事件循环产生的数据。 |
| everysec(默认) | 每次 aof_buf 追加到文件末尾之后,检查距离上一次 fsync 的事件,如果超过了一秒,则执行 fsync。这个策略确保服务故障后最多只有最近 1s 内产生的数据会丢失。 |
| no | 将 aof_buf 追加到文件末尾之后就不管了,又操作系统决定什么时候落盘。这个策略的性能最好,但是服务故障时可能丢失的数据会更多。 |
和 RDB 持久化类似,Redis 在刚启动时会尝试恢复 AOF 持久化的数据。Redis 会启动一个不带网络连接的伪客户端,然后这个伪客户端读入 AOF 持久化文件,并将其中记录的指令一条条发给服务端去执行。
由于 AOF 持久化的内容往往时效性更强,所以 Redis 启动时如果发现同时有 RDB 和 AOF 的持久化文件,那么选择使用 AOF 持久化文件来恢复。
当然这其中一个问题是,对于会设置 TTL 的指令,单纯地重放这些指令可能不妥,因为不能反映时间的流逝情况。Redis 的做法是,记录到 AOF 中设置 TTL 的指令,一律记录的是过期时间的时间戳,而不是一个时间间隔。
对比 RDB
AOF 和 RDB 主要有这些差异:
appendfsync everysec 通常最多丢 ~1 秒(always 更小,no 更大且不可控)。fork() + 子进程写快照(COW 可能带来内存额外占用、在写入密集时增加延迟抖动)。fsync 策略不同会直接影响吞吐/延迟(always 最抖、everysec 折中)。redis-check-aof 修复/截断;everysec 还可能遇到“fsync 慢”导致短时间写入堆积与延迟变化。随着执行的指令变得越来越多,AOF 文件肯定会越来越大。为了避免这个文件大小无限制地增长,会有一个 AOF 重写的机制。
AOF 重写有两种触发方式:
自动触发
Redis 会启动一个定时任务,不断地检查如下两种条件:
当前 AOF 文件大小相比上一次重写后的大小,增长的百分比超过了 auto-aof-rewrite-percentage(默认是 100)
当前 AOF 文件大小超过 auto-aof-rewrite-min-size (默认 64 MB)
只有当这两个条件同时满足时,才会启动 AOF 重写
手动触发
执行 BGREWRITEAOF 来立即触发一次 AOF 重写。如果已经有一个后台重写进程在进行了,那么就不再重复启动一个后台子进程。
AOF 重写时,会启动一个后台子进程,由于 fork 的 cow 机制,这个子进程持有当前数据库的快照。子进程会先根据这个数据库快照,构造能够得到这个快照的指令。在子进程执行期间,主进程也要继续处理新的指令,新的指令除了会放入 aof_buf 中外(现有的 AOF 文件会照常记录),还会放入 AOF 重写缓冲区中。
主进程会在 serverCron 中反复检查子进程是否结束。当子进程完成 AOF 重写工作之后,主进程会继续完成如下的操作:
fsync 将新的 AOF 文件落盘。rename 原子地替换掉现有的 AOF 文件。和 BGSAVE 同时存在
AOF 重写相当于是一个优化,即使被推迟了也没关系。而 RDB 持久化如果去等待 AOF 重写完成的话,就不好说会被推迟到什么时候,而往后推迟会降低持久化数据的时效性。
Redis 的文件事件指的是套接字相关的事件,和网络有关。整个的处理过程:
Redis 将系统层面的 IO 多路复用机制(如 select、epoll、evport、kqueue 等)进行了封装,在不同的环境下使用不同的多路复用机制。主要提供的接口如下:
ae.c/aeCreateFileEvent 函数接受一个套接字描述符、一个事件类型、一个事件处理器作为参数,监听目标套接字的事件并设置其处理器。
事件类型有两种:AE_READABLE 和 AE_WRITABLE。
ae.c/aeApiPoll 函数接受一个超时时间作为参数。该函数被调用后会一直阻塞,直到所有被监听的套接字事件中有一个事件产生,或是到达超时时间了,则返回。
文件事件的处理器有这么几种:
连接应答处理器
为 networking.c/acceptTcpHandler 函数。
Redis 服务器在初始化时,程序会将这个连接应答处理器和服务器监听套接字的 AE_READALE 事件关联起来。
当有客户端发起连接时,服务器监听套接字就会触发该事件。该处理器会接受客户端的连接,为客户端连接分配资源。
命令请求处理器
为 networking.c/readQueryFromClient 函数。
建立完毕一个客户端连接之后,服务器会将客户端套接字的 AE_READABLE 事件和命令请求处理器关联起来。
当客户端发送命令后,客户端套接字就会触发该事件。该处理器会对指令进行处理。
命令回复处理器
为 networking.c/sendReplyToClient 函数。
客户端的命令处理完毕后,服务端需要向客户端回复一些指令。回复的指令会被写入一个缓冲区中,然后服务器会将客户端套接字的 AE_WRITABLE 事件和命令回复处理器关联起来。
当服务端可以向客户端发送数据时则会触发该事件。该处理器会将缓冲区中未发送的数据进行发送。当命令回复发送完毕之后,服务器就会解除命令回复处理器和客户端套接字的 AE_WRITABLE 事件的关联。
命令请求处理器会进行如下的过程:
读取套接字中的数据,并将其保存到客户端状态的输入缓冲区 querybuf 中
querybuf 是一个 sds 类型的字段,这意味着输入缓冲区会根据输入的内容动态地缩小或者扩大。但是它的最大大小不能超过 1GB,否则服务器会关闭这个客户端。
对输入缓冲区中的命令进行解析,提取出其中的命令参数,将命令参数写入客户端状态的 argv 中,argv 是一个字符串对象的数组。同时 argc 保存命令参数的个数。
根据 argv[0],在命令表中查询其对应的指令的信息。
命令表是一个字典,字典的 key 为指令的名称,value 为一个 redisCommand 类型。
其中 sflags 的取值如下:
如果 argv[0] 能够对应上一个 redisCommand,则将客户端状态的 cmd 字段设置为指向其的指针。argv[0] 和命令表中的 key 进行匹配的时候是不区分大小写的。如果没有对应的 redisCommand,则直接返回错误。
接下来进行执行指令前的预备工作:
cmd 指向的 redisCommand 的 arity 属性,检查参数个数是否正确。authenticated 属性,用于记录客户端是否通过身份验证。如果服务器要求身份验证,并且客户端也没有通过身份验证的话,服务器只受理客户端的 AUTH 命令。AUTH 命令用于进行身份验证。stop-writes-on-bgsave-error 功能,并且服务器上一次执行 BGSAVE 命令出错,则服务器拒绝执行这个命令。随后将当前客户端状态的指针作为参数,调用目标指令的函数。
指令处理的函数会将回复内容保存在客户端状态的输出缓冲区中。
输出缓冲区有两部分:
char buf[REDIS_REPLY_CHUNK_BYTES],buf 默认大小为 16KB。服务器返回的内容优先写入该缓冲区中。客户端状态中 bufpos 字段记录目前 buf 已使用的字节数量。reply 字段,为一个元素为字符串对象类型的链表。如果 buf 剩余空间无法容纳要回复的内容,就将回复的内容存入该缓冲区中。不过输入缓冲区也是有限制的,有两种限制:
硬性限制:如果输出缓冲区的大小超过了硬性限制设置的大小,那么服务器会立即关闭客户端连接。
软性限制:如果输出缓冲区的大小超过了软性限制设置的大小,但是还没超过硬性限制,那么服务器会使用客户端状态中的 obuf_soft_limit_reached_time 属性记录下客户端到达软性限制的起始时间。
之后服务器会继续监视客户端,如果输出缓冲区的大小一直超出软性限制,并且持续时间超过了一定的时长,则服务器关闭客户端。
如果输出缓冲区的大小在指定时间之内,不再超出软性限制,那么就会将 obuf_soft_limit_reached_time 置零。
随后会为对应的客户端套接字注册 AE_WRITABLE 事件。
执行完指令之后,还需要一些后续工作:
milliseconds 属性,同时将 calls 属性加一。命令回复处理器在发送完回复的数据之后会清空客户端状态的输出缓冲区。
Redis 的时间事件分为两类:
一个时间事件由以下三个属性组成:
id:服务器为时间事件创建的全局唯一 ID。
when:一个毫秒精度的 UNIX 时间戳,记录这个时间事件什么时候会被触发。
timeProc:时间事件处理器,一个函数,当时间事件到达时,服务器会调用该函数。
一个时间事件是定时事件还是周期性事件取决于该函数的返回值:
ae.h/AE_NOMORE,那么这个事件为定时事件。该事件在到达一次之后就会被删除,之后不再到达。AE_NOMORE 的整数值,则服务器会根据这个值更新时间事件的 when 属性,并不会删除该时间事件。服务器将所有时间事件都放在一个无序的链表中。每当时间事件执行器运行时,就会遍历整个链表,查找所有已到达的时间事件并调用相应的事件处理器。
serverCron 是 Redis 中比较关键的一个周期性的时间事件,默认每隔 100 毫秒执行一次,可以通过配置文件来调整执行的周期。
serverCron 进行如下的操作:
更新服务器时间缓存
Redis 中有不少功能需要获取系统的当前时间,而每次获取系统当前时间都需要执行一次系统调用,为了减少系统调用次数,会缓存当前的毫秒级精度的时间戳(对应于服务器状态中的 mstime)和秒级精度时间戳(对应于服务器状态中的 unixtime)。serverCron 每隔 100 ms 更新一次这两个值,所以这两个值得精确度并不高。
服务器会在打印日志,更新服务器的 LRU 时钟,决定是否执行持久化任务,计算服务器上线时间这类对时间精确度要求不高的功能上使用时间缓存。
而对于设置键的过期时间,添加慢查询日志这种需要高精确度时间的功能来说,服务器还是会再次执行系统调用。
更新 LRU 时钟
服务器状态中的 lruclock 属性保存了服务器的 LRU 时钟,这个属性也相当于是一种时间缓存。服务器每 10 秒更新一次。
之前我们说过,每个 Redis 对象都会有一个 lru 属性,该属性保存了对象最后一次被命令访问的时间。当服务器要计算一个数据库键的空转时长时,就会用服务器状态的 lruclock 减去对象的 lru 属性来得到。由于 lruclock 每 10s 才更新一次,所以计算出来的空转时长是一个比较模糊的估算值。
更新服务器每秒执行命令次数的采样
服务器状态中维护了如下几个属性:
long long ops_sec_last_sample_time:上次抽样进行的时间long long ops_sec_last_sample_ops:上次抽样时服务器已经执行的命令的数量long long ops_sec_samples[REDIS_OPS_SEC_SAMPLES]:一个环形数组,数组中的每一项都记录了一次采样结果。REDIS_OPS_SEC_SAMPLES 默认为 16。int ops_sec_idx:ops_sec_samples 的索引值,每次采样后都将该值加一,在该值等于 REDIS_OPS_SEC_SAMPLES 时重置为 0。每次采样时,会根据 ops_sec_last_sample_time 和服务器当前时间、ops_sec_last_sample_ops 和服务器当前已经执行命令的情况,计算得到距离上一次采样,服务器平均一毫秒处理了多少个命令,然后再将平均值乘以 1000,得到服务器平均每秒处理命令的数量,这个新的估计值会丢到 ops_sec_samples 中。
查看当前 Redis 内存占用情况,并更新服务器状态中的内存峰值属性。
检查是否要进行安全停机
Redis 在启动时会注册 SIGTERM 信号的处理器。收到 SIGTERM 信号之后,信号处理器并不负责服务器的关闭,而是设置服务器状态的属性 shutdown_asap。
serverCron 会不断检查 shutdown_asap 的属性,如果发现该属性被设置,则会先执行一个 RDB 持久化的操作,然后再关闭服务器。
检查客户端连接
serverCron 每次会检查一定数量的客户端实例。具体来说进行如下两种检查:
lastinteraction 属性,表示最后一次和客户端进行互动的时间。如果客户端与服务器之间的连接已经长时间没有互动,则会断开该客户端连接。同时还会检查超出输出缓冲区大小超过限制(硬性限制/柔性限制)的客户端,将其关闭。
检查过期的 key
检查持久化运行的状态
服务端状态中的 rdb_child_pid 属性和 aof_child_pid 属性可以用来检查 BGSAVE 命令或是 BGREWRITEAOF 命令是否正在执行。如果没有对应的子进程,则值为 -1。
每次 serverCron 执行时都会检查一下这两个属性的值,如果有一个不为 -1,则调用 wait 函数来检查子进程是否已经退出。如果子进程退出了,则完成 RDB 持久化或是 AOF 重写的后续行为(fsync 和重命名替换之类的)
如果这两个属性的值均为 -1,则继续检查:
aof_rewrite_scheduled 属性,表示 BGREWRITEAOF 被延后。如果开启了 AOF 持久化,还会将 AOF 缓冲区中的数据写入文件中。
增加 cronloops 的值,该值为 serverCron 执行的次数。这个值可以用来实现”每执行 serverCron 函数 次就执行一次指定代码“的功能。
事件调度和执行由 ae.c/aeProcessEvents 函数负责,该函数大概的伪代码如下:
python12345678910111213# 获取到达时间离当前时间最接近的时间事件
time_event = aeSearchNearestTimer()
# 计算最接近的时间事件距离到达还有多少毫秒
remaind_ms = time_event.when - unix_ts_now()
# 如果事件已经到达,那么 remaind_ms 的值可能为负数,将它设置为 0
if remaind_ms < 0:
remaind_ms = 0
# 阻塞并等待文件事件产生
aeApiPoll(remaind_ms)
# 处理所有已产生的文件事件
processFileEvents()
# 处理所有已到达的时间事件
processTimeEvents()
通过计算距离最近的时间事件的剩余事件来设置 aeApiPoll 的超时时间,可以避免服务器轮询次数过于频繁,也可以确保 aeApiPoll 函数不会阻塞过长时间。
时间事件会在文件事件之后执行,因此时间事件的实际处理事件通常会比设定的到达时间稍晚一些。
Redis 对所有的事件的处理都是同步,有序,原子的,不存在事件的抢占,因此,无论是文件事件还是时间事件,其处理器在处理时都会尽可能减少阻塞事件,并在有需要时主动给让出执行权。
比如命令回复处理器在将命令回复写入客户端套接字时,如果写入字节数超过了一个预设的常量的话,命令回复处理器会主动返回,将剩余数据留到下次再写。