Springboot项目中如何优雅高效的清除Redis中的业务key

254 阅读5分钟

Springboot项目如何优雅高效的清除Redis中的业务key

1、问题背景

云服务运维工程师联系我说老系统有个服务连接redis集群实例使用keys命令导致实例夯住了并给我截了个图。然后刚开始我是挺懵逼的,同事跟我说在某个服务中,我去找了找压根没有,后来我仔细想了想,并看了运维老师提供的图,我想到了方法找对应的应用进程。以下是排查及解决过程。

2、如何找到对应的应用进程

根据下面的图,我们可以看到,redis集群的服务端口为9000,客户端连接分配的客户端本地通信端口【本地端口只是一个临时标识,用于客户端与 Redis 之间的通信,通常是由操作系统在每次创建新连接时自动分配的,并不会影响连接的实际功能。】为39720,那么我们就可以通过netstat命令来查找对应的应用进程了。 img_v3_02m1_7dbeb973-0d4a-4971-bb9a-235102efbanh.jpg

2.1、使用netstat查找进程

进入应用部署的服务器,使用如下netstat命令查找进程,如下图,从下图我们可以看出,进程是个java进程,进程号为15817

netstat -anlp |grep 9000 |grep EST |grep 39720

image.png

2.2、使用jps命令查看应用名称

使用jps命令查看java进程对应的应用名称,通过命令我们可以看出

jps -l |grep 15817

image.png

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、测试新增

执行新增测试方法后,新增成功,如下图,

image.png

5.3、测试批量删除

执行批量删除方法后,删除成功,如下图,100个TEST_KEY已被清除。

image.png