Bigkey 是指当 Redis 的字符串类型过大,非字符串类型元素过多。

危害

| 内存空间不均匀(平衡)

例如在 Redis Cluster 中,大量 bigkey 落在其中一个 Redis 节点上,会造成该节点的内存空间使用率比其他节点高,造成内存空间使用不均匀。

| 请求倾斜

对于非字符串类型的 bigkey 的请求,由于其元素较多,很可能对于这些元素的请求都落在 Redis cluster 的同一个节点上,造成请求不均匀,压力过大。

| 超时阻塞

由于 Redis 单线程的特性,操作 bigkey 比较耗时,也就意味着阻塞 Redis 可能性增大。这就是造成生产事故的罪魁祸首!导致 Redis 间歇性卡死、影响线上正常下单!

| 网络拥塞

每次获取 bigkey 产生的网络流量较大,假设一个 bigkey 为 1MB,每秒访问量为 1000,那么每秒产生 1000MB 的流量,对于普通的千兆网卡(按照字节算是 128MB/s)的服务器来说简直是灭顶之灾。

而且一般服务器会采用单机多实例的方式来部署,也就是说一个 bigkey 可能会对其他实例造成影响,其后果不堪设想。

| 过期删除

有个 bigkey,它安分守己(只执行简单的命令,例如 hget、lpop、zscore 等),但它设置了过期时间,当它过期后,会被删除,如果没有使用 Redis 4.0 的过期异步删除(lazyfree-lazy-expire yes),就会存在阻塞 Redis 的可能性。

排查

查看bigkeys,

对于 String 类型来说,会输出最大 bigkey 的字节长度,对于集合类型来说,会输出最大 bigkey 的元素个数

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
$ redis -p 6666 --bigkeys -a <pass>
Warning: Using a password with '-a' or '-u' option on the command line interface may not be safe.

# 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.00%] Biggest string found so far 'SP-TENANT:1xxxxxxxxx' with 139 bytes
[00.00%] Biggest hash found so far 'idempotent' with 1 fields
[76.92%] Biggest zset found so far 'redisson__timeout__set:{idempotent}' with 1 members

-------- summary -------

Sampled 13 keys in the keyspace!
Total key length in bytes is 365 (avg len 28.08)

Biggest string found 'SP-TENANT:1xxxxxxxxx' has 139 bytes
Biggest hash found 'idempotent' has 1 fields
Biggest zset found 'redisson__timeout__set:{idempotent}' has 1 members

11 strings with 1411 bytes (84.62% of keys, avg size 128.27)
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)
1 hashs with 1 fields (07.69% of keys, avg size 1.00)
1 zsets with 1 members (07.69% of keys, avg size 1.00)
0 streams with 0 entries (00.00% of keys, avg size 0.00)

判断一个 key 是否为 bigkey,只需要执行 debug object key 查看 serializedlength 属性即可,它表示 key 对应的 value 序列化之后的字节数。

1
2
127.0.0.1:6666> debug object SP-MEMBER:1xxxxxxxxx
Value at:0x7f6a5901f3f0 refcount:1 encoding:raw serializedlength:126 lru:6454313 lru_seconds_idle:8

可以看到 encoding 是 raw,也就是字符串类型,那么可以通过 strlen 来看一下字符串的字节数

1
2
> strlen SP-MEMBER:1xxxxxxxxx
(integer) 139

解决

开启Lazy Free delete(被动删除,过期策略)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
## 在内存到达最大内存需要逐出数据时使用
## 建议关闭,避免内存未及时释放
lazyfree-lazy-eviction no

## 在KEY过期时使用
## 建议开启
lazyfree-lazy-expire no

## 隐式删除服务器数据时,如RENAME操作
## 建议开启
lazyfree-lazy-server-del no

## 在对从库进行全量数据同步时
## 建议关闭
slave-lazy-flush no

主动删除(转自石杉老师)

| 如何提升删除的效率

既然不能用 del 命令,那有没有比较优雅的方式进行删除呢?Redis 提供了一些和 scan 命令类似的命令:sscan、hscan、zscan。

①string

字符串删除一般不会造成阻塞:

1
del bigkey

②hash、list、set、sorted set

下面以 hash 为例子,使用 hscan 命令,每次获取部分(例如 100 个)fieldvalue,再利用 hdel 删除每个 field(为了快速可以使用 Pipeline):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
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数组
* @param list
* @return
*/
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()]);
}

请勿忘记每次执行到最后执行 del key 操作。

| 实战代码

①JedisCluster 示例:

1
2
3
4
5
6
7
8
9
10
/**
* 刪除 BIG key
* 应用场景:对于 big key,可以使用 hscan 首先分批次删除,最后统一删除
* (1)比直接删除的耗时变长,但是不会产生慢操作。
* (2)新业务实现尽可能拆开,不要依赖此方法。
* @param key key
* @param scanCount 单次扫描总数(建议值:100)
* @param intervalMills 分批次的等待时间(建议值:5)
*/
void removeBigKey(final String key, final int scanCount, final long intervalMills)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
实现:
JedisCluster jedisCluster = redisClusterTemplate.getJedisClusterInstance();
// 游标初始值为0
String cursor = ScanParams.SCAN_POINTER_START;
ScanParams scanParams = new ScanParams();
scanParams.count(scanCount);
while (true) {
// 每次扫描后获取新的游标
ScanResult<Map.Entry<String, String>> scanResult = jedisCluster.hscan(key, cursor, scanParams);
cursor = scanResult.getStringCursor();
// 获取扫描结果为空
List<Map.Entry<String, String>> list = scanResult.getResult();
if (CollectionUtils.isEmpty(list)) {
break;
}
// 构建多个删除的 key
String[] fields = getFieldsKeyArray(list);
jedisCluster.hdel(key, fields);
// 游标为0时停止
if (ScanParams.SCAN_POINTER_START.equals(cursor)) {
break;
}
// 沉睡等待,避免对 redis 压力太大
DateUtil.sleepInterval(intervalMills, TimeUnit.MILLISECONDS);
}
// 执行 key 本身的删除
jedisCluster.del(key);

构建的 key:

1
2
3
4
5
6
7
8
9
10
11
12
/**
* 获取对应的 keys 信息
* @param list 列表
* @return 结果
*/
private String[] getFieldsKeyArray(List<Map.Entry<String, String>> list) {
String[] strings = new String[list.size()];
for(int i = 0; i < list.size(); i++) {
strings[i] = list.get(i).getKey();
}
return strings;
}

①redisTemplate 的写法

估计是 redis 进行了一次封装,发现还是存在很多坑。

语法如下:

1
2
3
4
5
6
7
8
9
10
11
12
/**
* 获取集合的游标。通过游标可以遍历整个集合。
* ScanOptions 这个类中使用了构造者 工厂方法 单例。 通过它可以配置返回的元素
* 个数 count 与正则匹配元素 match. 不过count设置后不代表一定返回的就是count个。这个只是参考
* 意义
*
* @param key
* @param options
* @return
* @since 1.4
*/
Cursor<V> scan(K key, ScanOptions options);

②注意的坑

实际上这个方法存在很多需要注意的坑:

  • cursor 要关闭,否则会内存泄漏
  • cursor 不要重复关闭,或者会报错
  • cursor 经测试,直接指定的 count 设置后,返回的结果其实是全部,所以需要自己额外处理

参考代码如下:

声明 StringRedisTemplate:

1
2
@Autowired
private StringRedisTemplate template;

核心代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
public void removeBigKey(String key, int scanCount, long intervalMills) throws CacheException {
final ScanOptions scanOptions = ScanOptions.scanOptions().count(scanCount).build();
//TRW 避免内存泄漏
try(Cursor<Map.Entry<Object,Object>> cursor =
template.opsForHash().scan(key, scanOptions)) {
if(ObjectUtil.isNotNull(cursor)) {
// 执行循环删除
List<String> fieldKeyList = new ArrayList<>();
while (cursor.hasNext()) {
String fieldKey = String.valueOf(cursor.next().getKey());
fieldKeyList.add(fieldKey);
if(fieldKeyList.size() >= scanCount) {
// 批量删除
Object[] fields = fieldKeyList.toArray();
template.opsForHash().delete(key, fields);
logger.info("[Big key] remove key: {}, fields size: {}",
key, fields.length);
// 清空列表,重置操作
fieldKeyList.clear();
// 沉睡等待,避免对 redis 压力太大
DateUtil.sleepInterval(intervalMills, TimeUnit.MILLISECONDS);
}
}
}
// 最后 fieldKeyList 中可能还有剩余,不过一般数量不大,直接删除速度不会很慢
// 执行 key 本身的删除
this.opsForValueDelete(key);
} catch (Exception e) {
// log.error();
}
}

这里我们使用 TRW 保证 cursor 被关闭,自己实现 scanCount 一次进行删除,避免一个一个删除网络交互较多。使用睡眠保证对 Redis 压力不要过大。