单机数据库的实现
9. 数据库
9.1 服务器中的数据库
redis服务器将所有数据库都保存在服务器状态redis.h/redisServer的结构的db数组中,db数组的每项都是一个redis.h/redisDb结构,每个redisDb结构代表一个数据库;在初始化服务器时,程序会根据服务器状态的dbnum属性来决定要创建多少个数据库:
struct redisServer{
//...
//一个数组,保存着服务器的所有数据库
redisDb *db;
//服务器的数据库数量
int dbnum;
//...
}
dbnun属性的值有服务器配置(redis.conf的databases选项决定,默认为16)
9.2 切换数据库
每个redis客户端都有自己的目标数据库,每当客户端执行命令时,操作的都是客户端自己的目标数据库;
默认情况下,redis客户端的目标数据库是0号数据库;可以通过select命令切换目标数据库。(select 2:切到2号库);
在服务器内部,客户端状态redisClient结构的db属性记录了客户端当前的目标数据库,这个属性是指向redisDb结构的指针:
typedef struct redisClient{
//...
//记录了当前客户端使用是数据库
redisDb *db;
//...
}redisClient;
执行select 2,就是修改redisClient.db指针,让其指向服务器的2号数据库。
注意:在执行像flushdb这样的危险命令前,最好显式执行一个select命令,明确切换到指定数据库,避免误操作。
9.3 数据库键空间
redis是一个键值对数据库服务器中的每个数据库都由一个redis.h/redusDb结构表示,其中,redusDb结构的dict字典保存了数据库中的所有键值对,我们将这个字典称为键空间(key space):
typedef struct redisDb{
//...
//数据库键空间,保存着数据库中的所有键值对
dict *dict;
//...
}redisDb;
9.4 设置键的生存时间或过期时间
通过expire命令或者pexpire命令,客户端可以以秒或毫秒精度为数据库中的键设置生存时间(time to live,TTL);服务器或自动删除生存时间为0的键。
setex命令相当于 set命令+expire命令
expire key ttl 命令用于将键key的生存时间设置为ttl秒;
pexpire key ttl 命令用于将键key的生存时间设置为ttl毫秒;
expireat key timestamp 将键key的过期时间设置为timestamp指定的秒数时间戳;
pexpireat key timestamp 将键key的过期时间设置为timestamp指定的毫秒数时间戳;
ttl key 返回这个键的剩余生存时间秒数
pttl key 返回这个键的剩余生存时间毫秒数
9.4.1 保存过期时间
redisDb结构的expire字典保存了数据库中所有键的过期时间,我们称这个字典为过期字典:
1. 过期字典的键是一个指针,这个指针指向键空间中的某个键对象(也即是某个数据库键)
2. 过期字典 的值是一个long long类型的整数,这个整数保存了键所指向数据库键的过期时间——毫秒精度的时间戳
typedef struct redisDb{
//...
//数据库键空间,保存着数据库中的所有键值对
dict *dict;
//...
//过期字典,保存着键的过期时间
dict *expires;
}redisDb;
9.4.2 移除过期时间
persist命令可以移除一个键的过期时间,persist命令就是pexpireat命令的反操作,persist命令在过期字典中查找给定的键,并接键和值(过期时间)在过期字典里的关联。
9.4.3 计算并返回剩余生存时间
ttl命令,pttl命令以秒和毫秒精度返回键的剩余生存时间。
ttl,pttl两个命令是通过计算键的过期时间和当前时间之间的差来实现的。
9.4.4 过期键的判定
通过过期字典,程序可以用以下步骤检查一个给定键是否过期:
1.检查给定键是否存在与过期字典,如果存在,那么取得键的过期时间;
2.检查当前UNIX时间戳是否大于键的过期时间,如果是,那么键已过期,否则未过期。
9.5 过期键删除策略
过期键的删除策略有以下三种:
1. 定时删除:在设置键的过期时间的同时创建一个定时器(timer),让定时器在键的过期时间来临时,立即执行对键的删除操作
2. 惰性删除:放任键国企不管,但每次从键空间中获取键时,会检查键是否过期,如过期则删除,未过期则返回该键;
3. 定期删除:每隔一段时间,程序就对数据库进行一次检查,删除里面的过期键;至于要删除多少过期键,以及要检查多少个数据库,都由算法决定。
9.5.1 定时删除
定时删除策略对内存是最友好的,使用定时器,可以保证过期键会尽可能快地被删除,并释放过期键所暂用的内存
另外,定时删除策略的缺点是,对CPU时间不友好,过期键较多时,会占用相当一部分CPU时间。
9.5.2 惰性删除
惰性删除策略对CPU时间来说是最友好的,程序只会在取出键的时候才对键进行过期检查
缺点是,对内存不友好,如果键已经过期了,只有操作这个键是才会过期检查,在被检查前,一直存在于库中。
9.5.3 定期删除
综合来看是最好的策略。可以自定义设置定期删除的的执行频率和执行时长
难点是如何确定操作的频率和时长,在需要根据具体业务和场景判定
9.6 AOF、RDB和复制功能对过期键的处理
9.6.1 rdb文件
在执行save命令,bgsave命令会创建一个新的rdb文件,程序会对数据库中的键进行检查,已过期的键不会被保存
在启动redis服务器时,如果服务器开启了rdb功能,那么服务器将对rdb文件进行载入:
如果服务器以主服务器模式运行,那么在载入rdb文件时,程序会对文件中保存的键进行检查,未过期的键被载入,过期的被忽略。
如果服务器以从服务器模式运行,那么在载入rdb文件时,载入所有键;因为主服务器在进行数据同步的时候会将从服务器的数据清空,所以不会有问题。
9.6.2 AOF文件写入
当服务器以AOF持久化模式运行时,持久化是以保存操作命令来记录数据库状态的。
AOF重写:与生成rdb文件时类似,在执行AOF重写的过程中,程序会对数据库中的键进行检查,已过期的键不会被保存到重写后的AOF文件中。
9.6.3 复制
当服务器运行在复制模式下时,从服务器的过期键删除动作由主服务器控制:
当主服务器删除一个过期键之后,会显式地向所有从服务器发送一个del命令。
从服务器在执行客户端发送的读命令时,即使碰到过期键也不会将过期键删除,而是继续像处理未过期的键一样处理。
9.7 RDB持久化
9.7.1 rdb文件的创建和载入:
有两个命令可以创建rdb文件,一个是save命令,一个是bgsave命令
save命令会阻塞redis服务器,直到rdb文件创建完成为止,在服务器阻塞期间,不能处理任何命令请求;
bgsave命令与save不同,bgsave命令会派生出一个子进程,然后由子进程负责创建rdb文件,服务器进程继续处理命令请求。
rdb文件的载入是由服务器自动完成的,在redis服务器启动的时候,根据redis.conf文件自动完成载入的。
另外,由于aof文件的更新频率比rdb文件的更新频率更高,所以,如果服务器开启了aof持久化功能,那么服务器会优先选择aof文件来还原数据库状态;
自由当aof功能关闭时,服务器才会选择rdb文件还原数据库状态。
9.7.2 自动保存条件
当Redis服务器启动时,用户可以通过指定配置文件或传入启动参数的方式来设置save的条件,如果用户没有设置自定义的保存条件,那么服务器会使用redis.conf里的默认条件:
save 900 1
save 300 10
save 60 10000
redis服务器会根据上面配置设置服务器状态redisServer结构的saveparams的属性:
typedef struct redisServer{
//...
//记录了保存条件的数组
struct saveparam *saveparams;
//...
}
saveparams属性是一个数组,数组的每个元素都是saveparam结构,每个saveparam都保存着一个save条件
typedef struct saveparam{
//秒数
time_t seconds;
//修改次数
int changes;
}
处理saveparams数组外,服务器还维护着一个dirty计数器,以及一个lastsave属性
dirty属性:记录了自从上次成功save或者bgsave后到现在进行了多少次修改(包括写入,更新,删除等操作)
lastsave属性:是一个Unix时间戳,记录了上一次成功执行save或者bgsave的时间。
typedef struct redisServer{
//...
//修改计数器
long long dirty;
//上次成功执行保存的时间
time_t lastsave;
//...
}
9.8 AOF持久化
除了rdb持久化功能外,redis还提供了aof(append only file)持久化功能。
与人rdb持久化通过保存数据库中的键值对来记录数据库状态不同,aof持久化是通过记录服务器所执行的命令来记录数据库状态的。
9.8.1 aof持久化的实现
aof持久化功能的实现可以分为:命令追加、文件写入、文件同步(sync)三个步骤。
文件追加
当aof功能开启时,服务器在执行完一个写命令后,会以协议格式将被执行的写命令追加到服务器状态的aof_buf缓冲区的末尾:
struct redisServer{
//...
//aof缓冲区
sds aof_buf;
//...
}
aof文件写入和同步
redis服务器的时间事件serverCron函数会调用flushAppendOnlyFile,判断是否需要将aof_buf缓冲区里的内容写入和保存到aof文件里
flushAppendOnlyFile函数的行为由服务器配置appendSync属性的值来决定(默认为everysec):
9.8.2 aof文件的载入和数据还原
aof文件里包含了重建数据库状态所需的所有写命令,主要服务器读入并执行完aof文件的所有命令,就能还原服务器关闭前的状态了。
redis读取aof文件并还原数据库状态的详细步骤:
- 创建一个不带网络连接的伪客户端(fake client):因为redis命令只能在客户端上下文中执行;
- 从aof文件中读取一条写命令,使用伪客户端执行;
- 循环步骤2,直到aof文件中所有写命令都处理完毕。 执行完以上步骤,就能还原redis服务器到关闭前的状态了。
9.8.3 aof重写
因为aof功能是通过保存已执行写命令来记录数据库状态的,随着服务器运行时间的流逝,aof文件的内容会越来越多,体积越来越大,如果不加以控制,体积过大的aof文件会对redis服务器,甚至整个宿主计算机造成影响,并且,过大的aof文件在载入和数据还原时会花费较多时间。
为了解决aof文件膨胀的问题,redis提供了aof文件重写(rewrite)的功能,通过该功能,redis服务器可以创建一个新的aof文件替换现有的aof文件。
新的aof文件所保存的数据库状态完全一样,但不会包含任何冗余命令,体积要小得多。
aof文件重写的实现
虽然叫aof文件重写,但aof文件重写不会对现有aof文件进行任何操作,这个功能是通过读取服务器当前数据库状态来实现的。
aof后台重写
aof重写过程中会进行大量写入操作,如果服务器使用主线程执行这个步骤,将会阻塞服务器主线程(redis服务器使用单个线程处理命令请求),以至于不能处理其他命令请求。
所以由主线程创建一个子线程来处理aof重写
在子进程进行重写期间,服务器可以正常处理命令请求
通如果在这个期间,服务器主进程执行的命令请求改变了数据库状态(如:写入了一个新的键值对),那么就导致aof重写后的aof文件里保存的数据库状态和当前数据库状态不一致了。
为了解决这一问题,redis服务器设置了一个aof重写缓冲区,在主进程处理命令请求的一个写命令后,会同时写入到aof重写缓冲区里。
当子进程完成aof重写工作后,会向主进程发出一个信号,主进程接到该信号,会调用一个信号处理函数执行以下步骤(期间不处理命令请求):
- 将aof重写缓冲区所有内容写到新的aof文件中,这时当前数据库状态就和文件中保存的数据库状态保持一致了
- 对新的aof文件进行改名,原子地(atomic)覆盖现有的aof文件,完成新旧aof文件的替换。
完成这些之后,主进程就可以照常处理命令请求了
这个aof重写过程中,只有信号函数执行时,会阻塞命令请求。