作者:京东云开发者-京东零售 高凯
链接:https://my.oschina.net/u/4090830/blog/10139889
userPin:{
storeId:{门店下加车的所有商品基本信息},
storeId:{门店下加车的所有商品基本信息},
......
}
String:String 类型的 Key 对应的 Value 超过 10KB
非 String 结构(Hash,Set,ZSet,List):Value 的数量达到 10000 个,或者 Vaule 的总大小为 100KB
集群中 Key 的总数超过 1 亿
$ redis-cli --bigkeys
# Scanning the entire keyspace to find biggest keys as well as
# average sizes per key type. You can use -i 0.01 to sleep 0.01 sec
# per SCAN command (not usually needed).
-------- 第一部分start -------
[00.00%] Biggest string found so far 'key-419' with 3 bytes
[05.14%] Biggest list found so far 'mylist' with 100004 items
[35.77%] Biggest string found so far 'counter:__rand_int__' with 6 bytes
[73.91%] Biggest hash found so far 'myobject' with 3 fields
-------- 第一部分end -------
-------- summary -------
-------- 第二部分start -------
Sampled 506 keys in the keyspace!
Total key length in bytes is 3452 (avg len 6.82)
Biggest string found 'counter:__rand_int__' has 6 bytes
Biggest list found 'mylist' has 100004 items
Biggest hash found 'myobject' has 3 fields
-------- 第二部分end -------
-------- 第三部分start -------
504 strings with 1403 bytes (99.60% of keys, avg size 2.78)
1 lists with 100004 items (00.20% of keys, avg size 100004.00)
0 sets with 0 members (00.00% of keys, avg size 0.00)
1 hashs with 3 fields (00.20% of keys, avg size 3.00)
0 zsets with 0 members (00.00% of keys, avg size 0.00)
-------- 第三部分end -------
以下我们分三步对 bigkeys 选项源码原理进行解析,简要流程如下图:
typedef struct {
char *name;//数据类型,如string
char *sizecmd;//查询大小命令,如string会调用STRLEN
char *sizeunit;//单位,string类型为bytes,而hash为field
unsigned long long biggest;//最大key信息域,此数据类型最大key的大小,如string类型是多少bytes,hash为多少field
unsigned long long count;//统计信息域,此数据类型的key的总数
unsigned long long totalsize;//统计信息域,此数据类型的key的总大小,如string类型是全部string总共多少bytes,hash为全部hash总共多少field
sds biggest_key;//最大key信息域,此数据类型最大key的键名,之所以在数据结构末尾是考虑字节对齐
} typeinfo;
dict *types_dict = dictCreate(&typeinfoDictType);
typeinfo_add(types_dict, "string", &type_string);
typeinfo_add(types_dict, "list", &type_list);
typeinfo_add(types_dict, "set", &type_set);
typeinfo_add(types_dict, "hash", &type_hash);
typeinfo_add(types_dict, "zset", &type_zset);
typeinfo_add(types_dict, "stream", &type_stream);
2. 调用 scan 命令迭代地获取一批 key(注意只是 key 的名称,类型和大小 scan 命令不返回)
/* scan循环扫描 */
do {
/* 计算完成的百分比情况 */
pct = 100 * (double)sampled/total_keys;//这里记录下扫描的进度
/* 获取一些键并指向键数组 */
reply = sendScan(&it);//这里发送SCAN命令,结果保存在reply中
keys = reply->element[1];//keys来保存这次scan获取的所有键名,注意只是键名,每个键的数据类型是不知道的。
......
} while(it != 0);
3. 对每个 key 获取它的数据类型(type)和 key 的大小(size)
/* 检索类型,然后检索大小*/
getKeyTypes(types_dict, keys, types);
getKeySizes(keys, types, sizes, memkeys, memkeys_samples);
4. 如果 key 的大小大于已记录的最大值的 key,则更新最大 key 的信息
/* Now update our stats */
for(i=0;i<keys->elements;i++) {
......//前面已解析
//如果遍历到比记录值更大的key时
if(type->biggest<sizes[i]) {
/* Keep track of biggest key name for this type */
if (type->biggest_key)
sdsfree(type->biggest_key);
//更新最大key的键名
type->biggest_key = sdscatrepr(sdsempty(), keys->element[i]->str, keys->element[i]->len);
if(!type->biggest_key) {
fprintf(stderr, "Failed to allocate memory for key!\n");
exit(1);
}
//每当找到一个更大的key时则输出该key信息
printf(
"[%05.2f%%] Biggest %-6s found so far '%s' with %llu %s\n",
pct, type->name, type->biggest_key, sizes[i],
!memkeys? type->sizeunit: "bytes");
/* Keep track of the biggest size for this type */
//更新最大key的大小
type->biggest = sizes[i];
}
......//前面已解析
}
5. 对每个 key 更新对应数据类型的统计信息
/* 现在更新统计数据 */
for(i=0;i<keys->elements;i++) {
typeinfo *type = types[i];
/* 跳过在SCAN和TYPE之间消失的键 */
if(!type)
continue;
//对每个key更新每种数据类型的统计信息
type->totalsize += sizes[i];//某数据类型(如string)的总大小增加
type->count++;//某数据类型的key数量增加
totlen += keys->element[i]->len;//totlen不针对某个具体数据类型,将所有key的键名的长度进行统计,注意只统计键名长度。
sampled++;//已经遍历的key数量
......//后续解析
/* 更新整体进度 */
if(sampled % 1000000 == 0) {
printf("[%05.2f%%] Sampled %llu keys so far\n", pct, sampled);
}
}
/* We're done */
printf("\n-------- summary -------\n\n");
if (force_cancel_loop) printf("[%05.2f%%] ", pct);
printf("Sampled %llu keys in the keyspace!\n", sampled);
printf("Total key length in bytes is %llu (avg len %.2f)\n\n",
totlen, totlen ? (double)totlen/sampled : 0);
2. 首先输出总共扫描了多少个 key、所有 key 的总长度是多少。
/* Output the biggest keys we found, for types we did find */
di = dictGetIterator(types_dict);
while ((de = dictNext(di))) {
typeinfo *type = dictGetVal(de);
if(type->biggest_key) {
printf("Biggest %6s found '%s' has %llu %s\n", type->name, type->biggest_key,
type->biggest, !memkeys? type->sizeunit: "bytes");
}
}
dictReleaseIterator(di);
di = dictGetIterator(types_dict);
while ((de = dictNext(di))) {
typeinfo *type = dictGetVal(de);
printf("%llu %ss with %llu %s (%05.2f%% of keys, avg size %.2f)\n",
type->count, type->name, type->totalsize, !memkeys? type->sizeunit: "bytes",
sampled ? 100 * (double)type->count/sampled : 0,
type->count ? (double)type->totalsize/type->count : 0);
}
dictReleaseIterator(di);
在不影响线上服务的同时得到精确的分析报告。使用 redis-rdb-tools 工具以定制化方式找出大 Key,该工具能够对 Redis 的 RDB 文件进行定制化的分析,但由于分析 RDB 文件为离线工作,因此对线上服务不会有任何影响,这是它的最大优点但同时也是它的最大缺点:离线分析代表着分析结果的较差时效性。对于一个较大的 RDB 文件,它的分析可能会持续很久很久。
redis-rdb-tools 的项目地址为:https://github.com/sripathikrishnan/redis-rdb-tools
例如对象为 {"userName":"京东到家","ciyt":"北京"},如果只需要用到 userName 属性,那就定义新对象,只具有 userName 属性,精简缓存中数据
import org.codehaus.jackson.annotate.JsonProperty;
import org.codehaus.jackson.map.ObjectMapper;
import java.io.IOException;
public class JsonTest {
@JsonProperty("u")
private String userName;
public String getUserName() {
return userName;
}
public void setUserName(String userName) {
this.userName = userName;
}
public static void main(String[] args) throws IOException {
JsonTest output = new JsonTest();
output.setUserName("京东到家");
System.out.println(new ObjectMapper().writeValueAsString(output));
String json = "{\"u\":\"京东到家\"}";
JsonTest r1 = new ObjectMapper().readValue(json, JsonTest.class);
System.out.println(r1.getUserName());
}
}
{"u":"京东到家"}
京东到家
此命令在 Redis 不同版本中删除的机制并不相同,以下分别进行分析:
redis_version < 4.0 版本:在主线程中同步删除,删除大 Key 会阻塞主线程,见如下源码基于 redis 3.0 版本。那针对非 String 结构数据,可以先通过 SCAN 命令读取部分数据,然后逐步进行删除,避免一次性删除大 key 导致 Redis 阻塞。
// 从数据库中删除给定的键,键的值,以及键的过期时间。
// 删除成功返回 1,因为键不存在而导致删除失败时,返回 0
int dbDelete(redisDb *db, robj *key) {
// 删除键的过期时间
if (dictSize(db->expires) > 0) dictDelete(db->expires,key->ptr);
// 删除键值对
if (dictDelete(db->dict,key->ptr) == DICT_OK) {
// 如果开启了集群模式,那么从槽中删除给定的键
if (server.cluster_enabled) slotToKeyDel(key);
return 1;
} else {
// 键不存在
return 0;
}
}
4.0 版本 < redis_version < 6.0 版本:引入 lazy-free,手动开启 lazy-free 时,有 4 个选项可以控制,分别对应不同场景下,是否开启异步释放内存机制:
lazyfree-lazy-expire:key 在过期删除时尝试异步释放内存
lazyfree-lazy-eviction:内存达到 maxmemory 并设置了淘汰策略时尝试异步释放内存
lazyfree-lazy-server-del:执行 RENAME/MOVE 等命令或需要覆盖一个 key 时,删除旧 key 尝试异步释放内存
replica-lazy-flush:主从全量同步,从库清空数据库时异步释放内存
public void scanRedis(String cursor,String endCursor) {
ReloadableJimClientFactory factory = new ReloadableJimClientFactory();
String jimUrl = "jim://xxx/546";
factory.setJimUrl(jimUrl);
Cluster client = factory.getClient();
ScanOptions.ScanOptionsBuilder scanOptions = ScanOptions.scanOptions();
scanOptions.count(100);
Boolean end = false;
int k = 0;
while (!end) {
KeyScanResult< String > result = client.scan(cursor, scanOptions.build());
for (String key :result.getResult()){
if (client.ttl(key) == -1){
logger.info("永久key为:{}" , key);
}
}
k++;
cursor = result.getCursor();
if (endCursor.equals(cursor)){
break;
}
}
}
del 与 unlink 命令底层都调用了 delGenericCommand () 方法;
void delCommand(client *c) {
delGenericCommand(c,server.lazyfree_lazy_user_del);
}
void unlinkCommand(client *c) {
delGenericCommand(c,1);
}
lazyfree-lazy-user-del 支持 yes 或者 no。默认是 no;
如果设置为 yes,那么 del 命令就等价于 unlink,也是异步删除,这也同时解释了之前咱们的问题,为什么设置了 lazyfree-lazy-user-del 后,del 命令就为异步删除。
void delGenericCommand(client *c, int lazy) {
int numdel = 0, j;
// 遍历所有输入键
for (j = 1; j < c->argc; j++) {
// 先删除过期的键
expireIfNeeded(c->db,c->argv[j],0);
int deleted = lazy ? dbAsyncDelete(c->db,c->argv[j]) :
dbSyncDelete(c->db,c->argv[j]);
// 尝试删除键
if (deleted) {
// 删除键成功,发送通知
signalModifiedKey(c,c->db,c->argv[j]);
notifyKeyspaceEvent(NOTIFY_GENERIC,"del",c->argv[j],c->db->id);
server.dirty++;
// 成功删除才增加 deleted 计数器的值
numdel++;
}
}
// 返回被删除键的数量
addReplyLongLong(c,numdel);
}
下面分析异步删除 dbAsyncDelete () 与同步删除 dbSyncDelete (),底层同时也是调用 dbGenericDelete () 方法
int dbSyncDelete(redisDb *db, robj *key) {
return dbGenericDelete(db, key, 0, DB_FLAG_KEY_DELETED);
}
int dbAsyncDelete(redisDb *db, robj *key) {
return dbGenericDelete(db, key, 1, DB_FLAG_KEY_DELETED);
}
int dbGenericDelete(redisDb *db, robj *key, int async, int flags) {
dictEntry **plink;
int table;
dictEntry *de = dictTwoPhaseUnlinkFind(db->dict,key->ptr,&plink,&table);
if (de) {
robj *val = dictGetVal(de);
/* RM_StringDMA may call dbUnshareStringValue which may free val, so we need to incr to retain val */
incrRefCount(val);
/* Tells the module that the key has been unlinked from the database. */
moduleNotifyKeyUnlink(key,val,db->id,flags);
/* We want to try to unblock any module clients or clients using a blocking XREADGROUP */
signalDeletedKeyAsReady(db,key,val->type);
// 在调用用freeObjAsync之前,我们应该先调用decrRefCount。否则,引用计数可能大于1,导致freeObjAsync无法正常工作。
decrRefCount(val);
// 如果是异步删除,则会调用 freeObjAsync 异步释放 value 占用的内存。同时,将 key 对应的 value 设置为 NULL。
if (async) {
/* Because of dbUnshareStringValue, the val in de may change. */
freeObjAsync(key, dictGetVal(de), db->id);
dictSetVal(db->dict, de, NULL);
}
// 如果是集群模式,还会更新对应 slot 的相关信息
if (server.cluster_enabled) slotToKeyDelEntry(de, db);
/* Deleting an entry from the expires dict will not free the sds of the key, because it is shared with the main dictionary. */
if (dictSize(db->expires) > 0) dictDelete(db->expires,key->ptr);
// 释放内存
dictTwoPhaseUnlinkFree(db->dict,de,plink,table);
return 1;
} else {
return 0;
}
}
如果为异步删除,调用 freeObjAsync () 方法,根据以下代码分析:
#define LAZYFREE_THRESHOLD 64
/* Free an object, if the object is huge enough, free it in async way. */
void freeObjAsync(robj *key, robj *obj, int dbid) {
size_t free_effort = lazyfreeGetFreeEffort(key,obj,dbid);
if (free_effort > LAZYFREE_THRESHOLD && obj->refcount == 1) {
atomicIncr(lazyfree_objects,1);
bioCreateLazyFreeJob(lazyfreeFreeObject,1,obj);
} else {
decrRefCount(obj);
}
}
size_t lazyfreeGetFreeEffort(robj *key, robj *obj, int dbid) {
if (obj->type == OBJ_LIST && obj->encoding == OBJ_ENCODING_QUICKLIST) {
quicklist *ql = obj->ptr;
return ql->len;
} else if (obj->type == OBJ_SET && obj->encoding == OBJ_ENCODING_HT) {
dict *ht = obj->ptr;
return dictSize(ht);
} else if (obj->type == OBJ_ZSET && obj->encoding == OBJ_ENCODING_SKIPLIST){
zset *zs = obj->ptr;
return zs->zsl->length;
} else if (obj->type == OBJ_HASH && obj->encoding == OBJ_ENCODING_HT) {
dict *ht = obj->ptr;
return dictSize(ht);
} else if (obj->type == OBJ_STREAM) {
...
return effort;
} else if (obj->type == OBJ_MODULE) {
size_t effort = moduleGetFreeEffort(key, obj, dbid);
/* If the module's free_effort returns 0, we will use asynchronous free
* memory by default. */
return effort == 0 ? ULONG_MAX : effort;
} else {
return 1; /* Everything else is a single allocation. */
}
}
分析后咱们可以得出如下结论:
当 Hash/Set 底层采用哈希表存储(非 ziplist/int 编码存储)时,并且元素数量超过 64 个
当 ZSet 底层采用跳表存储(非 ziplist 编码存储)时,并且元素数量超过 64 个
当 List 链表节点数量超过 64 个(注意,不是元素数量,而是链表节点的数量,List 的实现是在每个节点包含了若干个元素的数据,这些元素采用 ziplist 存储)
refcount == 1 就是在没有引用这个 Key 时
String 类型的大 Key:可以尝试将对象分拆成几个 Key-Value, 使用 MGET 或者多个 GET 组成的 pipeline 获取值,分拆单次操作的压力,对于集群来说可以将操作压力平摊到多个分片上,降低对单个分片的影响。
集合类型的大 Key,并且需要整存整取要在设计上严格禁止这种场景的出现,如无法拆分,有效的方法是将该大 Key 从 JIMDB 去除,单独放到其他存储介质上。
集合类型的大 Key,每次只需操作部分元素:将集合类型中的元素分拆。以 Hash 类型为例,可以在客户端定义一个分拆 Key 的数量 N,每次对 HGET 和 HSET 操作的 field 计算哈希值并取模 N,确定该 field 落在哪个 Key 上。
存储结构拆分成两种:
第一种:
userPin:storeId的集合
第二种:
userPin_storeId1:{门店下加车的所有商品基本信息};
userPin_storeId2:{门店下加车的所有商品基本信息}
以上介绍了大 key 的产生、识别、处理,以及如何使用合理策略和技术来应对。在使用 Redis 过程中,防范大于治理,在治理过程中也要做到业务无感。
END