使用 Spring Cache 实现缓存,这种方式才叫优雅 - 今日头条

本文由 简悦 SimpRead 转码, 原文地址 www.toutiao.com

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

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

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

https://p26.toutiaoimg.com/origin/tos-cn-i-qvj2lq49k0/004f4fbe052b4c24b9c79e81cc0b5cf3?from=pc

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

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

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
@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. 缓存操作和业务逻辑之间的代码耦合度高,对业务逻辑有较强的侵入性。侵入性主要体现如下两点:
  3. 开发联调阶段,需要去掉缓存,只能注释或者临时删除缓存操作代码,也容易出错;
  4. 某些场景下,需要更换缓存组件,每个缓存组件有自己的 API,更换成本颇高。

首先需要明确一点:Spring Cache 不是一个具体的缓存实现方案,而是一个对缓存使用的抽象 (Cache Abstraction)。

https://p26.toutiaoimg.com/origin/tos-cn-i-qvj2lq49k0/c11fd5ebd7ad4e589291c4db8b91f9bb?from=pc

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

通常情况下,定义一个对象,调用它的方法的时候,方法是直接被调用的。

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

https://p26.toutiaoimg.com/origin/tos-cn-i-qvj2lq49k0/16c97ca49498496d8515ced97f53cf14?from=pc

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

1
2
3
4
5
6
7
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();

https://p26.toutiaoimg.com/origin/tos-cn-i-qvj2lq49k0/85be9ab36a8049d6bc69cdcb16f1c793?from=pc

调用 pojo 的 foo 方法的时候,实际上是动态生成的代理类调用 foo 方法。

代理类在方法调用前可以获取方法的参数,当调用方法结束后,可以获取调用该方法的返回值,通过这种方式就可以实现缓存的逻辑。

缓存声明,也就是标识需要缓存的方法以及缓存策略

Spring Cache 提供了五个注解。

  • @Cacheable:根据方法的请求参数对其结果进行缓存,下次同样的参数来执行该方法时可以直接从缓存中获取结果,而不需要再次执行该方法;
  • @CachePut:根据方法的请求参数对其结果进行缓存,它每次都会触发真实方法的调用;
  • @CacheEvict:根据一定的条件删除缓存;
  • @Caching:组合多个缓存注解;
  • @CacheConfig:类级别共享缓存相关的公共配置。

我们重点讲解:@Cacheable,@CachePut,@CacheEvict 三个核心注解。

@Cacheble 注解表示这个方法有了缓存的功能。

1
2
3
4
5
@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 ,默认的生成器代码见下图:

https://p26.toutiaoimg.com/origin/tos-cn-i-qvj2lq49k0/1f60d705c9044edea56651edf4f7bb56?from=pc

它的算法也很容易理解:

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

当然 Spring Cache 也考虑到需要自定义 Key 生成方式,需要我们实现 org.springframework.cache.interceptor.KeyGenerator 接口。

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

然后指定 @Cacheable 的 keyGenerator 属性。

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

▍ 缓存条件

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

注解里可以通过 condition 属性,通过 Spel 表达式返回的结果是 true 还是 false 判断是否需要缓存。

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

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

除了 condition,unless 属性也可以决定结果是否缓存,不过是在执行方法后。

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

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

@CachePut 注解作用于缓存需要被更新的场景,和 @Cacheable 非常相似,但被注解的方法每次都会被执行。

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

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

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

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

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

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

Spring Cache 是一个对缓存使用的抽象,它提供了多种存储集成。

https://p26.toutiaoimg.com/origin/tos-cn-i-qvj2lq49k0/1b1956aef1f347b7992046908ae7fbb1?from=pc

要使用它们,需要简单地声明一个适当的 CacheManager - 一个控制和管理 Cache 的实体。

我们以 Spring Cache 默认的缓存实现 Simple 例子,简单探索下 CacheManager 的机制。

CacheManager 非常简单:

1
2
3
4
5
6
public interface CacheManager {
   @Nullable
   Cache getCache(String name);
   
   Collection<String> getCacheNames();
}

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

https://p26.toutiaoimg.com/origin/tos-cn-i-qvj2lq49k0/3a59bfa3bff845109b88b96582b25022?from=pc

通过 SpringBoot 的自动装配机制,创建 CacheManager 的实现类 ConcurrentMapCacheManager。

https://p26.toutiaoimg.com/origin/tos-cn-i-qvj2lq49k0/bc2415d3718e464ba333587e6e3920e4?from=pc

而 ConcurrentMapCacheManager 的 getCache 方法,会创建 ConcurrentCacheMap。

https://p26.toutiaoimg.com/origin/tos-cn-i-qvj2lq49k0/615c33a6c8d1482d9e25ae96ae4ff4c4?from=pc

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

https://p26.toutiaoimg.com/origin/tos-cn-i-qvj2lq49k0/8d3bf4036b3449858ecba5b67aa58049?from=pc

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

  • org.springframework.cache.CacheManager
  • org.springframework.cache.Cache

首先我们先创建一个工程 spring-cache-demo。

https://p26.toutiaoimg.com/origin/tos-cn-i-qvj2lq49k0/2e30b3b038ef42dda5a1c28472248a0d?from=pc

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

1
2
3
4
5
6
7
8
9
<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>

我们先创建一个缓存配置类 MyCacheConfig。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
@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

根据缓存声明这一节,我们很容易写出如下代码。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
@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,方便监控方法是否会缓存。

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

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

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

1
2
3
4
5
<dependency>
   <groupId>org.Redisson</groupId>
   <artifactId>Redisson</artifactId>
   <version>3.12.0</version>
</dependency>
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
@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 数据结构。

https://p26.toutiaoimg.com/origin/tos-cn-i-qvj2lq49k0/ae0069cd68294d95bb1541e56c2819ac?from=pc

因为 Redisson 默认的编解码是 FstCodec, 可以看到 key 的名称是:\xF6\x01。

在缓存配置代码里,可以修改编解码器。

1
2
3
4
5
6
7
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 方法 ,控制台就变成:

https://p26.toutiaoimg.com/origin/tos-cn-i-qvj2lq49k0/29eabff465a4498c9bc3ec4006397eb1?from=pc

可以观察到:缓存 key 已经变成了:[“java.lang.Long”,1],改变序列化后 key 和 value 已发生了变化。

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

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

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

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

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

https://p26.toutiaoimg.com/origin/tos-cn-i-qvj2lq49k0/838b2f558e144ad6b009458d2807a49c?from=pc

执行完成之后,控制台里可以看到:列表整体直接被缓存起来,用户列表缓存和用户条目缓存并没有共享,他们是平行的关系。

这种情况下,缓存的颗粒度控制也没有那么细致。

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

https://p26.toutiaoimg.com/origin/tos-cn-i-qvj2lq49k0/3dfe285c34f64b80b777c4c80ad4c933?from=pc

https://p26.toutiaoimg.com/origin/tos-cn-i-qvj2lq49k0/f1e2f8676cb749618ce4378f982d159c?from=pc

官方的回答也很明确:对于缓存抽象来讲,它并不关心方法返回的数据类型,假如是集合,那么也就意味着需要把集合数据在缓存中保存起来。

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

1
2
3
4
5
6
7
8
@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 做一定程度的扩展。

笔者曾经在原来的项目,高并发场景下多次使用多级缓存。多级缓存是一个非常有趣的功能点,值得我们去扩展。

多级缓存有如下优势:

  1. 离用户越近,速度越快;
  2. 减少分布式缓存查询频率,降低序列化和反序列化的 CPU 消耗;
  3. 大幅度减少网络 IO 以及带宽消耗。

进程内缓存做为一级缓存,分布式缓存做为二级缓存,首先从一级缓存中查询,若能查询到数据则直接返回,否则从二级缓存中查询,若二级缓存中可以查询到数据,则回填到一级缓存中,并返回数据。若二级缓存也查询不到,则从数据源中查询,将结果分别回填到一级缓存,二级缓存中。

https://p26.toutiaoimg.com/origin/tos-cn-i-qvj2lq49k0/2cc6998c7e474c798c6393d4e7524a69?from=pc

来自《凤凰架构》缓存篇

Spring Cache 并没有二级缓存的实现,我们可以实现一个简易的二级缓存 DEMO,加深对技术的理解。

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

MultiLevelCacheManager 是最核心的类,需要实现 getCachegetCacheNames 两个接口。

https://p26.toutiaoimg.com/origin/tos-cn-i-qvj2lq49k0/c91b0032d535452c8ae277a8d4e0350e?from=pc

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

https://p26.toutiaoimg.com/origin/tos-cn-i-qvj2lq49k0/8ebafe848b424e9a8a2d2a47c4a77ffd?from=pc

二级缓存,为了快速完成 DEMO,我们使用 Redisson 对 Spring Cache 的扩展类 RedissonCache 。它的底层是 RMap,底层存储是 Hash。

https://p26.toutiaoimg.com/origin/tos-cn-i-qvj2lq49k0/e38e9d823c9247db88955f214f31f5d5?from=pc

我们重点看下缓存的「查询」和「存储」的方法:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
@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. 若分布式缓存查询不到数据,则默认会执行被注解的方法。

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

1
2
3
4
5
6
public void put(Object key, Object value) {
    logger.info("写入一级缓存 key:" + key);
    localCache.put(key, value);
    logger.info("写入二级缓存 key:" + key);
    RedissonCache.put(key, value);
}

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

https://p26.toutiaoimg.com/origin/tos-cn-i-qvj2lq49k0/cea7624124014d7c99d952c39ea02dac?from=pc

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

1
2
3
4
5
6
7
- 从一级缓存查询key:1
- 从二级缓存查询key:1
- ==> Preparing: select * FROM user t where t.id = ? 
- ==> Parameters: 1(Long)
- <== Total: 1
- 写入一级缓存 key:1
- 写入二级缓存 key:1

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

1
- 从一级缓存查询key:1

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

1
2
- 从一级缓存查询key:1
- 从二级缓存查询key:1

一个简易的二级缓存就组装完了。

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

笔者认为 Spring Cache 的功能很强大,设计也非常优雅。特别适合缓存控制没有那么细致的场景。比如门户首页,偏静态展示页面,榜单等等。这些场景的特点是对数据实时性没有那么严格的要求,只需要将数据源缓存下来,过期之后自动刷新即可。这些场景下,Spring Cache 就是神器,能大幅度提升研发效率。

但在高并发大数据量的场景下,精细的缓存颗粒度的控制上,还是需要做功能扩展。

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

笔者也在思考这几点的过程,研读了 j2cache , jetcache 相关源码,受益匪浅。它们的设计思想很多可以用于扩展 Spring Cache。

本人花费 2 个月时间,整理了一套 JAVA 开发技术资料,内容涵盖 java 基础,分布式、微服务等主流技术资料,包含大厂面经,学习笔记、源码讲义、项目实战、讲解视频。

https://p26.toutiaoimg.com/origin/tos-cn-i-qvj2lq49k0/f06d57bacbe54141a2f0e6726278cde1?from=pc

https://p26.toutiaoimg.com/origin/tos-cn-i-qvj2lq49k0/92fc7a876ceb40f48333830850586db0?from=pc

https://p26.toutiaoimg.com/origin/tos-cn-i-qvj2lq49k0/823dd6bc08ad4dd8a2fea0a37d8e2a50?from=pc

希望可以帮助一些想通过自学提升能力的朋友,领取资料,扫码关注一下

记得转发 + 关注 + 私信

私信回复【2022 面试资料】

领取更多学习资料