在前面的博客中,对Redis的底层数据结构和Redis中的几种对象做了相对详尽的分析,但是对于Redis——作为一种高速分布式数据库,我们并没有对Redis的一些典型功能例如存取键、设置过期时间、主从复制等作出介绍。接下来我们将对Redis的一些功能作出详细的分析。
作为一种数据库,Redis的数据库功能是最基本也是最核心的功能,接下来就对此进行介绍。
一、数据库的结构定义
在redis.h文件中的redisDb定义了Redis数据库结构:
/* Redis database representation. There aremultiple databases identified
* byintegers from 0 (the default database) up to the max configured
* database.The database number is the 'id' field in the structure. */
typedef struct redisDb {
// 数据库键空间,保存着数据库中的所有键值对
dict*dict; /* The keyspacefor this DB */
// 键的过期时间,字典的键为键,字典的值为过期事件 UNIX 时间戳
dict*expires; /* Timeout of keyswith a timeout set */
// 正处于阻塞状态的键
dict*blocking_keys; /* Keys withclients waiting for data (BLPOP) */
// 可以解除阻塞的键
dict*ready_keys; /* Blocked keysthat received a PUSH */
// 正在被 WATCH 命令监视的键
dict*watched_keys; /* WATCHED keysfor MULTI/EXEC CAS */
structevictionPoolEntry *eviction_pool; /*Eviction pool of keys */
// 数据库号码
intid; /* Database ID */
// 数据库的键的平均 TTL ,统计信息
longlong avg_ttl; /* Average TTL,just for stats */
} redisDb;
其中用一个dict字典结构保存着数据库中所有的键值对,这个字典被称为键空间。除此之外,redisDb结构中还保存着键的过期时间的字典、数据库的ID等属性。
二、数据库相关操作
db.c文件中实现了数据库的相关操作:
1). 增加键值对:
/* Add the key to the DB. It's up to the callerto increment the reference
* counterof the value if needed.
*
* 尝试将键值对 key 和 val 添加到数据库中。
*
* 调用者负责对 key 和 val 的引用计数进行增加。
*
* Theprogram is aborted if the key already exists.
*
* 程序在键已经存在时会停止。
*/
void dbAdd(redisDb *db, robj *key, robj *val) {
// 复制键名
sdscopy = sdsdup(key->ptr);
// 尝试添加键值对
intretval = dictAdd(db->dict, copy, val);
// 如果键已经存在,那么停止
redisAssertWithInfo(NULL,key,retval == REDIS_OK);
// 如果开启了集群模式,那么将键保存到槽里面
if(server.cluster_enabled) slotToKeyAdd(key);
}
2). 删除键值对:
/* Delete a key, value, and associated expirationentry if any, from the DB
*
* 从数据库中删除给定的键,键的值,以及键的过期时间。
*
* 删除成功返回 1 ,因为键不存在而导致删除失败时,返回 0 。
*/
int dbDelete(redisDb *db, robj *key) {
/*Deleting an entry from the expires dict will not free the sds of
* thekey, because it is shared with the main dictionary. */
// 删除键的过期时间
if(dictSize(db->expires) > 0) dictDelete(db->expires,key->ptr);
// 删除键值对
if(dictDelete(db->dict,key->ptr) == DICT_OK) {
// 如果开启了集群模式,那么从槽中删除给定的键
if(server.cluster_enabled) slotToKeyDel(key);
return 1;
} else{
// 键不存在
return 0;
}
}
3). 查找键所对应的值:
/*
* 从数据库 db 中取出键 key 的值(对象)
*
* 如果 key 的值存在,那么返回该值;否则,返回 NULL 。
*/
robj *lookupKey(redisDb *db, robj *key) {
// 查找键空间
dictEntry *de = dictFind(db->dict,key->ptr);
// 节点存在
if (de){
// 取出值
robj *val = dictGetVal(de);
/*Update the access time for the ageing algorithm.
*Don't do it if we have a saving child, as this will trigger
* acopy on write madness. */
// 更新时间信息(只在不存在子进程时执行,防止破坏 copy-on-write 机制)
if(server.rdb_child_pid == -1 && server.aof_child_pid == -1)
val->lru = LRU_CLOCK();
// 返回值
return val;
} else{
// 节点不存在
return NULL;
}
}
4). 更新键所对应的值:
/* Overwrite an existing key with a new value.Incrementing the reference
* count ofthe new value is up to the caller.
*
* 为已存在的键关联一个新值。
*
* 调用者负责对新值 val 的引用计数进行增加。
*
* Thisfunction does not modify the expire time of the existing key.
*
* 这个函数不会修改键的过期时间。
*
* Theprogram is aborted if the key was not already present.
*
* 如果键不存在,那么函数停止。
*/
void dbOverwrite(redisDb *db, robj *key, robj*val) {
dictEntry *de = dictFind(db->dict,key->ptr);
// 节点必须存在,否则中止
redisAssertWithInfo(NULL,key,de != NULL);
// 覆写旧值
dictReplace(db->dict, key->ptr, val);
}
5). 设置键的过期时间,Redis中有四个不同的命令可以设置键的过期时间:EXPIRE(生存时间以秒为单位)、EXPIREAT(生存时间以毫秒为单位)、PEXPIRE(生存时间为秒的时间戳)、PEXPIREAT(生存时间为毫秒的时间戳),然而这四个指令都是使用PEXPIREAT实现的:
void expireCommand(redisClient *c) {
expireGenericCommand(c,mstime(),UNIT_SECONDS);
}
void expireatCommand(redisClient *c) {
expireGenericCommand(c,0,UNIT_SECONDS);
}
void pexpireCommand(redisClient *c) {
expireGenericCommand(c,mstime(),UNIT_MILLISECONDS);
}
void pexpireatCommand(redisClient *c) {
expireGenericCommand(c,0,UNIT_MILLISECONDS);
}
expireGenericCommand函数如下:
/* This is the generic command implementation forEXPIRE, PEXPIRE, EXPIREAT
* andPEXPIREAT. Because the commad second argument may be relative or absolute
* the"basetime" argument is used to signal what the base time is (either 0
* for *ATvariants of the command, or the current time for relative expires).
*
* 这个函数是 EXPIRE 、PEXPIRE 、 EXPIREAT 和PEXPIREAT 命令的底层实现函数。
*
* 命令的第二个参数可能是绝对值,也可能是相对值。
* 当执行 *AT 命令时, basetime 为 0 ,在其他情况下,它保存的就是当前的绝对时间。
*
* unit iseither UNIT_SECONDS or UNIT_MILLISECONDS, and is only used for
* theargv[2] parameter. The basetime is always specified in milliseconds.
*
* unit 用于指定 argv[2] (传入过期时间)的格式,
* 它可以是 UNIT_SECONDS 或UNIT_MILLISECONDS ,
* basetime参数则总是毫秒格式的。
*/
void expireGenericCommand(redisClient *c, longlong basetime, int unit) {
robj*key = c->argv[1], *param = c->argv[2];
longlong when; /* unix time in milliseconds when the key will expire. */
// 取出 when 参数
if(getLongLongFromObjectOrReply(c, param, &when, NULL) != REDIS_OK)
return;
// 如果传入的过期时间是以秒为单位的,那么将它转换为毫秒
if(unit == UNIT_SECONDS) when *= 1000;
when +=basetime;
/* Nokey, return zero. */
// 取出键
if(lookupKeyRead(c->db,key) == NULL) {
addReply(c,shared.czero);
return;
}
/* EXPIREwith negative TTL, or EXPIREAT with a timestamp into the past
*should never be executed as a DEL when load the AOF or in the context
* of a slave instance.
*
* 在载入数据时,或者服务器为附属节点时,
* 即使 EXPIRE 的 TTL 为负数,或者 EXPIREAT 提供的时间戳已经过期,
* 服务器也不会主动删除这个键,而是等待主节点发来显式的 DEL 命令。
*
*Instead we take the other branch of the IF statement setting an expire
*(possibly in the past) and wait for an explicit DEL from the master.
*
* 程序会继续将(一个可能已经过期的 TTL)设置为键的过期时间,
* 并且等待主节点发来 DEL 命令。
*/
if(when <= mstime() && !server.loading && !server.masterhost){
//when 提供的时间已经过期,服务器为主节点,并且没在载入数据
robj *aux;
redisAssertWithInfo(c,key,dbDelete(c->db,key));
server.dirty++;
/*Replicate/AOF this as an explicit DEL. */
// 传播 DEL 命令
aux= createStringObject("DEL",3);
rewriteClientCommandVector(c,2,aux,key);
decrRefCount(aux);
signalModifiedKey(c->db,key);
notifyKeyspaceEvent(REDIS_NOTIFY_GENERIC,"del",key,c->db->id);
addReply(c, shared.cone);
return;
} else{
// 设置键的过期时间
// 如果服务器为附属节点,或者服务器正在载入,
// 那么这个 when 有可能已经过期的
setExpire(c->db,key,when);
addReply(c,shared.cone);
signalModifiedKey(c->db,key);
notifyKeyspaceEvent(REDIS_NOTIFY_GENERIC,"expire",key,c->db->id);
server.dirty++;
return;
}
}
上面简单地列出了最简单的键值对操作,详细地请看db.c文件。Redis在操作数据库时,除了对键空间执行指令指定的读写操作,还会做一些额外的操作:
1). 在读取一个键时,服务器会根据键是否存在来对服务器中键命中(hit)和不命中(miss)属性进行更新:
robj *lookupKeyRead(redisDb *db, robj *key) {
robj*val;
// 检查 key 释放已经过期
expireIfNeeded(db,key);
// 从数据库中取出键的值
val =lookupKey(db,key);
// 更新命中/不命中信息
if (val== NULL)
server.stat_keyspace_misses++;
else
server.stat_keyspace_hits++;
// 返回值
returnval;
}
2). 读取键值以后,服务器会更新键的最后使用时间(LRU),这个时间用来计算键的空闲时间,这在服务器因内存不足而对缓存清除时有可能用到(比如使用最近最少使用算法淘汰缓存):
// 更新时间信息(只在不存在子进程时执行,防止破坏 copy-on-write 机制)
if(server.rdb_child_pid == -1 && server.aof_child_pid == -1)
val->lru = LRU_CLOCK();
3). 服务器对键进行操作时,会更新dirty计数器的值,这个值会在服务器持久化时被用到:
// 随便列举一个对键进行的操作
void persistCommand(redisClient *c) {
dictEntry *de;
// 取出键
de =dictFind(c->db->dict,c->argv[1]->ptr);
if (de== NULL) {
// 键没有过期时间
addReply(c,shared.czero);
} else{
// 键带有过期时间,那么将它移除
if(removeExpire(c->db,c->argv[1])) {
addReply(c,shared.cone);
server.dirty++;
// 键已经是持久的了
}else {
addReply(c,shared.czero);
}
}
}
4). 服务器开启通知时,对键进行修改后,会发出通知,如上面代码中的addReply函数
三、过期的键值删除策略
在上面我们介绍了Redis中的过期键的一些操作,那么过期键会在什么时候删除?按照正常人的逻辑一般可以想到如下两到三种删除策略:
1. 第一种最简单——定时删除:因为我们可以获得一个键的存活时间,那么我们为这个键定义一个定时器,时间到了就触发一个事件,这个时候再去删除这个键。如果有成千上万个过期键呢?难道需要开成千上万个定时器去处理么?那无疑将大部分的CPU都耗在了监控这些过期键了。这种方案性能很明显是不好的
2. 定期删除:这种方案是对第一种方案的改进——既然不能喂每一个键都创建一个定时器,那么就让服务器每隔一段时间就扫描一次数据库,从而清除过期的键。但是如何平衡定时删除的频率是一个难点。
3. 延时删除:也叫惰性删除,这是一种用内存换时间的方案——键过期了服务器并不去主动删除他,直到客户端对此过期键进行操作时,服务器告诉客户端“没有这个键”(因为过期了),然后把这个无效的键删除。那么考虑一种情况:成千上万个无效键一直没有被请求,那么这些无效的键就会堆积在内存中,这无疑对内存是很不利的。
那么作为一种高性能的缓存数据库,Redis是怎么做的呢?——Redis将定期删除和延时删除两种方案结合起来,从而平衡了时间和空间的取舍。
延时删除是通过expireIfNeeded函数来实现的,在对键进行操作之前都会调用expireIfNeeded来确认此键是否是无效的(如上面的lookupKeyRead函数):
/*
* 检查 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 */
/*Don't expire anything while loading. It will be done later. */
// 如果服务器正在进行载入,那么不进行任何过期检查
if(server.loading) return 0;
/* Ifwe 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
* onlythe first time it is accessed and not in the middle of the
*script execution, making propagation to slaves / AOF consistent.
* Seeissue #1525 on Github for more information. */
now =server.lua_caller ? server.lua_time_start : mstime();
/* Ifwe are running in the context of a slave, return ASAP:
* theslave key expiration is controlled by the master that will
* sendus synthesized DEL operations for expired keys.
*
*Still we try to return the right information to the caller,
* thatis, 0 if we think the key should be still valid, 1 if
* wethink 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);
// 将过期键从数据库中删除
returndbDelete(db,key);
}
定期删除策略是通过redis.c文件中的activeExpireCycle函数实现的, Redis会定期执行(默认100ms一次)redis.c/serverCron函数,其中就会执行databasesCron函数,而databasesCron函数包含了activeExpireCycle函数的执行:
/* Try to expire a few timed out keys. Thealgorithm used is adaptive and
* will usefew CPU cycles if there are few expiring keys, otherwise
* it willget more aggressive to avoid that too much memory is used by
* keysthat can be removed from the keyspace.
*
* 函数尝试删除数据库中已经过期的键。
* 当带有过期时间的键比较少时,函数运行得比较保守,
* 如果带有过期时间的键比较多,那么函数会以更积极的方式来删除过期键,
* 从而可能地释放被过期键占用的内存。
*
* No morethan REDIS_DBCRON_DBS_PER_CALL databases are tested at every
*iteration.
*
* 每次循环中被测试的数据库数目不会超过 REDIS_DBCRON_DBS_PER_CALL 。
*
* Thiskind of call is used when Redis detects that timelimit_exit is
* true, sothere is more work to do, and we do it more incrementally from
* thebeforeSleep() function of the event loop.
*
* 如果 timelimit_exit 为真,那么说明还有更多删除工作要做,
* 那么在 beforeSleep() 函数调用时,程序会再次执行这个函数。
*
* Expirecycle type:
*
* 过期循环的类型:
*
* If typeis ACTIVE_EXPIRE_CYCLE_FAST the function will try to run a
*"fast" expire cycle that takes no longer thanEXPIRE_FAST_CYCLE_DURATION
*microseconds, and is not repeated again before the same amount of time.
*
* 如果循环的类型为 ACTIVE_EXPIRE_CYCLE_FAST ,
* 那么函数会以“快速过期”模式执行,
* 执行的时间不会长过 EXPIRE_FAST_CYCLE_DURATION 毫秒,
* 并且在 EXPIRE_FAST_CYCLE_DURATION 毫秒之内不会再重新执行。
*
* If typeis ACTIVE_EXPIRE_CYCLE_SLOW, that normal expire cycle is
*executed, where the time limit is a percentage of the REDIS_HZ period
* asspecified by the REDIS_EXPIRELOOKUPS_TIME_PERC define.
*
* 如果循环的类型为 ACTIVE_EXPIRE_CYCLE_SLOW ,
* 那么函数会以“正常过期”模式执行,
* 函数的执行时限为 REDIS_HS 常量的一个百分比,
* 这个百分比由 REDIS_EXPIRELOOKUPS_TIME_PERC 定义。
*/
void activeExpireCycle(int type) {
/* Thisfunction has some global state in order to continue the work
*incrementally across calls. */
// 静态变量,用来累积函数连续执行时的数据
staticunsigned int current_db = 0; /* Last DB tested. */
staticint timelimit_exit = 0; /* Timelimit hit in previous call? */
staticlong long last_fast_cycle = 0; /* When last fast cycle ran. */
unsigned int j, iteration = 0;
// 默认每次处理的数据库数量
unsigned int dbs_per_call = REDIS_DBCRON_DBS_PER_CALL;
// 函数开始的时间
longlong 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. */
// 如果上次函数没有触发 timelimit_exit ,那么不执行处理
if(!timelimit_exit) return;
// 如果距离上次执行未够一定时间,那么不执行处理
if(start < last_fast_cycle + ACTIVE_EXPIRE_CYCLE_FAST_DURATION*2) return;
// 运行到这里,说明执行快速处理,记录当前时间
last_fast_cycle = start;
}
/* Weusually should test REDIS_DBCRON_DBS_PER_CALL per iteration, with
* twoexceptions:
*
* 一般情况下,函数只处理 REDIS_DBCRON_DBS_PER_CALL 个数据库,
* 除非:
*
* 1)Don't test more DBs than we have.
* 当前数据库的数量小于 REDIS_DBCRON_DBS_PER_CALL
* 2)If last time we hit the time limit, we want to scan all DBs
* inthis 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;
/* Wecan use at max ACTIVE_EXPIRE_CYCLE_SLOW_TIME_PERC percentage of CPU time
* periteration. 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. */
// 函数处理的微秒时间上限
//ACTIVE_EXPIRE_CYCLE_SLOW_TIME_PERC 默认为 25 ,也即是 25 % 的 CPU 时间
timelimit = 1000000*ACTIVE_EXPIRE_CYCLE_SLOW_TIME_PERC/server.hz/100;
timelimit_exit = 0;
if(timelimit <= 0) timelimit = 1;
// 如果是运行在快速模式之下
// 那么最多只能运行 FAST_DURATION 微秒
// 默认值为 1000 (微秒)
if(type == ACTIVE_EXPIRE_CYCLE_FAST)
timelimit = ACTIVE_EXPIRE_CYCLE_FAST_DURATION; /* in microseconds. */
// 遍历数据库
for (j= 0; j < dbs_per_call; j++) {
intexpired;
// 指向要处理的数据库
redisDb *db = server.db+(current_db % server.dbnum);
/*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. */
// 为 DB 计数器加一,如果进入 do 循环之后因为超时而跳出
// 那么下次会直接从下个 DB 开始处理
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. */
// 获取数据库中带过期时间的键的数量
// 如果该数量为 0 ,直接跳过这个数据库
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. */
// 这个数据库的使用率低于 1% ,扫描起来太费力了(大部分都会 MISS)
// 跳过,等待字典收缩程序运行
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 计数器
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++;
}
/* Update the average TTL stats for this database. */
// 为这个数据库更新平均 TTL 统计数据
if (ttl_samples) {
// 计算当前平均值
long long avg_ttl = ttl_sum/ttl_samples;
// 如果这是第一次设置数据库平均 TTL ,那么进行初始化
if (db->avg_ttl == 0) db->avg_ttl = avg_ttl;
/* Smooth the value averaging with the previous one. */
// 取数据库的上次平均 TTL 和今次平均 TTL 的平均值
db->avg_ttl = (db->avg_ttl+avg_ttl)/2;
}
/* We can't block forever here even ifthere are many keys to
* expire. So after a given amount of milliseconds return to the
* caller waiting for the other active expire cycle. */
// 我们不能用太长时间处理过期键,
// 所以这个函数执行一定时间之后就要返回
// 更新遍历次数
iteration++;
// 每遍历 16 次执行一次
if ((iteration & 0xf) == 0 && /* check once every 16iterations. */
(ustime()-start) > timelimit)
{
// 如果遍历次数正好是 16 的倍数
// 并且遍历的时间超过了 timelimit
// 那么断开 timelimit_exit
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. */
// 如果已删除的过期键占当前总数据库带过期时间的键数量的 25 %
// 那么不再遍历
}while (expired > ACTIVE_EXPIRE_CYCLE_LOOKUPS_PER_LOOP/4);
}
}