Redis 设计与实现(第九章) -- 数据库
概述
1.数据库结构
2.数据库键空间
3.键生存时间
4.持久化对过期键处理
5.数据库通知
1.数据库结构
Redis服务器将所有server状态都保存在数据结构中的db数组,服务器会根据dbnum来决定创建多个个数据库,默认为16个。
struct redisServer { //数据结构里面有很多属性,这里只取了相关的两个来说明
/* General */
redisDb *db;
int dbnum;
}redisServer;
创建db后,如下所示:
同样的在redisClient的数据结构中,也有一个指向当前db的属性,当在客户端执行select x时,指针就会指向对应的db
typedef struct redisClient {
redisDb *db;
}redisClient;
如下图所示,客户端选择db 2的时的结构图:
2.数据库键空间
Redis是一个键值对数据库,数据库的所有键值对都保存在字典中,可以看下redisDb的数据结构中,有个dict的属性,称这个字典属性为键空间;
typedef struct redisDb {
dict *dict; /* The keyspace for this DB */
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 */
dict *watched_keys; /* WATCHED keys for MULTI/EXEC CAS */
struct evictionPoolEntry *eviction_pool; /* Eviction pool of keys */
int id; /* Database ID */
long long avg_ttl; /* Average TTL, just for stats */
} redisDb;
键空间和用户所见的数据库是对应的,比如set msg aaa,对应每个键都是一个字符串对象,每个值可以为字符串对象、列表对象、hash表对象、集合对象等;
比如在客户端命令中,设置几个key对象,一个字符串,一个列表,一个哈希表,如下:
127.0.0.1:6379[12]> set msg 1
OK
127.0.0.1:6379[12]> rpush list "a" "b" "c"
(integer) 3
127.0.0.1:6379[12]> hset book author "Jony"
(integer) 1
127.0.0.1:6379[12]> hset book name "c++"
对应的关系如下:
读写键空间的时候,还有一些其他的维护操作,比如:
1.在读取一个键后(读和写都要读取键空间),服务器会根据键是否存在来更新键空间命中或未命中的次数,可以通过info stats命令查看;
keyspace_hits:12322422
keyspace_misses:1843426
2.在读取一个键后,服务器会更新这个键的LRU(最后一次使用时间),使用object idletime key可以查看这个key的空闲时间,前面章节有讲到过;
3.服务器读取的时候,如果发现key已经过期,则会先删除这个key,再执行余下操作;
4.如果有客户端使用WATCH 命令监视了某个键,服务器会在这个键被修改后,将这个键表尾dirty,从而让事务程序意识到这个键已经被修改了;
5.服务器每次修改一次键,就会将dirty数+1,这个键会触发服务器的持久化及复制操作;
6.如果服务器开启了数据库通知功能,则会在键被修改后,按照配置发送相应的数据库通知。
3.键的生存时间
在Redis中可以通过expire来设置键的过期时间,我们也可以通过ttl key命令来查看键的过期时间。具体是怎样实现的?如果保存的?如何超时的?接下来会分段来讲解。
设置超时
可以通过四种命令来设置key的过期时间:
expire key ttl:设置key的过期时间为ttl秒
pexpire key ttl:设置key的过期时间为ttl毫秒
expireat key timestamp:设置key的过期时间为timestamp所指定的秒数时间戳
pexpireat key timestamp:设置key的过期时间为timestamp所指定的毫秒数时间戳
这个不多说了,四个命令大体类似,最终执行结果和pexpireat一样;
保存过期时间
怎么保存的呢? 看下redisDb的数据结构,有个expires的dict来保存过期的键值对:
typedef struct redisDb {
dict *dict; /* The keyspace for this DB */
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 */
dict *watched_keys; /* WATCHED keys for MULTI/EXEC CAS */
struct evictionPoolEntry *eviction_pool; /* Eviction pool of keys */
int id; /* Database ID */
long long avg_ttl; /* Average TTL, just for stats */
} redisDb;
dict的键为指向键空间的某个键对象,值为超时时间,比如为某个key通过expire设置了超时时间,那么保存结构如下(键空间的地址其实只有一个),保存的为时间戳:
移除过期时间
通过persist key来移除key的超时时间,即将key对应的键值对从expires字典中移除
返回剩余生存时间
通常通过ttl或者pttl来获取key的剩余生存时间,具体实现为过期时间减去当前时间时间戳
过期键的判断
如果不通过ttl来返回剩余时间,就时间访问expires的dict字典:
1.先检查key是否在expires字典中存在;如果存在则获取时间;
2.获取unix的当前时间戳,如果大于过期时间的话,则过期,否则未过期;
过期键的删除
这个也是面试中比较常问的,给redis键设置超时时间后,到期后怎么就自动删除了?
Redis的删除机制,理论上来将可以有如下考虑:
1.设置定时机制
在给键设置过期时间的时候,同时设置一个定时器,定时器到期后,自动删除;这样做的优点是可以保存过期键能够及时被删除,并释放占用的内存;但是缺点也比较明显,当大量的键存在的时候,对cpu消耗会比较大,这样回导致无法响应正常的请求;
2.惰性删除
key到期后,不去删除它,当需要取key的时候,先获取过期时间,如果超时了,则先删除再进行下一步处理。这样对cpu是最友好的,但是会占用内存,如果过期key比较多,又没有程序使用这些key的话,就会一直占着内存。
3.定期删除
前面两种策略要不就是耗CPU,要不就是耗内存。定期删除每隔一段时间执行过期键的删除操作,并通过限制删除操作执行的时长和频率来减少对cpu的影响。定期删除极大的减少了内存浪费的情况。那定期删除以什么频率和执行时间来操作呢?
redis中过期键的删除策略
redis中采用的删除策略为惰性删除和定期删除两种,配合这两种策略,服务器可以很好的在使用cpu时间和避免内存浪费之间取得平衡。
惰性删除的实现
所有对数据库的读写命令执行之前都会先调用expireIfNeed函数来判断是否超时,具体代码如下:
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 */
/* Don't expire anything while loading. It will be done later. */
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. */
if (server.masterhost != NULL) return now > when;
/* Return when this key has not expired */
if (now <= when) return 0;
/* Delete the key */
server.stat_expiredkeys++;
propagateExpire(db,key);
notifyKeyspaceEvent(REDIS_NOTIFY_EXPIRED,
"expired",key,db->id);
return dbDelete(db,key);
}
执行流程如下:
定期删除的实现
通过activeExpireCycle函数来实现,该函数会分多次遍历服务器中的各个数据库,从数据库的expires字典中随机取出一部分键的过期时间,并删除其中的过期键;
void activeExpireCycle(int type) {
/* This function has some global state in order to continue the work
* incrementally across calls. */
static unsigned int current_db = 0; /* Last DB tested. */
static int timelimit_exit = 0; /* Time limit hit in previous call? */
static long long last_fast_cycle = 0; /* When last fast cycle ran. */
int j, iteration = 0;
int dbs_per_call = REDIS_DBCRON_DBS_PER_CALL;
long long start = ustime(), timelimit;
if (type == ACTIVE_EXPIRE_CYCLE_FAST) {
/* Don't start a fast cycle if the previous cycle did not exited
* for time limt. Also don't repeat a fast cycle for the same period
* as the fast cycle total duration itself. */
if (!timelimit_exit) return;
if (start < last_fast_cycle + ACTIVE_EXPIRE_CYCLE_FAST_DURATION*2) return;
last_fast_cycle = start;
}
/* We usually should test REDIS_DBCRON_DBS_PER_CALL per iteration, with
* two exceptions:
*
* 1) Don't test more DBs than we have.
* 2) If last time we hit the time limit, we want to scan all DBs
* in this iteration, as there is work to do in some DB and we don't want
* expired keys to use memory for too much time. */
if (dbs_per_call > server.dbnum || timelimit_exit)
dbs_per_call = server.dbnum;
/* We can use at max ACTIVE_EXPIRE_CYCLE_SLOW_TIME_PERC percentage of CPU time
* per iteration. Since this function gets called with a frequency of
* server.hz times per second, the following is the max amount of
* microseconds we can spend in this function. */
timelimit = 1000000*ACTIVE_EXPIRE_CYCLE_SLOW_TIME_PERC/server.hz/100; //执行时间限制
timelimit_exit = 0;
if (timelimit <= 0) timelimit = 1;
if (type == ACTIVE_EXPIRE_CYCLE_FAST)
timelimit = ACTIVE_EXPIRE_CYCLE_FAST_DURATION; /* in microseconds. */
for (j = 0; j < dbs_per_call; j++) {
int expired;
redisDb *db = server.db+(current_db % server.dbnum); //从0db开始,依次往上
/* Increment the DB now so we are sure if we run out of time
* in the current DB we'll restart from the next. This allows to
* distribute the time evenly across DBs. */
current_db++;
/* Continue to expire if at the end of the cycle more than 25%
* of the keys were expired. */
do {
unsigned long num, slots;
long long now, ttl_sum;
int ttl_samples;
/* If there is nothing to expire try next DB ASAP. */
if ((num = dictSize(db->expires)) == 0) {
db->avg_ttl = 0;
break;
}
slots = dictSlots(db->expires);
now = mstime();
/* When there are less than 1% filled slots getting random
* keys is expensive, so stop here waiting for better times...
* The dictionary will be resized asap. */
if (num && slots > DICT_HT_INITIAL_SIZE &&
(num*100/slots < 1)) break;
/* The main collection cycle. Sample random keys among keys
* with an expire set, checking for expired ones. */
expired = 0;
ttl_sum = 0;
ttl_samples = 0;
if (num > ACTIVE_EXPIRE_CYCLE_LOOKUPS_PER_LOOP)
num = ACTIVE_EXPIRE_CYCLE_LOOKUPS_PER_LOOP;
while (num--) {
dictEntry *de;
long long ttl;
if ((de = dictGetRandomKey(db->expires)) == NULL) break;
ttl = dictGetSignedIntegerVal(de)-now;
if (activeExpireCycleTryExpire(db,de,now)) expired++;
if (ttl < 0) ttl = 0;
ttl_sum += ttl;
ttl_samples++;
}
/* Update the average TTL stats for this database. */
if (ttl_samples) {
long long avg_ttl = ttl_sum/ttl_samples;
if (db->avg_ttl == 0) db->avg_ttl = avg_ttl;
/* Smooth the value averaging with the previous one. */
db->avg_ttl = (db->avg_ttl+avg_ttl)/2;
}
/* We can't block forever here even if there are many keys to
* expire. So after a given amount of milliseconds return to the
* caller waiting for the other active expire cycle. */
iteration++;
if ((iteration & 0xf) == 0) { /* check once every 16 iterations. */
long long elapsed = ustime()-start;
latencyAddSampleIfNeeded("expire-cycle",elapsed/1000);
if (elapsed > timelimit) timelimit_exit = 1;
}
if (timelimit_exit) return;
/* We don't repeat the cycle if there are less than 25% of keys
* found expired in the current DB. */
} while (expired > ACTIVE_EXPIRE_CYCLE_LOOKUPS_PER_LOOP/4); //循环,如果过期键超过25%
}
}
该函数怎么调用? 会有个serverCron,默认100ms一次,serverCron会调用databaseCron,databaseCron会调用这个函数进行定期删除key
4.持久化对过期键处理
对于RBD文件,在执行SAVE或BGSAVE命令时,对过期的键不会进行保存到RBD文件中。同理在恢复时,程序会对键的过期时间进行检查,如果过期则不会载入到数据库。
对于AOF文件,如果某个键已经过期,但是还没有到定期删除和惰性删除,对AOF文件没有任何影响。因为当键执行定期删除或惰性删除后,程序会往AOF中追加一个DEL命令。来显示记录该键已经被删除。
在执行AOF重写时,不会将过期的键保存到重写后的AOF文件中。
5.数据库通知
当客户端有订阅某个key时,数据库通知可以在key发生变化时,通知给key的订阅者。