Redis设计 - 数据库与键
前言
开始之前,我们可以设想一下,假设是我们自己要设计一款内存缓存系统,需要用什么结构来保存我们的数据呢?首选的当然就是map结构啦,保存key-value形式的键值对,简单易懂。
其实redis也是一样的,只不过redis有一些额外的数据结构用来支持其他功能(例如过期键,多数据库,RDB持久化),接下来我们来探究Redis中的数据库。其中Redis过期功能的实现,也是面试中的高频考察点之一,相信读者读完此篇之后,会对过期功能有个本质的认识。
服务器中的数据库
Redis服务器将所有的数据库都保存在redisServer结构的db数组中,意味着它不止一个数据库,数组保存着redisDB结构,每个redisDB结构都代表一个数据库对象。
struct redisServer {
...
//数据库数量(默认为16个)
int dbnum;
//数据库对象数组,保存着服务器中所有的数据库
redisDb *db;
//其他属性对象......
}
dbnum属性的值由服务器配置的database选项决定,默认情况下,该选项的值为16,所以Redis服务器默认会创建16个数据库,如下图所示:
服务器数据库示例
切换数据库
默认情况下,Redis客户端的目标数据库为0号数据库,但是客户端可以通过执行SELECT命令来切换目标数据库。
typedef struct redisClient {
//记录客户端正在使用的数据库
redisDB *db;
} redisClient;
在redis客户端执行> SELECT 1 时,将切换成1号数据库,如下图所示:
客户端的目标数据库由0号切换成1号需要注意的是,当你的程序在处理多数据库的Redis时,多次切换数据库后可能忘记当前处理哪个数据库,为了避免误操作,在执行redis命令如 flushdb时,最好先执行select命令显示切换到目标数据库。
数据库键空间
Redis服务器中的每个数据库都由一个redis.h/redisDb结构表示,redisDb结构的dict字典保存了数据库中的所有键值对,称为键空间(key space):
typedef struct redisDb {
// ...
// 数据库键空间,保存着数据库中所有键值对
dict *dict;
// ...
} redisDb;
键空间就是我们平时操作Redis存取数据的地方
- 键空间里面的key就是客户端命令的key,每个key都是一个字符串对象。
- 键空间里面的value就是客户端命令的各种值了,可以是字符串对象,哈希对象,列表对象,集合对象,有序集合对象等等Redis中的对象。
假设我们执行以下几个命令:
redis> SET message "hello world"
OK
redis> RPUSH alphabet "a" "b" "c"
(integer) 3
redis> HSET book name "Redis in Action"
(integer) 1
redis> HSET book author "Josiah L. Carlson"
(integer) 1
执行命令后,数据库的键空间如图:
数据库键空间示例
我们对Redis的增删改查等操作,都是通过对键空间字典进行操作来实现的,包括 exists, rename, keys等。
读写键空间时的维护操作
当使用Redis命令对数据库进行读写操作时,服务器不仅会对键空间执行相应的命令操作,还会执行一些额外的维护操作,包括:
- 读取一个键之后,服务器会根据键是否存在来更新键空间命中次数(hit)和miss次数,这个两个属性的值可以在INFO stats命令的 keyspacehits属性和keyspacemissses查看。
- 读取一个键后,需要更新该键的lru时间,lru用于计算闲置时间,在Redis控制内存使用时会用到此属性。
- 如果读取该键的时候发现键已经过期了,那么会删除这个过期键,直接进行余下的操作。
- 如果有客户端使用watch命令监视了某个键,那么服务器对被监视的键进行修改之后,会标记这个键为脏,从而让事务程序注意到键已经被修改了。
- 每次修改键,都会在数据库的脏值计数器+1(或者称之为修改次数计数器),这个计数器会触发数据的持久化和复制操作。
- 如果开启了数据库通知功能,那么对键进行修改之后,将按照配置发送相应的数据库通知。
键操作
设置过期时间
通过expire命令或者pexpire命令,客户端可以通过秒或者毫秒的精度为数据库中的某个键设置国过期时间(Time To Live, TTL)。
redis> set name "jasorchen"
OK
redis> expire name 5
(integer) 1
redis> get name // 5秒内
"jasorchen"
redis> get name // 5秒后
(nil)
与expire命令类似的,还有expireat命令,设置指定过期时间为指定的时间戳,本质上是一样的,expire命令就是在当前时间戳的基础上加上过期时间而已。Redis提供了四个命令设置键的过期时间:
- EXPIRE <key> <ttl>:将键key的生存时间设置为 ttl 秒
- PEXPIRE <key> <ttl>:将键key的生存时间设置为 ttl 毫秒
- EXPIREAT <key> <timestamp>:将键key的过期时间设置在tiemstamp指定的秒级别时间戳
- PEXPIREAT <key> <timestamp>:将键key的过期时间设置在tiemstamp指定的毫秒级别时间戳
命令转换虽然有多种命令设置键的过期时间,但是最终都是通过转换成PEXPIREAT命令来实现的。
过期时间的保存
redisDb结构的expires字典保存了数据库中所有键的过期时间,称为过期字典:
- 过期字典的键是一个指针,指向设置了过期时间的键。
- 过期字典的值是一个long long 类型的整数,保存了该键的过期时间。
typedef struct redisDb {
// ...
//过期字典,保存键的过期时间
dict *expires;
// ...
} redisDb;
过期字典示例
移除过期时间
PERSISIT命令可以移除一个键的过期时间,其大致原理就是在过期字典中找到给定的键,并从过期字典中移除。
过期键的判定
通过过期字典,程序可以判断以下步骤检查一个键是否过期:
- 检查键是否在过期字典中,如果在,取得过期时间。
- 判断过期时间是否到期,如果已到期,则判断为过期,否则未过期。
过期键的删除策略
如果一个键过期了,那么在何时会被删除呢?有如下三种策略:
- 立即删除:设置过期键的时间的同时,也创建一个定时器,让定时器在键的过期时间来临时,立即执行对键的删除操作。
- 惰性删除:不去主动删除,只有从键空间获取键的时候判断一下是否需要删除,如果需要就删除。因此如果不访问这个键,就永远不会删除。
- 定期删除:每个一段时间,就对数据库的过期字典做一次检查,删除过期键。
过期键删除策略——立即删除
- 优点:对内存友好,可以保证过期键会尽可能快的被删除,释放键所占用的空间。
- 缺点:对CPU时间非常不友好,而且带来大量的定时器,且客户端如果做了存活时间修改,需要再次维护定时器,这对服务器的响应时间和吞吐量造成影响。因此这种方案在现阶段来说不现实,Redis并没有采用这种方式。
过期键删除策略——惰性删除
- 优点:对CPU时间来说是最友好的,程序只会在取出键的时候才会对键进行检查过期与否。
- 缺点:对内存不友好:如果一个键已经过期,而这个键仍然保留在数据库中,且不再访问,那么它占用的内存就一直不会释放。从某种程度上来说是一种内存泄漏。
过期键删除策略——定期删除
定期策略是前面两种策略的一种整合折中:
每隔一段时间执行一次删除过期键操作,并通过限制删除操作执行的市场和频率来减少删除操作对CPU时间的影响,通过定期删除,有效的减少了因为过期键而带来的内存浪费。
该策略的难点是确定删除操作执行的时长和频率:
1.如果删除操作执行得太频繁,或者执行的时间太长,CPU将消耗过多的时间在删除键上面。
2.如果删除频率太低,或者执行的时间太短,一样会造成内存浪费的情况。
Redis的过期键删除策略
前面讨论了立即删除、惰性删除和定期删除三种过期键删除策略,那么Redis服务器实际采用了哪种方式删除过期键呢?
答案是,惰性删除和定期删除结合使用,使得服务器可以在合理使用CPU时间和避免浪费内存空间之间取得平衡。
惰性删除策略的实现
键的惰性删除策略由db.c/expireIfNeeded函数实现,所有读写数据库的Redis命令在执行前,都必须调用该函数作检查:
- 如果该键已经过期,那么expireIfNeeded函数将键从数据库删除
-
如果未过期,expireIfNeeded函数不做任何动作
惰性删除策略
定期删除策略实现
键的定期删除策略由activeExpireCycle函数实现,每当Redis的服务器周期性操作serverCron函数执行时,activeExpireCycle函数就会被调用,它在规定的时间内,分多次遍历服务器的各个数据库,从数据库的expires字典中随机检查一部分键的过期时间,并删除其中的过期键。
在下一次activeExpireCycle函数调用时,会接着上一次的进度进行处理。
伪代码如下
//默认每次检查的数据库数量
DEFAULT_DB_NUMBERS = 16;
//默认每次检查的键数量
DEFAULT_KEY_NUMBERS = 20;
//全局变量,记录数据库检查进度
current_db = 0;
def activeExpireCycle() {
//确保数据库数量不会越界
if server.dbnum < DEFAULT_DB_NUMBERS;
db_numbers = server.dbnum;
else
db_numbers = DEFAULT_DB_NUMBERS;
//获取当前要处理的数据库
redisDB = server.db[current_db];
//数据库索引加一
current_db++;
//获取过期字典里面的键,
for (int i = 0; i < DEFAULT_KEY_NUMBERS; i++) {
//没有过期键的直接跳过
if (redisDB.expires.size() == 0) {
break;
}
//随机获取一个带有过期时间的键
key_with_ttl = redisDB.expires.getRandomKey();
//检查是否过期
if (isExcepired(key_with_ttl)) {
deleteKey(key_with_ttl)
}
//如果已经达到时间上限,停止处理
if (reach_time_limit())
return;
}
}
AOF、RDB和复制功能对过期键的处理
在Redis的持久化(AOF和RDB),以及复制功能时,是如何处理数据库的过期键的呢?
生成RDB文件
用save命令或者bgsave命令创建一个新的RDB文件时,程序会对数据库中的键进行检查,已经过期的键不会被保存在新创建的RDB文件中。因此不会对生成RDB文件有任何的影响。
载入RDB文件
在启动Redis服务器时,如果开启了RDB功能,那么服务器将对RDB文件进行载入:
- 如果服务器以主服务器模式运行,那么在载入RDB文件时,程序会对文件中保存的键进行检查,未过期的键会被载入到数据库中,过期键直接忽略。
- 如果服务器以从库方式启动,那么载入RDB文件时,文件中保存的所有键,不论是否过期,都会被载入到数据库中。不过因为主从同步时,从服务器的数据库会被清空,因此过期键对于载入RDB的从库也没有影响。
AOF文件写入
当服务器以AOF持久化模式运行时,如果数据库中某个键已经过期,但它还没有被各种删除策略发现,那么AOF文件不会因为这个过期键而产生任何影响。当键被惰性或者定期策略删除删除后,程序会像AOF文件追加一条DEL命令, 显示的删除这个键。
因为AOF保存的操作命令,所以键的读写操作命令都会被保存在AOF文件中,当键过期时,通过惰性删除或者定时删除之后,AOF会保存相应的删除命令,显式记录该键已经被删除。
如:客户端使用GET message访问过期的message键,那么服务器将执行以下三个动作:
- 从数据库中删除message键
- 追加一条DEL message命令道AOF文件
- 向执行GET命令的客户端返回空回复
AOF重写
执行AOF文件重写,程序会对键进行检查,已经过期的不写入到新的AOF文件中。
为什么会有AOF重写?
- AOF 持久化是通过保存被执行的写命令来记录数据库状态的,所以AOF文件的大小随着时间的流逝一定会越来越大;影响包括但不限于:对于Redis服务器,计算机的存储压力;AOF还原出数据库状态的时间增加;
- 为了解决AOF文件体积膨胀的问题,Redis提供了AOF重写功能:Redis服务器可以创建一个新的AOF文件来替代现有的AOF文件,新旧两个文件所保存的数据库状态是相同的,但是新的AOF文件不会包含任何浪费空间的冗余命令,通常体积会较旧AOF文件小很多。
主从复制对过期键的处理
当服务器运行在复制模式下,从服务器的过期键删除策略又主服务器控制:
- 主服务器删除一个过期键之后,会显式的向所有从服务器发送一条DEL命令。
- 从服务器接收到客户端的读取key请求,即使该键已经过期,也不删除,而是当做未过期键来处理。
- 从服务器只有在接收到主服务器发送的DEL命令后,才会删除过期键。
通过主服务器来控制从服务器统一地删除过期键,可以最大程度的保证主从数据的一致性,由主服务器来确定何时删除过期键。