低版本 Spring Cache 高并发下的问题

低版本 Spring Cache 高并发下的问题

问题发现

今天排查线上问题,发现了一个使用 Springboot Cache 的注解 @Cacheable 获取缓存的结果为 null ,导致空指针异常的问题。

Spring Cache 缓存的实现是 spring-data-redis:1.8.1.RELEASE

伪代码如下:

@Override
@Cacheable(key = "#id", cacheNames = "cache.getListById", sync = true)
public List<String> getListById(String id) {
    // 模拟查询数据库
    log.info("查询数据库");
    return Lists.newArrayList("1", "2", "3");
}

测试代码:

@Test
public void selectCache() {
    ArrayList<Integer> list = new ArrayList<>();
    for (int i = 0; i < 100000; i++) {
        list.add(i);
    }
    list.parallelStream().forEach(integer -> {
        List<String> idList =  testService.getListById("1234");
        Assert.notNull(idList, "并发报错");
    });
}

然后把 redis 的缓存时间改为 2 秒钟,运行程序便可以成功复现的线上出现的错误。

只是一段很小的代码逻辑,而且数据库也是有值的,如果是数据库报错的话,也不是空指针异常,于是,我们看一下源码的逻辑,看一下 @Cacheable 底层是怎么获取缓存的。

  1. org.springframework.cache.interceptor.CacheAspectSupport#execute()
  2. org.springframework.cache.interceptor.CacheAspectSupport#findCachedItem
  3. org.springframework.cache.interceptor.CacheAspectSupport#findInCaches
  4. org.springframework.cache.interceptor.AbstractCacheInvoker#doGet
  5. org.springframework.data.redis.cache.RedisCache#get(org.springframework.data.redis.cache.RedisCacheKey)

获取缓存最主要的方法就是在 RedisCache#get

public RedisCacheElement get(final RedisCacheKey cacheKey) {

    Assert.notNull(cacheKey, "CacheKey must not be null!");

    Boolean exists = (Boolean) redisOperations.execute(new RedisCallback<Boolean>() {

        @Override
        public Boolean doInRedis(RedisConnection connection) throws DataAccessException {
            return connection.exists(cacheKey.getKeyBytes());
        }
    });

    if (!exists.booleanValue()) {
        return null;
    }

    return new RedisCacheElement(cacheKey, fromStoreValue(lookup(cacheKey)));
}

可以看到,源码中是先判断 key 是否存在,存在再去获取,而我们在高并发的情况下,判断 key 存在后,这个key 刚好过期,于是,再去进行取值的时候,就取不到值了,于是就返回 null。

问题解决 -1 升级版本

版本 1.8.11

spring-data-redis 在 1.8.11 的版本中修复了该问题

<dependency>
    <groupId>org.springframework.data</groupId>
    <artifactId>spring-data-redis</artifactId>
    <version>1.8.11.RELEASE</version>
</dependency>

RedisCache#get 的源码如下

public RedisCacheElement get(final RedisCacheKey cacheKey) {

    Assert.notNull(cacheKey, "CacheKey must not be null!");

    Boolean exists = (Boolean) redisOperations.execute(new RedisCallback<Boolean>() {

        @Override
        public Boolean doInRedis(RedisConnection connection) throws DataAccessException {
            return connection.exists(cacheKey.getKeyBytes());
        }
    });
    // 判断 key 是否存在
    if (!exists) {
        return null;
    }
    // 获取 key 对应的 value
    byte[] bytes = doLookup(cacheKey);

    // 再次判断,看获取到的 value 值是否为空
    if (bytes == null) {
        return null;
    }

    return new RedisCacheElement(cacheKey, fromStoreValue(deserialize(bytes)));
}

private byte[] doLookup(Object key) {

    RedisCacheKey cacheKey = key instanceof RedisCacheKey ? (RedisCacheKey) key : getRedisCacheKey(key);

    return (byte[]) redisOperations.execute(new AbstractRedisCacheCallback<byte[]>(
        new BinaryRedisCacheElement(new RedisCacheElement(cacheKey, null), cacheValueAccessor), cacheMetadata) {

        @Override
        public byte[] doInRedis(BinaryRedisCacheElement element, RedisConnection connection) throws DataAccessException {
            return connection.get(element.getKeyBytes());
        }
    });
}

可以看到增加多了一次判断校验,经过测试也没有并发问题

版本 2.1.9

刚好有一个项目是使用 spring-data-redis:2.1.9.RELEASE,于是便去查看该版本的源码看是怎么实现的

在 spring-data-redis:2.1.9.RELEASE 中,我们可以发现是通过直接获取 key 的值,而不是去判断 key 之后再去获取,经过测试也没有并发问题了。

具体的代码在:org.springframework.data.redis.cache.DefaultRedisCacheWriter#get

@Override
public byte[] get(String name, byte[] key) {

    Assert.notNull(name, "Name must not be null!");
    Assert.notNull(key, "Key must not be null!");

    return execute(name, connection -> connection.get(key));
}

问题解决 -2 打补丁

既然只是在那个 key 失效的那个时间点才会获取失败,那么,我们只需要获取为 null 的时候,重新去获取一次

@Test
public void selectCache() {
    ArrayList<Integer> list = new ArrayList<>();
    for (int i = 0; i < 100000; i++) {
        list.add(i);
    }
    list.parallelStream().forEach(integer -> {
        List<String> idList = Optional.ofNullable(testService.getListById("1234"))
                                      .orElse(testService.getListById("1234"));
        Assert.notNull(idList, "并发报错");
    });
}

经过测试,也没有并发问题了

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值