Springboot项目如何清理Redis业务key
最近在做项目的时候,遇到了一个让人头疼的问题,今天就来和大家分享一下我是怎么解决的,希望能帮到在Springboot项目中也面临同样困扰的小伙伴们。
一、问题的出现
云服务运维工程师突然找到我,说老系统里有个服务连接Redis集群实例时出状况了,还给我发了截图。原来是使用keys
命令,结果导致Redis实例直接夯住。我一开始有点懵,同事说在某个服务里,我去找了半天,啥也没发现。后来仔细琢磨,又看了运维给的图,终于想到了办法去找出对应的应用进程,下面就详细讲讲排查和解决的过程。
二、锁定问题进程
从运维提供的截图能看到,Redis集群的服务端口是9000,客户端连接分配的本地通信端口是39720。这里要给大家解释一下,本地端口只是客户端和Redis通信时,操作系统临时分配的标识,对连接功能没影响。知道了这些端口信息,我们就能用netstat
命令来查找对应的应用进程啦。
- 用netstat查找进程:登录到应用部署的服务器,执行下面这条
netstat
命令:
netstat -anlp |grep 9000 |grep EST |grep 39720
执行完后,就能发现这个进程是个Java进程,进程号是15817。这一步就像是在一堆文件里找到了关键的那一份,让我们离问题根源又近了一步。
2. 用jps查看应用名称:进程号找到了,还得知道这个Java进程对应的应用名称才行。这时候就用到jps
命令啦,执行下面这条命令:
jps -l |grep 15817
通过这个命令,我们就能看到这个进程对应的应用是zh1 - perso.jar
。到这里,我们已经成功锁定了出现问题的应用。
三、剖析问题代码及原因
找到了对应的应用,接下来就要深挖问题代码,看看为什么用keys
命令会出问题。
- 查找问题代码:根据前面找到的应用,在代码里通过Redis中的
YZ_MULTI_DIAG
这个关键词去搜索,还真找到了一段使用keys
命令的代码:
private void cleanCache(String toUserId) { Set<String> keys = stringRedisTemplate.keys("YZ_MULTI_DIAG:" + toUserId + "*"); stringRedisTemplate.delete(keys); }
- 原因分析:
keys
命令在Redis里就像是个“大扫荡”命令,它会把所有的键都遍历一遍。特别是当Redis里存的数据量很大的时候,这个操作就很容易让Redis实例卡住或者响应变得很慢。具体原因有下面这几点:- 阻塞和性能影响:不管Redis数据库里有多少个键,
keys
命令都得一个个去检查,这会消耗大量的CPU和内存资源。要是键的数量特别多,Redis就会被这个命令“拖后腿”,一直忙着执行它,其他客户端的请求就没办法处理了,这样就会导致延迟,甚至服务直接中断。 - 不适合生产环境:在生产环境中,大量键值对的情况下,
keys
命令可不是个好选择。它的性能和数据库里键的数量密切相关,键越多,执行花费的时间就越长,系统负载也就越重。相比之下,scan
命令就聪明多了,它采用增量式的方式,不会一下子把所有匹配的键都返回,而是分多次逐步获取,这样Redis在扫描键的时候就不会被完全堵住。 - 影响其他客户端请求:
keys
命令要扫描整个键空间,这会占用Redis实例的CPU和内存资源,其他客户端请求的响应时间就会被拉长,严重的话还会阻塞其他操作,让整个Redis实例的性能大打折扣。要是在Redis集群环境里用keys
命令,那它会把集群的每个节点都扫描一遍,对整个集群的性能影响可就更大了。
- 阻塞和性能影响:不管Redis数据库里有多少个键,
四、优化方案与代码实现
既然知道了问题所在,那肯定得想办法解决。优化的关键就是用scan
命令替代keys
命令,同时尽量用特定的键模式来缩小扫描范围,避免扫描整个数据库,还要注意在生产环境高负载的时候别用keys
命令。优化后的代码如下,它利用了类似分页的概念来实现批量删除:
private void cleanCache(String toUserId) { // 定义要匹配的键模式 String pattern = "YZ_MULTI_DIAG:" + toUserId + "*"; // 设置扫描选项,匹配模式并指定每次返回100个结果 ScanOptions scanOptions = ScanOptions.scanOptions().match(pattern).count(100).build(); stringRedisTemplate.execute((RedisCallback<Void>) connection -> { // 初始化游标为0 String cursor = "0"; try { do { // 使用SCAN命令分页获取匹配的键 Cursor<byte[]> scanCursor = connection.scan(scanOptions); List<byte[]> keysToDelete = new ArrayList<>(); while (scanCursor.hasNext()) { keysToDelete.add(scanCursor.next()); // 当收集到100个键时,进行批量删除,避免内存占用过高 if (keysToDelete.size() >= 100) { connection.del(keysToDelete.toArray(new byte[0][])); keysToDelete.clear(); } } // 处理剩余不足100个的键 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; }); }
五、测试验证优化效果
代码改好了,到底有没有用呢?还得测试一下才行。
- 编写测试类:新增一个测试类,代码如下。这个测试类会先新增100个键,然后按照每个批次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 { // 定义测试用的键前缀 private final static String TEST_KEY = "TEST_KEY:"; // 定义业务相关的键标识 private final static String BIZ_KEY = "userId"; @Resource private StringRedisTemplate stringRedisTemplate; @Test public void addCache() { // 循环新增100个键值对 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 { 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)); } catch (Exception e) { log.error("Error while scanning and deleting Redis keys with pattern: {}", pattern, e); } return null; }); } }
- 测试新增:执行新增测试方法后,发现新增成功啦,能看到数据库里多了100个以
TEST_KEY
开头的键。 - 测试批量删除:接着执行批量删除方法,再去检查数据库,发现那100个
TEST_KEY
都被成功清除了。这就说明我们的优化是有效的!
经过这一系列操作,总算是把Springboot项目中Redis业务键清理的问题解决了。希望大家在遇到类似问题的时候,也能像这样一步步排查、解决。要是还有什么疑问,欢迎在评论区留言交流哦!