过期键删除策略
对于过期键的删除,有三种常用的删除策略:
- 定时删除:在设置键的过期时间的同时,创建一个定时器,让定时器在键的过期时间来临时,立即执行对键的删除操作。
- 惰性删除:放任键过期不管,但是每次从键空间中获取键时,都检查取得的键是否过期,如果过期,就删除该键;如果没有过期,就返回该键。
- 定期删除:每隔一段时间,程序就对数据库进行一次检查,删除里面的过期键。
三种策略的对比
- 定时删除:
- 优点:对内存是最友好的,通过使用定时器,定时删除策略可以保证过期键尽可能快地被删除,并释放过期键所占用的内存。
- 缺点:对CPU是最不友好的,在过期键比较多的情况下,删除过期键这一行为可能会占用相当一部分CPU时间。此外,创建一个定时器需要用到Redis服务器中的时间事件,而当前时间事件的实现方式为无序链表,寻找一个时间事件的时间复杂度为O(N),并不能搞笑的处理大量时间事件。
- 惰性删除
- 优点:惰性删除对CPU时间来说是最友好的,程序只会在取出键时才对键进行过期检查,这可以保证删除过期键的操作只会在非昨不可能的情况下进行。
- 缺点:是对内存最不友好的,如果一个键已经过期,而这个键又依然保存在数据库中,那么只要这个过期键不被删除,它所占用的内存就不会释放。
- 定期删除
- 如果删除操作执行得太频繁,或者执行的时间太长,定期删除策略就会退化成定时删除策略,以至于将CPU时间过多地消耗在删除过期键上面。
- 如果删除操作执行得太少,或者执行得时间太短,定时删除策略又会和惰性删除策略一样,出现内存浪费地情况。
Redis的过期键删除策略
Redis服务器实际使用的是惰性删除和定期删除两种策略:通过配合使用两种删除策略,服务器可以很好地在合理使用CPU时间和避免内存空间浪费之间取得平衡。
惰性删除策略的实现
惰性删除策略由db.c/expireIfNeeded
函数实现,所有读写数据库的Redis命令在执行之前都会调用expireIfNeeded
函数对输入输出进行检查:
- 如果输入键已经过期,那么
expireIfNeeded
函数将输入键从数据库中删除。 - 如果输入键未过期,那么
expireIfNeeded
函数不做动作。
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;
now = server.lua_caller ? server.lua_time_start : mstime();
// 当服务器运行在 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);
}
定期删除策略的实现
过期键的定期删除策略由redis.c/activeExpireCycle
函数实现。
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. */
unsigned int j, iteration = 0;
// 默认每次处理的数据库数量
unsigned int dbs_per_call = REDIS_DBCRON_DBS_PER_CALL;
// 函数开始的时间
long long start = ustime(), timelimit;
// 快速模式
if (type == ACTIVE_EXPIRE_CYCLE_FAST) {
// 如果上次函数没有触发 timelimit_exit ,那么不执行处理
if (!timelimit_exit) return;
// 如果距离上次执行未够一定时间,那么不执行处理
if (start < last_fast_cycle + ACTIVE_EXPIRE_CYCLE_FAST_DURATION*2) return;
// 运行到这里,说明执行快速处理,记录当前时间
last_fast_cycle = start;
}
/ * 一般情况下,函数只处理 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
* 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;
// 函数处理的微秒时间上限
// 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++) {
int expired;
// 指向要处理的数据库
redisDb *db = server.db+(current_db % server.dbnum);
// 为 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;
// 获取数据库中带过期时间的键的数量
// 如果该数量为 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++;
}
/* 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;
}
// 我们不能用太长时间处理过期键,
// 所以这个函数执行一定时间之后就要返回
// 更新遍历次数
iteration++;
// 每遍历 16 次执行一次
if ((iteration & 0xf) == 0 && /* check once every 16 iterations. */
(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);
}
}
该函数的工作模式可以总结如下:
- 函数每次运行时,都从一定数量的数据库中取出一定数量的随机键进行检查,并删除其中的过期键。
- 全局变量
current_db
会记录当前activeExpireCycle
函数检查的进度,并在下一次activeExpireCycle
调用时,接着上一次的进度进行处理。 - 随着
activeExpireCycle
函数的不断执行,服务器中的所有数据库都会被检查一遍,这是函数将current_db
变量重置为0,然后再次开始新一轮的检查工作。
AOF、RDB和复制功能对过期键的处理
生成RBD文件
在执行SAVE命令或者BGSAVE命令创建一个新的RDB文件时,程序会对数据库中的键进行检查,已过期的键不会被保存到新创建的RDB文件中。
因此,数据库中包含过期键不会对生成新的RDB文件造成影响。
AOF文件写入
当服务器以AOF持久化模式运行时,如果数据库中的某个键已经过期,但它还没有被惰性删除或者定期删除,那么AOF文件不会因为这个过期键而产生任何影响。
当过期键被惰性删除或者定期删除之后,程序会向AOF文件追加一条DEL命令,来显式地记录该键已被删除。
AOF重写
和生成RDB文件时类似,在执行AOF重写地过程中,程序会对数据库中地键进行检查,已过期的键不会被保存到重写后的AOF文件中。
因此,数据库中包含过期键不会对AOF重写造成影响。
复制
当服务器运行在复制模式下时,从服务器的过期键删除动作由主服务器控制:
- 主服务器在删除一个过期键之后,会显式地向所有从服务器发送一个DEL命令,告知从服务器删除这个过期键。
- 从服务器在执行客户端发来的读命令时,即使碰到过期键也不会将过期键删除,而是继续像处理未过期的键一样来处理过期键。
- 从服务器只有在接到主服务器发来的DEL命令之后,才会删除过期键。