一、聊聊什么是硬编码使用缓存?

在学习Spring Cache之前,笔者经常会硬编码的方式使用缓存。

我们来举个实际中的例子,为了提升用户信息的查询效率,我们对用户信息使用了缓存,示例代码如下:

  1. @Autowire
  2. private UserMapper userMapper;
  3. @Autowire
  4. private RedisCache redisCache;
  5. //查询用户
  6. public User getUserById(Long userId) {
  7. //定义缓存key
  8. String cacheKey = "userId_" + userId;
  9. //先查询redis缓存
  10. User user = redisCache.get(cacheKey);
  11. //如果缓存中有就直接返回,不再查询数据库
  12. if (user != null) {
  13. return user;
  14. }
  15. //没有再查询数据库
  16. user = userMapper.getUserById(userId);
  17. //数据存入缓存,这样下次查询就能到缓存中获取
  18. if (user != null) {
  19. stringCommand.set(cacheKey, user);
  20. }
  21. return user;
  22. }

相信很多同学都写过类似风格的代码,这种风格符合面向过程的编程思维,非常容易理解。但它也有一些缺点:

代码不够优雅。业务逻辑有四个典型动作:存储,读取,修改,删除。每次操作都需要定义缓存Key ,调用缓存命令的API,产生较多的重复代码;

缓存操作和业务逻辑之间的代码耦合度高,对业务逻辑有较强的侵入性。侵入性主要体现如下两点:

  • 开发联调阶段,需要去掉缓存,只能注释或者临时删除缓存操作代码,也容易出错

  • 某些场景下,需要更换缓存组件,每个缓存组件有自己的API,更换成本颇高

如果说是下面这样的,是不是就优雅多了。

  1. @Mapper
  2. public interface UserMapper {
  3. /**
  4. * 根据用户id获取用户信息
  5. *
  6. * 如果缓存中有直接返回缓存数据,如果没有那么就去数据库查询,查询完再插入缓存中,这里缓存的key前缀为cache_user_id_,+传入的用户ID
  7. */
  8. @Cacheable(key = "'cache_user_id_' + #userId")
  9. User getUserById(Long userId);
  10. }

再看实现类

  1. @Autowire
  2. private UserMapper userMapper;
  3. //查询用户
  4. public User getUserById(Long userId) {
  5. return userMapper.getUserById(userId);
  6. }

这么一看是不是完全和缓存分离开来,如果开发联调阶段,需要去掉缓存那么直接注释掉注解就好了,是不是非常完美。

而且这一整套实现都不要自己手动写,Spring Cache就已经帮我定义好相关注解和接口,我们可以轻易实现上面的功能。

二、Spring Cache简介

Spring Cache是Spring-context包中提供的基于注解方式使用的缓存组件,定义了一些标准接口,通过实现这些接口,就可以通过在

方法上增加注解来实现缓存。这样就能够避免缓存代码与业务处理耦合在一起的问题。

Spring Cache核心的接口就两个:CacheCacheManager

1、Cache接口

该接口定义提供缓存的具体操作,比如缓存的放入、读取、清理:

  1. package org.Springframework.cache;
  2. import java.util.concurrent.Callable;
  3. public interface Cache {
  4. // cacheName,缓存的名字,默认实现中一般是CacheManager创建Cache的bean时传入cacheName
  5. String getName();
  6. //得到底层使用的缓存,如Ehcache
  7. Object getNativeCache();
  8. // 通过key获取缓存值,注意返回的是ValueWrapper,为了兼容存储空值的情况,将返回值包装了一层,通过get方法获取实际值
  9. ValueWrapper get(Object key);
  10. // 通过key获取缓存值,返回的是实际值,即方法的返回值类型
  11. <T> T get(Object key, Class<T> type);
  12. // 通过key获取缓存值,可以使用valueLoader.call()来调使用@Cacheable注解的方法。当@Cacheable注解的sync属性配置为true时使用此方法。
  13. // 因此方法内需要保证回源到数据库的同步性。避免在缓存失效时大量请求回源到数据库
  14. <T> T get(Object key, Callable<T> valueLoader);
  15. // 将@Cacheable注解方法返回的数据放入缓存中
  16. void put(Object key, Object value);
  17. // 当缓存中不存在key时才放入缓存。返回值是当key存在时原有的数据
  18. ValueWrapper putIfAbsent(Object key, Object value);
  19. // 删除缓存
  20. void evict(Object key);
  21. // 清空缓存
  22. void clear();
  23. // 缓存返回值的包装
  24. interface ValueWrapper {
  25. // 返回实际缓存的对象
  26. Object get();
  27. }
  28. }

2、CacheManager接口

主要提供Cache实现bean的创建,每个应用里可以通过cacheName来对Cache进行隔离,每个cacheName对应一个Cache实现。

  1. package org.Springframework.cache;
  2. import java.util.Collection;
  3. public interface CacheManager {
  4. // 通过cacheName创建Cache的实现bean,具体实现中需要存储已创建的Cache实现bean,避免重复创建,也避免内存缓存
  5. //对象(如Caffeine)重新创建后原来缓存内容丢失的情况
  6. Cache getCache(String name);
  7. // 返回所有的cacheName
  8. Collection<String> getCacheNames();
  9. }

3、常用注解说明

@Cacheable:主要应用到查询数据的方法上。

  1. public @interface Cacheable {
  2. // cacheNames,CacheManager就是通过这个名称创建对应的Cache实现bean
  3. @AliasFor("cacheNames")
  4. String[] value() default {};
  5. @AliasFor("value")
  6. String[] cacheNames() default {};
  7. // 缓存的key,支持SpEL表达式。默认是使用所有参数及其计算的hashCode包装后的对象(SimpleKey)
  8. String key() default "";
  9. // 缓存key生成器,默认实现是SimpleKeyGenerator
  10. String keyGenerator() default "";
  11. // 指定使用哪个CacheManager,如果只有一个可以不用指定
  12. String cacheManager() default "";
  13. // 缓存解析器
  14. String cacheResolver() default "";
  15. // 缓存的条件,支持SpEL表达式,当达到满足的条件时才缓存数据。在调用方法前后都会判断
  16. String condition() default "";
  17. // 满足条件时不更新缓存,支持SpEL表达式,只在调用方法后判断
  18. String unless() default "";
  19. // 回源到实际方法获取数据时,是否要保持同步,如果为false,调用的是Cache.get(key)方法;如果为true,调用的是Cache.get(key, Callable)方法
  20. boolean sync() default false;
  21. }

@CacheEvict:清除缓存,主要应用到删除数据的方法上。相比Cacheable多了两个属性

  1. public @interface CacheEvict {
  2. // ...相同属性说明请参考@Cacheable中的说明
  3. // 是否要清除所有缓存的数据,为false时调用的是Cache.evict(key)方法;为true时调用的是Cache.clear()方法
  4. boolean allEntries() default false;
  5. // 调用方法之前或之后清除缓存
  6. boolean beforeInvocation() default false;
  7. }

@CachePut:放入缓存,主要用到对数据有更新的方法上。属性说明参考@Cacheable

@Caching:用于在一个方法上配置多种注解

@EnableCaching:启用Spring cache缓存,作为总的开关,在SpringBoot的启动类或配置类上需要加上此注解才会生效

三、使用二级缓存需要思考的一些问题?

我们知道关系数据库(Mysql)数据最终存储在磁盘上,如果每次都从数据库里去读取,会因为磁盘本身的IO影响读取速度,所以就有了

像redis这种的内存缓存。

通过内存缓存确实能够很大程度的提高查询速度,但如果同一查询并发量非常的大,频繁的查询redis,也会有明显的网络IO上的消耗,

那我们针对这种查询非常频繁的数据(热点key),我们是不是可以考虑存到应用内缓存,如:caffeine。

当应用内缓存有符合条件的数据时,就可以直接使用,而不用通过网络到redis中去获取,这样就形成了两级缓存。

应用内缓存叫做一级缓存,远程缓存(如redis)叫做二级缓存

整个流程如下

流程看着是很清新,但其实二级缓存需要考虑的点还很多。

1.如何保证分布式节点一级缓存的一致性?

我们说一级缓存是应用内缓存,那么当你的项目部署在多个节点的时候,如何保证当你对某个key进行修改删除操作时,使其它节点

的一级缓存一致呢?

2.是否允许存储空值?

这个确实是需要考虑的点。因为如果某个查询缓存和数据库中都没有,那么就会导致频繁查询数据库,导致数据库Down,这也是我们

常说的缓存穿透。

但如果存储空值呢,因为可能会存储大量的空值,导致缓存变大,所以这个最好是可配置,按照业务来决定是否开启。

3.是否需要缓存预热?

也就是说,我们会觉得某些key一开始就会非常的热,也就是热点数据,那么我们是否可以一开始就先存储到缓存中,避免缓存击穿。

4.一级缓存存储数量上限的考虑?

既然一级缓存是应用内缓存,那你是否考虑一级缓存存储的数据给个限定最大值,避免存储太多的一级缓存导致OOM。

5.一级缓存过期策略的考虑?

我们说redis作为二级缓存,redis是淘汰策略来管理的。具体可参考redis的8种淘汰策略。那你的一级缓存策略呢?就好比你设置一级缓存

数量最大为5000个,那当第5001个进来的时候,你是怎么处理呢?是直接不保存,还是说自定义LRU或者LFU算法去淘汰之前的数据?

6.一级缓存过期了如何清除?

我们说redis作为二级缓存,我们有它的缓存过期策略(定时、定期、惰性),那你的一级缓存呢,过期如何清除呢?

这里4、5、6小点如果说用我们传统的Map显然实现是很费劲的,但现在有更好用的一级缓存库那就是Caffeine

四、Caffeine 简介

Caffeine,一个用于Java的高性能缓存库。

缓存和Map之间的一个根本区别是缓存会清理存储的项目

1、写入缓存策略

Caffeine有三种缓存写入策略:手动同步加载异步加载

2、缓存值的清理策略

Caffeine有三种缓存值的清理策略:基于大小基于时间基于引用

基于容量:当缓存大小超过配置的大小限制时会发生回收。

基于时间

  1. 写入后到期策略。
  2. 访问后过期策略。
  3. 到期时间由 Expiry 实现独自计算。

基于引用:启用基于缓存键值的垃圾回收。

  • Java种有四种引用:强引用,软引用,弱引用和虚引用,caffeine可以将值封装成弱引用或软引用。
  • 软引用:如果一个对象只具有软引用,则内存空间足够,垃圾回收器就不会回收它;如果内存空间不足了,就会回收这些对象的内存。
  • 弱引用:在垃圾回收器线程扫描它所管辖的内存区域的过程中,一旦发现了只具有弱引用的对象,不管当前内存空间足够与否,都会

    回收它的内存。

3、统计

Caffeine提供了一种记录缓存使用统计信息的方法,可以实时监控缓存当前的状态,以评估缓存的健康程度以及缓存命中率等,方便后

续调整参数。

4、高效的缓存淘汰算法

缓存淘汰算法的作用是在有限的资源内,尽可能识别出哪些数据在短时间会被重复利用,从而提高缓存的命中率。常用的缓存淘汰算法有

LRU、LFU、FIFO等。

  1. FIFO:先进先出。选择最先进入的数据优先淘汰。
  2. LRU:最近最少使用。选择最近最少使用的数据优先淘汰。
  3. LFU:最不经常使用。选择在一段时间内被使用次数最少的数据优先淘汰。

LRU(Least Recently Used)算法认为最近访问过的数据将来被访问的几率也更高。

LRU通常使用链表来实现,如果数据添加或者被访问到则把数据移动到链表的头部,链表的头部为热数据,链表的尾部如冷数据,当

数据满时,淘汰尾部的数据。

LFU(Least Frequently Used)算法根据数据的历史访问频率来淘汰数据,其核心思想是“如果数据过去被访问多次,那么将来被访问

的频率也更高”。根据LFU的思想,如果想要实现这个算法,需要额外的一套存储用来存每个元素的访问次数,会造成内存资源的浪费。

Caffeine采用了一种结合LRU、LFU优点的算法:W-TinyLFU,其特点:高命中率、低内存占用。

5、其他说明

Caffeine的底层数据存储采用ConcurrentHashMap。因为Caffeine面向JDK8,在jdk8中ConcurrentHashMap增加了红黑树,在hash冲突

严重时也能有良好的读性能。

五、基于Spring Cache实现二级缓存(Caffeine+Redis)

前面说了,使用了redis缓存,也会存在一定程度的网络传输上的消耗,所以会考虑应用内缓存,但有点很重要的要记住:

应用内缓存可以理解成比redis缓存更珍惜的资源,所以,caffeine 不适用于数据量大,并且缓存命中率极低的业务场景,如用户维度的缓存。

当前项目针对应用都部署了多个节点,一级缓存是在应用内的缓存,所以当对数据更新和清除时,需要通知所有节点进行清理缓存的操作。

可以有多种方式来实现这种效果,比如:zookeeper、MQ等,但是既然用了redis缓存,redis本身是有支持订阅/发布功能的,所以就

不依赖其他组件了,直接使用redis的通道来通知其他节点进行清理缓存的操作。

当某个key进行更新删除操作时,通过发布订阅的方式通知其它节点进行删除该key本地的一级缓存就可以了。

具体具体项目代码这里就不再粘贴出来了,这样只粘贴如何引用这个starter包。

1、maven引入使用

  1. <dependency>
  2. <groupId>com.jincou</groupId>
  3. <artifactId>redis-caffeine-cache-starter</artifactId>
  4. <version>1.0.0</version>
  5. </dependency>

2、application.yml

添加二级缓存相关配置

  1. # 二级缓存配置
  2. # 注:caffeine 不适用于数据量大,并且缓存命中率极低的业务场景,如用户维度的缓存。请慎重选择。
  3. l2cache:
  4. config:
  5. # 是否存储空值,默认true,防止缓存穿透
  6. allowNullValues: true
  7. # 组合缓存配置
  8. composite:
  9. # 是否全部启用一级缓存,默认false
  10. l1AllOpen: false
  11. # 是否手动启用一级缓存,默认false
  12. l1Manual: true
  13. # 手动配置走一级缓存的缓存key集合,针对单个key维度
  14. l1ManualKeySet:
  15. - userCache:user01
  16. - userCache:user02
  17. - userCache:user03
  18. # 手动配置走一级缓存的缓存名字集合,针对cacheName维度
  19. l1ManualCacheNameSet:
  20. - userCache
  21. - goodsCache
  22. # 一级缓存
  23. caffeine:
  24. # 是否自动刷新过期缓存 true 是 false 否
  25. autoRefreshExpireCache: false
  26. # 缓存刷新调度线程池的大小
  27. refreshPoolSize: 2
  28. # 缓存刷新的频率(秒)
  29. refreshPeriod: 10
  30. # 写入后过期时间(秒)
  31. expireAfterWrite: 180
  32. # 访问后过期时间(秒)
  33. expireAfterAccess: 180
  34. # 初始化大小
  35. initialCapacity: 1000
  36. # 最大缓存对象个数,超过此数量时之前放入的缓存将失效
  37. maximumSize: 3000
  38. # 二级缓存
  39. redis:
  40. # 全局过期时间,单位毫秒,默认不过期
  41. defaultExpiration: 300000
  42. # 每个cacheName的过期时间,单位毫秒,优先级比defaultExpiration高
  43. expires: {userCache: 300000,goodsCache: 50000}
  44. # 缓存更新时通知其他节点的topic名称 默认 cache:redis:caffeine:topic
  45. topic: cache:redis:caffeine:topic

3、启动类上增加@EnableCaching

  1. /**
  2. * 启动类
  3. */
  4. @EnableCaching
  5. @SpringBootApplication
  6. public class CacheApplication {
  7. public static void main(String[] args) {
  8. SpringApplication.run(CacheApplication.class, args);
  9. }
  10. }

4、在需要缓存的方法上增加@Cacheable注解

  1. /**
  2. * 测试
  3. */
  4. @Service
  5. public class CaffeineCacheService {
  6. private final Logger logger = LoggerFactory.getLogger(CaffeineCacheService.class);
  7. /**
  8. * 用于模拟db
  9. */
  10. private static Map<String, UserDTO> userMap = new HashMap<>();
  11. {
  12. userMap.put("user01", new UserDTO("1", "张三"));
  13. userMap.put("user02", new UserDTO("2", "李四"));
  14. userMap.put("user03", new UserDTO("3", "王五"));
  15. userMap.put("user04", new UserDTO("4", "赵六"));
  16. }
  17. /**
  18. * 获取或加载缓存项
  19. */
  20. @Cacheable(key = "'cache_user_id_' + #userId", value = "userCache")
  21. public UserDTO queryUser(String userId) {
  22. UserDTO userDTO = userMap.get(userId);
  23. try {
  24. Thread.sleep(1000);// 模拟加载数据的耗时
  25. } catch (InterruptedException e) {
  26. e.printStackTrace();
  27. }
  28. logger.info("加载数据:{}", userDTO);
  29. return userDTO;
  30. }
  31. /**
  32. * 获取或加载缓存项
  33. * <p>
  34. * 注:因底层是基于caffeine来实现一级缓存,所以利用的caffeine本身的同步机制来实现
  35. * sync=true 则表示并发场景下同步加载缓存项,
  36. * sync=true,是通过get(Object key, Callable<T> valueLoader)来获取或加载缓存项,此时valueLoader(加载缓存项的具体逻辑)会被缓存起来,所以CaffeineCache在定时刷新过期缓存时,缓存项过期则会重新加载。
  37. * sync=false,是通过get(Object key)来获取缓存项,由于没有valueLoader(加载缓存项的具体逻辑),所以CaffeineCache在定时刷新过期缓存时,缓存项过期则会被淘汰。
  38. * <p>
  39. */
  40. @Cacheable(value = "userCache", key = "#userId", sync = true)
  41. public List<UserDTO> queryUserSyncList(String userId) {
  42. UserDTO userDTO = userMap.get(userId);
  43. List<UserDTO> list = new ArrayList();
  44. list.add(userDTO);
  45. logger.info("加载数据:{}", list);
  46. return list;
  47. }
  48. /**
  49. * 更新缓存
  50. */
  51. @CachePut(value = "userCache", key = "#userId")
  52. public UserDTO putUser(String userId, UserDTO userDTO) {
  53. return userDTO;
  54. }
  55. /**
  56. * 淘汰缓存
  57. */
  58. @CacheEvict(value = "userCache", key = "#userId")
  59. public String evictUserSync(String userId) {
  60. return userId;
  61. }
  62. }

项目源码: https://github.com/yudiandemingzi/springboot-redis-caffeine-cache

推荐相关二级缓存相关项目

1.阿里巴巴jetcache: https://github.com/alibaba/jetcache

2.J2Cache: https://gitee.com/ld/J2Cache

3.l2cache: https://github.com/ck-jesse/l2cache(感谢)

这几个现在业界比较常用的二级缓存项目,功能更加强大,而且性能更高效,使用也非常方便只要引入jar包,添加配置注解就可以。

基于Spring Cache实现二级缓存(Caffeine+Redis)的更多相关文章

  1. 【开源项目系列】如何基于 Spring Cache 实现多级缓存(同时整合本地缓存 Ehcache 和分布式缓存 Redis)

    一.缓存 当系统的并发量上来了,如果我们频繁地去访问数据库,那么会使数据库的压力不断增大,在高峰时甚至可以出现数据库崩溃的现象.所以一般我们会使用缓存来解决这个数据库并发访问问题,用户访问进来,会先从 ...

  2. 「性能提升」扩展 Spring Cache 支持多级缓存

    为什么多级缓存 缓存的引入是现在大部分系统所必须考虑的 redis 作为常用中间件,虽然我们一般业务系统(毕竟业务量有限)不会遇到如下图 在随着 data-size 的增大和数据结构的复杂的造成性能下 ...

  3. ssh整合hibernate 使用spring管理hibernate二级缓存,配置hibernate4.0以上二级缓存

    ssh整合hibernate 使用spring管理hibernate二级缓存,配置hibernate4.0以上二级缓存 hibernate  : Hibernate是一个持久层框架,经常访问物理数据库 ...

  4. spring(三、spring中的eheche缓存、redis使用)

    spring(三.spring中的eheche缓存.redis使用) 本文主要介绍为什么要构建ehcache+redis两级缓存?以及在实战中如何实现?思考如何配置缓存策略更合适?这样的方案可能遗留什 ...

  5. Spring Boot 2.x 缓存应用 Redis注解与非注解方式入门教程

    Redis 在 Spring Boot 2.x 中相比 1.5.x 版本,有一些改变.redis 默认链接池,1.5.x 使用了 jedis,而2.x 使用了 lettuce Redis 接入 Spr ...

  6. MyBatis功能点一应用:二级缓存整合redis

    Mybatis提供了默认的cache实现PerpetualCache,那为什么还要整合第三方的框架redis?因为Mybatis提供的cache实现为单机版,无法实现分布式存储(即本机存储的数据,其他 ...

  7. Mybatis基于注解开启使用二级缓存

    关于Mybatis的一级缓存和二级缓存的概念以及理解可以参照前面文章的介绍.前文连接:https://www.cnblogs.com/hopeofthevillage/p/11427438.html, ...

  8. 基于spring的redisTemplate的缓存工具类

    pom.xml文件添加 <!-- config redis data and client jar --><dependency> <groupId>org.spr ...

  9. 基于LRU Cache的简单缓存

    package com.test.testCache; import java.util.Map; import org.json.JSONArray; import org.json.JSONExc ...

随机推荐

  1. c++类模板与其他

    static static的成员不再单独属于一个对象,他是单独的保存在内存的某个地址,也就只有一份.所以在设计程序的时候要看这个东西是不是只需要一份. static函数和一般的函数一样,在内存中只有一 ...

  2. 带你十天轻松搞定 Go 微服务系列(九、链路追踪)

    序言 我们通过一个系列文章跟大家详细展示一个 go-zero 微服务示例,整个系列分十篇文章,目录结构如下: 环境搭建 服务拆分 用户服务 产品服务 订单服务 支付服务 RPC 服务 Auth 验证 ...

  3. JAVA8学习——新的时间日期API&Java8总结

    JAVA8-时间日期API java8之前用过的时间日期类. Date Calendar SimpleDateFormat 有很多致命的问题. 1.没有时区概念 2.计算麻烦,实现困难 3.类是可变的 ...

  4. 为什么三层架构中业务层(service)、持久层(dao)需要使用一个接口?

    为什么三层架构中业务层(service).持久层(dao)需要使用一个接口? 如果没有接口那么我们在控制层使用业务层或业务层使用持久层时,必须要学习每个方法,若哪一天后者的方法名改变了则直接影响到前面 ...

  5. 清理 Docker 占用的磁盘空间

    Docker 很占用空间,每当我们运行容器.拉取镜像.部署应用.构建自己的镜像时,我们的磁盘空间会被大量占用. 如果你也被这个问题所困扰,咱们就一起看一下 Docker 是如何使用磁盘空间的,以及如何 ...

  6. Python数据分析 | Numpy与1维数组操作

    作者:韩信子@ShowMeAI 教程地址:http://www.showmeai.tech/tutorials/33 本文地址:http://www.showmeai.tech/article-det ...

  7. 快速上手 vue3

    当前为vue3的基础知识点,为总结b站某视频的知识文章,在刚开始学习时自我保存在语雀,现在分享到博客. 目前找不到原视频文章地址了!!!要有兄弟看到原文地址:欢迎在下面评论! Vue3新的特性 Com ...

  8. jmeter实现sha256算法加密

    方法一:自带函数 参数含义 算法摘要:MD2.MD5.SHA-1.SHA-224.SHA-256.SHA-384.SHA-512 String to be hashed:要计算的字符串: Salt t ...

  9. [Java]程序运行时的内存分配

    本文出处:<Thinking in JAVA> 寄存器这是最快的存储区,因为它位于不同于其他存储区的地方--处理器内部.但是寄存器的数量极其有限,所以寄存器根据需求进行分配.你不能直接控制 ...

  10. 巧用 CSS 实现炫彩三角边框动画

    最近有个小伙伴问我,在某个网站看到一个使用 SVG 实现的炫彩三角边框动画,问能否使用 CSS 实现: 很有意思的一个动画效果,立马让我想起了我在 CSS 奇思妙想边框动画 一文中介绍的边框动画,非常 ...