低版本 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
底层是怎么获取缓存的。
org.springframework.cache.interceptor.CacheAspectSupport#execute()
org.springframework.cache.interceptor.CacheAspectSupport#findCachedItem
org.springframework.cache.interceptor.CacheAspectSupport#findInCaches
org.springframework.cache.interceptor.AbstractCacheInvoker#doGet
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, "并发报错");
});
}
经过测试,也没有并发问题了