Redis 是一个使用 C 语言编写的 NoSql 的数据库,本篇就讲解在 Redis 中数据库是如何存储的?以及和数据库有关的一些操作。
Redis 中的所有数据库都保存在 redis.h/redisServer 结构中的 db 数组中,如下:
struct redisServer {
......
// 数据库
redisDb *db;
......
}
Redis 默认会创建 16 个数据库,每个数据库互不影响。
切换数据库
每个 Redis 客户端也都有自己的目标数据库,默认情况下,Redis客户端的目标数据库是 0 号数据库。但客户端也可以通过 select 命令来切换目标数据库。
在服务器内部,客户端状态 redisClient 结构的 db 属性记录了客户端当前的目标数据库,如下:
typedef struct redisClient {
// 套接字描述符
int fd;
// 当前正在使用的数据库
redisDb *db;
// 当前正在使用的数据库的 id (号码)
int dictid;
// 客户端的名字
robj *name; /* As set by CLIENT SETNAME */
} redisClient;
假如某个客户端的目标数据库为 1 号数据库,那么这个客户端所对应的客户端状态和服务器状态之间的关系如下(出自《Redis设计与实现第二版》第九章:数据库):
注意: 到目前为止,Redis 仍然没有返回客户端目标数据库的命令,所以尽量不要在项目中使用多数据库,以免造成混乱。
数据库键空间
Redis 是一个键值对数据库服务器,服务器中的每个数据库都由一个 redis.h/redisDb 结构表示,具体结构如下:
typedef struct redisDb {
// 数据库键空间,保存着数据库中的所有键值对
dict *dict; /* The keyspace for this DB */
// 键的过期时间,字典的键为键,字典的值为过期事件 UNIX 时间戳
dict *expires; /* Timeout of keys with a timeout set */
// 正处于阻塞状态的键
dict *blocking_keys; /* Keys with clients waiting for data (BLPOP) */
// 可以解除阻塞的键
dict *ready_keys; /* Blocked keys that received a PUSH */
// 正在被 WATCH 命令监视的键
dict *watched_keys; /* WATCHED keys for MULTI/EXEC CAS */
struct evictionPoolEntry *eviction_pool; /* Eviction pool of keys */
// 数据库号码
int id; /* Database ID */
// 数据库的键的平均 TTL ,统计信息
long long avg_ttl; /* Average TTL, just for stats */
} redisDb;
键空间(db 属性)和用户所见的数据库是直接对应的:
- 键空间的键也就是数据库的键,每个键都是一个字符串对象。
- 键空间的值也就是数据库的值,每个值可以是字符串对象、列表对象、哈希表对象、集合对象和有序集合对象中的任意一种 Redis 对象。
而在数据库中添加、修改、删除键也都是操作的 db 字典。
键的过期策略
Redis 中设置键的过期时间有四种写法:
- expire key t1 :表示将键 key 的生存时间设置为 t1 秒。
- pexpire key t1 :表示将键 key 的生存时间设置为 t1 毫秒。
- expireat key t1 :表示将键 key 的过期时间设置为 t1 所指定的秒数时间戳。
- pexpireat key t1 :表示将键 key 的过期时间设置为 t1 所指定的毫秒数时间戳。
虽然有 4 种不同的写法,但这些做的都是一件事,所以可以抽成一个统一的方法。而实际上 Redis 也正是这么做的,expire、pexpire、expireat 三个命令都是使用 pexpireat 命令来实现的。
Redis 如何存储键的过期时间呢?
redisDb 结构的 expires 字典保存了数据库中所有键的过期时间,我们称这个字典为过期字典:
- 过期字典的键是一个指针,这个指针指向键空间中的某个键对象(也即是某个数据库键)。
- 过期字典的值是一个 long long 类型的整数,这个整数保存了键所指向的数据库键的过期时间(一个毫秒精度的 UNIX 时间戳)。
Redis 如何移除键的过期时间呢?
命令是: persist key
Redis 数据库做的操作也仅仅是在 expires 字典中删除对应的键值对。
Redis 如何判断一个键是否过期呢?
通过 expires 字典,程序可以用以下步骤检查一个给定键是否过期:
- 检查给定键是否存在于过期字典:如果存在,那么取得键的过期时间。
- 检查当前 UNIX 时间戮是否大于键的过期时间:如果是的话,那么键已经过期;否则的话,键未过期。
Redis 具体是如何删除一个过期的键呢?
我们知道数据库键的过期时间都保存在过期字典中,又知道了如何根据过期时间去判断一个键是否过期,现在剩下的问题是:如果一个键过期了,那么它什么时候会被删除呢?
这个问题有三种可能的答案,它们分别代表了三种不同的删除策略:
- 定时删除:在设置键的过期时间的同时,创建一个定时器(timer) 。让定时器在键的过期时间来临时,立即执行对键的侧除操作。
- 惰性删除:放任键过期不管,但是每次从键空间中获取键时,都检查取得的键是否过期,如果过期的话,就侧除该键;如果没有过期,就返回该键。
- 定期删除:每隔一段时间,程序就对数据库进行一次检查,删除里面的过期键。至于要删除多少过期键,以及要检查多少个数据库,则由算法决定。
在这三种策略中,第一种和第三种为主动删除策略,而第二种为被动侧除策略。而无论是哪种策略都有其优点和缺点。
对于定时删除来说:
- 优点是可以保证过期键会尽可能快的被删除,并释放过期键所占用的内存;
- 缺点则是会占用一部分 CPU 时间,尤其是当键非常多的时候,占用的 CPU 时间也会增多,这是不可忍受的。
对于惰性删除来说:
- 程序只会在取出键是进行检查,所以优点是几乎不不占用 CPU 时间;
- 缺点则是可能会造成内存泄漏,比如当键过期之后永远不再访问,这时候就是内存泄漏了。
对于定期删除来说,则是以上两种策略的一种整合,定期删除策略每隔一段时间执行一次删除过期键操作,并通过限制删除操作执行的时长和频率来减少删除操作对 CPU 时间的影响;除此之外,通过定期删除过期键,定期删除策略有效地减少了因为过期键而带来的内存浪费。
定期删除策略的难点是确定删除操作执行的时长和频率:
- 如果删除操作执行得太频繁,或者执行的时间太长,定期删除策略就会退化成定时删除策略,以至于将 CPU 时间过多地消耗在侧除过期键上面。
- 如果删除操作执行得太少,或者执行的时间太短,定期删除策略又会和惰性删除策略一样,出现浪费内存的情况。
因此,如果采用定期侧除策略的话,服务器必须根据情况,合理地设置删除操作的执行时长和执行频率。
而 Redis 中则使用了定期删除和惰性删除两种策略,很好的在 CPU 和内存上面取得了一个平衡。
惰性删除
过期键的惰性删除策略由 db.c/expireIfNeeded 函数实现,所有读写数据库的 Redis 命令在执行之前都会调用 expireIfNeeded 函数对输入键进行检查:
- 如果输人键已经过期,那么 expireIfNeeded 函数将输入键从数据库中删除。
- 如果输人键未过期,那么 expireIfNeeded 函数不做动作。
expireIfNeeded 函数就像一个过滤器,它可以在命令真正执行之前,过滤掉过期的输人键,从而避免命令接触到过期键。函数的具体代码如下:
/*
* 检查 key 是否已经过期,如果是的话,将它从数据库中删除。
*
* 返回 0 表示键没有过期时间,或者键未过期。
*
* 返回 1 表示键已经因为过期而被删除了。
*/
int expireIfNeeded(redisDb *db, robj *key) {
// 取出键的过期时间
mstime_t when = getExpire(db,key);
mstime_t now;
// 没有过期时间
if (when < 0) return 0; /* No expire for this key */
// 如果服务器正在进行载入,那么不进行任何过期检查
if (server.loading) return 0;
/* If we are in the context of a Lua script, we claim that time is
* blocked to when the Lua script started. This way a key can expire
* only the first time it is accessed and not in the middle of the
* script execution, making propagation to slaves / AOF consistent.
* See issue #1525 on Github for more information. */
now = server.lua_caller ? server.lua_time_start : mstime();
/* If we are running in the context of a slave, return ASAP:
* the slave key expiration is controlled by the master that will
* send us synthesized DEL operations for expired keys.
*
* Still we try to return the right information to the caller,
* that is, 0 if we think the key should be still valid, 1 if
* we think the key is expired at this time. */
// 当服务器运行在 replication 模式时
// 附属节点并不主动删除 key
// 它只返回一个逻辑上正确的返回值
// 真正的删除操作要等待主节点发来删除命令时才执行
// 从而保证数据的同步
if (server.masterhost != NULL) return now > when;
// 运行到这里,表示键带有过期时间,并且服务器为主节点
/* Return when this key has not expired */
// 如果未过期,返回 0
if (now <= when) return 0;
/* Delete the key */
server.stat_expiredkeys++;
// 向 AOF 文件和附属节点传播过期信息
propagateExpire(db,key);
// 发送事件通知
notifyKeyspaceEvent(REDIS_NOTIFY_EXPIRED,
"expired",key,db->id);
// 将过期键从数据库中删除
return dbDelete(db,key);
}
命令调用 expireIfNeeded 来删除过期键的过程和 get 命令的执行过程如下(出自《Redis设计与实现第二版》第九章:数据库):
定期删除
过期键的定期删除策略由 redis.c/activeExpireCycle 函数实现,调用流程为 serverCron() -> databasesCron() -> activeExpireCycle()。核心代码如下(为了方便查看核心部分,对代码进行了截取):
int serverCron(struct aeEventLoop *eventLoop, long long id, void *clientData) {
......
// 对数据库执行各种操作
databasesCron();
......
}
// 对数据库执行删除过期键,调整大小,以及主动和渐进式 rehash
void databasesCron(void) {
// 函数先从数据库中删除过期键,然后再对数据库的大小进行修改
/* Expire keys by random sampling. Not required for slaves
* as master will synthesize DELs for us. */
// 如果服务器不是从服务器,那么执行主动过期键清除
if (server.active_expire_enabled && server.masterhost == NULL)
// 清除模式为 CYCLE_SLOW ,这个模式会尽量多清除过期键
activeExpireCycle(ACTIVE_EXPIRE_CYCLE_SLOW);
/* Perform hash tables rehashing if needed, but only if there are no
* other processes saving the DB on disk. Otherwise rehashing is bad
* as will cause a lot of copy-on-write of memory pages. */
// 在没有 BGSAVE 或者 BGREWRITEAOF 执行时,对哈希表进行 rehash
if (server.rdb_child_pid == -1 && server.aof_child_pid == -1) {
......
}
}
void activeExpireCycle(int type) {
......
// 遍历数据库
for (j = 0; j < dbs_per_call; j++) {
int expired;
// 指向要处理的数据库
redisDb *db = server.db+(current_db % server.dbnum);
// 为 DB 计数器加一,如果进入 do 循环之后因为超时而跳出
// 那么下次会直接从下个 DB 开始处理
current_db++;
do {
unsigned long num, slots;
long long now, ttl_sum;
int ttl_samples;
// 获取数据库中带过期时间的键的数量
// 如果该数量为 0 ,直接跳过这个数据库
if ((num = dictSize(db->expires)) == 0) {
db->avg_ttl = 0;
break;
}
// 获取数据库中键值对的数量
slots = dictSlots(db->expires);
// 当前时间
now = mstime();
// 这个数据库的使用率低于 1% ,扫描起来太费力了(大部分都会 MISS)
// 跳过,等待字典收缩程序运行
if (num && slots > DICT_HT_INITIAL_SIZE &&
(num*100/slots < 1)) break;
/*
* 样本计数器
*/
// 已处理过期键计数器
expired = 0;
// 键的总 TTL 计数器
ttl_sum = 0;
// 总共处理的键计数器
ttl_samples = 0;
// 每次最多只能检查 LOOKUPS_PER_LOOP 个键
if (num > ACTIVE_EXPIRE_CYCLE_LOOKUPS_PER_LOOP)
num = ACTIVE_EXPIRE_CYCLE_LOOKUPS_PER_LOOP;
// 开始遍历数据库
while (num--) {
dictEntry *de;
long long ttl;
// 从 expires 中随机取出一个带过期时间的键
if ((de = dictGetRandomKey(db->expires)) == NULL) break;
// 计算 TTL
ttl = dictGetSignedIntegerVal(de)-now;
// 如果键已经过期,那么删除它,并将 expired 计数器增一
if (activeExpireCycleTryExpire(db,de,now)) expired++;
if (ttl < 0) ttl = 0;
// 累积键的 TTL
ttl_sum += ttl;
// 累积处理键的个数
ttl_samples++;
}
......
// 已经超时了,返回
if (timelimit_exit) return;
// 如果已删除的过期键占当前总数据库带过期时间的键数量的 25 %
// 那么不再遍历
} while (expired > ACTIVE_EXPIRE_CYCLE_LOOKUPS_PER_LOOP/4);
}
}
几点说明:
- serverCron() 函数是 Redis 的定时器,默认每隔 100ms 运行一次。
- 在 databasesCron() 函数中不只进行了删除过期键还进行了 rehash 操作。
- 如果服务器不是从服务器,才会执行主动过期键清除。
- ACTIVE_EXPIRE_CYCLE_LOOKUPS_PER_LOOP 在 redis.h 中定义,为 20。也就是说一次删除20个过期键。如果不超过设定的时间,每个库可以删除多次。