当前位置: 首页 > 科技观察

Redis内存管理机制及实现详解

时间:2023-03-12 06:37:43 科技观察

本文转载自微信公众号《程序员李小兵》,作者李小兵。转载本文请联系程序员李小兵公众号。Redis是一个基于内存的key-value数据库,其内存管理非常重要。本文内存管理的内容包括:过期键的惰性删除、过期删除和内存溢出控制策略。最大内存限制Redis使用maxmemory参数来限制最大可用内存。默认值为0,表示无限制。限制内存的目的主要是:用于缓存场景,当超过内存maxmemory上限时,使用LRU等删除策略释放空间。防止内存使用超过服务器物理内存。因为Redis默认会使用尽可能多的服务器内存,可能会导致服务器内存不足,导致Redis进程被kill。maxmemory限制了Redis实际使用的内存量,即used_memory统计项对应的内存。由于内存碎片的存在,实际消耗的内存可能会比maxmemory设置的要大,所以在实际使用的时候要小心这部分内存溢出。Redis内存监控的具体内容可以参考文章了解Redis内存监控和内存消耗。默认情况下,Redis使用无限的服务器内存。为防止系统内存在极端情况下被耗尽,建议所有Redis进程都配置maxmemory。在保证物理内存可用的情况下,系统中的所有Redis实例都可以通过调整maxmemory参数来达到内存自由伸缩的目的。内存回收策略Redis一般有两种内存回收机制:一种是删除达到过期时间的key-value对象;另一种是当内存达到maxmemory时触发内存清除控制策略,强制删除选中的key-value对象。删除过期的键对象。Redis中的所有键都可以设置过期属性,过期属性存储在内部的过期表中。key-value表和expired表的结果如下图所示。当Redis保存了大量的key时,准确的过期删除每个key可能会消耗大量的CPU,阻塞Redis的主线程,拖累Redis的性能。因此,Redis采用了惰性删除和定时任务删除机制来实现过期键。记忆恢复。懒删除是指当客户端操作一个带有超时属性的key时,会检查key是否超过过期时间,然后同步或异步执行删除操作并返回key过期。这样可以节省CPU成本的考虑,也不需要维护一个单独的过期时间链表来处理过期键的删除。过期key的惰性删除策略由db.c/expireifNeeded函数实现。在所有对数据库的读写命令执行之前,会调用expireifNeeded检查命令执行的key是否过期。如果key过期,expireifNeeded会将过期的key从键值表和过期表中删除,然后同步或异步释放对应对象的空间。源代码显示Redis版本4.0。expireIfNeeded首先从过期表中获取key对应的过期时间,如果当前时间已经超过过期时间(lua脚本执行有特殊逻辑,详见代码注释),进入删除key的流程。deletekey过程主要做三件事:一是传播删除操作命令,通知slave实例并存入AOF缓冲区,二是记录key空间事件,三是进行异步删除或异步删除根据是否启用了lazyfreelazyexpire。inexpireIfNeeded(redisDb*db,robj*key){//获取key的过期时间mstime_twhen=getExpire(db,key);mstime_tnow;//key没有过期时间if(when<0)return0;//instance是从硬盘加载数据,如RDB或AOFif(server.loading)return0;//执行lua脚本时,只有当key在lua开始执行时//当到了过期时间,否则在lua执行过程中不会认为失效now=server.lua_caller?server.lua_time_start:mstime();//当本实例为slave时,过期key的删除由/控制/del命令由主人发送。但是该函数仍然向调用者返回正确的信息。if(server.masterhost!=NULL)returnnow>when;//判断是否未过期if(now<=when)return0;//代码在这里,说明key已经过期,需要删除server.stat_expiredkeys++;//commandPropagatetoslaveandAOFpropagateExpire(db,key,server.lazyfree_lazy_expire);//键空间通知使客户端能够通过订阅一个通道或模式接收以某种方式改变了Redis数据集的事件。notifyKeyspaceEvent(NOTIFY_EXPIRED,"expired",key,db->id);//如果是懒删除,调用dbAsyncDelete,否则调用dbSyncDeletereturnserver.lazyfree_lazy_expire?dbAsyncDelete(db,key):dbSyncDelete(db,key);}图片上图是写命令传播示意图,删除命令传播与之一致。propagateExpire函数首先调用feedAppendOnlyFile函数将命令同步到AOF缓冲区,然后调用replicationFeedSlaves函数将命令同步到所有slave。关于Redis复制的机制,请参考Redis复制过程详解。//将命令传递给slave和AOF缓冲区。当maser删除一个过期的key时,它会向所有slave和AOF缓冲区发送Del命令voidpropagateExpire(redisDb*db,robj*key,intlazy){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);//同步到所有slavereplicationFeedSlaves(server.slaves,db->id,argv,2);decrRefCount(argv[0]);decrRefCount(argv[1]);}dbAsyncDelete函数会先调用dictDelete删除过期表中的key,然后处理key-value表中的key-value对象。它会根据值占用的空间选择是直接释放值对象,还是交给bio异步释放值对象。判断是根据值的估计大小是否大于LAZYFREE_THRESHOLD阈值。key对象和dictEntry对象都是直接释放的。#defineLAZYFREE_THRESHOLD64intdbAsyncDelete(redisDb*db,robj*key){//删除对应的条目if(dictSize(db->e??xpires)>0)dictDelete(db->e??xpires,key->ptr);//解开条目dictEntry中的key*de=dictUnlink(db->dict,key->ptr)对应key-value表;//如果key-value占用的空间非常小,惰性删除会很低效。所以只有在一定条件下才会被异步删除(refcount==1)将其添加到惰性删除列表中,de,NULL);}}//释放键值对,或者只释放key,将val设置为NULL,以便后续懒删除if(de){dictFreeUnlinkedEntry(db->dict,de);//slot和key的映射关系用于快速定位某个key在哪个slot。table,但并没有释放key、val和对应的表项对象,而是直接删除return,然后调用dictFreeUnlinkedEntry释放。dictDelete是它的兄弟函数,但是会直接释放对应的对象。两个底层都是通过调用dictGenericDelete实现的。dbAsyncDeleted的兄弟函数dbSyncDelete是直接调用dictDelete删除过期键。voiddictFreeUnlinkedEntry(dict*d,dictEntry*he){if(he==NULL)return;//释放key对象dictFreeKey(d,he);//释放value对象,如果不是nulldictFreeVal(d,he);//释放dictEntry对象zfree(he);}Redis有自己的bio机制,主要处理AOF放置、懒删除逻辑和关闭大文件fd。bioCreateBackgroundJob函数将释放值对象的作业加入队列,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);}elseif(type==BIO_AOF_FSYNC){aof_fsync((long)job->arg1);}elseif(type==BIO_LAZY_FREE){//根据参数判断做什么。如果有参数1,需要释放。//有参数2和3,释放两个key-value表。//过期表,即释放db。只有参数3是释放跳表if(job->arg1)lazyfreeFreeObjectFromBioThread(job->arg1);elseif(job->arg2&&job->arg3)lazyfreeFreeDatabaseFromBioThread(job->arg2,job->arg3);elseif(job->arg3)lazyfreeFreeSlotsMapFromBioThread(job->arg3);}zfree(job);......}}dbSyncDelete直接删除过期key,释放key、value和DictEntry对象。intdbSyncDelete(redisDb*db,robj*key){//删除过期表中的entryif(dictSize(db->e??xpires)>0)dictDelete(db->e??xpires,key->ptr);//删除key-valuetableentryif(dictDelete(db->dict,key->ptr)==DICT_OK){//如果启用了集群,删除slot和key映射表中的key记录。if(server.cluster_enabled)slotToKeyDel(key);return1;}else{return0;}}但是这个方法单独存在内存泄漏问题。当过期key没有被访问时,不会及时删除,导致内存没有及时释放。.正因为如此,Redis还提供了另一种定时任务删除机制,作为懒删除的补充。Redis内部维护了一个定时任务,默认每秒运行10次(由配置控制)。定时任务中删除过期key的逻辑采用自适应算法,根据key过期比例采用两种速度模式恢复key,如下图所示。1)定时任务首先计算本次循环的最大执行时间,需要检查的数据库个数,以及每个数据库根据快慢模式扫描的次数(慢模式扫描的key数和执行时间比快速模式长)和相关的阈值配置。键的数量。2)从上次定时任务没有扫描到的数据库开始,依次遍历各个数据库。3)从数据库中随机选择ACTIVEEXPIRECYCLELOOKUPSPER_LOOP键。如果发现过期key,调用activeExpireCycleTryExpire函数将其删除。4)如果执行时间超过设置的最大执行时间,则退出并设置下一次以慢速模式执行。5)如果没有超时,则判断采样的key中是否有25%过期,如果是,则继续扫描当前数据库,跳到第3步。否则开始扫描下一个数据库。周期删除策略由expire.c/activeExpireCycle函数实现。redis事件驱动循环中的eventLoop->beforesleep和周期性操作databasesCron都会调用activeExpireCycle来处理过期key。但是两者传入的类型值不同,一个是ACTIVEEXPIRECYCLESLOW,一个是ACTIVEEXPIRECYCLEFAST。activeExpireCycle在指定的时间多次遍历每个数据库,从expires字典中随机检查一些过期键的过期时间,并删除过期键。相关源码如下。voidactiveExpireCycle(inttype){//上次检查dbstaticunsignedintcurrent_db=0;//上次检查最大执行时间staticinttimelimit_exit=0;//上次快速模式运行时间staticlonglonglast_fast_cycle=0;/*Whenlastfastcycleran.*/intj,iteration=0;//每个检查周期要遍历的DB个数intdbs_per_call=CRON_DBS_PER_CALL;longlongstart=ustime(),timelimit,elapsed;.....//有些状态不检查,直接返回//如果最后一个周期是由于Execution达到最大执行时间而退出,则本次遍历所有dbs,否则遍历dbs个数等于CRON_DBS_PER_CALLif(dbs_per_call>server.dbnum||timelimit_exit)dbs_per_call=server.dbnum;//计算最大执行timetimelimit=accordingtoACTIVE_EXPIRE_CYCLE_SLOW_TIME_PERC1000000*ACTIVE_EXPIRE_CYCLE_SLOW_TIME_PERC/server.hz/100;timelimit_exit=0;if(timelimit<=0)timelimit=1;//如果是fast模式,最大执行时间为ACTIVE_EXPIRE_CYCLE_FAST_DURATIONif(type==ACTIVE_EXPIRE_CYCLE_FAST)timelimit=ACTIVE_CYCLE_FAST)timelimit=ACTIVE_CY_EXPIALEinmicroseconds.*///采样记录longtotal_sampled=0;longtotal_expired=0;//遍历dbs_per_calldbfor(j=0;jACTIVE_EXPIRE_CYCLE_LOOKUPS_PER_LOOP)num=ACTIVE_EXPIRE_CYCLE_LOOKUPS_PER_LOOP;//主循环,在过期表中随机采样,判断比例是否大于25%while(num--){dictEntry*de;longlongttl;if((de=dictGetRandomKey(db->e??xpires))==NULL)break;ttl=dictGetSignedIntegerVal(de)-now;//删除过期keyif(activeExpireCycleTryExpire(db,de,now))expired++;if(ttl>0){/*WewanttheaverageTTLofkeysyetnotexpired.*/ttl_sum+=ttl;ttl_samples++;}total_sampled++;}//记录过期的总数total_expired+=expired;//即使有很多key过期,不会阻塞很长时间,如果执行超过最大执行时间,返回if((iteration&0xf)==0){/*checkonceevery16iterations.*/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);}.....//更新一些服务器记录数据}activeExpireCycleTryExpire函数的实现与expireIfNeeded类似,这里不再赘述intactiveExpireCycleTryExpire(redisDb*db,dictEntry*de,longlongnow){longlongt=dictGetSignedIntegerVal(de);if(now>t){sdskey=dictGetKey(de);robj*keyobj=createStringObject(key,sdslen(key));propagateExpire(db,keyobj,server.lazyfree_lazy_expire);如果(server.lazyfree_lazy_expire)dbAsyncDelete(db,keyobj);elsedbSyncDelete(db,keyobj);notifyKeyspaceEvent(NOTIFY_EXPIRED,"expired",keyobj,db->id);decrRefCount(keyobj);server.stat_expiredkeys++;return1;}else{return0;}}定时删除策略的关键点在于删除操作的持续时间和频率:如果删除操作过于频繁或者执行时间过长,则不是很对CPU时间友好,CPU时间删除过期密钥的成本过高。如果删除操作执行的次数太少或者执行时间太短,过期的key不能及时删除,造成内存浪费。内存溢出控制策略当Redis使用的内存达到maxmemory上限时,会触发相应的溢出控制策略。具体策略由maxmemory-policy参数控制。Redis支持6种策略,如下:1)noeviction:默认策略,不会删除任何数据,拒绝所有写操作并返回客户端错误信息(error)OOMcommandnotallowed当使用内存时,Redis只响应读操作此时。2)volatile-lru:根据LRU算法删除设置了超时属性(expire)的key,直到有足够的空间。如果无法删除关键对象,则回退到noeviction策略。3)allkeys-lru:按照LRU算法删除key,不管数据是否有超时属性,直到腾出足够的空间。4)allkeys-random:随机删除所有键,直到有足够的空间。5)volatile-random:随机删除过期的key,直到有足够的空间。6)volatile-ttl:根据key-value对象的ttl属性,删除最近即将过期的数据。如果不是,则退回到noeviction策略。可以使用configsetmaxmemory-policy{policy}语句动态配置内存溢出控制策略。Redis提供了丰富的空间溢出控制策略,我们可以根据自己的业务需求进行选择。当设置volatile-lru策略时,保证属性过期的key可以按照LRU剔除,而没有设置timeout的key可以永久保留。您还可以使用allkeys-lru策略将Redis变成一个纯缓存服务器。当Redis内存溢出删除key时,可以通过执行infostats命令查看evicted_keys指标,了解当前Redis服务器已经淘汰的key数量。如果每次Redis执行命令时都设置了maxmemory参数,它会尝试执行一次内存回收操作。当Redis一直工作在内存溢出状态(used_memory>maxmemory),并且设置了non-noeviction策略时,会频繁触发回收内存的操作,影响Redis服务器的性能,必须注意到。