Redis笔记2-单机数据库
数据库
介绍服务器保存数据库的方法,客户端切换数据库的方法,数据库保存键值对的方法,以及对数据库的增删改查
服务器中的数据库
Redis服务器将所有数据库都保存在服务器状态redisServer结构的db数组中,db数组的每一项都是一个redisDb结构,代表一个数据库
struct redisServer{
redisDb *db; //一个数组,保存所有数据库
int dbnum; //服务器数据库数量
}
初始化服务器时,根据redisServer的dbnum属性决定创建多少个数据库,默认为16
切换数据库
Redis客户端的目标数据库默认为0号,执行SELECT命令切换目标数据库
服务器内部,客户端状态redisClient结构的db属性记录客户端当前的目标数据库,这个属性是一个指向redisDb结构的指针:
typedef struct redisClient{
redisDb *db; //记录客户端正在使用的数据库
}
数据库键空间
Redis是KV数据库服务器,服务器每个数据库都是一个redisDb结构表示,redisDb结构的字典dict保存数据库中所有键值对,这个字典称为键空间(key space)
typedef struct redisDb{
dcit *dict; //保存数据库中所有键值对
}
键空间和用户所见的数据库是直接对应的
- 键空间的键也就是数据库的键,每个键都是一个字符串对象
- 键空间的值也就是数据库的值,可以是字符串、列表、哈希表、集合、有序集合对象
数据库的键空间是一个字典,所以对数据库的增删改查都是通过对字典进行操作来实现
键空间操作
redis> SET string_key, object_value1 //添加键
redis> DEL string_key //删除键
redis> SET string_key, object_value2 //更新键
redis> GET string_key //对键取值
FLUSHDB:清空数据库
RANDOMKEY:随机返回键
DBSIZE:返回数据库键数量
LRANGE
EXISTS
RENAME
KEYS
…
(待补充)
键生存/过期时间
过期时间是一个UNIX时间戳,当键过期时间来临,服务器自动从数据库中删除这个键
TTL/PTTL命令接受一个带生存时间或过期时间的键,返回其剩余生存时间
设置过期时间
Redis提供了4个命令设置过期时间:
- EXPIRE<key> <ttl>:将key的生存时间设为ttl秒。
- PEXPIRE<key> <ttl>:将key的生存时间设为ttl毫秒。
- EXPIREAT<key> <timestamp>:将key的过期时间设置为timestamp秒数时间戳。
- PEXPIREAT<key> <timestamp>:将key的过期时间设置为timestamp毫秒数时间戳。
保存过期时间
redisDb中有一个expires的字典数据结构保存所有键的过期时间,也称为过期字典。
- 过期字典的键是一个指针,指向键空间的某个键对象
- 过期字典的值是一个long long类型的整数,保存了键所指向的数据库键的过期时间(毫秒精度的Unix时间戳)
typedef struct redisDb{
dict *expires; //过期字典,保存键的过期时间
}
移除过期时间
PERSIST命令可以移除一个键的过期时间,在过期字段中查找给定键,并解除键和值(过期时间)在过期字典中的关联。
过期键删除策略
三种删除策略:
- 定时删除:在设置键的过期时间同时,创建一个定时器,让定时器在键的过期时间来临时,立即执行对键的删除操作。
- 惰性删除:放任键过期不管,但每次从键空间获取键时,检查该键是否过期,如果过期就删除
- 定期删除:每隔一段时间对数据库检查一次,删除过期键。
定时删除
定时删除策略对内存友好,但对CPU不友好。
能保证过期键尽可能快删除,释放过期键占用的内存。
但删除操作本身占用CPU时间,在CPU时间紧张的情况下,删除当前任务无关的过期键,会影响服务器性能(响应时间和吞吐量)。
Redis定时器需要创建时间事件,时间事件底层由无序链表实现,查找复杂度为O(N),如果需要高效处理必然要创建大量的定时器,并不现实。
惰性删除
惰性删除策略对CPU最友好,对内存最不友好。
程序取出键才进行过期检查,可以保证删除过期键操作只会在非做不可的情况下进行。
过期键仍然保存在数据库中,只要不被删除,占用的内存就不会释放。可能永远不会被删除,造成内存泄漏。
定期删除
前两种删除策略有明显缺陷,定期删除是一种折中的方式,隔一段时间执行一次,并限制删除操作执行的时长和频率减少对CPU的占用。定期删除还能减少庞大的过期键对内存的占用。
如何确定时长和频率是难点,过长或过少,会退变为定时删除和惰性删除。因此服务器需合理设置删除操作执行时长和执行频率
RDB持久化
由于Redis是内存数据库,数据状态都存储于内存,如果不想办法将存储在内存中的数据库状态保存到磁盘里,那么一旦服务器进程退出,服务器中的数据库状态也会消失。
为解决这个问题,Redis提供了持久化的功能,可将内存中的数据库保存到磁盘,防止意外丢失。RDS持久化(默认持久化策略)就是将某一时间点上的状态保存到一个RDB文件里。RDB文件是经过压缩的二进制文件,可通过该文件还原成数据库状态。
RDB文件创建与载入
执行SAVE或BGSAVE创建新RDB文件,过期键不会保存到新创建的RDB
区别:
- SAVE会阻塞Redis服务器进程,直到RDB文件创建完毕为止,阻塞期间,服务器不能处理任何命令请求。
- BGSAVE会fork出一个子进程,由子进程负责创建RDB文件,父进程继续处理命令请求。当子进程完成之后,向父进程发送信号。
启动Redis服务器时,如果服务器开启了RDB功能,服务器对RDB文件载入有两种模式:
- 服务器以主服务器模式运行, 程序对文件保存的键检查,未过期的载入到数据库,过期则忽略。
- 服务器以从服务器模式运行,文件中保存的键无论是否过期都会被载入到数据库。但主从服务器在进行数据同步时,从服务器的数据库会被清空,所以过期键对从服务器不会造成影响。
另外,如果服务器开启AOF持久化功能,服务器会优先使用AOF文件还原数据库状态
SAVE执行时服务器状态
SAVE命令执行时,Redis服务器会被阻塞,客户端所有命令请求都会被拒绝
执行完SAVE,重新开始接受命令请求之后,客户端发送的命令才会被处理
BGSAVE执行时服务器状态
BGSAVE命令的保存工作是由子进程执行的,Redis仍然可以处理客户端命令
在BGSAVE执行期间,服务器处理SAVE、BGSAVE、BGREWRITEAOF(AOF持久化命令)三个命令和平时有所不同:
- 首先,BGSAVE执行期间,会拒绝SAVE命令。为了避免父进程和子进程同时执行rdbSAVE,防止产生竞争条件
- 其次,BGSAVE执行期间, BGSAVE命令也会被拒绝。因为两个BGSAVE命令也会产生竞争
- 最后BGSAVE和BGREWRITEAOF不能同时执行,BGREWRITEAOF会被延迟
自动间隔性保存
BGSAVE可在不阻塞服务器的情况下执行,因此Redis允许用户通过设置服务器配置的save选项,让服务器每隔一段时间自动执行一次BGSAVE命令
可以通过save选项设置多个保存条件,只要一个被满足就会执行BGSAVE
默认保存条件为
save 900 1
save 300 10
save 60 10000
例如save 900 1指的是服务器在900秒内,对数据库进行了至少1次修改
设置保存条件
服务器会根据save选项所设置的条件,设置服务器状态redisServer结构的saveparams属性:
struct redisServer{
//...
struct saveparam *saveparams; //记录了保存条件的数组
//...
}
saveparams是一个数组,每个元素都是一个saveparam结构,每个saveparam结构都保存一个save的保存条件
saveparam结构如下:
struct saveparam{
time_t seconds;
int changes;
}
dirty计数器和lastsave
dirty计数器记录距离上一次成功执行SAVE或BGSAVE之后,服务器对数据库的修改(包括写入、删除、更新)次数
lastsave是一个UNIX时间戳,记录上一次成功执行SAVE或BGSAVE的时间
struct redisServer{
long long dirty;
time_t lastsave;
}
检查保存条件
周期函数serverCron每隔100毫秒执行一次,其中一项工作就是检查保存条件是否满足
def serverCron():
#遍历所有保存条件
for saveparam in server.saveparams:
#计算距离上次执行保存操作的时间
save_interval = unixtime_now() - server.lastsave
#如果满足条件,则执行保存操作
if server.dirty >= saveparam.changes and \
save_interval > saveparam.seconds:
BGSAVE()
程序遍历检查saveparams数组中的所有保存条件,只要有一个满足,就会执行BGSAVE命令
RDB文件结构
一个完整RDB文件包含:
RDB文件保存的是二进制数据,不是C字符串
- REDIS:长5字节,保存”REDIS“这五个字符,可以快速检查文件是否为RDB
- db_version:长4字节,值是一个字符串表示的整数,记录RDB文件版本号
- databases:包含零个或任意个数据库,以及各数据库中键值对数据
- 如果服务器数据库状态为空,该部分也为空
- 如果服务器数据库状态不为空,该部分也不为空
- EOF:常量,长度1字节,标志RDB文件正文内容的结束
- check_sum:8字节无符号整数,保存校验和
database
每个非空数据库在RDB文件中都可表示为SELECTDB,db_number,key_value_pairs三部分
- selectdb:1字节,标志位,标志着下一位存储的是数据库号码。
- db_number:是一个数据库号码。
- key_value_pairs:保存了数据库中所有键值对数据,如果有过期时间,则过期时间也会保存。
key_value_pairs
不带过期时间的键值对在RDB文件由TYPE,key,value组成,带过期时间则含有EXPIRETIME_MS,ms
EXPIRETIME_MS:标志位,长度为1字节,告知程序下一个读入的是以毫秒为单位的过期时间。
ms:是8字节长的带符号整数,记录UNIX时间戳,即过期时间。
type:记录value的类型,长度1字节,这个常量其实就是Redis对象类型和底层编码的组装
分析RDB
使用od命令分析Redis服务器产生的RDB文件
od -c dump.rdb
Redis本身也带有RDB文件检查工具redis-check-dump
AOF持久化
AOF:Append Only File
与RDB持久化通过保存数据库中的键值对来记录数据库状态不同,AOF持久化是通过Redis服务器执行的写命令来记录数据库状态
实现
AOF持久化实现可分为命令追加(append)、文件写入、文件同步(sync)三个步骤
命令追加
打开AOF持久化功能,服务器执行完写命令之后,会以协议格式将被执行的写命令追加到服务器状态的aof_buf缓冲区末尾:
struct redisServer{
//...
sds aof_buf //AOF缓冲区
//...
}
写入与同步
Redis服务器进程就是一个事件循环,循环中,文件事件负责接收客户端命令请求,以及向客户端发送命令回复;时间事件负责执行周期性函数
服务器每次结束一个事件循环之前,都会调用flushAppendOnlyFile函数,考虑是否将aof_buf缓冲区中的内容写入和保存到AOF文件
flushAppendOnlyFile函数行为由服务器配置的appendfsync选项值决定,不同值对应的行为如图所示
默认为everysec
为提高写效率,操作系统一般将写入数据暂时保存在内存缓冲区,等缓冲区填满或超过指定时间后才会真正地将数据同步到磁盘里。操作系统提供了fsync和fdatasync两同步函数,可强制操作系统同步数据,保证数据安全性。
也就是说,每一次的事件循环,aof_buf中的指令都会被写入操作系统的缓冲区,根据appendfsync配置,当操作系统缓冲区满足一定条件后,就被真实地写入磁盘内。
AOF载入与数据还原
载入过程如图所示
- 创建一个没有网络连接的伪客户端。由于Redis命令只能在客户端上下文中执行,并且AOF文件在本地而不是网络。
- 解析AOF文件并取出一条写命令。
- 使用伪客户端执行被读出的写命令
- 持续执行2和3,直到所有写命令都已经执行完毕
对过期键的处理:
如果数据库中的某个键已经过期且没有被删除,AOF文件不会因为这个对过期键产生影响。当过期间被惰性删除或定期删除后,AOF文件追加一条DEL命令来显式删除。
AOF重写时,程序会对数据库的键检查,已过期的不会保存到AOF文件中。
AOF重写
因为AOF持久化会将所有的写命令都记录,所以会有冗余情况,比如频繁地创建删除键值对,或者对同一个键的值频繁更新,都会导致文件的内容越来越多。为了解决AOF文件体积膨胀问题,Redis提供AOF文件重写功能,让服务器创建一个新的AOF文件,替代现有的AOF文件,减少冗余命令。
实现
在新的AOF文件的重写过程中,不会读取旧AOF文件,而是通过读取数据库状态来实现的。首先从数据库中读取键现在的值,然后用一条命令记录键值对,代替之前记录的多条命令。
比如服务器对list键执行了一系列RPUSH和LPOP操作,使得list包含"F",“G”,Redis最后只用一条命令来代替保存在AOF文件中的一系列操作:
RPUSH list “F” “G”
在重写时会先检查键所包含的元素数量,因为多元素的键在命令转换时可能会导致客户端输入缓冲区溢出。因此读取配置中对应的常量,默认超过64个就用多条指令记录。
AOF后台重写
Redis服务器使用单个线程来处理命令请求,服务器调用aof_rewrite重写会进行大量写入操作,因此调用这个函数的线程会被长时间阻塞,重新AOF期间服务器无法处理客户端发来的命令请求
因此Redis将AOF重写放到子进程中执行,好处是:
- 重写期间,父进程仍然可继续处理请求。
- 子进程有自己的数据副本,而非子线程,可以避免一些线程安全性问题的出现。
子进程在执行AOF重写期间,服务器进程还需要继续处理命令请求,而新的命令可能会对现有的数据库状态进行修改,导致当前数据库状态与重写后的AOF文件保存状态不一致。为解决这个问题,设置了AOF重写缓冲区。
当重写子进程创建后,Redis服务器执行完写命令就会将其写入AOF缓冲区和AOF重写缓冲区,子进程执行重写期间,服务器进程要执行3个工作:
- 执行客户端发来的命令。
- 将执行后的写命令追加到AOF缓冲区。
- 将执行后的写命令追加到AOF重写缓冲区。
当子进程完成重写后,会向父进程发送一个信号,父进程接收并调用信号处理函数,将重写缓冲区的所有内容写到新AOF文件中,原子地覆盖现有的AOF文件。因此整个AOF文件重写的过程中,只有信号处理函数执行时,才会阻塞,将性能损耗降到最低。
事件
Redis服务器是一个事件驱动程序,需要处理两类事件:
- 文件事件
Redis通过socket与客户端连接,文件事件就是服务器对socket操作的抽象。服务器与客户端的通信会产生相应的文件事件,服务器通过监听并处理这些事件来完成一系列网络通信操作 - 时间事件
Redis服务器中的一些操作需要在给定时间点执行,时间事件是这类定时操作的抽象
文件事件
文件事件处理器的四个组成部分如下图所示
- 套接字
每当一个套接字准备好执行应答、写入、读取、关闭等操作,就会产生一个文件事件,多个文件事件可能会并发出现 - I/O多路复用程序
负责监听多个套接字,将产生事件的套接字通过队列有序、同步地向分派器传送,并且每次传送一个,当上一个套接字产生的事件处理完毕之后才传送下一个 - 文件事件分派器
接收I/O多路复用程序传来的套接字,根据事件类型调用相应的处理器 - 事件处理器
是处理函数,定义了某事件发生时,服务器应执行的动作
连接事件示例
一次完整的客户端与服务器连接事件示例如下
首先Redis的监听套接字的AE_READBLE事件处于监听状态下,对应处理器为连接应答处理器
若此时有客户端向服务器发起连接,产生该事件,触发处理器执行。处理器会对客户端连接请求进行应答,然后创建客户端套接字以及状态,并将客户端套接字的AE_READBLE事件与命令请求处理器相关联,使客户端可以向主服务器发送命令请求
假设客户端向主服务器发送一个命令请求,客户端套接字产生AE_READBLE事件,引发命令请求处理器执行,处理器读取客户端命令内容,传给相关程序执行
执行命令将产生相关命令回复,服务器会将客户端套接字的AE_WRITABLE事件与命令回复处理器相关联
客户端尝试读取命令回复时,客户端套接字产生AE_WRITABLE事件,触发命令回复处理器执行,处理器将命令回复写入到套接字
处理器写完后,服务器解除客户端套接字的事件与命令回复处理器的关联
时间事件
时间事件分类:
- 定时事件:让一段程序在指定时间后执行一次
- 周期性事件:让一段程序每隔指定时间执行一次
事件的分类取决于处理器的返回值:
- 返回ae.h/AE_NOMORE为定时事件,到达一次后就删除
- 返回非AE_NOMORE为周期性事件。当时间事件到达后,根据返回值更新when属性,让该时间在一段时间后再次到达
时间事件的三个主要属性:
- id
服务器为时间事件创建的全局唯一ID,从小到大递增。新事件ID比旧事件ID大 - when
毫秒精度的UNIX时间戳,记录时间事件的到达时间 - timeProc
时间事件处理器,即函数。时间事件到达时,服务器调用相应处理器来处理事件
时间事件实现
所有时间事件都是在一个无序链表中,当时间事件执行器运行时,遍历整个链表,查找所有已到达的时间事件,调用相应处理器。
新时间事件总是插入到链表表头,所以时间事件按ID逆序排序。
应用实例
Redis需要定期对自身的资源和状态进行检查和调整,这些定期操作由redis.c/serverCron函数负责执行,主要工作包括
- 更新服务器的各类统计信息,如时间、内存占用、数据库占用情况
- 清理数据库中过期键值对
- 关闭和清理连接失效的客户端
- 尝试进行AOF/RDB持久化操作
- 主服务器对从服务器进行定期同步
- 若处于集群模式,对集群进行定期同步和连接测试
事件的调度与执行
事件的调度与执行由aeProcessEvens函数负责,伪代码如下:
def aeProcessEvens():
time_event = aeSearchNearestTimer() #获取到达时间离当前时间最接近的时间事件
remaind_ms = time_event.when - unix_ts_now() #计算距离到达时间
if remaind_ms < 0: #如果事件已到达,设定为0
remaind_ms = 0
timeval = create_timeval_with_ms(remaind_ms) #创建timeval结构
aeApiPoll(timeval) #阻塞并等待文件事件产生
processFileEvents() #先处理已产生的文件事件
processTimeEvents() #再处理已到达的时间事件
Redis服务器的运行流程如图所示
事件的调度与执行规则
- aeApiPoll函数(redis封装的多路复用函数)的最大阻塞时间由到达时间最接近当前时间的时间事件决定,这个方法既可以避免服务器对时间事件进行频繁的轮询(忙等待),也可以确保aeApiPoll函数不会阻塞过长时间。
- 因为文件事件是随机出现的,如果等待并处理完一次文件事件之后,仍未有任何时间事件到达,那么服务器将再次等待并处理文件事件。
- 对文件事件和时间事件的处理都是同步、有序、原子地执行的,服务器不会中途中断事件处理,也不会对事件进行抢占,因此,不管是文件事件的处理器,还是时间事件的处理器,它们都会尽可地减少程序的阻塞时间,并在有需要时主动让出执行权,从而降低造成事件饥饿的可能性。
客户端
服务器
(待补充)