Spring 缓存抽象之基于注解的声明式缓存

Spring 缓存抽象之基于注解的声明式缓存

Spring 提供了一些 Java 注解用于声明缓存。

  1. @Cacheable 用于触发缓存
  2. @CacheEvict 用于失效缓存
  3. @CachePut 可用于在不影响方法运行的情况下更新缓存
  4. @Caching 用于将多种缓存操作应用到一个方法中
  5. @CacheConfig 用于配置同一个类的缓存配置,类中的所有缓存都将共享这个配置

@Cacheable 注解

同注解的名字一样,@Cacheable 用于区别可缓存的方法,这个方法的结果将被缓存,下次该方法同样参数的调用将直接从缓存中返回,不会真正的调用方法。在最简单的使用中,注解中必须包含关联到被注解方法的缓存名称,例如:

1
2
@Cacheable("books")
public Book findBook(ISBN isbn) {...}

上面的代码片断,方法 findBook 被关联到缓存 books 上。每次方法的调用都会检查该方法是否被调用过,是否必须再次调用。在大多数情况下,都是只有一个缓存被使用,但注解支持同时指定多个缓存名称,这样可以同时使用多个缓存。在多缓存的情况下,调用会检查每个缓存,如果有一个缓存被命中则方法将直接返回。

其他缓存将重新缓存命中的结果

1
2
@Cacheable({"books", "isbns"})
public Book findBook(ISBN isbn) {...}

默认的缓存 Key 生成

实际上,缓存是键-值对的存储,每次缓存方法的调用被当成对对应 key 的缓存访问。在默认情况下,缓存抽象使用基于以下算法的简单 KeyGenerator

  • 如果没有参数,返回 SimpleKey.EMPTY
  • 如果只有一个参数,返回该实例
  • 如果不止一个参数,返回包含所有参数的 SimpleKey

这种方案适用于大多数情况; 只要参数中有原生键(natural keys)且实现了 hashCode()equals() 方法,否则需要自行实现生成器。

自定义缓存 Key

由于缓存的通用性,缓存的方法有多种签名时不能被简单地应用到缓存结构上。当有多个参数的方法中只有部分参数可以用于缓存时这种情况变得非常明显(其它参数用于控制函数的逻辑)。例如:

1
2
@Cacheable("books")
public Book findBook(ISBN isbn, boolean checkWarehouse, boolean includeUsed)

一眼看过去,后面两个 boolean 参数会影响 book 的获取,但实际上它们并不用于缓存 key 生成。

对于这种情况,@Cacheable 注解允许用户通过 key 属性指定 key 生成的方式。开发者可以使用 SpEL 获取需要的参数(或参数的属性)、执行操作甚至不必写任何代码或实现接口就可以调用任意方法。这是随着代码量的增长保持不同方法 key 值的推荐方式。默认方式也许可以适用部分函数方法,但极少适用所有函数方法。

下面是使用 SpEL 的样例,如果下面的不符合你的情况,自己去研究一下 Spring Expression Language 的文档:

1
2
3
4
5
6
7
8
@Cacheable(cacheNames="books", key="#isbn")
public Book findBook(ISBN isbn, boolean checkWarehouse, boolean includeUsed)

@Cacheable(cacheNames="books", key="#isbn.rawNumber")
public Book findBook(ISBN isbn, boolean checkWarehouse, boolean includeUsed)

@Cacheable(cacheNames="books", key="T(someType).hash(#isbn)")
public Book findBook(ISBN isbn, boolean checkWarehouse, boolean includeUsed)

上面的代码展示了获取确定参数、属性或者调用(静态)方法的方法。如果生成 key 的算法太特别或需要在多个地方使用,你需要定义成 keyGenerator,然后在注解中指定实现 bean 的名字:

1
2
@Cacheable(cacheNames="books", keyGenerator="myKeyGenerator")
public Book findBook(ISBN isbn, boolean checkWarehouse, boolean includeUsed)

key 和 keyGenerator 属性是互斥的,同时存在将产生异常

默认缓存实现

基于开箱既用原则,缓存抽象通过一个简单的 CacheResolver,在已配置的 CacheManager 实现中获取定义的缓存。

要使用不同的解释器须实现一个 org.springframework.cache.interceptor.CacheResolver 接口。

自定义缓存实现

默认的方式非常适用在单 CacheManager 和不太复杂的缓存实现需求中。如果应用需要同时使用几种缓存管理器,可以在每个操作中指定 cacheManager

1
2
@Cacheable(cacheNames="books", cacheManager="anotherCacheManager")
public Book findBook(ISBN isbn) {...}

当然也可以直接整个替换 CacheResolver,这种方式可以在每次缓存操作中实时地根据运行参数确定使用的缓存:

1
2
@Cacheable(cacheResolver="runtimeCacheResolver")
public Book findBook(ISBN isbn) {...}

Spring 4.1 后, @Cacheable 的信息可以从 CacheResolver 中获取, value 属性不再是必需的。
类似 keykeyGeneratorcacheManagercacheResolver 参数是互斥的,如果同时指定两个会导致异常,CacheManager 将会被忽略,这可能导致非预期的结果。

同步缓存

不多线程环境中,某些操作可能会有同参数并行调用的情况(通常在启动时)。缓存抽象默认不会加锁,同一个值可能会被多次计算,从而导致缓存失败。

对于这种特别情况,可以使用 sync 属性通知缓存的底层实现在计算缓存的时候对缓存条目加锁。这样只有一个线程能处理,直到缓存被更新完成前,其它线程都将被阻塞。

1
2
@Cacheable(cacheNames="foos", sync=true)
public Foo executeExpensiveOperation(String id) {...}

这是个可选特性,可能你使用的缓存框架并不支持。所有核心框架提供的的 CacheManager 都支持该属性。请检查所用的缓存框架实现的相关文档是否支持。

条件缓存

有时,一个方法并不是所有时候都应该被缓存(这可能依赖于方法的参数)。@Cacheable 可以通过在 condition 参数中使用 SpEL 的 boolean 表达式来控制。如果结果为 true,方法会被缓存。如果为 false,不管该方法是否被缓存过或方法用到了哪些参数,方法的结果都会被重新计。

下面是简单的样例,下面的方法只有在参数 name 的长度小于 32 时被缓存:

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

除了 condition,还可以用 unless 参数排除一些值被加到缓存中。与 condition 不同的是,unless 是在方法被调用后才被计算。

译注:满足 condition 条件的被缓存;满足 unless 条件的被排除。

基于上一个示例,也许我们只想缓存平装本:

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

缓存抽象支持 java.util.Optional,当内容存在的时候才缓存。#result 用于表示具体的业务实体,上面的样例可以改写成这样:

1
2
@Cacheable(cacheNames="book", condition="#name.length() < 32", unless="#result?.hardback")
public Optional<Book> findBook(String name)

注意,result 依然指的是 Book 而不是 Optional。由于可能为空,我们应该使用安全访问操作。

可用的缓存 SpEL 上下文取值操作

每个 SpEL 表达式的取值基于一个专用的上下文。除了在参数中构造的,框架还提供了类似参数名字这样关联缓存专用的元数据。下面的表格列举了上下文中可以在 key 或条件计算中使用的内容:

名称 位置 描述 样例
methodName root 对象 被调用的方法名称 #root.methodName
method root 对象 被调用的方法 #root.method.name
target root 对象 被调用的目标对象 #root.target
targetClass root 对象 被调用的对象的类 #root.targetClass
args root 对象 调用方法的参数(数组) #root.args[0]
caches root 对象 当前执行的方法对应的缓存集 #root.caches[0].name
argument name 取值上下文 方法的参数名称。如果名称不存在,参数名可以用 #a<#arg> 取得,#arg 表示参数索引(从0开始) #iban#a0 (也可以使用 #p0#p<#arg>,这是个别名)
result 取值上下文 方法调用的返回结果(被缓存的值)只在 unless 表达式、cache put 表达式(计算 key)或 cache evict 表达式(beforeInvocationfalse)中有效。支持 Optional 这样的包装,#result 表示的是实际的对象,不是包装的对象 #result

@CachePut 注解

如果需要更新缓存而不影响方法的执行,可以使用 @CachePut 注解。使用这个注解,方法总会被执行并且其结果会被直接缓存(根据 @CachePut 注解的设置)。这个注解的参数和 @Cacheable 的一样,但应该用于缓存数据而不是方法流程的优化:

1
2
@CachePut(cacheNames="book", key="#isbn")
public Book updateBook(ISBN isbn, BookDescriptor descriptor)

@CachePut@Cacheable 注解不应用在同一个方法下,因为这两个注解的行为是完全不一样的。后者导致方法返回缓存的值,跳过方法执行,前者强制执行方法以更新缓存。这将导致不可预期的行为和临界情况的异常(比如注解配置了相互排除的条件),因此应当避免这样使用。另外有点要注意的是,注解的条件不应依赖于方法的结果对象(例如 #resul 变量),因为他们被用于提前校验是否排除。

@CacheEvict 注解

缓存抽象不仅支持增加缓存,还支持失效缓存。这个操作对于移除旧数据或无用数据非常有效。与 @Cacheable 相反,注解 @CacheEvict 标明方法将执行缓存回收操作。像其它注解一样,@CacheEvict 需要指定操作影响的一个或多个缓存,支持自定义缓存和 key 的实现方式,也可以增加额外的条件,特别的是有一个 allEntries 属性用于设定缓存回收是影响整个缓存还是只针对单个(基于 key)。

1
2
@CacheEvict(cacheNames="books", allEntries=true)
public void loadBooks(InputStream batch)

这个选项在需要清除整个缓存的时候就派上用场了,不应该一个个地失效每个(这会花费很多时间而且效率低下),上面代码将用一个操作就移除了所有的记录。要注意的是,在这个场景中框架将忽略指定的 key(整个缓存被回收包含了单个 key)。

通过 beforeInvocation 属性还可以指定回收操作是发生在方法执行后(默认)还是执行前。执行前和其它注解一样:只要方法执行成功,一个操作将执行(这里是回收)。如果方法未执行(可能被缓存了)或抛出了异常,回收操作就不会执行。执行后(beforeInvocation=true)会使回收操作总是在方法还未执行之前就发生了,这个操作在不关心方法的执行结果就回收缓存的场景下比较有用。

有点比较重要的点要注意的是, void 方法也可以使用 @CacheEvict,这样的方法相当于一个触发器,返回的结果被忽略(结果不影响缓存),这里就不能用 @Cacheable,因为 @Cacheable 需要将数据增加到缓存中,所以需要结果。

@Caching 注解

在需要同时使用多个同类注解(如 @CacheEvict@CachePut)但注解的条件或 key expression 不同时可以使用 @Caching@Caching 注解允许多个内嵌的 @Cacheable@CachePut@CacheEvict 用在同一个方法上:

1
2
3
4
5
@Caching(evict = {
@CacheEvict("primary"),
@CacheEvict(cacheNames="secondary", key="#p0")
})
public Book importBooks(String deposit, Date date)

@CacheConfig 注解

到目前为止,我们已经看到缓存操作提供了许多自定义配置,这些配置可以配在一个上。然而这些配置如果对类中所有的操作都有效,这样配可能太过冗长。例如每个操作都指定同一个缓存名称可以只在类上配置就可以了。这就是 @CacheConfig 注解要做的事情。

1
2
3
4
5
6
@CacheConfig("books")
public class BookRepositoryImpl implements BookRepository {

@Cacheable
public Book findBook(ISBN isbn) {...}
}

@CacheConfig 注解是类级别的注解,可以用于配置缓存名、自定义 KeyGenerator 、自定义 CacheManager 和自定义 CacheResolver。将该注解应用在类上并未启用缓存操作。

操作级别的自定义配置会覆盖 @CacheConfig 的配置。缓存的自定义有三个级别:

  • 全局配置,对 CacheManagerKeyGenerator 有效
  • 类级配置,使用 @CacheConfig
  • 操作上的配置

开启缓存的注解

特别要注意的是像其它 Spring 组件一样,仅管上面声明了缓存注解,但是并没有启用缓存,必需要声明启用(这意味着,如果你怀疑缓存有问题,你可以移除一行配置就可以禁用缓存而不用删掉所有注解)。

要启用缓存注解,只需将 @EnableCaching 注解添加到 @Configuration 类上:

1
2
3
@Configuration
@EnableCaching
public class AppConfig {}

若使用 XML 配置,可以使用 cache:annotation-driven 元素:

1
2
3
4
5
6
7
8
9
10
<beans xmlns="http://www.springframework.org/schema/beans"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xmlns:cache="http://www.springframework.org/schema/cache"
xsi:schemaLocation="
http://www.springframework.org/schema/beans http://www.springframework.org/schema/beans/spring-beans.xsd
http://www.springframework.org/schema/cache http://www.springframework.org/schema/cache/spring-cache.xsd">

<cache:annotation-driven />

</beans>

cache:annotation-driven 元素和 @EnableCaching 注解都允许指定多种选项,通过 AOP 的方式改变应用中的缓存行为。这些配置特地设计得和 @Transactional 类似。

缓存注解的默认处理模式是 proxy 模式,调用将会通过代理类拦截。这种模式下,当前类的本地调用将不能被拦截到。要使用更高级的拦截方式,可以考虑使用 aspectj 模式,该模式支持编译时和加载时织入。

方法可见性和缓存注解

使用代理的时候应该保证缓存注解的方法是 public 的。如果是 protectedprivatepackage-visible 的方法,虽不会报错,但缓存的配置是无效的。如果确实需要非 public 的方法,考虑使用 AspectJ 的方式。

Spring 建议只在实现类(和实现类的方法)上使用 @Cache* 注解,而不是在接口上。@Cache* 注解确实可以用在接口类(或接口方法)上,但是只有在代理的接口上才能如期工作。事实上,Java 注解不能从接口上继承,这意味着如果使用基于类的代理(proxy-target-class="true")或基于植入切面的方式(mode="aspectj"),缓存的配置都不会被代理或植入代码识别,这些对象也不会被缓存框架代理。
在代理模式中(默认),只有外部方法调用才会被代理拦截。这意味着类内调用的方法就算有 @Cacheable 注解也不会被缓存,这种情况要使用 aspectj 才行。同样,代理必须被完全初始化才能提供需要的功能,所以你不应该在初始化代码中使用这个特性(比如 @PostConstruct)。

使用自定义注解

自定义注解和 AspectJ

这个特性只能在基于代理的方法中开箱可用,但可以销加配置既可用于 AspectJ

spring-aspects 模块只定义了标准注解的切面。如果你需要定义自己的注解,你还需为这些注解定义切面。可以到 AnnotationCacheAspect 中查看示例。

缓存抽象允许你使用自己的注解来定义缓存行为。这就像模板一样,不用写重复的缓存注解(特别是 key 和条件要指定的时候),非常方便。

类似于其它原有注解,@Cacheable@CachePut@CacheEvict@CacheConfig 可以用作 meta-annotations,可以注解在其它的注解上。也就是说,可以自我们自己定义的注解替代 @Cacheable:

1
2
3
4
5
@Retention(RetentionPolicy.RUNTIME)
@Target({ElementType.METHOD})
@Cacheable(cacheNames="books", key="#isbn")
public @interface SlowService {
}

上面我们用 @Cacheable 定义了自己的注解 SlowService

1
2
@Cacheable(cacheNames="books", key="#isbn")
public Book findBook(ISBN isbn, boolean checkWarehouse, boolean includeUsed)

现在上面的代码可以替换为下面的:

1
2
@SlowService
public Book findBook(ISBN isbn, boolean checkWarehouse, boolean includeUsed)

仅管 @SlowService 不是 Spring 定义的注解,但是上下文在进行时会自动获取声明并正确理解它的作用。最后要注意的一点,基于注解驱动的缓存是需要启用的。

本文翻译自 Spring 文档:https://docs.spring.io/spring/docs/current/spring-framework-reference/integration.html#cache-annotations

Spring 缓存抽象之基于注解的声明式缓存

https://blog.imoe.tech/2018/05/09/spring-declarative-annotation-based-caching/

作者

Jakes Lee

发布于

2018-05-09

更新于

2021-11-18

许可协议

评论