lazyfree 和memory usage源码分析
Redis使用者应该都遇到过大key删除或者过期时,Redis服务器出现阻塞的应用场景。本文将根据Redis4.0引入的lazyfree惰性删除机制、memory usage命令对解决大key删除造成的阻塞以及预防大key产生做相应的介绍.
lazyfree机制
lazyfree的原理是在删除的时候只进行逻辑删除,把key释放操作放在bio(Background I/O)单独的子线程处理中,减少删除大key对redis主线程的阻塞。有效地避免删除big key带来的性能和可用性问题。在此提一下bio线程,很多人把Redis理解单线程内存数据库,这并不完全正确。Redis将最主要的网络收发和执行命令等操作都放在了主工作线程,然而除此之外还有几个bio后台线程,从源码中可以看到有处理关闭文件和刷盘的后台线程,以及Redis4.0新增加的lazyfree线程。
/* Background job opcodes */ #define BIO_CLOSE_FILE 0 /* Deferred close(2) syscall. */ #define BIO_AOF_FSYNC 1 /* Deferred AOF fsync. */ #define BIO_LAZY_FREE 2 /* Deferred objects freeing. */ #define BIO_NUM_OPS 3
下面我们通过几个命令来理解lazyfree的实现
unlink命令
首先来看新增的unlink命令
{"del",delCommand,-2,"w",0,NULL,1,-1,1,0,0}, {"unlink",unlinkCommand,-2,"wF",0,NULL,1,-1,1,0,0}, void delCommand(client *c) { delGenericCommand(c,0); } void unlinkCommand(client *c) { delGenericCommand(c,1); }
通过这几段源码可以看出del命令和unlink命令都是调用delGenericCommand,唯一的差别的第二个参数不一样。这个参数就是异步删除参数。
/* This command implements DEL and LAZYDEL. */ void delGenericCommand(client *c, int lazy) { int numdel = 0, j; for (j = 1; j < c->argc; j++) { expireIfNeeded(c->db,c->argv[j]); int deleted = lazy ? dbAsyncDelete(c->db,c->argv[j]) : dbSyncDelete(c->db,c->argv[j]); if (deleted) { signalModifiedKey(c->db,c->argv[j]); notifyKeyspaceEvent(NOTIFY_GENERIC, "del",c->argv[j],c->db->id); server.dirty++; numdel++; } } addReplyLongLong(c,numdel); }
可以看到delGenericCommand函数根据lazy参数来决定是同步删除还是异步删除。当执行unlink命令时,传入lazy参数值1,调用异步删除函数dbAsyncDelete。否则执行del命令传入参数值0,调用同步删除函数dbSyncDelete。我们重点来看异步删除dbAsyncDelete的实现逻辑。
/* 当数据库里有删除键、值或者相关的过期条目时,如果有足够的内存来释放key,则可以将其放入延迟释放列表来替代同步释放。惰性删除的列表将在另一个bio.c线程中回收。*/ #define LAZYFREE_THRESHOLD 64 /*定义后台删除的阈值,key的元素大于该阈值时才真正丢给后台线程去删除*/ int dbAsyncDelete(redisDb *db, robj *key) { if (dictSize(db->expires) > 0) dictDelete(db->expires,key->ptr); /*...*/ dictEntry *de = dictUnlink(db->dict,key->ptr); if (de) { /*lazyfreeGetFreeEffort来获取val对象所包含的元素个数*/ robj *val = dictGetVal(de); size_t free_effort = lazyfreeGetFreeEffort(val); /* 对删除key进行判断,满足阈值条件时进行后台删除 */ if (free_effort > LAZYFREE_THRESHOLD && val->refcount == 1) { atomicIncr(lazyfree_objects,1); bioCreateBackgroundJob(BIO_LAZY_FREE,val,NULL,NULL); /*将删除对象放入BIO_LAZY_FREE后台线程任务队列*/ dictSetVal(db->dict,de,NULL); /*将第一步获取到的val值设置为null*/ } } /* 删除数据库字典条目,释放资源*/ if (de) { dictFreeUnlinkedEntry(db->dict,de); if (server.cluster_enabled) slotToKeyDel(key); return 1; } else { return 0; } }
上面提到了当删除key满足阈值条件时,会将key放入BIO_LAZY_FREE后台线程任务队列。接下来我们来看BIO_LAZY_FREE后台线程。
/* Process the job accordingly to its type. */ if (type == BIO_CLOSE_FILE) { close((long)job->arg1); } else if (type == BIO_AOF_FSYNC) { redis_fsync((long)job->arg1); } else if (type == BIO_LAZY_FREE) { /* What we free changes depending on what arguments are set: * arg1 -> free the object at pointer. * arg2 & arg3 -> free two dictionaries (a Redis DB). * only arg3 -> free the skiplist. */ if (job->arg1) lazyfreeFreeObjectFromBioThread(job->arg1); else if (job->arg2 && job->arg3) lazyfreeFreeDatabaseFromBioThread(job->arg2,job->arg3); else if (job->arg3) lazyfreeFreeSlotsMapFromBioThread(job->arg3); } else { serverPanic("Wrong job type in bioProcessBackgroundJobs()."); } zfree(job);
可以看到BIO_LAZY_FREE后台线程根据job参数执行相应的函数
/* 后来删除对象函数,调用decrRefCount减少key的引用计数,引用计数为0时会真正的释放资源 */ void lazyfreeFreeObjectFromBioThread(robj *o) { decrRefCount(o); atomicDecr(lazyfree_objects,1); } /* 后台清空数据库字典,调用dictRelease循环遍历数据库字典删除所有key */ void lazyfreeFreeDatabaseFromBioThread(dict *ht1, dict *ht2) { size_t numkeys = dictSize(ht1); dictRelease(ht1); dictRelease(ht2); atomicDecr(lazyfree_objects,numkeys); } /* 后台删除key-slots映射表,在Redis集群模式下会用*/ void lazyfreeFreeSlotsMapFromBioThread(rax *rt) { size_t len = rt->numele; raxFree(rt); atomicDecr(lazyfree_objects,len); }
unlink命令的逻辑可以总结为:执行unlink调用delGenericCommand函数传入lazy参数值1,来调用异步删除函数dbAsyncDelete,将满足阈值的大key放入BIO_LAZY_FREE后台线程任务队列进行异步删除。
类似的后台删除命令还有flushdb async、flushall async,原理都是获取删除标识进行判断,然后调用异步删除函数emptyDbAsnyc来清空数据库,具体实现逻辑可自行查看flushdbCommand部分源码,在此不做赘述。
除了主动的大key删除和数据空清空操作外,过期key驱逐引发的删除操作也会阻塞Redis服务。因此Redis4.0这次除了增加了上述的三个后台删除的命令外,还增加了4个后台删除配置项,分别为:
slave-lazy-flush:slave接收完RDB文件后清空数据选项;建议开启,可减少slave节点flush操作时间,从而减少主从全量同步耗时。
lazyfree-lazy-eviction:内存满逐出选项;开启此选项可能使淘汰key的内存释放不够及时,导致内存超用。
lazyfree-lazy-expire:过期key删除选项。建议开启。
lazyfree-lazy-server-del:内部删除选项,比如rename命令将oldkey修改为一个已存在的newkey时,会先将newkey删除掉。如果newkey是一个大key,可能会引起阻塞删除。建议开启。
后台删除的逻辑差异不大,都是通过参数选项进行判断,来选择是否采用dbAsyncDelete或者emptyDbAsync进行异步删除。
memory usage
大key删除造成的阻塞问题可以通过lazyfree来解决,然而大key的日常操作造成Redis服务阻塞却无法避免。预防大key产生是解决问题的根本。
在Redis4.0推出memory usage命令之前,主要有以下两种办法查找某个Redis实例是否存在大key:
1、redis-rdb-tools工具。redis实例上执行bgsave,然后对dump出来的rdb文件进行分析,找到其中的大KEY。
2、redis-cli --bigkeys命令。可以找到某个实例5种数据类型(String、hash、list、set、zset)的最大key。
3、debug object key命令。可以查看某个key序列化后的长度,每次只能查找单个key的信息。官方不推荐。
1、redis-rdb-tools工具。
关于rdb工具的详细介绍请查看链接https://github.com/sripathikrishnan/redis-rdb-tools,在此只介绍内存相关的使用方法。
基本的命令为
rdb -c memory dump.rdb (其中dump.rdb为Redis实例的rdb文件,可通过bgsave生成)。
例如# rdb -c memory dump.rdb
database,type,key,size_in_bytes,encoding,num_elements,len_largest_element 0,hash,hello1,1050,ziplist,86,22, 0,hash,hello2,2517,ziplist,222,8, 0,hash,hello3,2523,ziplist,156,12, 0,hash,hello4,62020,hashtable,776,32, 0,hash,hello5,71420,hashtable,1168,12,
可以看到输出的信息包括数据类型,key,内存大小,编码类型等。
rdb工具优点在于获取的key信息详细,可选参数多,支持定制化需求。可生成json或csv格式,后续处理方便。缺点是需要离线操作,获取结果时间较长。
2、redis-cli --bigkeys命令
redis-cli --bigkeys是redis-cli自带的一个命令。它对整个redis进行扫描,寻找较大的key,并打印统计结果。
例如redis-cli -p 6379 --bigkeys
# Scanning the entire keyspace to find biggest keys as well as # average sizes per key type. You can use -i 0.1 to sleep 0.1 sec # per 100 SCAN commands (not usually needed). [00.72%] Biggest hash found so far 'hello6' with 43 fields [02.81%] Biggest string found so far 'hello7' with 31 bytes [05.15%] Biggest string found so far 'hello8' with 32 bytes [26.94%] Biggest hash found so far 'hello9' with 1795 fields [32.00%] Biggest hash found so far 'hello10' with 4671 fields [35.55%] Biggest string found so far 'hello11' with 36 bytes -------- summary ------- Sampled 293070 keys in the keyspace! Total key length in bytes is 8731143 (avg len 29.79) Biggest string found 'hello11' has 36 bytes Biggest hash found 'hello10' has 4671 fields 238027 strings with 2300436 bytes (81.22% of keys, avg size 9.66) 0 lists with 0 items (00.00% of keys, avg size 0.00) 0 sets with 0 members (00.00% of keys, avg size 0.00) 55043 hashs with 289965 fields (18.78% of keys, avg size 5.27) 0 zsets with 0 members (00.00% of keys, avg size 0.00)
可以看到打印结果分为两部分,扫描过程部分,只显示了扫描到当前阶段里最大的key。summary部分给出了每种数据结构中最大的Key以及统计信息。
redis-cli --bigkeys的优点是在线scan,不阻塞服务。缺点是信息较少,内容不够精确。
扫描结果中只有string类型是以字节长度为衡量标准的。list,set,zset等都是以元素个数作为衡量标准,元素个数多不能说明占用内存就一定多。
总之,之前的方法要么是用时较长离线解析,或者是不够详细的抽样扫描。离理想的在线扫描获取详细信息有一定距离。Redis4.0引入的memory usage的显示出优越性了。
下面我们从源码来理解memory usage的特点。
{"memory",memoryCommand,-2,"rR",0,NULL,0,0,0,0,0} void memoryCommand(client *c) { /*...*/ /*计算key大小是通过抽样部分field来估算总大小。*/ else if (!strcasecmp(c->argv[1]->ptr,"usage") && c->argc >= 3) { size_t usage = objectComputeSize(dictGetVal(de),samples); usage += sdsAllocSize(dictGetKey(de)); usage += sizeof(dictEntry); addReplyLongLong(c,usage); } }
从上述源码看到memory usage是通过调用objectComputeSize来计算key的大小。我们来看objectComputeSize函数的逻辑。
#define OBJ_COMPUTE_SIZE_DEF_SAMPLES 5 /* Default sample size. */ size_t objectComputeSize(robj *o, size_t sample_size) { /*...代码对数据类型进行了分类,此处只去hash类型说明*/ else if (o->type == OBJ_HASH) { /*判断hash类型的编码模式,ziplist或者hashtable*/ if (o->encoding == OBJ_ENCODING_ZIPLIST) { asize = sizeof(*o)+(ziplistBlobLen(o->ptr)); } else if (o->encoding == OBJ_ENCODING_HT) { d = o->ptr; di = dictGetIterator(d); asize = sizeof(*o)+sizeof(dict)+(sizeof(struct dictEntry*)*dictSlots(d)); /*循环抽样个field,累加获取抽样样本内存值,默认抽样样本为5*/ while((de = dictNext(di)) != NULL && samples < sample_size) { ele = dictGetKey(de); ele2 = dictGetVal(de); elesize += sdsAllocSize(ele) + sdsAllocSize(ele2); elesize += sizeof(struct dictEntry); samples++; } dictReleaseIterator(di); /*根据上一步计算的抽样样本内存值除以样本量,再乘以总的filed个数计算总内存值*/ if (samples) asize += (double)elesize/samples*dictSize(d); } else { serverPanic("Unknown hash encoding"); }
由此,可以知道memory usage默认抽样5个field来循环累加计算整个key的内存大小,样本的数量决定了key的内存大小的准确性和计算成本,样本越大,循环次数越多,计算结果更精确,性能消耗也越多。
我们可以通过Python脚本在集群低峰时扫描Redis,用较小的代价去获取所有key的内存大小。以下为部分伪代码,可根据实际情况设置大key阈值进行预警。
for key in r.scan_iter(count=1000): redis-cli = '/usr/bin/redis-cli' configcmd = '%s -h %s -p %s memory usage %s' % (redis-cli, rip,rport,key) keymemory = commands.getoutput(configcmd)
总结
Redis4.0以及Redis5.0相较于早期的版本不仅解决了之前的大key删除导致的阻塞问题,还提供了大key删除后的内存碎片回收功能,避免碎片率过高占用更多内存同时降低性能。除此之外,还提供了LFU过期算法、rdb&aof混合持久化、动态HZ、新数据类型stream等特性,在功能和易用性有显示提升,同时又兼容早期版本迁移平滑,版本升级是个不错的选择。