SET key1 "zerok" EX 60
上面命令是设置一个key1的字符类记录,生命周期为60秒。这个命令过程在redis内部代码调用是如下流程:
setCommand() ->setGenericCommand()->setKey();
这就是内部调用关系,SET实现的关键是在setGenericCommand()在此函数。代码如下:
void setGenericCommand(redisClient *c, int nx, robj *key, robj *val, robj *expire, int unit)
{
long long milliseconds = 0; /* initialized to avoid an harmness warning */
// 如果带有 expire 参数,那么将它从 sds 转为 long long 类型
if (expire) {
if (getLongLongFromObjectOrReply(c, expire, &milliseconds, NULL) != REDIS_OK)
return;
if (milliseconds <= 0) {
addReplyError(c,"invalid expire time in SETEX");
return;
}
// 决定过期时间是秒还是毫秒
if (unit == UNIT_SECONDS) milliseconds *= 1000;
}
// 如果给定了 nx 参数,并且 key 已经存在,那么直接向客户端返回
if (nx && lookupKeyWrite(c->db,key) != NULL) {
addReply(c,shared.czero);
return;
}
// 设置 key-value 对
setKey(c->db,key,val);
server.dirty++;
// 为 key 设置过期时间
if (expire) setExpire(c->db,key,mstime()+milliseconds);
// 向客户端返回回复
addReply(c, nx ? shared.cone : shared.ok);
}
如果是设置了过期时间,就会调用setExpire,这个函数会进行如下操作:
void setExpire(redisDb *db, robj *key, long long when) {
dictEntry *kde, *de;
/* Reuse the sds from the main dict in the expire dict */
kde = dictFind(db->dict,key->ptr);
redisAssertWithInfo(NULL,key,kde != NULL);
de = dictReplaceRaw(db->expires,dictGetKey(kde));
dictSetSignedIntegerVal(de,when);
}
会将key和过期时间隐射到一个过期字典中(db->expires,具体定义在redisDb中).这样设置过期就完成了。
那么redis是怎么处理过期字典的呢?redis有两种情况会让key过期,一种是在查询对应的key的时候,会检测key是否过期了。第二中情况是周期性检查key过期。我们先看第一种。假如我们输入:
GET key1
在redis源代码中,会有如下调用过程:getGenericCommand()->lookupKeyReadOrReply()->lookupKeyRead()->lookupKey();
整个查找过程关键实现是在lookupKeyRead(),代码如下:
robj *lookupKeyRead(redisDb *db, robj *key) {
robj *val;
// 检查 key 是否过期,如果是的话,将它删除
expireIfNeeded(db,key);
// 查找 key ,并根据查找结果更新命中/不命中数
val = lookupKey(db,key);
if (val == NULL)
server.stat_keyspace_misses++;
else
server.stat_keyspace_hits++;
// 返回 key 的值
return val;
}
其中expireIfNeeded是检查是否过期,代码如下:
int expireIfNeeded(redisDb *db, robj *key) {
// 取出 key 的过期时间
long long when = getExpire(db,key);
// key 没有过期时间,直接返回
if (when < 0) return 0; /* No expire for this key */
// 不要在服务器载入数据时执行过期
if (server.loading) return 0;
// 如果服务器作为附属节点运行,那么直接返回
// 因为附属节点的过期是由主节点通过发送 DEL 命令来删除的
// 不必自主删除,slave db
if (server.masterhost != NULL) {
// 返回一个理论上正确的值,但不执行实际的删除操作
return mstime() > when;
}
/* Return when this key has not expired */
// 未过期
if (mstime() <= when) return 0;
/* Delete the key */
server.stat_expiredkeys++;
// 传播过期命令
propagateExpire(db,key);
// 从数据库中删除 key
return dbDelete(db,key);
}
除了lookupKeyRead以外,还有lookupKeyWrite、dbRandomKey、existsCommand、keysCommand等函数会调用expireIfNeeded。redis用此方法检查过期时间是可以提高效率,不需要每次都通过周期检测来。
还有一种就是周期性检测过期,周期性过期是通过周期心跳函数(serverCron)来触发的。首先redis会在初始化的时候调用:
aeCreateTimeEvent(server.el, 1, serverCron, NULL, NULL);
这是在ae的异步模型插入一个1毫秒触发一次的serverCron。接下来的流程如下;
aeLoopEvent->serverCron->activeExpireCycle
其中activeExpireCycle整个检测的关键函数实现:
void activeExpireCycle(void) {
int j, iteration = 0;
long long start = ustime(), timelimit;
// 这个函数可以使用的时长(毫秒)
timelimit = 1000000*REDIS_EXPIRELOOKUPS_TIME_PERC/REDIS_HZ/100;
if (timelimit <= 0) timelimit = 1;
for (j = 0; j < server.dbnum; j++) {
int expired;
redisDb *db = server.db+j;
/* Continue to expire if at the end of the cycle more than 25%
* of the keys were expired. */
do {
unsigned long num = dictSize(db->expires);
unsigned long slots = dictSlots(db->expires);
long long now = mstime();
// 过期字典里只有 %1 位置被占用,调用随机 key 的消耗比较高
// 等 key 多一点再来
if (num && slots > DICT_HT_INITIAL_SIZE &&
(num*100/slots < 1)) break;
// 从过期字典中随机取出 key ,检查它是否过期
expired = 0; // 被删除 key 计数
if (num > REDIS_EXPIRELOOKUPS_PER_CRON) // 最多每次可查找的次数
num = REDIS_EXPIRELOOKUPS_PER_CRON;
while (num--) {
dictEntry *de;
long long t;
// 随机查找带有 TTL 的 key ,看它是否过期
// 如果数据库为空,跳出
if ((de = dictGetRandomKey(db->expires)) == NULL) break;
t = dictGetSignedIntegerVal(de);
if (now > t) {
// 已过期
sds key = dictGetKey(de);
robj *keyobj = createStringObject(key,sdslen(key));
propagateExpire(db,keyobj);
dbDelete(db,keyobj);
decrRefCount(keyobj);
expired++;
server.stat_expiredkeys++;
}
}
// 每次进行 16 次循环之后,检查时间是否超过,如果超过,则退出
iteration++;
if ((iteration & 0xf) == 0 && /* check once every 16 cycles. */
(ustime()-start) > timelimit) return;
} while (expired > REDIS_EXPIRELOOKUPS_PER_CRON/4);
}
}
检查到至少两个超时或者检查完16个循环且运行时长超过指定timelimit指标就结束。因为serverCron是毫秒级间隔的触发,所以必须保证activeExpireCycle尽量不堵塞才能保证redis整个服务的高效率。
总结,从代码上看,redis主要检查过期应该是是依赖第一种情况,第二种情况是防止数据长时间未访问的情况下内存占用过高做的举措,而且不过多占用主线程处理时间。redis在整个设计的过程中非常精巧,除了数据过期检测以外,还有其他大量基于单线程单进程分时复用的精妙写法,以后用其他篇幅来介绍。