问题暴露
某个缓存key和对应的value被缓存到guava
后,一段时间后该key对应的记录在数据库中被删除;
当这个key再次被获取的时候,到达refreshAfterWrite
设置时间触发refresh
方法, 然后委托给我们自己实现的reload
方法,我们的reload
方法又调用了load
方法来重新刷新该key对应的最新值。那么这个时候问题来了。因为我们知道,如果一个key曾经没有存在过,触发load方法的时候,我们return null,最终guava不会去增加一个key来保存这一对无效键值对。
但是现在我们的问题是这个key是存在的,只是现在刷新的时候发现值不存在了,变成了null。程序如果不管这种情况的话,由于guava不接收value为null的缓存,然后内部会抛出异常,最终那本该不存在的旧值就又会一直占用这个key的缓存。然后获取缓存的线程又会只会判断是否为null,然后执行业务,最终就会拿着被删除的垃圾数据走业务,而且这个垃圾数据会一直存在。
问题演示
下面会简单写一个demo,然后用来演示上面那个问题, 步骤描述
- 有一个对象为
Person
, 然后使用guava
的refreshAfterWrite
来进行缓存 - 某一个
Person
被缓存后,随后这个对象对应的记录在数据库被删除了 - 然后发现我们从缓存中获取对应被删除的对象的key即使已经刷新了无数遍发现数据还是会一直存在,而不是null
import com.google.common.cache.CacheBuilder;
import com.google.common.cache.CacheLoader;
import com.google.common.cache.LoadingCache;
import com.google.common.util.concurrent.ListenableFuture;
import com.google.common.util.concurrent.ListenableFutureTask;
import com.google.common.util.concurrent.ThreadFactoryBuilder;
import jdk.reflect.StringUtil;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import java.text.MessageFormat;
import java.util.HashMap;
import java.util.Map;
import java.util.concurrent.ArrayBlockingQueue;
import java.util.concurrent.ThreadPoolExecutor;
import java.util.concurrent.TimeUnit;
/**
* <p>description</p >
*
* @author Snowball
* @version 1.0
* @date 2020/08/27 14:46
*/
@Slf4j
public class Demo {
/**
* 原始数据集合,模拟数据库记录
*/
private final static Map<String, Person> DATA_MAP;
/**
* guava缓存Person
*/
private static LoadingCache<String, Person> INIT_LOADING_CACHE;
/**
* reload线程池
*/
private final static ThreadPoolExecutor EXECUTOR = new ThreadPoolExecutor(Runtime.getRuntime().availableProcessors(),
Runtime.getRuntime().availableProcessors() * 2, 60L, TimeUnit.SECONDS,
new ArrayBlockingQueue<>(500), new ThreadFactoryBuilder().setNameFormat("reload-executor-pool").build());
static {
// 初始化原始数据,模拟数据库中的记录
DATA_MAP = new HashMap<>();
DATA_MAP.put("1", new Person("1", "jack"));
DATA_MAP.put("2", new Person("2", "tom"));
// 初始化缓存
initLazyCache();
}
/**
* 初始化缓存
*/
private static void initLazyCache() {
INIT_LOADING_CACHE = CacheBuilder.newBuilder()
.maximumSize(500)
.recordStats()
.softValues()
.refreshAfterWrite(3, TimeUnit.SECONDS)
.build(new CacheLoader<String, Person>() {
@Override
public Person load(String key) throws Exception {
System.out.println("=================load===================: " + key);
if (StringUtil.isBlank(key)) {
return null;
}
String[] data= key.split(CacheKeyEnum.SPLIT_CHAR);
// LoadingCacheDemo1:INIT:{0}
String id = data[2];
return get(id);
}
@Override
public ListenableFuture<Person> reload(String key, Person oldValue) throws Exception {
// 如果使用异步的话,短期内多次获取,因为此时异步尚未执行结束,大概率会造成获取的时候使用的还是旧的缓存值
ListenableFutureTask<Person> task = ListenableFutureTask.create(() -> load(key));
EXECUTOR.execute(task);
// 可选是否采用闭锁再刷新的时候让读线程等待刷新完成,一般不需要,就异步刷新即可
task.get();
return task;
}
});
// 这里也可以直接初始化缓存,就不用懒加载获取某个key的时候再去缓存
DATA_MAP.forEach((k, v) -> {
INIT_LOADING_CACHE.put(MessageFormat.format(CacheKeyEnum.LOADING_CACHE_DEMO1_INIT.getTemplate(), k), v);
});
}
public static void main(String[] args) throws InterruptedException {
System.out.println("==============================================initLoad===========================================");
String id = "2";
System.out.println("由于缓存进行了启动初始化加载,所以第一次如果key存在,会直接拿缓存");
logTimeGet(INIT_LOADING_CACHE, CacheKeyEnum.LOADING_CACHE_DEMO1_INIT.getTemplate(), id);
DATA_MAP.put(id, new Person(id, DATA_MAP.get(id).getUsername() + "被修改了"));
System.out.println("id=2的对象,由于没有到刷新时间, 所以用的还是旧值");
logTimeGet(INIT_LOADING_CACHE, CacheKeyEnum.LOADING_CACHE_DEMO1_INIT.getTemplate(), id);
Thread.sleep(3000);
System.out.println("故意睡眠超过过期时间,过期之后的第一次请求触发刷新操作,由于我们使用了闭锁,让获取值的线程强制等待刷新任务完整,所以这里会耗时比较久,但值是最新的");
logTimeGet(INIT_LOADING_CACHE, CacheKeyEnum.LOADING_CACHE_DEMO1_INIT.getTemplate(), id);
// 下面就要演示如果这个key被删除了,其实最终guava不接受为null的value,会导致这个无效key一直存在,且对应的value为最后一次缓存的数据
DATA_MAP.remove(id);
// 故意超过过期时间,保证会触发reload
Thread.sleep(3000);
System.out.println("模拟数据库删除key=2的数据");
// 我们已经使用了闭锁来保证刷新时的同步获取来保证演示效果, 这里guava会抛出异常CacheLoader returned null for key LoadingCacheDemo1:INIT:2.
// 因为它不接受为null的value, 最终这个缓存会无法置空, 然后就悲剧了,这个key的value我们以为是null,实际一直是最后一次缓存的数据
logTimeGet(INIT_LOADING_CACHE, CacheKeyEnum.LOADING_CACHE_DEMO1_INIT.getTemplate(), id);
System.out.println("打印一下缓存中的数据,需要说明的是,这里不是由于缓存未刷新造成的数据不一致,而是由于刷新时对应key的记录变成了null而guava不接受所造成的");
System.out.println(INIT_LOADING_CACHE.asMap());
EXECUTOR.shutdown();
}
/**
* 模拟获取原始数据
* @param key
* @return
*/
public static Person get(String key) {
try {
// 模拟向数据库中取数据需要耗时1000毫秒
Thread.sleep(1000);
} catch (InterruptedException e) {
e.printStackTrace();
}
return DATA_MAP.get(key);
}
private static <T> void logTimeGet(LoadingCache<String, T> loadingCache, String template, String... parameter) {
long before = System.currentTimeMillis();
System.out.println("缓存中[" + MessageFormat.format(template, parameter) + "]对应的值: "
+ LocalCacheUtil.getGuavaCache(loadingCache, template, parameter));
long after = System.currentTimeMillis();
System.out.println("耗时: " + (after - before));
System.out.println("--------------------------------------");
}
/**
* 获取对应缓存中的值
* @param loadingCache
* @param template
* @param parameter
* @param <T>
* @return
*/
public static <T> T getGuavaCache(LoadingCache<String, T> loadingCache, String template, String... parameter) {
try {
return loadingCache.get(MessageFormat.format(template, parameter));
} catch (CacheLoader.InvalidCacheLoadException e) {
return null;
} catch (Exception e) {
log.error("查询缓存异常 template={},parameter={}", template, parameter);
return null;
}
}
/**
* 缓存key维护
*/
public enum CacheKeyEnum {
/**
* 演示初始化加载的缓存
* {0} id
*/
LOADING_CACHE_DEMO1_INIT("LoadingCacheDemo1:INIT:{0}"),
;
public static final String SPLIT_CHAR = ":";
CacheKeyEnum(String template) {
this.template = template;
}
private final String template;
public String getTemplate() {
return template;
}
}
@Data
@AllArgsConstructor
@NoArgsConstructor
public static class Person {
private String id;
private String username;
}
}
打印结果如下:
==============================================initLoad===========================================
由于缓存进行了启动初始化加载,所以第一次如果key存在,会直接拿缓存
缓存中[LoadingCacheDemo1:INIT:2]对应的值: Demo.Person(id=2, username=tom)
耗时: 1
--------------------------------------
id=2的对象,由于没有到刷新时间, 所以用的还是旧值
缓存中[LoadingCacheDemo1:INIT:2]对应的值: Demo.Person(id=2, username=tom)
耗时: 0
--------------------------------------
故意睡眠超过过期时间,过期之后的第一次请求触发刷新操作,由于我们使用了闭锁,让获取值的线程强制等待刷新任务完整,所以这里会耗时比较久,但值是最新的
=================load===================: LoadingCacheDemo1:INIT:2
缓存中[LoadingCacheDemo1:INIT:2]对应的值: Demo.Person(id=2, username=tom被修改了)
耗时: 1038
--------------------------------------
模拟数据库删除key=2的数据
=================load===================: LoadingCacheDemo1:INIT:2
缓存中[LoadingCacheDemo1:INIT:2]对应的值: Demo.Person(id=2, username=tom被修改了)
耗时: 1016
--------------------------------------
打印一下缓存中的数据,需要说明的是,这里不是由于缓存未刷新造成的数据不一致,而是由于刷新时对应key的记录变成了null造成的
{LoadingCacheDemo1:INIT:1=Demo.Person(id=1, username=jack), LoadingCacheDemo1:INIT:2=Demo.Person(id=2, username=tom被修改了)}
解决思路
- 首先明白问题是出现在这个key曾经存在过,然后被删除之后重新从数据库查询发现为null之后,然后我们直接将null交给guava,本以为会让这个value为null,然后业务判断不为null即可,但是最终guava不受理一个存在的key被重置为null, 才会引起这个问题
- 那么就只能从默认值下手了。给每个要缓存的对象一个代表无效的空对象,如果为集合则为空集合,然后在
load
方法内部通过key
去获取最新值的时候需要判断这个值是否为null, 如果为null并且这个key在缓存中存在,则返回我们预定义的空对象。记住一定要加上这个key之前在缓存中存在才做默认值的处理。因为如果key不存在我们返回null, guava根本不会去缓存这个key。如果不加这个判断,我们就让guava多缓存了一堆无效数据了。 - 在通过guava获取值的地方,除了要判断值不为null之外,还要判断这个值是不是我们之前存入的代表无效value的对象。如果是的话,这个数据不可使用,不能走业务代码。同时调用
LoadingCache
的invalidate
方法来将key失效掉
步骤2对应调整
- 增加要缓存对象的默认无效值
public static final Person INVALID_PERSON = new Person();
- 调整guava缓存定义时的
load
方法,调整部分如下Person person = get(id); // 如果值为null且这个key之前存在过, 要放入默认值;如果值为null但这个key不存在,guava本就不会缓存这个key if (person == null && INIT_LOADING_CACHE.asMap().containsKey(key)) { System.out.printf("【%s】对应的记录被删除,存入默认值\r\n", key); person = Person.INVALID_PERSON; } return person;
步骤3调整
介于步骤3,实在是对在使用的地方有侵入,我们可以更改一下前面写的获取缓存的工具方法,除了允许调用方传入要从哪个缓存中取数据,以及对应的key之外,我们再增加默认对象参数,然后我们再工具方法中去完成步骤3的动作,然后发现获取的值为传入的默认对象后,调用invalidate
方法并且返回null给调用方,这样就可以让获取缓存的地方无感这一切,依然只判断null即可
public static <T> T getGuavaCacheCheckDefault(LoadingCache<String, T> loadingCache, T defaultValidValue, String template, String... parameter) {
try {
String key = MessageFormat.format(template, parameter);
T t = loadingCache.get(key);
if (defaultValidValue instanceof String) {
if (Objects.equals(t, defaultValidValue)) {
log.info("key: {}对应的值为无效值: {}, 转换为null返回并清除该key", key, t);
loadingCache.invalidate(key);
return null;
}
} else {
if (t == defaultValidValue) {
log.info("key: {}对应的值为无效值: {}, 转换为null返回并清除该key", key, t);
loadingCache.invalidate(key);
return null;
}
}
return t;
} catch (CacheLoader.InvalidCacheLoadException e) {
return null;
} catch (Exception e) {
log.error("查询缓存异常 template={},parameter={}", template, parameter);
return null;
}
}
最终完成代码和演示效果
import com.google.common.cache.CacheBuilder;
import com.google.common.cache.CacheLoader;
import com.google.common.cache.LoadingCache;
import com.google.common.util.concurrent.ListenableFuture;
import com.google.common.util.concurrent.ListenableFutureTask;
import com.google.common.util.concurrent.ThreadFactoryBuilder;
import jdk.reflect.StringUtil;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import java.text.MessageFormat;
import java.util.HashMap;
import java.util.Map;
import java.util.concurrent.ArrayBlockingQueue;
import java.util.concurrent.ThreadPoolExecutor;
import java.util.concurrent.TimeUnit;
/**
* <p>description</p >
*
* @author Snowball
* @version 1.0
* @date 2020/08/27 14:46
*/
@Slf4j
public class Demo {
/**
* 原始数据集合,模拟数据库记录
*/
private final static Map<String, Person> DATA_MAP;
/**
* 初始化直接缓存全部资
*/
private static LoadingCache<String, Person> INIT_LOADING_CACHE;
/**
* reload线程池
*/
private final static ThreadPoolExecutor EXECUTOR = new ThreadPoolExecutor(Runtime.getRuntime().availableProcessors(),
Runtime.getRuntime().availableProcessors() * 2, 60L, TimeUnit.SECONDS,
new ArrayBlockingQueue<>(500), new ThreadFactoryBuilder().setNameFormat("reload-executor-pool").build());
static {
// 初始化原始数据
DATA_MAP = new HashMap<>();
DATA_MAP.put("1", new Person("1", "jack"));
DATA_MAP.put("2", new Person("2", "tom"));
// 初始化缓存
initLazyCache();
}
/**
* 初始化缓存
*/
private static void initLazyCache() {
INIT_LOADING_CACHE = CacheBuilder.newBuilder()
.maximumSize(500)
.recordStats()
.softValues()
.refreshAfterWrite(3, TimeUnit.SECONDS)
.build(new CacheLoader<String, Person>() {
@Override
public Person load(String key) throws Exception {
System.out.println("=================load===================: " + key);
if (StringUtil.isBlank(key)) {
return null;
}
String[] data= key.split(CacheKeyEnum.SPLIT_CHAR);
// LoadingCacheDemo1:INIT:{0}
String id = data[2];
Person person = get(id);
// 如果值为null且这个key之前存在过, 要放入默认值;如果值为null但这个key不存在,guava本就不会缓存这个key,我们没必要增加
if (person == null && INIT_LOADING_CACHE.asMap().containsKey(key)) {
System.out.printf("【%s】对应的记录被删除,存入默认值\r\n", key);
person = Person.INVALID_PERSON;
}
return person;
}
@Override
public ListenableFuture<Person> reload(String key, Person oldValue) throws Exception {
// 如果使用异步的话,短期内多次获取,因为此时异步尚未执行结束,大概率会造成获取的时候使用的还是旧的缓存值
ListenableFutureTask<Person> task = ListenableFutureTask.create(() -> load(key));
EXECUTOR.execute(task);
// 可选是否采用闭锁再刷新的时候让读线程等待刷新完成,一般不需要,就异步刷新即可
task.get();
return task;
}
});
// 这里也可以直接初始化缓存,就不用懒加载获取某个key的时候再去缓存
DATA_MAP.forEach((k, v) -> {
INIT_LOADING_CACHE.put(MessageFormat.format(CacheKeyEnum.LOADING_CACHE_DEMO1_INIT.getTemplate(), k), v);
});
}
public static void main(String[] args) throws InterruptedException {
System.out.println("==============================================initLoad===========================================");
String id = "2";
System.out.println("由于缓存进行了启动初始化加载,所以第一次如果key存在,会直接拿缓存");
logTimeGet(INIT_LOADING_CACHE, Person.INVALID_PERSON, guava.cache.CacheKeyEnum.LOADING_CACHE_DEMO1_INIT.getTemplate(), id);
DATA_MAP.put(id, new Person(id, DATA_MAP.get(id).getUsername() + "被修改了"));
System.out.println("id=4的对象,由于没有到刷新时间, 所以用的还是旧值");
logTimeGet(INIT_LOADING_CACHE, Person.INVALID_PERSON, CacheKeyEnum.LOADING_CACHE_DEMO1_INIT.getTemplate(), id);
Thread.sleep(3000);
System.out.println("故意睡眠超过过期时间,过期之后的第一次请求触发刷新操作,由于我们使用了闭锁,让获取值的线程强制等待刷新任务完整,所以这里会耗时比较久,但值是最新的");
logTimeGet(INIT_LOADING_CACHE, Person.INVALID_PERSON, CacheKeyEnum.LOADING_CACHE_DEMO1_INIT.getTemplate(), id);
// 下面就要演示如果这个key被删除了,其实最终guava不接受为null的value,会导致这个无效key一直存在,且对应的value为最后一次缓存的数据
DATA_MAP.remove(id);
// 故意超过过期时间,保证会触发reload
Thread.sleep(3000);
System.out.println("模拟数据库删除key=4的数据");
// 我们已经使用了闭锁来保证刷新时的同步获取来保证演示效果, 这里写的工具方法内部会判断是否为默认值,然后转换为null返回,并且删除key
logTimeGet(INIT_LOADING_CACHE, Person.INVALID_PERSON, CacheKeyEnum.LOADING_CACHE_DEMO1_INIT.getTemplate(), id);
System.out.println("打印一下缓存中的数据,我们发现上面工具完成了返回null的操作方便我们使用的地方判断,而且也完成了对该key的删除");
System.out.println(INIT_LOADING_CACHE.asMap());
EXECUTOR.shutdown();
}
/**
* 模拟获取原始数据
* @param key
* @return
*/
public static Person get(String key) {
try {
// 模拟向数据库中取数据需要耗时1000毫秒
Thread.sleep(1000);
} catch (InterruptedException e) {
e.printStackTrace();
}
return DATA_MAP.get(key);
}
private static <T> void logTimeGet(LoadingCache<String, T> loadingCache, T invalidValue, String template, String... parameter) {
long before = System.currentTimeMillis();
System.out.println("缓存中[" + MessageFormat.format(template, parameter) + "]对应的值: "
+ LocalCacheUtil.getGuavaCacheCheckDefault(loadingCache, invalidValue, template, parameter));
long after = System.currentTimeMillis();
System.out.println("耗时: " + (after - before));
System.out.println("--------------------------------------");
}
/**
* 获取对应缓存中的值
* @param loadingCache
* @param template
* @param parameter
* @param <T>
* @return
*/
public static <T> T getGuavaCache(LoadingCache<String, T> loadingCache, String template, String... parameter) {
try {
return loadingCache.get(MessageFormat.format(template, parameter));
} catch (CacheLoader.InvalidCacheLoadException e) {
return null;
} catch (Exception e) {
log.error("查询缓存异常 template={},parameter={}", template, parameter);
return null;
}
}
/**
* 缓存key维护
*/
public enum CacheKeyEnum {
/**
* 演示初始化加载的缓存
* {0} id
*/
LOADING_CACHE_DEMO1_INIT("LoadingCacheDemo1:INIT:{0}"),
;
public static final String SPLIT_CHAR = ":";
CacheKeyEnum(String template) {
this.template = template;
}
private final String template;
public String getTemplate() {
return template;
}
}
@Data
@AllArgsConstructor
@NoArgsConstructor
public static class Person {
/**
* 代表无效值的Person对象
*/
public static final Person INVALID_PERSON = new Person();
private String id;
private String username;
}
}
打印效果如下:
==============================================initLoad===========================================
由于缓存进行了启动初始化加载,所以第一次如果key存在,会直接拿缓存
缓存中[LoadingCacheDemo1:INIT:2]对应的值: Demo.Person(id=2, username=tom)
耗时: 1
--------------------------------------
id=4的对象,由于没有到刷新时间, 所以用的还是旧值
缓存中[LoadingCacheDemo1:INIT:2]对应的值: Demo.Person(id=2, username=tom)
耗时: 0
--------------------------------------
故意睡眠超过过期时间,过期之后的第一次请求触发刷新操作,由于我们使用了闭锁,让获取值的线程强制等待刷新任务完整,所以这里会耗时比较久,但值是最新的
=================load===================: LoadingCacheDemo1:INIT:2
缓存中[LoadingCacheDemo1:INIT:2]对应的值: Demo.Person(id=2, username=tom被修改了)
耗时: 1033
--------------------------------------
模拟数据库删除key=4的数据
=================load===================: LoadingCacheDemo1:INIT:2
【LoadingCacheDemo1:INIT:2】对应的记录被删除,存入默认值
16:58:23.510 [main] INFO guava.cache.LocalCacheUtil - key: LoadingCacheDemo1:INIT:2对应的值为无效值: Demo.Person(id=null, username=null), 转换为null返回并清除该key
缓存中[LoadingCacheDemo1:INIT:2]对应的值: null
耗时: 1019
--------------------------------------
打印一下缓存中的数据,我们发现上面工具完成了返回null的操作方便我们使用的地方判断,而且也完成了对该key的删除
{LoadingCacheDemo1:INIT:1=Demo.Person(id=1, username=jack)}