前言
一开始总会把这两个问题搞混淆了。一句话简单解释一下:
过期策略:已经过期的key删除方案。【定期删除和惰性过期】
淘汰策略:内存不够用的时候的处理方案。【就是常说的LRU算法】
一、过期策略
redisDb结构的expires字典保存了数据库中所有键的过期时间,我们称这个字典为过期字典:
过期字典的键是一个指针,这个指针指向键空间中的某个键对象(也即是某个数据库键)。
过期字典的值是一个long long类型的整数,这个整数保存了键所指向的数据库键的过期时间——一个毫秒精度的UNIX时间戳。
Redis服务器实际使用的是惰性删除和定期删除两种策略:通过配合使用这两种删除策略,服务器可以很好地在合理使用CPU时间和避免浪费内存空间之间取得平衡。
惰性删除策略的实现
所有读写数据库的Redis命令在执行之前都会对输入键进行检查:
- 如果输入键已经过期,那么expireIfNeeded函数将输入键从数据库中删除。
- 如果输入键未过期,那么expireIfNeeded函数不做动作。
可以看看setKey的源码:setKey -> lookupKeyWrite -> expireIfNeeded
void setKey(redisDb *db, robj *key, robj *val) {
if (lookupKeyWrite(db,key) == NULL) {
dbAdd(db,key,val);
} else {
dbOverwrite(db,key,val);
}
incrRefCount(val);
removeExpire(db,key);
signalModifiedKey(db,key);
}
robj *lookupKeyWrite(redisDb *db, robj *key) {
expireIfNeeded(db,key);
return lookupKey(db,key,LOOKUP_NONE);
}
调用expireIfNeeded来删除过期键
int expireIfNeeded(redisDb *db, robj *key) {
if (!keyIsExpired(db,key)) return 0;
if (server.masterhost != NULL) return 1;
/* Delete the key */
server.stat_expiredkeys++;
propagateExpire(db,key,server.lazyfree_lazy_expire);
notifyKeyspaceEvent(NOTIFY_EXPIRED,
"expired",key,db->id);
return server.lazyfree_lazy_expire ? dbAsyncDelete(db,key) :
dbSyncDelete(db,key);
}
另外,因为每个被访问的键都可能因为过期而被expireIfNeeded函数删除,所以每个命令的实现函数都必须能同时处理键存在以及键不存在这两种情况:
定期删除策略的实现
过期键的定期删除策略由activeExpireCycle函数实现,每当Redis的服务器周期性操作serverCron函数执行时,activeExpireCycle函数就会被调用,它在规定的时间内,分多次遍历服务器中的各个数据库,从数据库的expires字典中随机检查一部分键的过期时间,并删除其中的过期键。
先看一下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; //默认REDIS_DBCRON_DBS_PER_CALL=16
// 函数开始的时间
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. */
// 如果上次函数没有触发 timelimit_exit ,那么不执行处理
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:
*
* 一般情况下,每次迭代(也就是每次调用这个函数)函数只处理 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;
/* 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. */
// 函数处理的微秒时间上限
// 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;
// 如果是运行在快速模式之下
// 那么最多只能运行 ACTIVE_EXPIRE_CYCLE_FAST_DURATION 微秒
// 默认值为 1000 (微秒) = 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);
/* 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. */
// 为 currrnt_DB 计数器加一,如果进入 do 循环之后因为超时而跳出
// 那么下次会直接从下个 currrnt_DB 开始处理。这样使得分配在每个数据库上处理时间比较平均
current_db++;
/* Continue to expire if at the end of the cycle more than 25%
* of the keys were expired. */
//如果每次循环清理的过期键是过期键的25%以上,那么就继续清理
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;
// 每次最多只能检查 LOOKUPS_PER_LOOP 个键,默认是20
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. */
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++;
// 每遍历 16 次执行一次
if ((iteration & 0xf) == 0 && /* check once every 16 iterations. */
(ustime()-start) > timelimit)
{
// 如果遍历次数正好是 16 的倍数
// 并且遍历的时间超过了 timelimit,超时了
// 那么将timelimit_exit赋值为1,下一个if返回吧
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 %,那么不再遍历。当然如果超过了25%,继续清理
} while (expired > ACTIVE_EXPIRE_CYCLE_LOOKUPS_PER_LOOP/4);
}
}
简单归纳一下执行逻辑:
- 遍历至多16个DB 。【由宏CRON_DBS_PER_CALL定义,默认为16】
- 随机挑选20个带过期时间的key。【由宏ACTIVE_EXPIRE_CYCLE_LOOKUPS_PER_LOOP定义,默认20】
- 如果key过期,则将key相关的内存释放,或者放入失效队列。
- 为了保证不会循环过度,导致卡顿,扫描时间上限默认不超过25ms。(timelimit = 1000000*ACTIVE_EXPIRE_CYCLE_SLOW_TIME_PERC/server.hz/100,ACTIVE_EXPIRE_CYCLE_SLOW_TIME_PERC=25,server.hz=10), 则此次淘汰操作结束返回,否则进入5。
- 如果该DB下,有超过5个key (ACTIVE_EXPIRE_CYCLE_LOOKUPS_PER_LOOP/4=5) 实际失效,则进入 2,否则选择下一个DB,再次进入2。
- 遍历完成,结束。
#define CONFIG_DEFAULT_HZ 10 /* Time interrupt calls/sec. 每秒调用server.hz次数*/
因此每100ms会执行一次activeExpireCycle,每次activeExpireCycle执行最大时间为25ms。
二、淘汰策略
LRU行为配置参数:
maxmemory.:该参数即为缓存数据占用的内存限制. 当缓存的数据消耗的内存超过这个数值限制时, 将触发数据淘汰. 该数据配置为0时,表示缓存的数据量没有限制, 即LRU功能不生效
maxmemory-samples:随机采样的精度. 该数值配置越大, 越接近于真实的LRU算法,但是数值越大, 消耗的CPU计算时间越多,执行效率越低
Redis5.0的配置文件:
#maxmemory-policy noeviction
volatile-lru -> Evict using approximated LRU among the keys with an expire set. #在设置了过期时间的key中,删除最近最少使用的key
allkeys-lru -> Evict any key using approximated LRU. #删除最近最少使用的key 最常用的策略
volatile-lfu -> Evict using approximated LFU among the keys with an expire set.
allkeys-lfu -> Evict any key using approximated LFU.
volatile-random -> Remove a random key among the ones with an expire set. #随机删除设置了过期时间的key
allkeys-random -> Remove a random key, any key. #随机删除某个key
volatile-ttl -> Remove the key with the nearest expire time (minor TTL) #在设置了过期时间的key中,把最早要过期的key优先删除
noeviction -> Don't evict anything, just return an error on write operations. #拒绝写操作, 读、删除可以正常使用。默认策略,不建议使用
# LRU means Least Recently Used 最近最少使用
# LFU means Least Frequently Used 最近经常使用
# The default is:
#
# maxmemory-policy noeviction
noeviction
4种对设置了过期时间的key进行淘汰删除配置
volatile-lru :在设置了过期时间的key中,删除最近最少使用的key
volatile-lfu :在设置了过期时间的key中,删除最近经常使用的key(新增)
volatile-random :在设置了过期时间的key中,随机删除某个key
volatile-ttl :在设置了过期时间的key中,把最早要过期的key优先删除
3种对所有key进行淘汰删除配置
allkeys-lru :删除最近最少使用的key(最为常用)
allkeys-lfu : 删除最近经常使用的key (新增)
allkeys-random : 随机删除某个key
总结
- Redis使用惰性删除和定期删除两种策略来删除过期的键:惰性删除策略只在碰到过期键时才进行删除操作,定期删除策略则每隔一段时间主动查找并删除过期键。
- 执行SAVE命令或者BGSAVE命令所产生的新RDB文件不会包含已经过期的键。
- 执行BGREWRITEAOF命令所产生的重写AOF文件不会包含已经过期的键。
- 当一个过期键被删除之后,服务器会追加一条DEL命令到现有AOF文件的末尾,显式地删除过期键。
- 主服务器删除一个过期键之后,它会向所有从服务器发送一条DEL命令,显式地删除过期键。
- 从服务器即使发现过期键也不会自作主张地删除它,而是等待主节点发来DEL命令,这种统一、中心化的过期键删除策略可以保证主从服务器数据的一致性。