Springboot项目如何优雅高效的清除Redis中的业务key
1、问题背景
云服务运维工程师联系我说老系统有个服务连接redis集群实例使用keys命令导致实例夯住了并给我截了个图。然后刚开始我是挺懵逼的,同事跟我说在某个服务中,我去找了找压根没有,后来我仔细想了想,并看了运维老师提供的图,我想到了方法找对应的应用进程。以下是排查及解决过程。
2、如何找到对应的应用进程
根据下面的图,我们可以看到,redis集群的服务端口为9000,客户端连接分配的客户端本地通信端口【本地端口只是一个临时标识,用于客户端与 Redis 之间的通信,通常是由操作系统在每次创建新连接时自动分配的,并不会影响连接的实际功能。】为39720,那么我们就可以通过netstat命令来查找对应的应用进程了。
2.1、使用netstat查找进程
进入应用部署的服务器,使用如下netstat命令查找进程,如下图,从下图我们可以看出,进程是个java进程,进程号为15817
netstat -anlp |grep 9000 |grep EST |grep 39720
2.2、使用jps命令查看应用名称
使用jps命令查看java进程对应的应用名称,通过命令我们可以看出
jps -l |grep 15817
3、问题代码及原因分析
3.1、查找问题代码
根据步骤2我们找到了对应的应用,下面我们就可以通过redis中的key关键词YZ_MULTI_DIAG搜索代码了,然后找到了如下图的代码,确实使用了keys命令。
private void cleanCache(String toUserId) {
Set<String> keys = stringRedisTemplate.keys("YZ_MULTI_DIAG:" + toUserId + "*");
stringRedisTemplate.delete(keys);
}
3.2、原因分析
keys
命令在 Redis 中遍历所有的键,是一个阻塞操作,尤其是当 Redis 数据量大时,可能会导致 Redis 实例卡住或响应变慢。在 Redis 中,keys
命令用于查找与给定模式匹配的所有键,它会扫描整个数据库,并返回符合条件的所有键。这个命令在某些情况下会导致 Redis 实例“夯住”或变得非常缓慢,原因如下:
3.2.1、 阻塞和性能影响
keys
命令需要遍历 Redis 实例中所有的键,无论数据库中有多少个键。对于存储大量键的 Redis 实例来说,keys
命令会消耗大量的 CPU 和内存资源,因为它必须检查每个键,并将结果返回给客户端。- 如果有大量的键,
keys
命令可能会导致 Redis 被阻塞,直到命令完成执行。在此期间,Redis 无法处理其他客户端请求,这可能会导致延迟或服务中断。
3.2.2、 不适合生产环境
- 在生产环境中,通常不建议使用
keys
命令,特别是在有大量键值对的情况下。keys
命令的性能是 O(N),其中 N 是数据库中键的数量。这意味着数据库中键越多,执行时间就越长,负载越重。 - 更适合使用
scan
命令,它是增量式的,并不会一次性返回所有匹配的键,而是通过多次迭代逐步获取。这使得 Redis 在扫描键时不会被完全阻塞。
3.2.3、 其他客户端请求的影响
- 由于
keys
命令会导致 Redis 扫描整个键空间,它会占用 Redis 实例的 CPU 和内存资源,这可能导致其他客户端请求的响应时间延迟,甚至阻塞其他操作,导致整个 Redis 实例性能下降。 - 在 Redis 集群环境中,
keys
命令会对集群的每个节点进行全局扫描,可能会对整个集群的性能产生影响。
4、优化方案
- 使用
scan
命令替代keys
命令。scan
命令是增量的,可以分批次扫描键,避免一次性操作导致的阻塞。 - 如果需要列出键,尽量使用特定的键模式(例如,前缀)来限制扫描的范围,避免扫描整个数据库。
- 在生产环境中,应该避免在高负载期间使用
keys
命令。
优化后的代码如下,使用类似分页概念进行批量删除。
private void cleanCache(String toUserId) {
String pattern = "YZ_MULTI_DIAG:" + toUserId + "*";
ScanOptions scanOptions = ScanOptions.scanOptions().match(pattern).count(100).build();
stringRedisTemplate.execute((RedisCallback<Void>) connection -> {
String cursor = "0"; // 初始游标
try {
do {
// 使用SCAN命令分页获取匹配的键
Cursor<byte[]> scanCursor = connection.scan(scanOptions);
List<byte[]> keysToDelete = new ArrayList<>();
while (scanCursor.hasNext()) {
keysToDelete.add(scanCursor.next());
// 分批删除,避免内存占用过高
if (keysToDelete.size() >= 100) {
connection.del(keysToDelete.toArray(new byte[0][]));
keysToDelete.clear();
}
}
// 删除剩余的键
if (!keysToDelete.isEmpty()) {
connection.del(keysToDelete.toArray(new byte[0][]));
}
cursor = scanCursor.getCursorId() + ""; // 更新游标
} while (!"0".equals(cursor)); // 如果游标为0,表示扫描结束
} catch (Exception e) {
log.error("Error while scanning and deleting Redis keys with pattern: {}", pattern, e);
}
return null;
});
}
5、测试验证
5.1、编写测试类
新增测试类,代码如下,新增100个key,然后按照每个批次10个进行删除测试,代码如下
package com.jianjang.zhgl.person.service.impl;
import lombok.extern.slf4j.Slf4j;
import org.junit.jupiter.api.Test;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.data.redis.core.Cursor;
import org.springframework.data.redis.core.RedisCallback;
import org.springframework.data.redis.core.ScanOptions;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.test.context.ActiveProfiles;
import javax.annotation.Resource;
import java.util.ArrayList;
import java.util.List;
/**
* @program: zhgl_server
* @description: 缓存清理测试类
* @author: Jian Jang
* @create: 2025-05-06 11:25:51
* @blame ZHSF Team
*/
@Slf4j
@ActiveProfiles("local")
@SpringBootTest
public class RedisCleanCacheTest {
/**
* 测试key
*/
private final static String TEST_KEY = "TEST_KEY:";
private final static String BIZ_KEY = "userId";
@Resource
private StringRedisTemplate stringRedisTemplate;
@Test
public void addCache() {
for (int i = 0; i < 100; i++) {
stringRedisTemplate.opsForValue().set(TEST_KEY+BIZ_KEY+i, "value" + i);
}
}
@Test
public void cleanCache() {
cleanCache(BIZ_KEY, 10);
}
/**
* 清除缓存内容
*
* @param redisKey
* @param batchSize
*/
private void cleanCache(String redisKey, int batchSize) {
String pattern = TEST_KEY + redisKey + "*";
ScanOptions scanOptions = ScanOptions.scanOptions().match(pattern).count(batchSize).build();
stringRedisTemplate.execute((RedisCallback<Void>) connection -> {
String cursor = "0"; // 初始游标
try {
do {
// 使用SCAN命令分页获取匹配的键
Cursor<byte[]> scanCursor = connection.scan(scanOptions);
List<byte[]> keysToDelete = new ArrayList<>();
while (scanCursor.hasNext()) {
keysToDelete.add(scanCursor.next());
// 分批删除,避免内存占用过高
if (keysToDelete.size() >= batchSize) {
connection.del(keysToDelete.toArray(new byte[0][]));
keysToDelete.clear();
}
}
// 删除剩余的键
if (!keysToDelete.isEmpty()) {
connection.del(keysToDelete.toArray(new byte[0][]));
}
cursor = scanCursor.getCursorId() + ""; // 更新游标
} while (!"0".equals(cursor)); // 如果游标为0,表示扫描结束
} catch (Exception e) {
log.error("Error while scanning and deleting Redis keys with pattern: {}", pattern, e);
}
return null;
});
}
}
5.2、测试新增
执行新增测试方法后,新增成功,如下图,
5.3、测试批量删除
执行批量删除方法后,删除成功,如下图,100个TEST_KEY已被清除。