详解 Redis 内存管理机制和实现
redis是一个基于内存的键值数据库,其内存管理是非常重要的。本文内存管理的内容包括:过期键的懒性删除和过期删除以及内存溢出控制策略。
最大内存限制
redis使用 maxmemory 参数限制最大可用内存,默认值为0,表示无限制。限制内存的目的主要 有:
- 用于缓存场景,当超出内存上限 maxmemory 时使用 lru 等删除策略释放空间。
- 防止所用内存超过服务器物理内存。因为 redis 默认情况下是会尽可能多使用服务器的内存,可能会出现服务器内存不足,导致 redis 进程被杀死
maxmemory 限制的是redis实际使用的内存量,也就是 used_memory统计项对应的内存。由于内存碎片率的存在,实际消耗的内存 可能会比maxmemory设置的更大,实际使用时要小心这部分内存溢出。具体redis 内存监控的内容请查看一文了解 redis 内存监控和内存消耗。
redis默认无限使用服务器内存,为防止极端情况下导致系统内存耗 尽,建议所有的redis进程都要配置maxmemory。 在保证物理内存可用的情况下,系统中所有redis实例可以调整 maxmemory参数来达到*伸缩内存的目的。
内存回收策略
redis 回收内存大致有两个机制:一是删除到达过期时间的键值对象;二是当内存达到 maxmemory 时触发内存移除控制策略,强制删除选择出来的键值对象。
删除过期键对象
redis 所有的键都可以设置过期属性,内部保存在过期表中,键值表和过期表的结果如下图所示。当 redis保存大量的键,对每个键都进行精准的过期删除可能会导致消耗大量的 cpu,会阻塞 redis 的主线程,拖累 redis 的性能,因此 redis 采用惰性删除和定时任务删除机制实现过期键的内存回收。
惰性删除是指当客户端操作带有超时属性的键时,会检查是否超过键的过期时间,然后会同步或者异步执行删除操作并返回键已经过期。这样可以节省 cpu成本考虑,不需要单独维护过期时间链表来处理过期键的删除。
过期键的惰性删除策略由 db.c/expireifneeded 函数实现,所有对数据库的读写命令执行之前都会调用 expireifneeded 来检查命令执行的键是否过期。如果键过期,expireifneeded 会将过期键从键值表和过期表中删除,然后同步或者异步释放对应对象的空间。源码展示的时 redis 4.0 版本。
expireifneeded 先从过期表中获取键对应的过期时间,如果当前时间已经超过了过期时间(lua脚本执行则有特殊逻辑,详看代码注释),则进入删除键流程。删除键流程主要进行了三件事:
- 一是删除操作命令传播,通知 slave 实例并存储到 aof 缓冲区中
- 二是记录键空间事件,
- 三是根据 lazyfreelazyexpire 是否开启进行异步删除或者异步删除操作。
1 int expireifneeded(redisdb *db, robj *key) { 2 // 获取键的过期时间 3 mstime_t when = getexpire(db,key); 4 mstime_t now; 5 // 键没有过期时间 6 if (when < 0) return 0; 7 // 实例正在从硬盘 laod 数据,比如说 rdb 或者 aof 8 if (server.loading) return 0; 9 10 // 当执行lua脚本时,只有键在lua一开始执行时 11 // 就到了过期时间才算过期,否则在lua执行过程中不算失效 12 now = server.lua_caller ? server.lua_time_start : mstime(); 13 14 // 当本实例是slave时,过期键的删除由master发送过来的 15 // del 指令控制。但是这个函数还是将正确的信息返回给调用者。 16 if (server.masterhost != null) return now > when; 17 // 判断是否未过期 18 if (now <= when) return 0; 19 20 // 代码到这里,说明键已经过期,而且需要被删除 21 server.stat_expiredkeys++; 22 // 命令传播,到 slave 和 aof 23 propagateexpire(db,key,server.lazyfree_lazy_expire); 24 // 键空间通知使得客户端可以通过订阅频道或模式, 来接收那些以某种方式改动了 redis 数据集的事件。 25 notifykeyspaceevent(notify_expired, 26 "expired",key,db->id); 27 // 如果是惰性删除,调用dbasyncdelete,否则调用 dbsyncdelete 28 return server.lazyfree_lazy_expire ? dbasyncdelete(db,key) : 29 dbsyncdelete(db,key); 30 }
上图是写命令传播的示意图,删除命令的传播和它一致。propagateexpire 函数先调用 feedappendonlyfile 函数将命令同步到 aof 的缓冲区中,然后调用 replicationfeedslaves函数将命令同步到所有的 slave 中。redis 复制的机制可以查看redis 复制过程详解。
// 将命令传递到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次(通过配置控制)。定时任务中删除过期键逻辑采用了自适应算法,根据键的 过期比例、使用快慢两种速率模式回收键,流程如下图所示。
- 1)定时任务首先根据快慢模式( 慢模型扫描的键的数量以及可以执行时间都比快模式要多 )和相关阈值配置计算计算本周期最大执行时间、要检查的数据库数量以及每个数据库扫描的键数量。
- 2) 从上次定时任务未扫描的数据库开始,依次遍历各个数据库。
- 3)从数据库中随机选手 activeexpirecyclelookupsper_loop 个键,如果发现是过期键,则调用 activeexpirecycletryexpire 函数删除它。
- 4)如果执行时间超过了设定的最大执行时间,则退出,并设置下一次使用慢模式执行。
- 5)未超时的话,则判断是否采样的键中是否有25%的键是过期的,如果是则继续扫描当前数据库,跳到第3步。否则开始扫描下一个数据库。
定期删除策略由 expire.c/activeexpirecycle 函数实现。在redis事件驱动的循环中的eventloop->beforesleep和 周期性操作 databasescron 都会调用 activeexpirecycle 来处理过期键。但是二者传入的 type 值不同,一个是activeexpirecycleslow 另外一个是activeexpirecyclefast。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; } }
定期删除策略的关键点就是删除操作执行的时长和频率:
- 如果删除操作太过频繁或者执行时间太长,就对 cpu 时间不是很友好,cpu 时间过多的消耗在删除过期键上。
- 如果删除操作执行太少或者执行时间太短,就不能及时删除过期键,导致内存浪费。
内存溢出控制策略
当redis所用内存达到maxmemory上限时会触发相应的溢出控制策略。 具体策略受maxmemory-policy参数控制,redis支持6种策略,如下所示:
- 1)noeviction:默认策略,不会删除任何数据,拒绝所有写入操作并返 回客户端错误信息(error)oom command not allowed when used memory,此 时redis只响应读操作。
- 2)volatile-lru:根据lru算法删除设置了超时属性(expire)的键,直 到腾出足够空间为止。如果没有可删除的键对象,回退到noeviction策略。
- 3)allkeys-lru:根据lru算法删除键,不管数据有没有设置超时属性, 直到腾出足够空间为止。
- 4)allkeys-random:随机删除所有键,直到腾出足够空间为止。
- 5)volatile-random:随机删除过期键,直到腾出足够空间为止。
- 6)volatile-ttl:根据键值对象的ttl属性,删除最近将要过期数据。如果没有,回退到noeviction策略。
内存溢出控制策略可以使用 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 服务器的性能,这一点千万要引起注意。
推荐阅读
-
详解通过docker和docker-compose实现eureka高可用
-
JS实现加载和读取XML文件的方法详解
-
详解利用nginx和docker实现一个简易的负载均衡
-
详解MySQL中DROP,TRUNCATE 和DELETE的区别实现mysql从零开始
-
JavaScript使用原型和原型链实现对象继承的方法详解
-
Python PyAutoGUI模块控制鼠标和键盘实现自动化任务详解
-
JAVAEE——宜立方商城06:Redis安装、数据类型和持久化方案、Redis集群分析与搭建、实现缓存和同步
-
详解C#中对于接口的实现方式(隐式接口和显式接口)
-
详解JS实现系统登录页的登录和验证
-
PHP实现二叉树深度优先遍历(前序、中序、后序)和广度优先遍历(层次)实例详解