经过一轮压测,觉得光用redis缓存已经达到一定瓶颈,便想着引入本地缓存试试,众多选择下最终定了guava缓存。以下简要谈谈项目中使用的guava缓存。
缓存是什么
1、Cache是高速缓冲存储器 一种特殊的存储器子系统,其中复制了频繁使用的数据以利于快速访问
2、凡是位于速度相差较大的两种硬件/软件之间的,用于协调两者数据传输速度差异的结构,均可称之为 Cache
为什么要用缓存
为了系统的高并发,高性能,提高访问速度
缓存分类
根据缓存应用的耦合度,可以分为local cache(本地缓存)和remote cache(分布式缓存):
- 本地缓存:指的是在应用中的缓存组件,其最大的优点是应用和cache在同一个进程内部,请求缓存非常快速,没有过多的网络开销等,在单应用不需要集群支持或者集群情况下各节点无需互相通知的场景下使用本地缓存较为合适;同时,它的缺点也是因为缓存跟应用程序耦合,多个应用程序无法直接共享缓存,各应用或集群的各节点都需要维护自己的单独缓存,对内存是一种浪费。(编程直接实现缓存(没用过)、Ehcache(没用过)、Guava Cache)
- 分布式缓存:指的是与应用分离的缓存组件或服务,其最大的优点是自身就是一个独立的应用,与本地应用隔离,多个应用可直接共享缓存。(memcached(没用过)、redis)
guava缓存测试
public static void main(String[] args) throws ExecutionException { CacheLoader<String, String> loader = new CacheLoader<String, String> () { public String load(String key) throws Exception { Thread.sleep(1000); //休眠1s,模拟加载数据 System.out.println(key + " is loaded from a cacheLoader!"); return key + "'s value"; } }; LoadingCache<String,String> loadingCache = CacheBuilder.newBuilder() .maximumSize(3) .build(loader);//在构建时指定自动加载器
//maximumSize为最大存储,超过将把之前的缓存删掉
loadingCache.get("key1"); loadingCache.get("key2"); LoadingCache<String, String> apiValidRulecache = CacheBuilder .newBuilder().initialCapacity(10) .expireAfterWrite(10, TimeUnit.MINUTES) .build(new CacheLoader<String, String>() { @Override public String load(String key) throws Exception { Thread.sleep(1000); //休眠1s,模拟加载数据 System.out.println(key + " is loaded from a cacheLoader!"); return key + "'s value"; } });
//说明:设置过期时间有expireAfterAccess(读之后)和expireAfterWrite(写之后)两种方式
apiValidRulecache.get("key3"); apiValidRulecache.get("key4"); apiValidRulecache.get("key3"); System.out.println(apiValidRulecache.get("key1")); System.out.println(apiValidRulecache.get("key2")); System.out.println(apiValidRulecache.get("key3")); System.out.println(apiValidRulecache.get("key4")); Cache<String,String> cache = CacheBuilder.newBuilder() .maximumSize(3) .recordStats() //开启统计信息开关 .build(); cache.put("key1","value1"); cache.put("key2","value2"); cache.put("key3","value3"); cache.put("key4","value4"); cache.getIfPresent("key1"); cache.getIfPresent("key2"); cache.getIfPresent("key3"); cache.getIfPresent("key4"); cache.getIfPresent("key5"); cache.getIfPresent("key6"); System.out.println(cache.stats()); //获取统计信息 }
测试结果:
guava缓存使用
其中一个例子为,产品接口的限额信息获取。由于与产品方约定好修改内容十分钟生效,所以以下设置为十分钟。
/** 创建guava缓存,缓存所有产品时段限额配置 */
private LoadingCache<String, Map<String, String>> proTpsLimitcache = CacheBuilder
.newBuilder().initialCapacity(10)
.expireAfterWrite(10, TimeUnit.MINUTES)
.build(new CacheLoader<String, Map<String, String>>() {
@Override
public Map<String, String> load(String key) throws Exception {
return saveProTpsLimit(key);
} });
//load缓存数据的方法
private Map<String, String> saveProTpsLimit(String key) {
Map<String, String> info = redisService.getAllTpsLimit(key);
if (info == null) {
info = new HashMap<>();
}
return info;
}
// 获取缓存中product所有的时段限额信息
Map<String, String> proAllTpsLimit = proTpsLimitcache.get(product);
从我的使用来看其实是不涉及缓存更新策略的,而我的应用又是集群部署的,所以我的数据确实会存在数据不一致性。那么会不会有影响呢?实际上是会的,但是我这个业务上允许十分钟生效,没有那么的实时性,所以影响性有限。不过为了以后其他业务还是了解下缓存数据一致性策略。
缓存一致性策略
一致性当然涉及的就是数据库和缓存数据的顺序问题,根据排序有以下几种可能(数据库只涉及更新,缓存涉及更新和删除):
- 先更新缓存再更新数据库
- 先更新数据库再更新缓存
- 先更新数据库再删除缓存(有效)
- 先删除缓存再更新数据库
先更新缓存再更新数据库
(1)线程A更新了缓存
(2)线程B读取数据库老数据更新了缓存
(3)线程A更新了数据库
(4)线程B更新了数据库
还是出现了不一致现象,没用。
先更新数据库再更新缓存
这套方案,大家是普遍反对的。为什么呢?有如下两点原因。
- 原因一(线程安全角度)
同时有请求A和请求B进行更新操作,那么会出现
(1)线程A更新了数据库
(2)线程B更新了数据库
(3)线程B更新了缓存
(4)线程A更新了缓存
这就出现请求A更新缓存应该比请求B更新缓存早才对,但是因为网络等原因,B却比A更早更新了缓存。这就导致了脏数据,因此不考虑。
- 原因二(业务场景角度)
有如下两点:
(1)如果你是一个写数据库场景比较多,而读数据场景比较少的业务需求,采用这种方案就会导致,数据压根还没读到,缓存就被频繁的更新,浪费性能。
(2)如果你写入数据库的值,并不是直接写入缓存的,而是要经过一系列复杂的计算再写入缓存。那么,每次写入数据库后,都再次计算写入缓存的值,无疑是浪费性能的。显然,删除缓存更为适合。
先删除缓存再更新数据库
(1)请求A进行写操作,删除缓存
(2)请求B查询发现缓存不存在
(3)请求B去数据库查询得到旧值
(4)请求B将旧值写入缓存
(5)请求A将新值写入数据库
先更新数据库再删除缓存
Cache-Aside pattern中指出:
-
失效:应用程序先从cache取数据,没有得到,则从数据库中取数据,成功后,放到缓存中。
-
命中:应用程序从cache中取数据,取到后返回。
-
更新:先把数据存到数据库中,成功后,再让缓存失效。
这种情况不存在并发问题么?
不是的。假设这会有两个请求,一个请求A做查询操作,一个请求B做更新操作,那么会有如下情形产生
(1)缓存刚好失效
(2)请求A查询数据库,得一个旧值
(3)请求B将新值写入数据库
(4)请求B删除缓存
(5)请求A将查到的旧值写入缓存
ok,如果发生上述情况,确实是会发生脏数据。
然而,发生这种情况的概率又有多少呢?
发生上述情况有一个先天性条件,就是步骤(3)的写数据库操作比步骤(2)的读数据库操作耗时更短,才有可能使得步骤(4)先于步骤(5)。可是,大家想想,数据库的读操作的速度远快于写操作的(不然做读写分离干嘛,做读写分离的意义就是因为读操作比较快,耗资源少),因此步骤(3)耗时比步骤(2)更短,这一情形很难出现。
假设,有人非要抬杠,有强迫症,一定要解决怎么办?
如何解决上述并发问题?
首先,给缓存设置有效时间是一种方案。其次,采用策略(2)里给出的异步延时删除策略,保证读请求完成以后,再进行删除操作
还有其他造成不一致的原因么?
有的,这也是缓存更新策略(2)和缓存更新策略(3)都存在的一个问题,如果删缓存失败了怎么办,那不是会有不一致的情况出现么。比如一个写数据请求,然后写入数据库了,删缓存失败了,这会就出现不一致的情况了。这也是缓存更新策略(2)里留下的最后一个疑问。
如何解决?
提供一个保障的重试机制即可,这里给出两套方案。
方案一:
如下图所示
流程如下所示
(1)更新数据库数据;
(2)缓存因为种种问题删除失败
(3)将需要删除的key发送至消息队列
(4)自己消费消息,获得需要删除的key
(5)继续重试删除操作,直到成功
然而,该方案有一个缺点,对业务线代码造成大量的侵入。于是有了方案二,在方案二中,启动一个订阅程序去订阅数据库的binlog,获得需要操作的数据。在应用程序中,另起一段程序,获得这个订阅程序传来的信息,进行删除缓存操作。
方案二:
流程如下图所示:
(1)更新数据库数据
(2)数据库会将操作信息写入binlog日志当中
(3)订阅程序提取出所需要的数据以及key
(4)另起一段非业务代码,获得该信息
(5)尝试删除缓存操作,发现删除失败
(6)将这些信息发送至消息队列
(7)重新从消息队列中获得该数据,重试操作。