事件的缘由:在一次线上数据访问过多的场景下出发了redis-cpu暴涨,经过排查发现是有大量的HSCAN,再经过一番业务的排查,发现是如下代码引起的
RMap<String, String> map = redissonClient.getMap(key);
map.entrySet().stream().map(x -> Integer.valueOf(x.getKey())).collect(Collectors.toList());
经过上述的源码分析发现Redisson 的 RMap 实现原理如下:
Redisson 是一个用于 Redis 的 Java 客户端,它提供了许多高级数据结构,例如 RMap。在调用 RMap.entrySet() 时,Redisson 需要遍历 Redis 中的 Hash 表(或类似结构)。由于 Redis 的 HGETALL 等命令无法处理非常大的数据集,Redisson 使用 HSCAN 来实现增量遍历。
Redisson RMap 的 entrySet 实现
以下是一个简化的 RMap.entrySet() 实现的示例:
public Set<Map.Entry<String, String>> entrySet() {
Set<Map.Entry<String, String>> entries = new HashSet<>();
int cursor = 0;
do {
MapScanResult<String, String> scanResult = scan(cursor, SCAN_COUNT);
entries.addAll(scanResult.getMap());
cursor = scanResult.getPos();
} while (cursor != 0);
return entries;
}
scan(int cursor, int count)
:一个内部方法,使用 Redis 的 HSCAN 命令来获取部分结果。
SCAN_COUNT
:每次扫描的条目数。
scan 方法示例
private MapScanResult<String, String> scan(int cursor, int count) {
RFuture<MapScanResult<String, String>> future = commandExecutor.readAsync(getName(), codec, RedisCommands.HSCAN, getName(), cursor, "COUNT", count);
return future.get();
}
commandExecutor.readAsync
:执行 Redis 命令的异步方法。
RedisCommands.HSCAN
:表示 Redis 的 HSCAN 命令。
产生多次 SCAN 的原因
当调用 RMap.entrySet() 时,Redisson 需要遍历整个 Hash 表。由于 Redis 的 SCAN 命令每次只能返回部分结果,为了获取全部数据,Redisson 必须多次调用 SCAN,每次传入上一次的游标值,直到遍历完所有条目。
因此解决上述问题的关键点是减少 Redis 的 SCAN 操作
解决办法
Map<String, String> valueMap = map.readAllMap();
使用 readAllMap() 可以避免上述问题的主要原因在于它一次性读取所有数据并返回一个 Java Map,而不是逐条扫描和处理。这减少了 Redis 的 SCAN 命令调用次数,降低了 Redis 服务器的压力。
readAllMap() 的工作原理
readAllMap() 方法在内部执行一个单一的 Redis 命令(如 HGETALL)来获取所有数据,而不是使用 SCAN 命令分批次地获取。这意味着它可以一次性获取整个 Hash 表的内容,从而避免了多次 SCAN 的问题。
RMap<String, String> map = redissonClient.getMap("myMap");
Map<String, String> allData = map.readAllMap();
原理对比
entrySet() 方法:
- 多次 SCAN:entrySet() 方法在 Redisson 中需要遍历整个 Hash 表,通常会通过多次 HSCAN 命令来获取所有条目。
- 高频次 IO:由于 Redis 的 HSCAN 命令每次只能返回部分结果,为了获取所有数据,需要多次调用,导致高频次 IO 操作。
- 潜在性能问题:在大数据集的情况下,频繁的 SCAN 操作会增加 Redis 服务器的负载,可能导致性能问题。
- readAllMap() 方法:
- 单次读取:readAllMap() 方法在内部执行一个类似于 HGETALL 的命令,一次性获取所有数据。
- 低频次 IO:由于只需要一次 IO 操作,减少了网络通信的频次,降低了 Redis 服务器的压力。
- 高效:在数据量不大的情况下,HGETALL 可以高效地返回所有数据。
源码分析
以下是 readAllMap() 方法的简化实现示例:
public Map<K, V> readAllMap() {
RFuture<Map<K, V>> future = commandExecutor.readAsync(getName(), codec, RedisCommands.HGETALL, getName());
return future.join();
}
commandExecutor.readAsync:执行异步命令的方法。
RedisCommands.HGETALL:表示 Redis 的 HGETALL 命令。
在 readAllMap() 中,Redisson 只需要执行一次 HGETALL 命令即可获取所有键值对。
示例代码
import org.redisson.api.RMap;
import org.redisson.api.RedissonClient;
import org.redisson.Redisson;
import org.redisson.config.Config;
import java.util.Map;
public class RedisExample {
public static void main(String[] args) {
// 配置和初始化 Redisson 客户端
Config config = new Config();
config.useSingleServer().setAddress("redis://127.0.0.1:6379");
RedissonClient redissonClient = Redisson.create(config);
// 获取 RMap 实例
RMap<String, String> map = redissonClient.getMap("myMap");
// 使用 readAllMap() 一次性获取所有数据
Map<String, String> allData = map.readAllMap();
// 打印结果
allData.forEach((key, value) -> System.out.println(key + ": " + value));
// 关闭 Redisson 客户端
redissonClient.shutdown();
}
}
总结
避免多次 SCAN:readAllMap() 方法通过一次性读取所有数据,避免了多次 SCAN 操作,减少了 Redis 服务器的压力。
减少 IO 操作:一次性获取数据的方式减少了网络通信的频次,提高了性能。
适用场景:适用于数据量相对较小的场景。在数据量非常大的情况下,HGETALL 可能会导致内存占用增加,需要谨慎使用。
通过使用 readAllMap() 方法,可以显著提高读取 Redis 数据的效率,避免 Redis 内存和 CPU 过度消耗的问题。