二、Redis Key失效机制
Redis的Key失效机制,主要借助借助EXPIRE命令:
EXPIRE key 30
上面的命令即为key设置30秒的过期时间,超过这个时间,我们应该就访问不到这个值了。接下来我们继续深入探究这个问题,Redis缓存失效机制是如何实现的呢?
惰性淘汰机制
惰性淘汰机制即当客户端请求操作某个key的时候,Redis会对客户端请求操作的key进行有效期检查,如果key过期才进行相应的处理,惰性淘汰机制也叫消极失效机制。
我们看看t_string组件下面对get请求处理的服务端端执行堆栈:
getCommand
-> getGenericCommand
-> lookupKeyReadOrReply
-> lookupKeyRead
-> expireIfNeede
关键的地方是expireIfNeed,Redis对key的get操作之前会判断key关联的值是否失效,我们看看expireIfNeeded的流程,大致如下:
1、从expires中查找key的过期时间,如果不存在说明对应key没有设置过期时间,直接返回。
2、如果是slave机器,则直接返回,因为Redis为了保证数据一致性且实现简单,将缓存失效的主动权交给Master机器,slave机器没有权限将key失效。
3、如果当前是Master机器,且key过期,则master会做两件重要的事情:1)将删除命令写入AOF文件。2)通知Slave当前key失效,可以删除了。
4、master从本地的字典中将key对于的值删除。
惰性删除策略流程:
1. 在进行get或setnx等操作时,先检查key是否过期;
2. 若过期,删除key,然后执行相应操作; 若没过期,直接执行相应操作;
在redis源码中,实现懒惰淘汰策略的是函数expireIfNeeded,所有读写数据库命令在执行之前都会调用expireIfNeeded函数对输入键进行检查。如果过期就删除,如果没过期就正常访问。
我们看下expireIfNeeded函数在文件db.c中的具体实现:
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;
/* If we 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
* only the first time it is accessed and not in the middle of the
* script execution, making propagation to slaves / AOF consistent.
* See issue #1525 on Github for more information. */
now = server.lua_caller ? server.lua_time_start : mstime();
/* If we are running in the context of a slave, return ASAP:
* the slave key expiration is controlled by the master that will
* send us synthesized DEL operations for expired keys.
*
* Still we try to return the right information to the caller,
* that is, 0 if we think the key should be still valid, 1 if
* we think the key is expired at this time. */
/*如果我们正在slaves上执行读写命令,就直接返回,
*因为slaves上的过期是由master来发送删除命令同步给slaves删除的,
*slaves不会自主删除*/
if (server.masterhost != NULL) return now > when;
/* Return when this key has not expired */
/*只是回了一个判断键是否过期的值,0表示没有过期,1表示过期
*但是并没有做其他与键值过期相关的操作
*如果没有过期,就返回当前键
*/
if (now <= when) return 0;
/* Delete the key */
/*增加过期键个数*/
server.stat_expiredkeys++;
/*向AOF文件和从节点传播过期信息.当key过期时,DEL操作也会传递给所有的AOF文件和从节点*/
propagateExpire(db,key);
/*发送事件通知,关于redis的键事件通知和键空间通知*/
notifyKeyspaceEvent(NOTIFY_EXPIRED,
"expired",key,db->id);
/*将过期键从数据库中删除*/
return dbDelete(db,key);
}
函数描述propagateExpire:
/* Propagate expires into slaves and the AOF file.
* When a key expires in the master, a DEL operation for this key is sent
* to all the slaves and the AOF file if enabled.
*
* This way the key expiry is centralized in one place, and since both
* AOF and the master->slave link guarantee operation ordering, everything
* will be consistent even if we allow write operations against expiring
* keys. */
主动删除机制
主动失效机制也叫积极失效机制,即服务端定时的去检查失效的缓存,如果失效则进行相应的操作。
我们都知道Redis是单线程的,基于事件驱动的,Redis中有个EventLoop,EventLoop负责对两类事件进行处理:
1、一类是IO事件,这类事件是从底层的多路复用器分离出来的。
2、一类是定时事件,这类事件主要用来事件对某个任务的定时执行。
为什么讲到Redis的单线程模型,因为Redis的主动失效机制逻辑是被当做一个定时任务来由主线程执行的,相关代码如下:
if(aeCreateTimeEvent(server.el, 1, serverCron, NULL, NULL) == AE_ERR) {
redisPanic("Can't create the serverCron time event.");
exit(1);
}
serverCron就是这个定时任务的函数指针,adCreateTimeEvent将serverCron任务注册到EventLoop上面,并设置初始的执行时间是1毫秒之后。接下来,我们想知道的东西都在serverCron里面了。serverCron做的事情有点多,我们只关心和本篇内容相关的部分,也就是缓存失效是怎么实现的,我认为看代码做什么事情,调用堆栈还是比较直观的:
aeProcessEvents
->processTimeEvents
->serverCron
-> databasesCron
-> activeExpireCycle
-> activeExpireCycleTryExpire
EventLoop通过对定时任务的处理,触发对serverCron逻辑的执行,最终之执行key过期处理的逻辑,值得一提的是,activeExpireCycle逻辑只能由master来做。
我们看下函数activeExpireCycle在server.c中的实现:
/* Try to expire a few timed out keys. The algorithm used is adaptive and
* will use few CPU cycles if there are few expiring keys, otherwise
* it will get more aggressive to avoid that too much memory is used by
* keys that can be removed from the keyspace.
*
* 函数尝试删除数据库中已经过期的键。
* 当带有过期时间的键比较少时,函数运行得比较保守,
* 如果带有过期时间的键比较多,那么函数会以更积极的方式来删除过期键,
* 从而可能地释放被过期键占用的内存。*
* No more than CRON_DBS_PER_CALL databases are tested at every
* iteration.
*
* 每次循环中被测试的数据库数目不会超过 REDIS_DBCRON_DBS_PER_CALL
*
* This kind of call is used when Redis detects that timelimit_exit is
* true, so there is more work to do, and we do it more incrementally from
* the beforeSleep() function of the event loop.
*
* 如果 timelimit_exit 为真,那么说明还有更多删除工作要做,(在我看来timelimit_exit如果为真的话那表示上一次删除过期键时是因为删除时间过长超时了才退出的,
* 所以这次将删除方法更加积极),那么在 beforeSleep() 函数调用时,程序会再次执行这个函数。
** Expire cycle type:
*
* 过期循环的类型:
*
* If type is ACTIVE_EXPIRE_CYCLE_FAST the function will try to run a
* "fast" expire cycle that takes no longer than EXPIRE_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 type is ACTIVE_EXPIRE_CYCLE_SLOW, that normal expire cycle is
* executed, where the time limit is a percentage of the REDIS_HZ period
* as specified by the REDIS_EXPIRELOOKUPS_TIME_PERC define.
* 如果循环的类型为ACTIVE_EXPIRE_CYCLE_SLOW ,
* 那么函数会以“正常过期”模式执行,
* 函数的执行时限为 REDIS_HS 常量的一个百分比,
* 这个百分比由 REDIS_EXPIRELOOKUPS_TIME_PERC 定义。*/
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. 上次快速模式执行的时间*/
int j, iteration = 0;
// 默认每次处理的数据库数量
int dbs_per_call = CRON_DBS_PER_CALL;
// 函数开始的时间
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 CRON_DBS_PER_CALL per iteration, with
* two exceptions:
*
* 一般情况下,每次迭代(也就是每次调用这个函数)函数只处理 CRON_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;
// 如果是运行在快速模式之下
// 那么最多只能运行 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);
/* 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. */
// 获取数据库中带过期时间的键的数量
// 如果该数量为 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 个键,默认是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) {
/* We want the average TTL of keys yet not expired. */
// 累积键的 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;
/* Do a simple running average with a few samples.
* We just use the current estimate with a weight of 2%
* and the previous estimate with a weight of 98%. */
if (db->avg_ttl == 0) db->avg_ttl = avg_ttl;
db->avg_ttl = (db->avg_ttl/50)*49 + (avg_ttl/50);
}
/* 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++;
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;
/* 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);
}
}
函数activeExpireCycleTryExpire描述:
/* Helper function for the activeExpireCycle() function.
* This function will try to expire the key that is stored in the hash table
* entry 'de' of the 'expires' hash table of a Redis database.
*
* If the key is found to be expired, it is removed from the database and
* 1 is returned. Otherwise no operation is performed and 0 is returned.
*
* When a key is expired, server.stat_expiredkeys is incremented.
*
* The parameter 'now' is the current time in milliseconds as is passed
* to the function to avoid too many gettimeofday() syscalls. */
三、Redis内存管理
我们在使用Redis,需要注意以下几点:
当某些缓存被删除后Redis并不是总是立即将内存归还给操作系统。这并不是redis所特有的,而是函数malloc()的特性。例如你缓存了6G的数据,然后删除了2G数据,从操作系统看,redis可能仍然占用了6G的内存,即使redis已经明确声明只使用了3G的空间。这是因为redis使用的底层内存分配器不会这么简单的就把内存归还给操作系统,可能是因为已经删除的key和没有删除的key在同一个页面(page),这样就不能把完整的一页归还给操作系统。
内存分配器是智能的,可以复用用户已经释放的内存。所以当使用的内存从6G降低到3G时,你可以重新添加更多的key,而不需要再向操作系统申请内存。分配器将复用之前已经释放的3G内存.
当redis的peak内存非常高于平时的内存使用时,碎片所占可用内存的比例就会波动很大。当前使用的内存除以实际使用的物理内存(RSS)就是fragmentation;因为RSS就是peak memory,所以当大部分key被释放的时候,此时内存的mem_used / RSS就比较高。
如果 maxmemory 没有设置,redis就会一直向OS申请内存,直到OS的所有内存都被使用完。所以通常建议设置上redis的内存限制。或许你也想设置 maxmemory-policy 的值为 no-enviction。
设置了maxmemory后,当redis的内存达到内存限制后,再向redis发送写指令,会返回一个内存耗尽的错误。错误通常会触发一个应用程序错误,但是不会导致整台机器宕掉。
如果redis没有设置expire,它是否默认永不过期?如果实际内存超过你设置的最大内存,就会使用LRU删除机制。
--EOF--