在Redis中,一个字符串最大512MB,一个二级数据结构(例如hash、list、set、zset)可以存储大约40亿个(2^32-1)个元素,但实际上中如果下面两种情况,我就会认为它是bigkey。 Redis中的大key一直是重点需要优化的对象,big key既占用比较多的内存,也可能占用比较多的网卡资源,造成redis阻塞,因此我们需要找到这些big key进行优化
1.超时阻塞(慢查询)
由于Redis单线程的特性,操作bigkey的通常比较耗时,也就意味着阻塞Redis可能性越大,这样会造成客户端阻塞或者引起故障切换,它们通常出现在慢查询中。
2.网络拥塞
bigkey也就意味着每次获取要产生的网络流量较大,假设一个bigkey为1MB,客户端每秒访问量为1000,那么每秒产生1000MB的流量,对于普通的千兆网卡(按照字节算是128MB/s)的服务器是不堪重负的。
3.过期删除阻塞
有个bigkey,它安分守己(只执行简单的命令,例如hget、lpop、zscore等),但它设置了过期时间,当它过期后,会被删除,如果没有使用Redis 4.0的过期异步删除(lazyfree-lazy-expire yes),就会存在阻塞Redis的可能性,而且这个过期删除不会从主节点的慢查询发现(因为这个删除不是客户端产生的,是内部循环事件,可以从latency命令中获取或者从slave节点慢查询发现)。
4.迁移困难(迁移中阻塞)
Redis 部署方式为 redis cluster的并迁移slot,当实际上是通过migrate命令来完成的,migrate实际上是通过dump + restore + del三个命令组合成原子命令完成,如果是bigkey,可能会使迁移失败,而且较慢的migrate会阻塞Redis。
5.内存空间不均匀
在 Redis cluster集群中,会造成节点的内存使用不均匀。存在丢失数据的隐患。
总结:
- bigkey 的读写操作会阻塞线程,降低 Redis 的处理效率
- 在 Redis 基本 IO 模型中,主要是主线程在执行操作,任何耗时的操作,例如 bigkey、全量返回等操作,都是潜在的性能瓶颈。
- AOF 重写过程中:主进程 fork 出后台的子进程会阻塞住子进程,阻塞时间取决于整个实例的内存大小。当主线程收到新写或修改的操作时,主线程会申请新的内存空间,用来保存新写或修改的数据,如果操作的是 bigkey,也就是数据量大的集合类型数据,那么主线程会因为申请大空间而面临阻塞风险。因为操作系统在分配内存空间时,有查找和锁的开销,这就会导致阻塞。
一般来说,bigkey的产生都是由于程序设计不当,或者对于数据规模预料不清楚造成的,来看几个例子:
- 社交类:粉丝列表,如果某些明星的粉丝数据,如果不精心设计下,一个明星的粉丝 百万很少了吧,你都把这百万的>粉丝数据放到一个key中存储,毫无疑问是bigkey
- 统计类:比如按天存储某项功能或者网站的用户集合,用户很少,倒是没多大问题,一旦用户多了起来,必是bigkey
- 缓存类:将数据从数据库加载出来以后序列化放到Redis里,这个方式非常常用,但有两个地方需要注意,
第一,是不>是有必要把所有字段都缓存;
第二,有没有相关关联的数据,不要为了图方便把相关数据都存一个key下,产生bigkey。
- 使用 redis-cli 客户端的命令 --bigkeys
- 生成 rdb 文件,离线分析 rdb 文件。比如:redis-rdb-cli,rdbtools;
- 通过 scan 命令,对扫描出来的key进行类型判断,例如:string长度大于10K,list长度大于10240认为是big bigkeys
核心思想: 分治 拆分
- 拆
big list: list1、list2、…listN
big hash:可以讲数据分段存储,比如一个大的key,假设存了1百万的用户数据,可以拆分成 200个key,每个key下面存放5000个用户数据- 控制key的生命周期
建议使用expire设置过期时间(条件允许可以打散过期时间,防止集中过期)。- 如果无法避免使用BigKey
可以使用其他的存储形式,文档性数据库 MongoDB。
对于非字符串的bigkey,比如 hash list set zset , 不要使用del 删除, 请使用 hscan 、sscan、zscan方式渐进式删除。
同时要注意防止bigkey过期时间自动删除问题(例如一个100万的hash设置1小时过期,会触发del操作,造成阻塞)
del bigkey
public void delBigHash(String bigKey) {
Jedis jedis = new Jedis("127.0.0.1", 6379);
// 游标
String cursor = "0";
while(true) {
ScanResult<Map.Entry<String, String>> scanResult = jedis.hscan(bigKey, cursor, new ScanParams().count(100));
// 每次扫描后获取新的游标
cursor = scanResult.getStringCursor(); // 获取扫描结果
List<Entry<String, String>> list = scanResult.getResult(); if(list == null || list.size() == 0) {
continue; } String[] fields = getFieldsFrom(list); // 删除多个field
jedis.hdel(bigKey, fields); // 游标为0时停止
if(cursor.equals("0")) {
break;
} } // 最终删除key
jedis.del(bigKey);
}
/**
* 获取field数组 */
private String[] getFieldsFrom(List<Entry<String, String>> list) {
List<String> fields = new ArrayList<String>();
for (Entry<String, String> entry : list) {
fields.add(entry.getKey());
}
return fields.toArray(new String[fields.size()]);
3.list
Redis并没有提供lscan这样的API来遍历列表类型,但是提供了ltrim这样的命令可以渐进式的删除列表元素,直到把列表删除。
public void delBigList(String bigKey) {
Jedis jedis = new Jedis("127.0.0.1", 6379);
long llen = jedis.llen(bigKey);
int counter = 0;
int left = 100;
while(counter < llen) {
// 每次从左侧截掉100个
jedis.ltrim(bigKey, left, llen);
counter += left;
}
// 最终删除key
jedis.del(bigKey);
}
public void delBigSet(String bigKey) {
Jedis jedis = new Jedis("127.0.0.1", 6379);
// 游标
String cursor = "0";
while(true) {
ScanResult<String> scanResult = jedis.sscan(bigKey, cursor, new ScanParams().count(100));
// 每次扫描后获取新的游标
cursor = scanResult.getStringCursor(); // 获取扫描结果
List<String> list = scanResult.getResult(); if(list == null || list.size() == 0) {
continue;
} jedis.srem(bigKey, list.toArray(new String[list.size()]));
// 游标为0时停止
if(cursor.equals("0")) {
break;
} } // 最终删除key
jedis.del(bigKey);}
public void delBigSortedSet(String bigKey) {
long startTime = System.currentTimeMillis(); Jedis jedis = new Jedis(HOST, PORT); // 游标
String cursor = "0";
while(true) {
ScanResult<Tuple> scanResult = jedis.zscan(bigKey, cursor, new ScanParams().count(100));
// 每次扫描后获取新的游标
cursor = scanResult.getStringCursor(); // 获取扫描结果
List<Tuple> list = scanResult.getResult(); if(list == null || list.size() == 0) {
continue; } String[] members = getMembers(list); jedis.zrem(bigKey, members); // 游标为0时停止
if(cursor.equals("0")) {
break;
} } // 最终删除key
jedis.del(bigKey);
}
public void delBigSortedSet2(String bigKey) {
Jedis jedis = new Jedis(HOST, PORT);
long zcard = jedis.zcard(bigKey);
int counter = 0;
int incr = 100;
while(counter < zcard) {
jedis.zremrangeByRank(bigKey, 0, 100);
// 每次从左侧截掉100个
counter += incr;
}
// 最终删除key
jedis.del(bigKey);
}
(1)【建议】: 可读性和可管理性
以业务名(或数据库名)为前缀(防止key冲突),用冒号分隔,比如业务名:表名:id
o2o:order:1
(2)【建议】:简洁性
保证语义的前提下,控制key的长度,当key较多时,内存占用也不容忽视,例如:
user:{uid}:friends:messages:{mid} 简化为 u:{uid}m:{mid}
(3)【强制】:不要包含特殊字符
反例:包含空格、换行、单双引号以及其他转义字符