乐趣区

关于java:品味Spring-Cache设计之美

最近负责教育类产品的架构工作,两位研发同学倡议:“团队封装的 Redis 客户端可否适配Spring Cache,这样加缓存就会不便多了”。

于是边查阅文档边实战,播种颇丰,写这篇文章,想和大家分享笔者学习的过程,一起品尝 Spring Cache 设计之美。

1 硬编码

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

举个例子,为了晋升用户信息的查问效率,咱们对用户信息应用了缓存,示例代码如下:

  @Autowire
  private UserMapper userMapper;
  @Autowire
  private StringCommand stringCommand;
  // 查问用户
  public User getUserById(Long userId) {
   String cacheKey = "userId_" + userId;
   User user=stringCommand.get(cacheKey);
   if(user != null) {return user;}
   user = userMapper.getUserById(userId);
   if(user != null) {stringCommand.set(cacheKey,user);
    return user;
   }
   // 批改用户
   public void updateUser(User user){userMapper.updateUser(user);
    String cacheKey = "userId_" + userId.getId();
    stringCommand.set(cacheKey , user);
   }
   // 删除用户
   public void deleteUserById(Long userId){userMapper.deleteUserById(userId);
     String cacheKey = "userId_" + userId.getId();
     stringCommand.del(cacheKey);
   }
  }

置信很多同学都写过相似格调的代码,这种格调合乎面向过程的编程思维,非常容易了解。但它也有一些毛病:

  1. 代码不够优雅。业务逻辑有四个典型动作:存储 读取 批改 删除 。每次操作都须要定义缓存 Key,调用缓存命令的 API,产生较多的 反复代码
  2. 缓存操作和业务逻辑之间的代码 耦合度高,对业务逻辑有较强的侵入性。

    侵入性次要体现如下两点:

    • 开发联调阶段,须要去掉缓存,只能正文或者长期删除缓存操作代码,也容易出错;
    • 某些场景下,须要更换缓存组件,每个缓存组件有本人的 API,更换老本颇高。

2 缓存形象

首先须要明确一点:Spring Cache 不是一个具体的缓存实现计划,而是一个对 <font color=”red”> 缓存应用的形象 </font>(Cache Abstraction)。

2.1 Spring AOP

Spring AOP 是基于代理模式(proxy-based)。

通常状况下,定义一个对象,调用它的办法的时候,办法是间接被调用的。

 Pojo pojo = new SimplePojo();
 pojo.foo();

将代码做一些调整,pojo 对象的援用批改成代理类。

ProxyFactory factory = new ProxyFactory(new SimplePojo());
factory.addInterface(Pojo.class);
factory.addAdvice(new RetryAdvice());

Pojo pojo = (Pojo) factory.getProxy(); 
//this is a method call on the proxy!
pojo.foo();

调用 pojo 的 foo()办法的时候,实际上是动静生成的代理类调用 foo 办法。

代理类在办法调用前能够获取办法的参数,当调用办法完结后,能够获取调用该办法的返回值,通过这种形式就能够实现缓存的逻辑。

2.2 缓存申明

缓存申明,也就是标识须要缓存的办法以及 缓存策略

Spring Cache 提供了五个注解。

  • @Cacheable:依据办法的申请参数对其后果进行缓存,下次同样的参数来执行该办法时能够间接从缓存中获取后果,而不须要再次执行该办法;
  • @CachePut:依据办法的申请参数对其后果进行缓存,它每次都会触发实在办法的调用;
  • @CacheEvict:依据肯定的条件删除缓存;
  • @Caching:组合多个缓存注解;
  • @CacheConfig:类级别共享缓存相干的公共配置。

咱们重点解说:@Cacheable,@CachePut,@CacheEvict 三个外围注解。

2.2.1 @Cacheable 注解

@Cacheble 注解示意这个办法有了缓存的性能。

@Cacheable(value="user_cache",key="#userId", unless="#result == null")
public User getUserById(Long userId) {User user = userMapper.getUserById(userId);
  return user;
}

下面的代码片段里,getUserById办法和缓存user_cache 关联起来,若办法返回的 User 对象不为空,则缓存起来。第二次雷同参数 userId 调用该办法的时候,间接从缓存中获取数据,并返回。

▍ 缓存 key 的生成

咱们都晓得,缓存的实质是 key-value 存储模式,每一次办法的调用都须要生成相应的 Key, 能力操作缓存。

通常状况下,@Cacheable 有一个属性 key 能够间接定义缓存 key,开发者能够应用 SpEL 语言定义 key 值。

若没有指定属性 key,缓存形象提供了 KeyGenerator来生成 key,默认的生成器代码见下图:

它的算法也很容易了解:

  • 如果没有参数,则间接返回SimpleKey.EMPTY
  • 如果只有一个参数,则间接返回该参数;
  • 若有多个参数,则返回蕴含多个参数的 SimpleKey 对象。

当然 Spring Cache 也思考到须要自定义 Key 生成形式,须要咱们实现org.springframework.cache.interceptor.KeyGenerator 接口。

Object generate(Object target, Method method, Object... params);

而后指定 @Cacheable 的 keyGenerator 属性。

@Cacheable(value="user_cache", keyGenerator="myKeyGenerator", unless="#result == null")
public User getUserById(Long userId) 

▍ 缓存条件

有的时候,办法执行的后果是否须要缓存,依赖于办法的参数或者办法执行后的返回值。

注解里能够通过 condition 属性,通过 Spel 表达式返回的后果是 true 还是 false 判断是否须要缓存。

@Cacheable(cacheNames="book", condition="#name.length() < 32")
public Book findBook(String name)

下面的代码片段里,当参数的长度小于 32,办法执行的后果才会缓存。

除了 condition,unless属性也能够决定后果是否缓存,不过是在执行办法后。

@Cacheable(value="user_cache",key="#userId", unless="#result == null")
public User getUserById(Long userId) {

下面的代码片段里,当返回的后果为 null 则不缓存。

2.2.2 @CachePut 注解

@CachePut 注解作用于缓存须要被更新的场景,和 @Cacheable 十分类似,但被注解的办法每次都会被执行。

返回值是否会放入缓存,依赖于 condition 和 unless,默认状况下后果会存储到缓存。

@CachePut(value = "user_cache", key="#user.id", unless = "#result != null")
public User updateUser(User user) {userMapper.updateUser(user);
    return user;
}

当调用 updateUser 办法时,每次办法都会被执行,然而因为 unless 属性每次都是 true,所以并没有将后果缓存。当去掉 unless 属性,则后果会被缓存。

2.2.3 @CacheEvict 注解

@CacheEvict 注解的办法在调用时会从缓存中移除已存储的数据。

@CacheEvict(value = "user_cache", key = "#id")
public void deleteUserById(Long id) {userMapper.deleteUserById(id);
}

当调用 deleteUserById 办法实现后,缓存 key 等于参数 id 的缓存会被删除,而且办法的返回的类型是 Void,这和 @Cacheable 显著不同。

2.3 缓存配置

Spring Cache 是一个对 <font color=”red”> 缓存应用的形象 </font>,它提供了多种存储集成。

要应用它们,须要简略地申明一个适当的 CacheManager – 一个管制和治理Cache 的实体。

咱们以 Spring Cache 默认的缓存实现 Simple 例子,简略摸索下 CacheManager 的机制。

CacheManager 非常简单:

public interface CacheManager {
   @Nullable
   Cache getCache(String name);
   
   Collection<String> getCacheNames();}

在 CacheConfigurations 配置类中,能够看到不同集成类型有不同的缓存配置类。

通过 SpringBoot 的主动拆卸机制,创立 CacheManager 的实现类ConcurrentMapCacheManager

ConcurrentMapCacheManager 的 getCache 办法,会创立ConcurrentCacheMap

ConcurrentCacheMap实现了 org.springframework.cache.Cache 接口。

从 Spring Cache 的 Simple 的实现,缓存配置须要实现两个接口:

1 org.springframework.cache.CacheManager

2 org.springframework.cache.Cache

3 入门例子

首先咱们先创立一个工程 spring-cache-demo。

caffeine 和 redisson 别离是本地内存和分布式缓存 Redis 框架中的佼佼者,咱们别离演示如何集成它们。

3.1 集成 caffeine

3.1.1 maven 依赖

<dependency>
  <groupId>org.springframework.boot</groupId>
  <artifactId>spring-boot-starter-cache</artifactId>
</dependency>
<dependency>
  <groupId>com.github.ben-manes.caffeine</groupId>
  <artifactId>caffeine</artifactId>
  <version>2.7.0</version>
</dependency>

3.1.2 Caffeine 缓存配置

咱们先创立一个缓存配置类 MyCacheConfig。

@Configuration
@EnableCaching
public class MyCacheConfig {
  @Bean
  public Caffeine caffeineConfig() {
    return
      Caffeine.newBuilder()
      .maximumSize(10000).
      expireAfterWrite(60, TimeUnit.MINUTES);
  }
  @Bean
  public CacheManager cacheManager(Caffeine caffeine) {CaffeineCacheManager caffeineCacheManager = new CaffeineCacheManager();
    caffeineCacheManager.setCaffeine(caffeine);
    return caffeineCacheManager;
  }
}

首先创立了一个 Caffeine 对象,该对象标识本地缓存的最大数量是 10000 条,每个缓存数据在写入 60 分钟后生效。

另外,MyCacheConfig 类上咱们增加了注解:@EnableCaching

3.1.3 业务代码

依据 缓存申明 这一节,咱们很容易写出如下代码。

@Cacheable(value = "user_cache", unless = "#result == null")
public User getUserById(Long id) {return userMapper.getUserById(id);
}
@CachePut(value = "user_cache", key = "#user.id", unless = "#result == null")
public User updateUser(User user) {userMapper.updateUser(user);
    return user;
}
@CacheEvict(value = "user_cache", key = "#id")
public void deleteUserById(Long id) {userMapper.deleteUserById(id);
}

这段代码与硬编码里的代码片段显著精简很多。

当咱们在 Controller 层调用 getUserById 办法时,调试的时候,配置 mybatis 日志级别为 DEBUG,不便监控办法是否会缓存。

第一次调用会查询数据库,打印相干日志:

Preparing: select * FROM user t where t.id = ? 
Parameters: 1(Long)
Total: 1

第二次调用查询方法的时候,数据库 SQL 日志就没有呈现了,也就阐明缓存失效了。

3.2 集成 redisson

3.2.1 maven 依赖

<dependency>
   <groupId>org.redisson</groupId>
   <artifactId>redisson</artifactId>
   <version>3.12.0</version>
</dependency>

3.2.2 redisson 缓存配置

@Bean(destroyMethod = "shutdown")
public RedissonClient redisson() {Config config = new Config();
  config.useSingleServer()
        .setAddress("redis://127.0.0.1:6201").setPassword("ts112GpO_ay");
  return Redisson.create(config);
}
@Bean
CacheManager cacheManager(RedissonClient redissonClient) {Map<String, CacheConfig> config = new HashMap<String, CacheConfig>();
 // create "user_cache" spring cache with ttl = 24 minutes and maxIdleTime = 12 minutes
  config.put("user_cache", 
             new CacheConfig(
             24 * 60 * 1000, 
             12 * 60 * 1000));
  return new RedissonSpringCacheManager(redissonClient, config);
}

能够看到,从 Caffeine 切换到 Redisson,只须要批改缓存配置类,定义CacheManager 对象即可。而业务代码并不需要改变。

Controller 层调用 getUserById 办法,用户 ID 为 1 的时候,能够从 Redis Desktop Manager 里看到:用户信息已被缓存,user_cache 缓存存储是 Hash 数据结构。

因为 redisson 默认的编解码是FstCodec,能够看到 key 的名称是:\xF6\x01。

在缓存配置代码里,能够批改编解码器。

public RedissonClient redisson() {Config config = new Config();
  config.useSingleServer()
        .setAddress("redis://127.0.0.1:6201").setPassword("ts112GpO_ay");
  config.setCodec(new JsonJacksonCodec());
  return Redisson.create(config);
}

再次调用 getUserById 办法,控制台就变成:

能够察看到:缓存 key 曾经变成了:[“java.lang.Long”,1],扭转序列化后 key 和 value 已产生了变动。

3.3 从列表缓存再次了解缓存形象

列表缓存在业务中常常会遇到。通常有两种实现模式:

  1. 整体列表缓存;
  2. 依照每个条目缓存,通过 redis,memcached 的聚合查询方法批量获取列表,若缓存没有命中,则从数据库从新加载,并放入缓存里。

那么 Spring cache 整合 Redisson 如何缓存列表数据呢?

@Cacheable(value = "user_cache")
public List<User> getUserList(List<Long> idList) {return userMapper.getUserByIds(idList);
}

执行 getUserList 办法,参数 id 列表为:[1,3]。

执行实现之后,控制台里能够看到:<font color=”red”> 列表整体间接被缓存起来 </font>,用户列表缓存和用户条目缓存并 没有共享,他们是平行的关系。

这种状况下,缓存的颗粒度管制也没有那么粗疏。

相似这样的思考,很多开发者也向 Spring Framework 研发团队提过。

官网的答复也很明确:对于缓存形象来讲,它并不关怀办法返回的数据类型,如果是汇合,那么也就意味着须要把汇合数据在缓存中保存起来。

还有一位开发者,定义了一个 @CollectionCacheable注解,并做出了原型,扩大了 Spring Cache 的列表缓存性能。

 @Cacheable("myCache")
 public String findById(String id) {//access DB backend return item}
 @CollectionCacheable("myCache") 
 public Map<String, String> findByIds(Collection<String> ids) {//access DB backend,return map of id to item}

官网也未驳回,因为 缓存形象并不想引入太多的复杂性

写到这里,置信大家对缓存形象有了更进一步的了解。当咱们想实现更简单的缓存性能时,须要对 Spring Cache 做肯定水平的扩大。

4 自定义二级缓存

4.1 利用场景

笔者已经在原来的我的项目,高并发场景下屡次应用多级缓存。多级缓存是一个十分乏味的性能点,值得咱们去扩大。

多级缓存有如下劣势:

  1. 离用户越近,速度越快;
  2. 缩小分布式缓存查问频率,升高序列化和反序列化的 CPU 耗费;
  3. 大幅度缩小网络 IO 以及带宽耗费。

过程内缓存做为一级缓存,分布式缓存做为二级缓存,首先从一级缓存中查问,若能查问到数据则间接返回,否则从二级缓存中查问,若二级缓存中能够查问到数据,则回填到一级缓存中,并返回数据。若二级缓存也查问不到,则从数据源中查问,将后果别离回填到一级缓存,二级缓存中。

Spring Cache 并没有二级缓存的实现,咱们能够实现一个繁难的二级缓存 DEMO,加深对技术的了解。

4.2 设计思路

咱们设计了四个类,用了大略两个小时开发实现

  1. MultiLevelCacheManager:多级缓存管理器;
  2. MultiLevelChannel:封装 Caffeine 和 RedissonClient;
  3. MultiLevelCache:实现 org.springframework.cache.Cache 接口;
  4. MultiLevelCacheConfig:配置缓存过期工夫等;

MultiLevelCacheManager 是最外围的类,须要实现 getCachegetCacheNames两个接口。

创立多级缓存,第一级缓存是:Caffeine , 第二级缓存是:Redisson。

二级缓存,为了疾速实现 DEMO,咱们应用 Redisson 对 Spring Cache 的扩大类RedissonCache。它的底层是RMap,底层存储是 Hash。

咱们重点看下缓存的「查问」和「存储」的办法:

@Override
public ValueWrapper get(Object key) {Object result = getRawResult(key);
    return toValueWrapper(result);
}

public Object getRawResult(Object key) {logger.info("从一级缓存查问 key:" + key);
    Object result = localCache.getIfPresent(key);
    if (result != null) {return result;}
    logger.info("从二级缓存查问 key:" + key);
    result = redissonCache.getNativeCache().get(key);
    if (result != null) {localCache.put(key, result);
    }
    return result;
}

查问」数据的流程:

  1. 先从本地缓存中查问数据,若能查问到,间接返回;
  2. 本地缓存查问不到数据,查问分布式缓存,若能够查问进去,回填到本地缓存,并返回;
  3. 若分布式缓存查问不到数据,则默认会执行被注解的办法。

上面来看下「存储」的代码:

public void put(Object key, Object value) {logger.info("写入一级缓存 key:" + key);
    localCache.put(key, value);
    logger.info("写入二级缓存 key:" + key);
    redissonCache.put(key, value);
}

最初配置缓存管理器,原有的业务代码不变。

执行下 getUserById 办法,查问用户编号为 1 的用户信息。

- 从一级缓存查问 key:1
- 从二级缓存查问 key:1
- ==> Preparing: select * FROM user t where t.id = ? 
- ==> Parameters: 1(Long)
- <== Total: 1
- 写入一级缓存 key:1
- 写入二级缓存 key:1

第二次执行雷同的动作,从日志可用看到从优先会从本地内存中查问出后果。

- 从一级缓存查问 key:1

期待 30s,再执行一次,因为本地缓存会生效,所以执行的时候会查问二级缓存

- 从一级缓存查问 key:1
- 从二级缓存查问 key:1

一个繁难的二级缓存就组装完了。

5 什么场景抉择 Spring Cache

在做技术选型的时候,须要针对场景抉择不同的技术。

笔者认为 Spring Cache 的性能很弱小,设计也十分优雅。特地适宜缓存管制没有那么粗疏的场景。比方门户首页,偏动态展现页面,榜单等等。这些场景的特点是对数据实时性没有那么严格的要求,只须要将数据源缓存下来,过期之后主动刷新即可。这些场景,Spring Cache 就是神器,能大幅度晋升研发效率。

但在缓存颗粒度的管制上,还是须要做性能扩大,重点实现如下三点:

  1. 多级缓存;
  2. 列表缓存;
  3. 缓存变更监听器;

笔者也在思考这几点的过程,研读了 j2cache , jetcache 相干源码,受益匪浅。后续的文章会重点分享下笔者的心得。


如果我的文章对你有所帮忙,还请帮忙 点赞、在看、转发 一下,你的反对会激励我输入更高质量的文章,非常感谢!

本文由博客一文多发平台 OpenWrite 公布!

退出移动版