Redisson中RMapCache底层实现源码分析及数据倾斜处理背景 最近自研的多级缓存框架线上出现了redis数据倾
大家好,这里是小奏,觉得文章不错可以关注公众号小奏技术
背景
最近自研的多级缓存框架线上出现了redis
数据倾斜的问题。而自研多级缓存框架中redis
使用的client
是Redisson
,
缓存底层封装使用的数据结构是RMapCache
RMapCache使用
RMapCache
的使用其实很简单
public class XiaoZou {
private final RedissonClient redissonClient;
public void test() {
RMapCache<String, String> mapCache = redissonClient.getMapCache("xiaozou");
mapCache.put("key", "value", 10, TimeUnit.SECONDS);
String value = mapCache.get("key");
System.out.println(value);}
}
疑惑
实际可以看到RMapCache
的api设计还是非常简单的,但是有一个疑问就是redis
默认的数据结构就那么几种,String
、List
、Set
、ZSet
、Hash
,那么RMapCache
底层是啥呢?看着四不像啊
源码分析
RMapCache
本身仅是一个接口,如果我们要查看底层原理,我们还是需要看看具体的实现类
好消息是RMapCache
的实现类只有两个,要是像spring
一个类很多个实现类估计又要封了
两个实现类如下
- RedissonMapCache
- RedissonTransactionalMapCache
我们这里需要研究的实现类主要是RedissonMapCache
因为通过 redissonClient.getMapCache("xiaozou");
我们可以看看具体创建的实现类是RedissonMapCache
获取缓存的核心方法是getOperationAsync
,我们看看这个方法的实现
public RFuture<V> getOperationAsync(K key) {
String name = getRawName(key);
return commandExecutor.evalWriteAsync(name, codec, RedisCommands.EVAL_MAP_VALUE,
"local value = redis.call('hget', KEYS[1], ARGV[2]); "
+ "if value == false then "
+ "return nil; "
+ "end; "
+ "local t, val = struct.unpack('dLc0', value); "
+ "local expireDate = 92233720368547758; " +
"local expireDateScore = redis.call('zscore', KEYS[2], ARGV[2]); "
+ "if expireDateScore ~= false then "
+ "expireDate = tonumber(expireDateScore) "
+ "end; "
+ "if t ~= 0 then "
+ "local expireIdle = redis.call('zscore', KEYS[3], ARGV[2]); "
+ "if expireIdle ~= false then "
+ "if tonumber(expireIdle) > tonumber(ARGV[1]) then "
+ "redis.call('zadd', KEYS[3], t + tonumber(ARGV[1]), ARGV[2]); "
+ "end; "
+ "expireDate = math.min(expireDate, tonumber(expireIdle)) "
+ "end; "
+ "end; "
+ "if expireDate <= tonumber(ARGV[1]) then "
+ "return nil; "
+ "end; "
+ "local maxSize = tonumber(redis.call('hget', KEYS[5], 'max-size')); " +
"if maxSize ~= nil and maxSize ~= 0 then " +
"local mode = redis.call('hget', KEYS[5], 'mode'); " +
"if mode == false or mode == 'LRU' then " +
"redis.call('zadd', KEYS[4], tonumber(ARGV[1]), ARGV[2]); " +
"else " +
"redis.call('zincrby', KEYS[4], 1, ARGV[2]); " +
"end; " +
"end; "
+ "return val; ",
Arrays.asList(name, getTimeoutSetName(name), getIdleSetName(name), getLastAccessTimeSetName(name), getOptionsName(name)),
System.currentTimeMillis(), encodeMapKey(key));
}
可以看到核心逻辑都是基于lua
脚本实现的。我们来分析分析这段lua脚本的逻辑
- 获取并检查缓存值
local value = redis.call('hget', KEYS[1], ARGV[2]);
if value == false then
return nil;
end;
可以看到缓存的存储主要用的数据结构是hash
- 解析缓存值
local t, val = struct.unpack('dLc0', value);
如果值存在,解包获取时间戳t和实际值val。
- 检查过期时间
local expireDate = 92233720368547758;
local expireDateScore = redis.call('zscore', KEYS[2], ARGV[2]);
if expireDateScore ~= false then
expireDate = tonumber(expireDateScore)
end;
设置一个初始的极大过期时间,然后尝试从过期时间集合中获取实际的过期时间。
- 处理空闲时间
if t ~= 0 then
local expireIdle = redis.call('zscore', KEYS[3], ARGV[2]);
if expireIdle ~= false then
if tonumber(expireIdle) > tonumber(ARGV[1]) then
redis.call('zadd', KEYS[3], t + tonumber(ARGV[1]), ARGV[2]);
end;
expireDate = math.min(expireDate, tonumber(expireIdle))
end;
end;
如果启用了空闲时间检查(t != 0),获取空闲过期时间,更新空闲时间,并取最小的过期时间。 5. 检查是否过期
if expireDate <= tonumber(ARGV[1]) then
return nil;
end;
如果已过期,返回nil。
- 处理缓存大小限制和淘汰策略
local maxSize = tonumber(redis.call('hget', KEYS[5], 'max-size'));
if maxSize ~= nil and maxSize ~= 0 then
local mode = redis.call('hget', KEYS[5], 'mode');
if mode == false or mode == 'LRU' then
redis.call('zadd', KEYS[4], tonumber(ARGV[1]), ARGV[2]);
else
redis.call('zincrby', KEYS[4], 1, ARGV[2]);
end;
end;
如果设置了最大大小限制,根据淘汰策略(LRU或LFU)更新访问时间或频率。 7. 返回缓存值
return val;
可以看到用到的reids数据结构有hash
、zset
,hash
主要用来存储缓存值,zset
主要用来存储过期时间和空闲时间
既然后用到了hash,那么在redis
集群模式下面就容易出现数据倾斜
数据倾斜
如果我们的reids是集群模式,现在主流的集群模式应该还是切片集群的方式
对于一个key,在存储的时候会进行hash计算,然后存储在某个分片中
所以这里一个RMapCache
只有一个name
,只会被分配到一个分片中,如果我们只有一个name
,然后下面很多个key,就会导致整个系统只会使用一个分片,出现数据倾斜,导致redis被打爆。
如何解决数据倾斜
两种解决方式
- 缓存底层存储使用
String
,即redisson
中的RBucket
- 对
RMapCache
的name
进行分片
public class ShardedMapCache {
private final int shardCount;
private final RedissonClient redissonClient;
private final String name;
public ShardedMapCache(RedissonClient redissonClient, String name, int shardCount) {
this.redissonClient = redissonClient;
this.name = name;
this.shardCount = shardCount;
}
public RMapCache<K, Object> getRMapCache(String key) {
return redissonClient.getMapCache(getShardName(key));
}
private String getShardName(String key) {
int shard = Math.abs(key.hashCode() % shardCount);
return "cache:" + shard;
}
}
这种方式能够缓解单个缓存数据倾斜的问题。如果系统存储的缓存很大,需要更省内存,可以使用RBucket
存储,如果系统缓存数据量不大,可以使用RMapCache
存储
总结
RMapCache
底层实现只要是hash
+zset
,所以相对单纯的RBucket
来说更耗费内存,但是也多了一些对缓存的高级操作,比如全量清除
如果使用RMapCache
存储单name
大量数据,需要注意数据倾斜问题
出现数据倾斜可以考虑使用RBucket
存储或者对RMapCache
的name
进行手动分片
无论是使用RBucket
还是RMapCache
,根据自己的业务场景选择合适的存储方式
如果是缓存框架可以考虑两者都支持,让用户自己选择合适的存储方式
转载自:https://juejin.cn/post/7408631611041202216