Redis⼤key的发现与删除⽅法全解析
个推作为国内第三⽅推送市场的早期进⼊者,专注于为开发者提供⾼效稳定的推送服务,经过9年的积累和发展,服务了包括新浪、滴滴在内的数⼗万APP。由于我们推送业务对并发量、速度要求很⾼,为此,我们选择了⾼性能的内存数据库Redis。然⽽,在实际业务场景中我们也遇到了⼀些Redis⼤key造成的服务阻塞问题,因此积累了⼀些应对经验。本⽂将对⼤key的发现、解决⼤key删除造成的阻塞做相应的介绍。
Redis⼤key的⼀些场景及问题
⼤key场景
Redis使⽤者应该都遇到过⼤key相关的场景,⽐如:
1、热门话题下评论、答案排序场景。
2、⼤V的粉丝列表。
3、使⽤不恰当,或者对业务预估不准确、不及时进⾏处理垃圾数据等。
⼤key问题
由于Redis主线程为单线程模型,⼤key也会带来⼀些问题,如:
1、集模式在slot分⽚均匀情况下,会出现数据和查询倾斜情况,部分有⼤key的Redis节点占⽤内存多,QPS⾼。
2、⼤key相关的删除或者⾃动过期时,会出现qps突降或者突升的情况,极端情况下,会造成主从复制异常,Redis服务阻塞⽆法响应请求。⼤key的体积与删除耗时可参考下表:
key类型
field数量
耗时
Hash
~100万
~1000ms
List
~100万
~1000ms
Set
~100万
~1000ms
Sorted Set
~100万
~1000ms
Redis 4.0之前的⼤key的发现与删除⽅法
1、redis-rdb-tools⼯具。redis实例上执⾏bgsave,然后对dump出来的rdb⽂件进⾏分析,到其中的⼤KEY。
2、redis-cli --bigkeys命令。可以到某个实例5种数据类型(String、hash、list、set、zset)的最⼤key。
3、⾃定义的扫描脚本,以Python脚本居多,⽅法与redis-cli --bigkeys类似。
4、debug object key命令。可以查看某个key序列化后的长度,每次只能查单个key的信息。官⽅不推荐。
redis-rdb-tools⼯具
输出结果如下:
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格式,后续处理⽅便,其缺点是需要离线操作,获取结果时间较长。
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).
安卓在线解析json[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的优点是可以在线扫描,不阻塞服务;缺点是信息较少,内容不够精确。扫描结果中只有string类型是以字节长度为衡量标准的。List、set、zset等都是以元素个数作为衡量标准,元素个数多不能说明占⽤内存就⼀定多。
⾃定义Python扫描脚本
通过strlen、hlen、scard等命令获取字节⼤⼩或者元素个数,扫描结果⽐redis-cli --keys更精细,但是缺点和redis-cli --keys⼀样,不赘述。
总之,之前的⽅法要么是⽤时较长离线解析,或者是不够详细的抽样扫描,离理想的以内存为维度的在线扫描获取详细信息有⼀定距离。由于在redis4.0前,没有lazy free机制;针对扫描出来的⼤key,DBA只能通过hscan、sscan、zscan⽅式渐进删除若⼲个元素;但⾯对过期删除键的场景,这种取巧的删除就⽆能为⼒。我们只能祈祷⾃动清理过期key刚好在系统低峰时,降低对业务的影响。
Redis 4.0之后的⼤key的发现与删除⽅法
Redis 4.0引⼊了memory usage命令和lazyfree机制,不管是对⼤key的发现,还是解决⼤key删除或者过期造成的阻塞问题都有明显的提升。
下⾯我们从源码(摘⾃Redis 5.0.4版本)来理解memory usage和lazyfree的特点。
memory usage
{"memory",memoryCommand,-2,"rR",0,NULL,0,0,0,0,0}
(server.c 285⾏)
void memoryCommand(client c) {
/.../
/计算key⼤⼩是通过抽样部分field来估算总⼤⼩。/
else if (!strcasecmp(c->argv[1]->ptr,"usage") && c->argc >= 3) {
size_t usage = objectComputeSize(dictGetVal(de),samples);
/...*/
}
}
(object.c 1299⾏)
从上述源码看到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类型说明/
/.../
/循环抽样个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);
/.../
}
(object.c 779⾏)
由此,我们发现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 = utput(configcmd)
lazyfree机制
Lazyfree的原理是在删除的时候只进⾏逻辑删除,把key释放操作放在bio(Background I/O)单独的⼦线程处理中,减少删除⼤key对redis主线程的阻塞,有效地避免因删除⼤key带来的性能问题。在此提⼀下bio线程,很多⼈把Redis通常理解为单线程内存数据库, 其实不然。Redis 将最主要的⽹络收发和执⾏命令等操作都放在了主⼯作线程,然⽽除此之外还有⼏个bio后台线程,从源码中可以看到有处理关闭⽂件和刷盘的后台线程,以及Redis4.0新增加的lazyfree线程。
/* Background job opcodes */
define BIO_LAZY_FREE 2 /* Deferred objects freeing. */
(bio.h 38⾏)
下⾯我们以unlink命令为例,来理解lazyfree的实现原理。
{"unlink",unlinkCommand,-2,"wF",0,NULL,1,-1,1,0,0},
(server.c 137⾏)
void unlinkCommand(client *c) {
delGenericCommand(c,1);
}
(db.c 490⾏)
通过这⼏段源码可以看出del命令和unlink命令都是调⽤delGenericCommand,唯⼀的差别在于第⼆个参数不⼀样。这个参数就是异步删除参数。
/* This command implements DEL and LAZYDEL. /
void delGenericCommand(client c, int lazy) {
/.../
int deleted = lazy ? dbAsyncDelete(c->db,c->argv[j]) :
dbSyncDelete(c->db,c->argv[j]);
/.../
}
(db.c 468⾏)
可以看到delGenericCommand函数根据lazy参数来决定是同步删除还是异步删除。当执⾏unlink命令时,传⼊lazy参数值1,调⽤异步删除函数dbAsyncDelete。否则执⾏del命令传⼊参数值0,调⽤同步删除函数dbSyncDelete。我们重点来看异步删除dbAsyncDelete的实现逻辑:
define LAZYFREE_THRESHOLD 64
/定义后台删除的阈值,key的元素⼤于该阈值时才真正丢给后台线程去删除/
int dbAsyncDelete(redisDb db, robj key) {
/.../
/lazyfreeGetFreeEffort来获取val对象所包含的元素个数/
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*/
}
/*...*/
}
(lazyfree.c 53⾏)
上⾯提到了当删除key满⾜阈值条件时,会将key放⼊BIO_LAZY_FREE后台线程任务队列。接下来我们来看BIO_LAZY_FREE后台线程。
/.../
else if (type == BIO_LAZY_FREE) {
if (job->arg1)
/* 后台删除对象函数,调⽤decrRefCount减少key的引⽤计数,引⽤计数为0时会真正的释放资源/ lazyfreeFreeObjectFromBioThread(job->arg1);
else if (job->arg2 && job->arg3)
/后台清空数据库字典,调⽤dictRelease循环遍历数据库字典删除所有key /
lazyfreeFreeDatabaseFromBioThread(job->arg2,job->arg3);
else if (job->arg3)
/后台删除key-slots映射表,在Redis集模式下会⽤*/
lazyfreeFreeSlotsMapFromBioThread(job->arg3);
}
(bio.c 197⾏)
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、lazyfree-lazy-eviction、lazyfree-lazy-expire和lazyfree-lazy-server-del。
slave-lazy-flush:slave接收完RDB⽂件后清空数据选项。建议⼤家开启slave-lazy-flush,这样可减少slave节点flush操作时间,从⽽降低主从全量同步耗时的可能性。
lazyfree-lazy-eviction:内存⽤满逐出选项。若开启此选项可能导致淘汰key的内存释放不够及时,内存超⽤。
lazyfree-lazy-expire:过期key删除选项。建议开启。
lazyfree-lazy-server-del:内部删除选项,⽐如rename命令将oldkey修改为⼀个已存在的newkey时,会先将newkey删除掉。如果newkey是⼀个⼤key,可能会引起阻塞删除。建议开启。
上述四个后台删除相关的参数实现逻辑差异不⼤,都是通过参数选项进⾏判断,从⽽选择是否采⽤dbAsyncDelete或者emptyDbAsync进⾏异步删除。
总结
在某些业务场景下,Redis⼤key的问题是难以避免的,但是,memory usage命令和lazyfree机制分别提供了内存维度的抽样算法和异步删除优化功能,这些特性有助于我们在实际业务中更好的预防⼤key的产⽣和解决⼤key造成的阻塞。关于Redis内核的优化思路也可从Redis作者Antirez的博客中窥测⼀⼆,他提出"Lazy Redis is better Redis"、"Slow commands threading"(允许在不同的线程中执⾏慢操作命令),异步化应该是Redis优化的主要⽅向。
Redis作为个推消息推送的⼀项重要的基础服务,性能的好坏⾄关重要。个推将Redis版本从2.8升级到5.0后,有效地解决了部分⼤key删除或过期造成的阻塞问题。未来,个推将会持续关注Redis 5.0及后续的Redis 6.0,与⼤家共同探讨如何更好地使⽤Redis。
版权声明:本站内容均来自互联网,仅供演示用,请勿用于商业和其他非法用途。如果侵犯了您的权益请与我们联系QQ:729038198,我们将在24小时内删除。
发表评论