学会这几个技巧让Redis大key问题远离你

1次阅读

共计 7207 个字符,预计需要花费 19 分钟才能阅读完成。

个推作为国内第三方推送市场的早期进入者,专注于为开发者提供高效稳定的推送服务,经过 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 工具

关于 rdb 工具的详细介绍请查看链接 https://github.com/sripathikr…,在此只介绍内存相关的使用方法。基本的命令为 rdb -c memory dump.rdb (其中 dump.rdb 为 Redis 实例的 rdb 文件,可通过 bgsave 生成)。

输出结果如下:
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).

[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 = commands.getoutput(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。

参考文档:
1、http://antirez.com/news/93
2、http://antirez.com/news/126

正文完
 0