• 作者:老汪软件技巧
  • 发表时间:2024-08-31 17:02
  • 浏览量:

大家好,这里是小奏,觉得文章不错可以关注公众号小奏技术

背景

最近自研的多级缓存框架线上出现了redis数据倾斜的问题。而自研多级缓存框架中redis使用的client是Redisson,

缓存底层封装使用的数据结构是RMapCache

RMapCache使用

RMapCache的使用其实很简单

public class XiaoZou {
    private final RedissonClient redissonClient;
    
    
    public void test() {
        RMapCache 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

因为通过 redissonClient.getMapCache("xiaozou");我们可以看看具体创建的实现类是RedissonMapCache

获取缓存的核心方法是getOperationAsync,我们看看这个方法的实现

    public RFuture 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 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,根据自己的业务场景选择合适的存储方式

如果是缓存框架可以考虑两者都支持,让用户自己选择合适的存储方式