Redis是一个基于内存的键值数据库,其内存管理是很是重要的。本文内存管理的内容包括:过时键的懒性删除和过时删除以及内存溢出控制策略。redis
Redis使用 maxmemory 参数限制最大可用内存,默认值为0,表示无限制。限制内存的目的主要 有:算法
maxmemory 限制的是Redis实际使用的内存量,也就是 used_memory统计项对应的内存。因为内存碎片率的存在,实际消耗的内存 可能会比maxmemory设置的更大,实际使用时要当心这部份内存溢出。具体Redis 内存监控的内容请查看一文了解 Redis 内存监控和内存消耗。数据库
Redis默认无限使用服务器内存,为防止极端状况下致使系统内存耗 尽,建议全部的Redis进程都要配置maxmemory。 在保证物理内存可用的状况下,系统中全部Redis实例能够调整 maxmemory参数来达到自由伸缩内存的目的。缓存
Redis 回收内存大体有两个机制:一是删除到达过时时间的键值对象;二是当内存达到 maxmemory 时触发内存移除控制策略,强制删除选择出来的键值对象。bash
Redis 全部的键均可以设置过时属性,内部保存在过时表中,键值表和过时表的结果以下图所示。当 Redis保存大量的键,对每一个键都进行精准的过时删除可能会致使消耗大量的 CPU,会阻塞 Redis 的主线程,拖累 Redis 的性能,所以 Redis 采用惰性删除和定时任务删除机制实现过时键的内存回收。服务器
惰性删除是指当客户端操做带有超时属性的键时,会检查是否超过键的过时时间,而后会同步或者异步执行删除操做并返回键已通过期。这样能够节省 CPU成本考虑,不须要单独维护过时时间链表来处理过时键的删除。dom
过时键的惰性删除策略由 db.c/expireifNeeded 函数实现,全部对数据库的读写命令执行以前都会调用 expireifNeeded 来检查命令执行的键是否过时。若是键过时,expireifNeeded 会将过时键从键值表和过时表中删除,而后同步或者异步释放对应对象的空间。源码展现的时 Redis 4.0 版本。异步
expireIfNeeded 先从过时表中获取键对应的过时时间,若是当前时间已经超过了过时时间(lua脚本执行则有特殊逻辑,详看代码注释),则进入删除键流程。删除键流程主要进行了三件事:函数
int expireIfNeeded(redisDb *db, robj *key) {
// 获取键的过时时间
mstime_t when = getExpire(db,key);
mstime_t now;
// 键没有过时时间
if (when < 0) return 0;
// 实例正在从硬盘 laod 数据,好比说 RDB 或者 AOF
if (server.loading) return 0;
// 当执行lua脚本时,只有键在lua一开始执行时
// 就到了过时时间才算过时,不然在lua执行过程当中不算失效
now = server.lua_caller ? server.lua_time_start : mstime();
// 当本实例是slave时,过时键的删除由master发送过来的
// del 指令控制。可是这个函数仍是将正确的信息返回给调用者。
if (server.masterhost != NULL) return now > when;
// 判断是否未过时
if (now <= when) return 0;
// 代码到这里,说明键已通过期,并且须要被删除
server.stat_expiredkeys++;
// 命令传播,到 slave 和 AOF
propagateExpire(db,key,server.lazyfree_lazy_expire);
// 键空间通知使得客户端能够经过订阅频道或模式, 来接收那些以某种方式改动了 Redis 数据集的事件。
notifyKeyspaceEvent(NOTIFY_EXPIRED,
"expired",key,db->id);
// 若是是惰性删除,调用dbAsyncDelete,不然调用 dbSyncDelete
return server.lazyfree_lazy_expire ? dbAsyncDelete(db,key) :
dbSyncDelete(db,key);
}
复制代码
上图是写命令传播的示意图,删除命令的传播和它一致。propagateExpire 函数先调用 feedAppendOnlyFile 函数将命令同步到 AOF 的缓冲区中,而后调用 replicationFeedSlaves函数将命令同步到全部的 slave 中。Redis 复制的机制能够查看Redis 复制过程详解。oop
// 将命令传递到slave和AOF缓冲区。maser删除一个过时键时会发送Del命令到全部的slave和AOF缓冲区
void propagateExpire(redisDb *db, robj *key, int lazy) {
robj *argv[2];
// 生成同步的数据
argv[0] = lazy ? shared.unlink : shared.del;
argv[1] = key;
incrRefCount(argv[0]);
incrRefCount(argv[1]);
// 若是开启了 AOF 则追加到 AOF 缓冲区中
if (server.aof_state != AOF_OFF)
feedAppendOnlyFile(server.delCommand,db->id,argv,2);
// 同步到全部 slave
replicationFeedSlaves(server.slaves,db->id,argv,2);
decrRefCount(argv[0]);
decrRefCount(argv[1]);
}
复制代码
dbAsyncDelete 函数会先调用 dictDelete 来删除过时表中的键,而后处理键值表中的键值对象。它会根据值的占用的空间来选择是直接释放值对象,仍是交给 bio 异步释放值对象。判断依据就是值的估计大小是否大于 LAZYFREE_THRESHOLD 阈值。键对象和 dictEntry 对象则都是直接被释放。
#define LAZYFREE_THRESHOLD 64
int dbAsyncDelete(redisDb *db, robj *key) {
// 删除该键在过时表中对应的entry
if (dictSize(db->expires) > 0) dictDelete(db->expires,key->ptr);
// unlink 该键在键值表对应的entry
dictEntry *de = dictUnlink(db->dict,key->ptr);
// 若是该键值占用空间很是小,懒删除反而效率低。因此只有在必定条件下,才会异步删除
if (de) {
robj *val = dictGetVal(de);
size_t free_effort = lazyfreeGetFreeEffort(val);
// 若是释放这个对象消耗不少,而且值未被共享(refcount == 1)则将其加入到懒删除列表
if (free_effort > LAZYFREE_THRESHOLD && val->refcount == 1) {
atomicIncr(lazyfree_objects,1);
bioCreateBackgroundJob(BIO_LAZY_FREE,val,NULL,NULL);
dictSetVal(db->dict,de,NULL);
}
}
// 释放键值对,或者只释放key,而将val设置为NULL来后续懒删除
if (de) {
dictFreeUnlinkedEntry(db->dict,de);
// slot 和 key 的映射关系是用于快速定位某个key在哪一个 slot中。
if (server.cluster_enabled) slotToKeyDel(key);
return 1;
} else {
return 0;
}
}
复制代码
dictUnlink 会将键值从键值表中删除,可是却不释放 key、val和对应的表entry对象,而是将其直接返回,而后再调用dictFreeUnlinkedEntry进行释放。dictDelete 是它的兄弟函数,可是会直接释放相应的对象。两者底层都经过调用 dictGenericDelete来实现。dbAsyncDelete d的兄弟函数 dbSyncDelete 就是直接调用dictDelete来删除过时键。
void dictFreeUnlinkedEntry(dict *d, dictEntry *he) {
if (he == NULL) return;
// 释放key对象
dictFreeKey(d, he);
// 释放值对象,若是它不为null
dictFreeVal(d, he);
// 释放 dictEntry 对象
zfree(he);
}
复制代码
Redis 有本身的 bio 机制,主要是处理 AOF 落盘、懒删除逻辑和关闭大文件fd。bioCreateBackgroundJob 函数将释放值对象的 job 加入到队列中,bioProcessBackgroundJobs会从队列中取出任务,根据类型进行对应的操做。
void *bioProcessBackgroundJobs(void *arg) {
.....
while(1) {
listNode *ln;
ln = listFirst(bio_jobs[type]);
job = ln->value;
if (type == BIO_CLOSE_FILE) {
close((long)job->arg1);
} else if (type == BIO_AOF_FSYNC) {
aof_fsync((long)job->arg1);
} else if (type == BIO_LAZY_FREE) {
// 根据参数来决定要作什么。有参数1则要释放它,有参数2和3是释放两个键值表
// 过时表,也就是释放db 只有参数三是释放跳表
if (job->arg1)
lazyfreeFreeObjectFromBioThread(job->arg1);
else if (job->arg2 && job->arg3)
lazyfreeFreeDatabaseFromBioThread(job->arg2,job->arg3);
else if (job->arg3)
lazyfreeFreeSlotsMapFromBioThread(job->arg3);
}
zfree(job);
......
}
}
复制代码
dbSyncDelete 则是直接删除过时键,而且将键、值和 DictEntry 对象都释放。
int dbSyncDelete(redisDb *db, robj *key) {
// 删除过时表中的entry
if (dictSize(db->expires) > 0) dictDelete(db->expires,key->ptr);
// 删除键值表中的entry
if (dictDelete(db->dict,key->ptr) == DICT_OK) {
// 若是开启了集群,则删除slot 和 key 映射表中key记录。
if (server.cluster_enabled) slotToKeyDel(key);
return 1;
} else {
return 0;
}
}
复制代码
可是单独用这种方式存在内存泄露的问题,当过时键一直没有访问将没法获得及时删除,从而致使内存不能及时释放。正由于如此,Redis还提供另外一种定时任 务删除机制做为惰性删除的补充。
Redis 内部维护一个定时任务,默认每秒运行10次(经过配置控制)。定时任务中删除过时键逻辑采用了自适应算法,根据键的 过时比例、使用快慢两种速率模式回收键,流程以下图所示。
按期删除策略由 expire.c/activeExpireCycle 函数实现。在redis事件驱动的循环中的eventLoop->beforesleep和 周期性操做 databasesCron 都会调用 activeExpireCycle 来处理过时键。可是两者传入的 type 值不一样,一个是ACTIVE_EXPIRE_CYCLE_SLOW 另一个是ACTIVE_EXPIRE_CYCLE_FAST。activeExpireCycle 在规定的时间,分屡次遍历各个数据库,从 expires 字典中随机检查一部分过时键的过时时间,删除其中的过时键,相关源码以下所示。
void activeExpireCycle(int type) {
// 上次检查的db
static unsigned int current_db = 0;
// 上次检查的最大执行时间
static int timelimit_exit = 0;
// 上一次快速模式运行时间
static long long last_fast_cycle = 0; /* When last fast cycle ran. */
int j, iteration = 0;
// 每次检查周期要遍历的DB数
int dbs_per_call = CRON_DBS_PER_CALL;
long long start = ustime(), timelimit, elapsed;
..... // 一些状态时不进行检查,直接返回
// 若是上次周期由于执行达到了最大执行时间而退出,则本次遍历全部db,不然遍历db数等于 CRON_DBS_PER_CALL
if (dbs_per_call > server.dbnum || timelimit_exit)
dbs_per_call = server.dbnum;
// 根据ACTIVE_EXPIRE_CYCLE_SLOW_TIME_PERC计算本次最大执行时间
timelimit = 1000000*ACTIVE_EXPIRE_CYCLE_SLOW_TIME_PERC/server.hz/100;
timelimit_exit = 0;
if (timelimit <= 0) timelimit = 1;
// 若是是快速模式,则最大执行时间为ACTIVE_EXPIRE_CYCLE_FAST_DURATION
if (type == ACTIVE_EXPIRE_CYCLE_FAST)
timelimit = ACTIVE_EXPIRE_CYCLE_FAST_DURATION; /* in microseconds. */
// 采样记录
long total_sampled = 0;
long total_expired = 0;
// 依次遍历 dbs_per_call 个 db
for (j = 0; j < dbs_per_call && timelimit_exit == 0; j++) {
int expired;
redisDb *db = server.db+(current_db % server.dbnum);
// 将db数增长,一遍下一次继续从这个db开始遍历
current_db++;
do {
..... // 申明变量和一些状况下 break
if (num > ACTIVE_EXPIRE_CYCLE_LOOKUPS_PER_LOOP)
num = ACTIVE_EXPIRE_CYCLE_LOOKUPS_PER_LOOP;
// 主要循环,在过时表中进行随机采样,判断是否比率大于25%
while (num--) {
dictEntry *de;
long long ttl;
if ((de = dictGetRandomKey(db->expires)) == NULL) break;
ttl = dictGetSignedIntegerVal(de)-now;
// 删除过时键
if (activeExpireCycleTryExpire(db,de,now)) expired++;
if (ttl > 0) {
/* We want the average TTL of keys yet not expired. */
ttl_sum += ttl;
ttl_samples++;
}
total_sampled++;
}
// 记录过时总数
total_expired += expired;
// 即便有不少键要过时,也不阻塞好久,若是执行超过了最大执行时间,则返回
if ((iteration & 0xf) == 0) { /* check once every 16 iterations. */
elapsed = ustime()-start;
if (elapsed > timelimit) {
timelimit_exit = 1;
server.stat_expired_time_cap_reached_count++;
break;
}
}
// 当比率小于25%时返回
} while (expired > ACTIVE_EXPIRE_CYCLE_LOOKUPS_PER_LOOP/4);
}
.....// 更新一些server的记录数据
}
复制代码
activeExpireCycleTryExpire 函数的实现就和 expireIfNeeded 相似,这里就不赘述了。
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,server.lazyfree_lazy_expire);
if (server.lazyfree_lazy_expire)
dbAsyncDelete(db,keyobj);
else
dbSyncDelete(db,keyobj);
notifyKeyspaceEvent(NOTIFY_EXPIRED,
"expired",keyobj,db->id);
decrRefCount(keyobj);
server.stat_expiredkeys++;
return 1;
} else {
return 0;
}
}
复制代码
按期删除策略的关键点就是删除操做执行的时长和频率:
当Redis所用内存达到maxmemory上限时会触发相应的溢出控制策略。 具体策略受maxmemory-policy参数控制,Redis支持6种策略,以下所示:
内存溢出控制策略可使用 config set maxmemory-policy {policy} 语句进行动态配置。Redis 提供了丰富的空间溢出控制策略,咱们能够根据自身业务须要进行选择。
当设置 volatile-lru 策略时,保证具备过时属性的键能够根据 LRU 剔除,而未设置超时的键能够永久保留。还能够采用allkeys-lru 策略把 Redis 变为纯缓存服务器使用。
当Redis由于内存溢出删除键时,能够经过执行 info stats 命令查看 evicted_keys 指标找出当前 Redis 服务器已剔除的键数量。
每次Redis执行命令时若是设置了maxmemory参数,都会尝试执行回收 内存操做。当Redis一直工做在内存溢出(used_memory>maxmemory)的状态下且设置非 noeviction 策略时,会频繁地触发回收内存的操做,影响Redis 服务器的性能,这一点千万要引发注意。