过期键的删除策略
在这里我们应当仔细思考键的删除策略,因为redis是单线程架构,如果删除键时占用了大量的时间的话,便会是整体的性能变差,而redis中同时采取了两种策略,一种是惰性删除策略,Redis进行读写操作时,都会检查该键是否过期,过期则删除,这种策略有时会造成严重的内存泄露,如果一致没有程序访问该键时;而另一种策略是定期删除,定期删除代码如下:
//函数尝试删除数据库中已经过期的键。
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));
// 传播过期命令
propagateExpire(db,keyobj);
// 从数据库中删除该键
dbDelete(db,keyobj);
// 发送事件
notifyKeyspaceEvent(REDIS_NOTIFY_EXPIRED,
"expired",keyobj,db->id);
decrRefCount(keyobj);
// 更新计数器
server.stat_expiredkeys++;
return 1;
} else {
// 键未过期
return 0;
}
}
//默认数据库的数量,16个,如果没有更改数据库的数量的话,会处理16个数据库
#define REDIS_DBCRON_DBS_PER_CALL 16
//快模式默认每次处理的时长,1s
#define ACTIVE_EXPIRE_CYCLE_FAST_DURATION 1000
#define ACTIVE_EXPIRE_CYCLE_LOOKUPS_PER_LOOP 20
/*
* 函数尝试删除数据库中已经过期的键。
* 当带有过期时间的键比较少时,函数运行得比较保守,
* 如果带有过期时间的键比较多,那么函数会以更积极的方式来删除过期键,
* 从而可能地释放被过期键占用的内存。
* 每次循环中被测试的数据库数目不会超过 REDIS_DBCRON_DBS_PER_CALL 。
* 如果 timelimit_exit 为真,那么说明还有更多删除工作要做,
* 那么在 beforeSleep() 函数调用时,程序会再次执行这个函数。
* 过期循环的类型:
* 如果循环的类型为 ACTIVE_EXPIRE_CYCLE_FAST ,
* 那么函数会以“快速过期”模式执行,
* 执行的时间不会长过 EXPIRE_FAST_CYCLE_DURATION 毫秒,
* 并且在 EXPIRE_FAST_CYCLE_DURATION 毫秒之内不会再重新执行。
* 如果循环的类型为 ACTIVE_EXPIRE_CYCLE_SLOW ,
* 那么函数会以“正常过期”模式执行,
* 函数的执行时限为 REDIS_HS 常量的一个百分比,
* 这个百分比由 REDIS_EXPIRELOOKUPS_TIME_PERC 定义。
*/
void activeExpireCycle(int type) {
// 静态变量,用来累积函数连续执行时的数据
static unsigned int current_db = 0;
static int timelimit_exit = 0;
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 个数据库,
* 除非:
* 当前数据库的数量小于 REDIS_DBCRON_DBS_PER_CALL
* 如果上次处理遇到了时间上限,那么这次需要对所有数据库进行扫描,
* 这可以避免过多的过期键占用空间
*/
if (dbs_per_call > server.dbnum || timelimit_exit)
dbs_per_call = server.dbnum;
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;
// 遍历数据库
for (j = 0; j < dbs_per_call; j++) {
int expired;
// 指向要处理的数据库
redisDb *db = server.db+(current_db % server.dbnum);
// 为 DB 计数器加一,如果进入 do 循环之后因为超时而跳出
// 那么下次会直接从下个 DB 开始处理
current_db++;
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++;
}
// 为这个数据库更新平均 TTL 统计数据
if (ttl_samples) {
// 计算当前平均值
long long avg_ttl = ttl_sum/ttl_samples;
// 如果这是第一次设置数据库平均 TTL ,那么进行初始化
if (db->avg_ttl == 0) db->avg_ttl = avg_ttl;
// 取数据库的上次平均 TTL 和今次平均 TTL 的平均值
db->avg_ttl = (db->avg_ttl+avg_ttl)/2;
}
// 我们不能用太长时间处理过期键,
// 所以这个函数执行一定时间之后就要返回
// 更新遍历次数
iteration++;
// 每遍历 16 次执行一次
if ((iteration & 0xf) == 0 &&
(ustime()-start) > timelimit)
{
// 如果遍历次数正好是 16 的倍数
// 并且遍历的时间超过了 timelimit
// 那么断开 timelimit_exit
timelimit_exit = 1;
}
// 已经超时了,返回
if (timelimit_exit) return;
// 如果已删除的过期键占当前总数据库带过期时间的键数量的 25 %
// 那么不再遍历
} while (expired > ACTIVE_EXPIRE_CYCLE_LOOKUPS_PER_LOOP/4);
}
}
与字典rehash过程一样,定时删除策略也是分批次和分时间段完成的,也就是渐进式删除,定时删除策略每次运行时,都从一定的数据库中取出一定数量的随机键进行检查,删除其中的过期键。current_db记录执行的进度,即数据库的编号,方便下次接着上次未完成的地方进行。同时分为两种模式,键值较少时采用慢模式,较多时采用快模式,快模式每次处理不能超过1s。而定时删除的缺点是耗时间,所以redis中根据不同的场景来选择不同的删除策略,来完成性能上的均衡。
键的遍历策略
说到耗时操作,首当其冲的便是遍历操作,redis的键操作自然也不可避免,一般产生键遍历操作的是返回所有的键名给客户端,这就给单线程的redis带来了挑战,与键的删除与字典的rehash不同,所有键的返回并不能分时段进行,因为这样便不能满足要求。但是同样的,在redis中,对键的遍历操作也采用了两种策略,全量遍历与渐进式遍历,先来看全量遍历的代码:
//KEYS命令的实现
void keysCommand(redisClient *c) {
dictIterator *di;
dictEntry *de;
// 模式,即客户端输入的模式匹配,比如命令:keys * 表示返回所有的键
//*代表所有,?代表匹配一个字符等等
sds pattern = c->argv[1]->ptr;
int plen = sdslen(pattern), allkeys;
unsigned long numkeys = 0;
void *replylen = addDeferredMultiBulkLength(c);
// 遍历整个数据库,返回(名字)和模式匹配的键
di = dictGetSafeIterator(c->db->dict);
allkeys = (pattern[0] == '*' && pattern[1] == '\0');
while((de = dictNext(di)) != NULL) {
sds key = dictGetKey(de);
robj *keyobj;
// 将键名和模式进行比对
if (allkeys || stringmatchlen(pattern,plen,key,sdslen(key),0)) {
// 创建一个保存键名字的字符串对象
keyobj = createStringObject(key,sdslen(key));
// 删除已过期键
if (expireIfNeeded(c->db,keyobj) == 0) {
addReplyBulk(c,keyobj);
numkeys++;
}
decrRefCount(keyobj);
}
}
dictReleaseIterator(di);
setDeferredMultiBulkLength(c,replylen,numkeys);
}
对全量遍历没有什么好讨论的,只是从头到尾的将整个字典遍历了一遍,当你一定是要用该命令时,建议开个从服务器上进行遍历,这样不会影响主服务器上的命令操作了,但会影响主从复制。而渐进式遍历是分批次进行的,也就是客户端需要多次向服务器请求这个命令,才能得到全部的数据,当服务器返回给客户端状态为0时,表明已经全部遍历完了,因为本质上是遍历数据库其实是遍历一个字典,所以采用了字典中的遍历函数dictScan,这个函数遍历性能优越,在以前的博客中讲过,在此不再重复讲解:
//这个函数是保存一个链表的数据用
void scanCallback(void *privdata, const dictEntry *de) {
void **pd = (void**) privdata;
list *keys = pd[0];
robj *o = pd[1];
robj *key, *val = NULL;
if (o == NULL) {
sds sdskey = dictGetKey(de);
key = createStringObject(sdskey, sdslen(sdskey));
} else if (o->type == REDIS_SET) {
key = dictGetKey(de);
incrRefCount(key);
} else if (o->type == REDIS_HASH) {
key = dictGetKey(de);
incrRefCount(key);
val = dictGetVal(de);
incrRefCount(val);
} else if (o->type == REDIS_ZSET) {
key = dictGetKey(de);
incrRefCount(key);
val = createStringObjectFromLongDouble(*(double*)dictGetVal(de));
} else {
redisPanic("Type not handled in SCAN callback.");
}
listAddNodeTail(keys, key);
if (val) listAddNodeTail(keys, val);
}
/*
* 这是 SCAN 、 HSCAN 、 SSCAN 命令的实现函数。
* 如果给定了对象 o ,那么它必须是一个哈希对象或者集合对象,
* 如果 o 为 NULL 的话,函数将使用当前数据库作为迭代对象。
* 如果参数 o 不为 NULL ,那么说明它是一个键对象,函数将跳过这些键对象,
* 对给定的命令选项进行分析(parse)。
* 如果被迭代的是哈希对象,那么函数返回的是键值对。
*/
void scanGenericCommand(redisClient *c, robj *o, unsigned long cursor) {
int rv;
int i, j;
char buf[REDIS_LONGSTR_SIZE];
list *keys = listCreate();
listNode *node, *nextnode;
long count = 10;
sds pat;
int patlen, use_pattern = 0;
dict *ht;
/* Object must be NULL (to iterate keys names), or the type of the object
* must be Set, Sorted Set, or Hash. */
// 输入类型检查
redisAssert(o == NULL || o->type == REDIS_SET || o->type == REDIS_HASH ||
o->type == REDIS_ZSET);
/* Set i to the first option argument. The previous one is the cursor. */
// 设置第一个选项参数的索引位置
// 0 1 2 3
// SCAN OPTION <op_arg> SCAN 命令的选项值从索引 2 开始
// HSCAN <key> OPTION <op_arg> 而其他 *SCAN 命令的选项值从索引 3 开始
i = (o == NULL) ? 2 : 3; /* Skip the key argument if needed. */
/* Step 1: Parse options. */
// 分析选项参数
//步骤一就是分析命令带的参数,模式匹配,判断要返回什么范围的数据
while (i < c->argc) {
j = c->argc - i;
// COUNT <number>
if (!strcasecmp(c->argv[i]->ptr, "count") && j >= 2) {
if (getLongFromObjectOrReply(c, c->argv[i+1], &count, NULL)
!= REDIS_OK)
{
goto cleanup;
}
if (count < 1) {
addReply(c,shared.syntaxerr);
goto cleanup;
}
i += 2;
// MATCH <pattern>
} else if (!strcasecmp(c->argv[i]->ptr, "match") && j >= 2) {
pat = c->argv[i+1]->ptr;
patlen = sdslen(pat);
/* The pattern always matches if it is exactly "*", so it is
* equivalent to disabling it. */
use_pattern = !(pat[0] == '*' && patlen == 1);
i += 2;
// error
} else {
addReply(c,shared.syntaxerr);
goto cleanup;
}
}
/* Step 2: Iterate the collection.
//第二步是迭代元素
// 如果对象的底层实现为 ziplist 、intset 而不是哈希表,
// 那么这些对象应该只包含了少量元素,
// 为了保持不让服务器记录迭代状态的设计
// 我们将 ziplist 或者 intset 里面的所有元素都一次返回给调用者
// 并向调用者返回游标(cursor) 0,0代表着所有的元素已经被遍历
*/
ht = NULL;
if (o == NULL) {
// 迭代目标为数据库
ht = c->db->dict;
} else if (o->type == REDIS_SET && o->encoding == REDIS_ENCODING_HT) {
// 迭代目标为 HT 编码的集合
ht = o->ptr;
} else if (o->type == REDIS_HASH && o->encoding == REDIS_ENCODING_HT) {
// 迭代目标为 HT 编码的哈希
ht = o->ptr;
count *= 2; /* We return key / value for this type. */
} else if (o->type == REDIS_ZSET && o->encoding == REDIS_ENCODING_SKIPLIST) {
// 迭代目标为 HT 编码的跳跃表
zset *zs = o->ptr;
ht = zs->dict;
count *= 2; /* We return key / value for this type. */
}
if (ht) {
void *privdata[2];
// 我们向回调函数传入两个指针:
// 一个是用于记录被迭代元素的列表
// 另一个是字典对象
// 从而实现类型无关的数据提取操作
privdata[0] = keys;
privdata[1] = o;
do {
//调用dictSCan函数
cursor = dictScan(ht, cursor, scanCallback, privdata);
} while (cursor && listLength(keys) < count);
} else if (o->type == REDIS_SET) {
int pos = 0;
int64_t ll;
//获取整数集合里的元素,并保存在list列表里
while(intsetGet(o->ptr,pos++,&ll))
listAddNodeTail(keys,createStringObjectFromLongLong(ll));
cursor = 0;
} else if (o->type == REDIS_HASH || o->type == REDIS_ZSET) {
unsigned char *p = ziplistIndex(o->ptr,0);
unsigned char *vstr;
unsigned int vlen;
long long vll;
while(p) {
ziplistGet(p,&vstr,&vlen,&vll);
listAddNodeTail(keys,
(vstr != NULL) ? createStringObject((char*)vstr,vlen) :
createStringObjectFromLongLong(vll));
p = ziplistNext(o->ptr,p);
}
cursor = 0;
} else {
redisPanic("Not handled encoding in SCAN.");
}
/* Step 3: Filter elements. */
node = listFirst(keys);
//第三步是过滤掉与第一步模式不匹配的结果
while (node) {
robj *kobj = listNodeValue(node);
nextnode = listNextNode(node);
int filter = 0;
/* Filter element if it does not match the pattern. */
//过滤掉与pattern不匹配的元素
if (!filter && use_pattern) {
if (sdsEncodedObject(kobj)) {
if (!stringmatchlen(pat, patlen, kobj->ptr, sdslen(kobj->ptr), 0))
filter = 1;
} else {
char buf[REDIS_LONGSTR_SIZE];
int len;
redisAssert(kobj->encoding == REDIS_ENCODING_INT);
len = ll2string(buf,sizeof(buf),(long)kobj->ptr);
if (!stringmatchlen(pat, patlen, buf, len, 0)) filter = 1;
}
}
/* 过滤掉该键如果过期了的话 */
if (!filter && o == NULL && expireIfNeeded(c->db, kobj)) filter = 1;
/* Remove the element and its associted value if needed. */
//移除元素以及它相关联的值如果需要的话
if (filter) {
decrRefCount(kobj);
listDelNode(keys, node);
}
/* If this is a hash or a sorted set, we have a flat list of
* key-value elements, so if this element was filtered, remove the
* value, or skip it if it was not filtered: we only match keys. */
* 这里是传进的o不为空的情况下对哈希对象与有序集合的操作只匹配,不过滤
* /
if (o && (o->type == REDIS_ZSET || o->type == REDIS_HASH)) {
node = nextnode;
nextnode = listNextNode(node);
if (filter) {
kobj = listNodeValue(node);
decrRefCount(kobj);
listDelNode(keys, node);
}
}
node = nextnode;
}
/* Step 4: Reply to the client. */
//第四步是返回结果给客户端,如果返回cursor是零,那么返回了全部的元素
addReplyMultiBulkLen(c, 2);
rv = snprintf(buf, sizeof(buf), "%lu", cursor);
redisAssert(rv < sizeof(buf));
addReplyBulkCBuffer(c, buf, rv);
addReplyMultiBulkLen(c, listLength(keys));
while ((node = listFirst(keys)) != NULL) {
robj *kobj = listNodeValue(node);
addReplyBulk(c, kobj);
decrRefCount(kobj);
listDelNode(keys, node);
}
//释放keys
cleanup:
listSetFreeMethod(keys,decrRefCountVoid);
listRelease(keys);
}
//SCAN命令的实现
void scanCommand(redisClient *c) {
unsigned long cursor;
if (parseScanCursorOrReply(c,c->argv[1],&cursor) == REDIS_ERR) return;
scanGenericCommand(c,NULL,cursor);
}
总结
1. 过期键的删除策略与键的遍历策略都采用了两种方式,是不同的场合选择不同的方法;
2. 渐进式遍历与定时删除都采用了渐进式的策略,分多批次来完成指定任务。