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已被清除。
6、命令行方案
6.1、redis-cli 结合 xargs(最常用、高效)
这是在 Linux/macOS 终端中最常用的批量删除方式,通过 redis-cli 的 keys 命令匹配目标 KEY,再通过 xargs 传递给 del 命令删除。
基础用法(匹配固定前缀 / 后缀)
# 批量删除以 "business:" 为前缀的所有 KEY(替换为你的业务前缀)
redis-cli keys "business:*" | xargs redis-cli del
# 如果 Redis 有密码/指定端口/指定主机,添加对应参数
# redis-cli -h 127.0.0.1 -p 6379 -a yourpassword keys "business:*" | xargs redis-cli -h 127.0.0.1 -p 6379 -a yourpassword del
# 批量删除包含 "order" 的所有 KEY
# redis-cli keys "*order*" | xargs redis-cli del
关键说明:
keys "business:*":匹配所有以business:开头的 KEY(*是通配符,?匹配单个字符,[]匹配指定字符集)。xargs:将前一个命令的输出(匹配到的 KEY 列表)作为参数传递给redis-cli del。- 注意:
keys命令在 Redis 数据量大时会阻塞主线程,建议在低峰期执行;生产环境优先用方法二。
6.2、使用 SCAN 命令(生产环境推荐,非阻塞)
SCAN 是渐进式遍历 KEY 的命令,不会阻塞 Redis 主线程,适合生产环境批量删除:
编写批量删除脚本(scan_del.sh)
#!/bin/bash
# 配置 Redis 连接信息
REDIS_HOST="127.0.0.1"
REDIS_PORT="6379"
REDIS_PASSWORD="yourpassword" # 无密码则注释此行
MATCH_PATTERN="business:*" # 替换为你的业务 KEY 匹配规则
# 初始化游标
cursor=0
while true; do
# 执行 SCAN 命令(无密码则去掉 -a $REDIS_PASSWORD)
result=$(redis-cli -h $REDIS_HOST -p $REDIS_PORT -a $REDIS_PASSWORD scan $cursor MATCH $MATCH_PATTERN COUNT 1000)
# 解析游标和 KEY 列表
new_cursor=$(echo $result | awk 'NR==1{print $1}')
keys=$(echo $result | awk 'NR==1{for(i=2;i<=NF;i++)print $i}')
# 如果有 KEY 则删除
if [ -n "$keys" ]; then
echo "Deleting keys: $keys"
echo "$keys" | xargs redis-cli -h $REDIS_HOST -p $REDIS_PORT -a $REDIS_PASSWORD del
fi
# 游标为 0 时结束循环
if [ "$new_cursor" = "0" ]; then
break
fi
cursor=$new_cursor
done
echo "Batch delete completed!"
使用步骤:
- 将脚本保存为
scan_del.sh; - 修改脚本中的
REDIS_HOST、REDIS_PORT、REDIS_PASSWORD、MATCH_PATTERN为实际值; - 赋予执行权限:
chmod +x scan_del.sh; - 执行脚本:
./scan_del.sh。
6.3、Redis 客户端交互模式(手动批量删除)
如果不想用脚本,也可以在 Redis 客户端中手动执行批量删除(适合少量 KEY):
# 1. 进入 Redis 客户端
redis-cli -h 127.0.0.1 -p 6379 -a yourpassword
# 2. 先查看匹配的 KEY(确认无误再删除)
keys "business:*"
# 3. 批量删除(将 KEY1 KEY2 KEY3 替换为实际匹配到的 KEY)
del KEY1 KEY2 KEY3
# 或者用 eval 执行 Lua 脚本(批量删除,避免 KEY 过多)
eval "return redis.call('del', unpack(redis.call('keys', ARGV[1])))" 0 "business:*"
6.4、 重要注意事项
- 先验证再删除:执行删除前,务必先用
keys/scan命令确认匹配的 KEY 列表,避免误删; - 生产环境慎用 keys:
keys命令会遍历所有 KEY,数据量大时会导致 Redis 阻塞,优先用SCAN; - 区分库名:如果 Redis 分库(如 db0、db1),确保在目标库中执行(默认 db0,可通过
select 1切换); - 集群环境适配:如果是 Redis 集群,
keys/scan命令只能匹配当前节点的 KEY,需在每个节点执行,或使用集群管理工具(如 redis-cli --cluster)。
6.5 总结
- 快速批量删除:测试 / 低数据量场景用
redis-cli keys "前缀:*" | xargs redis-cli del; - 生产环境安全删除:优先使用
SCAN命令的脚本(scan_del.sh),避免阻塞 Redis; - 核心原则:批量删除前必须先验证匹配的 KEY,生产环境禁用
keys命令,优先用非阻塞的SCAN。