背景
对于高频访问但是低频更新的数据我们一般会做缓存, 尤其是在并发量比较高的业务里, 原始的手段我们可以使用 HashMap 或者 ConcurrentHashMap 来存储.
这样没什么毛病, 但是会面临一个问题, 对于缓存中的数据只有当我们显示的调用 remove 方法, 才会移除某个元素, 即便是高频的数据, 也会有访问命中率的高低之分, 内存总是有限的, 我们不可能无限地去增加 Map 中的数据.
我希望的比较完美的场景时. 对于一个业务, 我只想分配给你 2k 的内存, 我们假设 map 中一条数据 (键值对) 是 1B, 那么最多它能存 2048 条数据, 当数据达到这个量级的时候, 需要淘汰一些访问率比较低的数据来给新的数据腾地方, 使用传统的 HashMap 比较难实现, 因为我们不知道哪些数据访问率低(除非专门去记录), 那么 Guava 针对内存缓存优化的一个组件就闪亮登场了.
准备
上面说到我们需要一种淘汰策略来自动筛选缓存数据, 下面简单了解下, 几种淘汰算法
先进先出算法(FIFO): 这种淘汰策略顾名思义, 先存的先淘汰. 这样简单粗暴, 但是会错杀一些高频访问的数据
最近最少使用算法(LRU): 这个算法能有效优化 FIFO 的问题, 高频访问的数据不太容易被淘汰掉, 但也不能完全避免. GuavaCache 一些特性符合这种算法
最近最少频率算法(LFU): 这个算法又对 LRU 做了优化, 会记录每个数据的访问次数, 综合访问时间和访问次数来淘汰数据.
Guava Cache 基础
GuavaCache 提供了线程安全的实现机制, 简单易用, 上手成本很低, 在需要使用内存做缓存的业务场景时可以考虑使用.
GuavaCache 缓存机制有两个接口, Cache 和 LoadingCache, 后者也是一个接口, 继承自 Cache, 并额外多了几个接口, 如果我们想实例化一个 Cache 对象, 还需要了解一个 CacheBuilder 类, 这个类就是雨从来构建 Cache 对象的, 我们先来用 CacheBuilder 实例化一个 Cache 对象再学习它的一些字段含义.
- public static void main(String[] args) {
- Cache<String,String> myMap = CacheBuilder.newBuilder()
- .expireAfterAccess(30L, TimeUnit.SECONDS)
- .expireAfterWrite(3L,TimeUnit.MINUTES)
- .concurrencyLevel(6)
- .initialCapacity(100)
- .maximumSize(1000)
- .softValues()
- .build();
- myMap.put("name", "张三");
- System.out.println(myMap.getIfPresent("name"));
- }
这样我们就创建一个类似 map 接口的 Cache 对象, 描述一下上面创建的这个对象:
创建了一个 Cache 对象, 这个对象有这样的特性, 初始大小为 100(能存 100 个键值对), 最大 size 为 1000, 在数据写入 3 分钟后会被自动移除, 并且数据如果在 30 秒内, 没有被访问则会被移除, 另外这 Map 结构的对象支持最多 6 个调用方同时更新这个缓存结构的数据, 即并发更新操作最大数量为 6.
我们看到还有一个 softValues()属性没有讲, 会放在下面说明, 其实 CacheBuilder 并不只有这么几个属性可设置, 下面我们具体讲一下.
CacheBuilder 中一些常用的属性字段:
concurrencyLevel(int): 指定允许同时更新的操作数, 若不设置 CacheBuilder 默认为 4, 这个参数会影响缓存存储空间的分块, 可以简单理解为, 默认会创建指定 size 个 map, 每个 map 称为一个区块, 数据会分别存到每个 map 里, 我们根据实际需要设置这个值的大小.
initialCapacity(int): 指定缓存初始化的空间大小, 如果设置了 40, 并且 concurrencyLevel 取默认, 会分成 4 个区块, 每个区块最大的 size 为 10, 当更新数据时, 会对这个区块进行加锁, 这就是为什么说, 允许同时更新的操作数为 4, 延伸一点, 在淘汰数据时, 也是每个区块单独维护自己的淘汰策略. 也就是说, 如果每个区块 size 太大, 竞争就会很激烈.
maximumSize(long): 指定最大缓存大小. 当缓存数据达到最大值时, 会按照策略淘汰掉一些不常用的数据, 需要注意的是, 在缓存数据量快要到达最大值的时候, 就会开始数据的回收, 简单理解为 "防患于未然" 吧
下边三个参数分别是, SoftValues(),weakKeys(),weakValues(), 在解释这三个参数前, 需要我们先了解一下 java 中的软引用, 和弱引用.
和弱引用对应的是强引用, 也是我们在编码过程中最常使用的, 我们声明的变量, 对象, 基本都是强引用, 这样的对象, jvm 在 GC 时不会回收, 哪怕是抛出 OOM.
而弱引用就不一样了, 在 java 中, 用 java.lang.ref.WeakReference 标示声明的值, jvm 在垃圾回收的时候会将它回收掉, 那么软引用呢? 就是用 SoftReference 标示的, 声明为弱引用的对象, 会在 jvm 的内存不足时回收掉.
看出区别了吗, 简单总结下就是, 软引用, 只有在内存不足时才可能被回收, 在正常的垃圾回收时不会被回收, 弱引用, 会在 jvm 进行垃圾回收的时候被删除.
softValues(): 将缓存中的数据设置为 softValues 模式. 数据使用 SoftReference 类声明, 就是在 SoftReference 实例中存储真实的数据. 设置了 softValues()的数据, 会被全局垃圾回收管理器托管, 按照 LRU 的原则来定期 GC 数据. 数据被 GC 后, 可能仍然会被 size 方法计数, 但是对其执行 read 或 write 方法已经无效
weakKeys()和 weakValues(): 当设置为 weakKey 或 weakValues 时, 会使用 (==) 来匹配 key 或者 value 值(默认强引用时, 使用的是 equals 方法), 这种情况下, 数据可能会被 GC, 数据被 GC 后, 可能仍然会被 size 方法计数, 但是对其执行 read 或 write 方法已经无效
Guava cache 在 spring 项目中的使用
下面以一个我在项目中的实际应用梳理一下在 spring 项目中应该如果整合 guava cache
1. 引入 guava 的 maven 依赖
- <dependency>
- <groupId>com.google.guava</groupId>
- <artifactId>guava</artifactId>
- <version>26.0-jre</version>
- </dependency>
上面使用的版本是我在写这篇笔记时的最新版本.
2. 在 application-context.xml 加入配置
- <!-- 开启缓存注解 -->
- <cache:annotation-driven />
- <bean id="cacheManager" class="org.springframework.cache.guava.GuavaCacheManager">
- <property name="cacheSpecification" value="initialCapacity=500,maximumSize=5000,expireAfterAccess=2m,softValues" />
- <property name="cacheNames">
- <list>
- <value>questionCreatedTrack</value>
- </list>
- </property>
- </bean>
在上面配置中我们实现了一个 cacheManager, 这是必须要配置的, 默认配置的是 org.springframework.cache.support.SimpleCacheManager, 我们这里把它改成了 Guava 的缓存管理器的实现. 如果使用其他的实现, 比如 redis, 这里只需要配置成 redis 的相关缓存管理器即可
cacheManager 可以简单理解为保存 Cache 的地方, Cache 里边有我们具体想要缓存的数据, 一般以 key-value 的键值对形式
上述配置的 bean 中声明的两个属性, 一个是 cacheSpecification, 不需要多说了, 参考上面的详细参数, 需要了解一点的是, 这里的参数使用的是 CacheBuilderSpec 类, 以解析代表 CacheBuilder 配置的字符串的形式来创建 CacheBuilder 实例
cacheNames 可以根据自己的实际业务命名, 可声明多个
3. 在代码中使用 spring 的 cache 相关注解
- @Cacheable(value = "questionCreatedTrack",key="#voiceId",condition = "#voiceId>0")
- public Long getQuestionIdByVoiceId(Long anchorId, Long voiceId) {
- String key = String.format(HOMEWORK_QUESTION_ANCHOR_KEY, anchorId);
- String value = redisProxy.getValue(key, String.valueOf(voiceId));
- return StringUtils.isEmpty(value) ? null : Long.parseLong(value);
- }
- @CachePut(value = "questionCreatedTrack",key = "#voiceId",condition = "#voiceId>0")
- public Long statCollectionQuestionToCache(Long anchorId, Long voiceId, Long questionId) {
- String key = String.format(HOMEWORK_QUESTION_ANCHOR_KEY, anchorId);
- redisProxy.setOneToHash(key, String.valueOf(voiceId), String.valueOf(questionId));
- return questionId;
- }
- @CacheEvict(value = "questionCreatedTrack",key="#voiceId")
- public void removeCollectionQuestionFromCache(Long anchorId, Long voiceId) {
- String key = String.format(HOMEWORK_QUESTION_ANCHOR_KEY, anchorId);
- redisProxy.deleteOneToHash(key, String.valueOf(voiceId));
- }
先简单说一下这里的逻辑, 我主要是使用内存做一个一级缓存, redis 做第二级的缓存, 上面三个方法的作用分别是
getQuestionIdByVoiceId(..): 通过 voiceId 查询 questionId, 使用 @Cacheable 注解标记的意思是, 代码执行到这个方法时, 会先去 guava cache 里去找, 有的话直接返回不走方法, 没有的话再去执行方法, 返回后同时加入 Cache, 缓存结构中的 value 是方法的返回值, key 是方法入参中的 vocieId, 这里的缓存结构是, key=voiceId,value=questionId
statCollectionQuestionToCache(): 方法的逻辑是将 voiceId 和 questionId 保存进 redis 里, 使用 @CachePut 注解标记的意思是, 不去缓存里找, 直接执行方法, 执行完方法后, 将键值对加入 Cache.
removeCollectionQuestionFromCache(): 方法的逻辑是删除 redis 中的 key 为 voiceId 的数据, 使用 @CacheEvict 注解标记的意思是, 清除 Cache 中 key 为 voiceId 的数据
通过以上三个注解, 可以实现这样的功能, 当查询 voiceId=123 的对应 questionId 时, 会先去 Cache 里查, Cache 里如果没有再去 redis 里查, 有的话同时加入 Cache,(没有的话, 也会加入 Cache, 这个下面会说), 然后在新增数据以及移除数据的时候, redis 和 Cache 都会同步.
@Cacheable 方法查询结果为 null 怎么处理
这个需要我们根据实际需要, 决定要不要缓存查询结果为 null 的数据, 如果不需要, 需要使用下面的注解
@Cacheable(value = "questionCreatedTrack",key="#voiceId",condition = "#voiceId>0",unless = "#result==null")
参考资料
- http://www.voidcn.com/article/p-pvvfgdga-bos.html
- https://www.cnblogs.com/fashflying/p/6908028.html
来源: https://www.cnblogs.com/fingerboy/p/9549937.html