redis 支持 key 级别的过期设置,可以使用 EXPIRE
相关的命令对此进行设置,同时支持相对时间和绝对时间两种方式。
设置过期时间命令
redis 中设置 key 相对过期时间的命令 EXPIRE
/ PEXPIRE
,设置 key 绝对过期时间的命令 EXPIREAT
/PEXPIREAT
,最终都是调用 expireGenericCommand
函数实现的。
代码分析大致如下,
void expireGenericCommand(client *c, long long basetime, int unit) {
robj *key = c->argv[1], *param = c->argv[2];
long long when; /* unix time in milliseconds when the key will expire. */
if (getLongLongFromObjectOrReply(c, param, &when, NULL) != C_OK)
return;
if (unit == UNIT_SECONDS) when *= 1000;
when += basetime;
/* No key, return zero. */
if (lookupKeyWrite(c->db,key) == NULL) {
addReply(c,shared.czero);
return;
}
if (when <= mstime() && !server.loading && !server.masterhost) {
robj *aux;
serverAssertWithInfo(c,key,dbDelete(c->db,key));
server.dirty++;
/* Replicate/AOF this as an explicit DEL. */
aux = createStringObject("DEL",3);
rewriteClientCommandVector(c,2,aux,key);
decrRefCount(aux);
signalModifiedKey(c->db,key);
notifyKeyspaceEvent(NOTIFY_GENERIC,"del",key,c->db->id);
addReply(c, shared.cone);
return;
} else {
setExpire(c->db,key,when);
addReply(c,shared.cone);
signalModifiedKey(c->db,key);
notifyKeyspaceEvent(NOTIFY_GENERIC,"expire",key,c->db->id);
server.dirty++;
return;
}
}
首先是解析参数,相对时间会被转换成绝对时间 when
。
找一个下这个 key 是否存在,如果不存在,那么直接返回。
如果 when
比当前时间还要小,没有做数据的 loading,且当前节点是 master(slave 节点等着 master 传过去的 DEL 就好),这时把 expire 命令转换成 DEL。
否则,调用 setExpire
函数为 key 设置过期时间,
代码分析如下,
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);
serverAssertWithInfo(NULL,key,kde != NULL);
// 在 expires 中寻找 key,找不到就新建一个
de = dictReplaceRaw(db->expires,dictGetKey(kde));
dictSetSignedIntegerVal(de,when);
}
通过以上代码可以发现,含有过期时间的 key 都会放到 db->expires
变量中(在数据库结构体 redisDb
中,使用 expires
字典存放这些 key)。
typedef struct redisDb {
dict *dict; // 存放所有 key
dict *expires; // 存放过期 key
int id; // 数据库 id
....
} redisDb;
过期时间通过 dictSetSignedIntegerVal
函数,存放到 key 所在的 dictEntry
结构,如下,
typedef struct dictEntry {
void *key;
union {
void *val;
uint64_t u64;
int64_t s64; // 存放过期时间
double d;
} v;
struct dictEntry *next;
} dictEntry;
查询过期时间命令
查询某个 key 的过期时间,redis 提供了 TTL
这个命令,有三种返回值,
- 返回 -2,表示查询的 key 不存在。
- 返回 -1,表示查询的 key 没有设置过期。
- 返回正常的过期时间。
上面已经知道,key 过期时间存放位置了,那么直接取出来就好了。
long long getExpire(redisDb *db, robj *key) {
dictEntry *de;
/* No expire? return ASAP */
if (dictSize(db->expires) == 0 ||
(de = dictFind(db->expires,key->ptr)) == NULL) return -1;
serverAssertWithInfo(NULL,key,dictFind(db->dict,key->ptr) != NULL);
return dictGetSignedIntegerVal(de);
}
通过 dictGetSignedIntegerVal
函数取到过期时间。
删除过期时间
如果一个 key 设置了过期时间后想删除怎么办?redis 提供了 PERSIST
命令,或者直接用 SET
命令去覆盖,它们都涉及到函数 removeExpire
。
具体代码如下,
int removeExpire(redisDb *db, robj *key) {
/* An expire may only be removed if there is a corresponding entry in the
* main dict. Otherwise, the key will never be freed. */
serverAssertWithInfo(NULL,key,dictFind(db->dict,key->ptr) != NULL);
return dictDelete(db->expires,key->ptr) == DICT_OK;
}
从 db->expires
中删掉这个 key,但是 dictEntry
结构体中的过期时间并不会重置。
删除过期 key
redis 3.x 中,过期 key 的删除方式有两种,惰性删除和周期删除。
惰性删除
当 key 过期后,并不会立刻删除,即,它们占用的内存不能够得到及时释放。
redis 在对每个 key 进行读写时,都会去检查这个 key 是否过期需要删除了,这样就把清理过期 key 的工作分摊到每一次访问中。类似的思路还有,redis 中的 dict 的扩容,称为渐进式 rehash。
这样会导致一个问题,当检查到一个大 key 要删除时,会占用比较长的时间,导致此次访问的响应时间变长。
检查 key 的 expire 的逻辑在 expireIfNeeded
函数中实现,代码如下,
int expireIfNeeded(redisDb *db, robj *key) {
// 获得 key 的过期时间
mstime_t when = getExpire(db,key);
mstime_t now;
// key 没有设置过期时间
if (when < 0) return 0;
// 在 load 数据时,暂时先不要处理过期的 key
if (server.loading) return 0;
// 有 lua 脚本调用时,now 取 lua 脚本开始的时间,否则取当前时间
now = server.lua_caller ? server.lua_time_start : mstime();
// 如果本节点是 slave,等着 master 同步 DEL 命令
if (server.masterhost != NULL) return now > when;
// 如果没过期,返回 0
if (now <= when) return 0;
// 过期 key 的统计
server.stat_expiredkeys++;
// 同步 DEL 命令给 slave 和 aof 文件
propagateExpire(db,key);
notifyKeyspaceEvent(NOTIFY_EXPIRED,
"expired",key,db->id);
// 删 key
return dbDelete(db,key);
}
经过一些前置校验,在 propagateExpire
函数中,将 DEL
命令分发给所有的 slave,以及写入 aof。
void propagateExpire(redisDb *db, robj *key) {
robj *argv[2];
argv[0] = shared.del;
argv[1] = key;
incrRefCount(argv[0]);
incrRefCount(argv[1]);
if (server.aof_state != AOF_OFF)
feedAppendOnlyFile(server.delCommand,db->id,argv,2);
replicationFeedSlaves(server.slaves,db->id,argv,2);
decrRefCount(argv[0]);
decrRefCount(argv[1]);
}
当一个 key 在 master 上过期后,将会给所有的 slave 发送相应的 DEL 命令,如果 aof 打开了,也会写入 aof。
这种在一个地方集中化管理 key 的方式,并且在 aof 和主从链接里保证操作顺序,即使有对于过期 key 的写操作也是允许的。
而删 key 的操作,在函数 dbDelete
中完成,代码如下,
int dbDelete(redisDb *db, robj *key) {
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;
}
}
如上代码可以看到,分别从 db->expires
和 db->dict
这两个 dict 里删除相应的 key。如果开启了 cluster 模式,还有在相应的 slot 里删掉。
周期删除
上面的惰性删除,只有在访问到 key 时才会触发,这使得过期 key 的清理时间拉的很长,所以只有惰性删除一种方式是不行的,因此增加周期删除这个方式作为补充。
周期删除使用的函数是 activeExpireCycle
。这个函数在调用时,入参分情况有 2 种过期循环类型,两者的主要区别是执行时间的差异。
- 常量
ACTIVE_EXPIRE_CYCLE_FAST
,执行时间限制是 1000 us。在beforeSleep
函数中调用的,即,每次 redis 要进入事件循环之前调用,因此需要比较快的返回 - 常量
ACTIVE_EXPIRE_CYCLE_SLOW
,执行时间限制有一个复杂公式计算,后面会说到。在周期性任务databasesCron
中调用的,执行时间可以稍微长一点。
在 activeExpireCycle
函数里,会尝试删除一些过期的 key。使用到的算法是自适应的,如果几乎没有过期 key,仅使用少量的 CPU 周期,否则,为了避免过期 key 过多占用内存,将会更积极地从数据库删除它们。每轮检查的数据库个数不超过常量 CRON_DBS_PER_CALL (16) 个。
代码大概如下,
if (type == ACTIVE_EXPIRE_CYCLE_FAST) {
if (!timelimit_exit) return;
if (start < last_fast_cycle + ACTIVE_EXPIRE_CYCLE_FAST_DURATION*2) return;
last_fast_cycle = start;
}
如果上一次循环不是因为 timeout 而结束的,那么这一次没必要跑 fast 循环,也就是说,时间够用了,可以跑 slow 多清理一些过期 key。
另外,不要在上一次跑过 fast 之后的 2 倍 ACTIVE_EXPIRE_CYCLE_FAST_DURATION (1000) us 时间内再跑一次 fast 循环。
dbs_per_call
变量保存的是,本轮循环需要遍历的 db 数量,默认值是 16,在以下 2 种情况下需要修改,
- 检查的 db 数超过现有的。
- 上一次以为 timelimit 离开了。此时需要尽快的把已有的 db 里的过期 key 给清理掉,减少内存占用,留出更多空间供正常使用。
判断代码如下,
if (dbs_per_call > server.dbnum || timelimit_exit)
dbs_per_call = server.dbnum;
下面是循环时间 limit 的计算,
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. 1000 us */
然后开启遍历每个 db 了,如下逻辑均在此循环中实现,
for (j = 0; j < dbs_per_call; j++) {}
代码里有一个记录上一次遍历到那个 db 的静态变量 current_db
,每次都加 1。
static unsigned int current_db = 0; /* Last DB tested. */
current_db++;
选择一个 db 进行数据清理,
redisDb *db = server.db+(current_db % server.dbnum);
下面就是在选择的 db 里对 key 进行抽样检查的过程,
// 如果没有过期的 key,那么这个 db 的检查结束
if ((num = dictSize(db->expires)) == 0) {
db->avg_ttl = 0;
break;
}
slots = dictSlots(db->expires);
if (num && slots > DICT_HT_INITIAL_SIZE && (num*100/slots < 1))
break;
如果 expires 字典不为空,存储的数据可能已经很少了,但是字典还是大字典(数据不足 1%),这样遍历数据有效命中率会很低,处理起来会浪费时间,后面的访问会很快触发字典的缩容,缩容后再进行处理效率更高, 暂时结束这个 db 的检查。
每一次抽样最多 20 个 key。
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++; // 过期的 key 删掉
if (ttl > 0) {
/* We want the average TTL of keys yet not expired. */
ttl_sum += ttl;
ttl_samples++;
}
}
随机选取 key,调用 activeExpireCycleTryExpire
函数进行过期 key 的删除,该函数逻辑见附录。
这里还有个统计平均 ttl 的逻辑,
if (ttl_samples) {
// 抽样 key 的平均 ttl 时间
long long avg_ttl = ttl_sum/ttl_samples;
// 本轮 avg_ttl 占比 2%,历史值占比 98%
if (db->avg_ttl == 0) db->avg_ttl = avg_ttl;
db->avg_ttl = (db->avg_ttl/50)*49 + (avg_ttl/50);
}
每个 db 的检查什么时候退出呢?有 2 个时刻。
1.通过超时时间 timelimit
。
每 16 轮循环检查一次是否超时,到时间后 ,timelimit_exit
变量置 1,接着就退出了。
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;
2.在每个 db 的检查循环外,是有条件的。
每检查到一个过期的 key,就把 expired
变量加 1,所以,这个循环的条件时,如果一轮抽样到的 key 中过期的比例小于 25%,那么这个 db 就不必再抽样了。
#define ACTIVE_EXPIRE_CYCLE_LOOKUPS_PER_LOOP 20
do {
...
} while (expired > ACTIVE_EXPIRE_CYCLE_LOOKUPS_PER_LOOP/4);
注意:
在一个时间范围内,过期 key 最好不要太密集,因为系统发现到期数据很多,会迫切希望尽快处理掉这些过期数据,所以每次检查都要耗尽分配的时间片,直到到期数据到达一个可接受的密度比例。
由上总结,redis 主逻辑在单进程主线程中实现,要保证不能影响主业务前提下,检查过期数据,不能太影响系统性能。主要三方面进行限制:
- 检查时间限制。
- 过期数据检查数量限制。
- 过期数据是否达到可接受比例。
至此,redis 中 key 的过期逻辑就讲完了。顺便说一下,为了解决删大 key 带来的阻塞风险,在更高版本的 redis 中,将删 key 放到了 bio 后台线程中。
附录
activeExpireCycleTryExpire
函数将试着将存储的过期 key 从全局 key 的 dict 和 expire 的 dict 中删掉。如果发现 key 过期了,操作后返回 1,否则什么也不做,返回 0。代码逻辑如下,
int activeExpireCycleTryExpire(redisDb *db, dictEntry *de, long long now) {
long long t = dictGetSignedIntegerVal(de);
if (now > t) {
sds key = dictGetKey(de);
robj *keyobj = createStringObject(key,sdslen(key));
// 广播到 slave 和 aof
propagateExpire(db,keyobj);
// 删 key
dbDelete(db,keyobj);
notifyKeyspaceEvent(NOTIFY_EXPIRED,
"expired",keyobj,db->id);
decrRefCount(keyobj);
// 更新统计
server.stat_expiredkeys++;
return 1;
} else {
return 0;
}
}
**粗体** _斜体_ [链接](http://example.com) `代码` - 列表 > 引用
。你还可以使用@
来通知其他用户。