基于声明性注解的缓存
对于缓存声明,Spring的缓存抽象提供了一组Java注解:
@Cacheable:触发缓存填充。@CacheEvict:触发缓存清除。@CachePut:更新缓存而不干扰方法执行。@Caching:将多个缓存操作组合起来应用于一个方法。@CacheConfig:在类级别共享一些与缓存相关的通用设置。
@Cacheable 注解
顾名思义,你可以使用@Cacheable来标记可缓存的方法——也就是说,那些方法的结果会被存储在缓存中,这样在后续的调用(使用相同的参数)时,就可以直接从缓存中获取结果,而无需再次实际调用该方法。最简单的形式下,该注解声明需要指定与被标注方法关联的缓存名称,如下例所示:
@Cacheable("books")
public Book findBook(ISBN isbn) {...}
在前面的代码片段中,findBook方法与名为books的缓存相关联。每次调用该方法时,都会检查缓存,以确定该调用是否已经执行过,从而避免重复执行。虽然在大多数情况下只声明一个缓存,但通过注解可以指定多个名称,从而使用多个缓存。在这种情况下,在调用方法之前会检查每个缓存——如果至少有一个缓存命中(即找到了所需数据),就会返回该缓存中存储的相应值。
即使实际上没有调用被缓存的方法,所有其他不包含该值的缓存也会被更新。
以下示例在具有多个缓存的findBook方法上使用了@Cacheable注解:
@Cacheable({"books", "isbns"})
public Book findBook(ISBN isbn) {...}
默认密钥生成
由于缓存本质上是键值存储,因此每次调用被缓存的方法都需要转换为适合缓存访问的键。缓存抽象使用了一个基于以下算法的简单KeyGenerator:
- 如果没有给出参数,返回
SimpleKey_EMPTY。 - 如果只给出了一个参数,返回该参数的实例。
- 如果给出了多个参数,返回一个包含所有参数的
SimpleKey。
这种方法在大多数用例中都适用,只要参数具有自然的键(natural keys),并且实现了有效的hashCode()和equals()方法。如果不是这种情况,你就需要改变策略。
要提供不同的默认键生成器,你需要实现 org.springframework.cache.interceptor.KeyGenerator 接口。
从Spring 4.0版本开始,键生成策略发生了变化。在早期版本的Spring中,对于多个键参数,键生成策略仅考虑了这些参数的hashCode()方法,而忽略了equals()方法。这可能会导致意外的键冲突(有关背景信息,请参阅spring-framework#14870)。新的SimpleKeyGenerator在遇到这种情况时会使用复合键。
如果你希望继续使用之前的键生成策略,可以配置已弃用的org.springframework.cache.interceptor.DefaultKeyGenerator类,或者创建一个自定义的基于哈希的KeyGenerator实现。
自定义密钥生成声明
由于缓存是通用的,目标方法很可能具有多种签名(function signatures),这些签名无法直接映射到缓存结构上。当目标方法有多个参数时,这种情况就变得尤为明显:其中只有部分参数适合被缓存(而其余参数仅用于该方法的内部逻辑)。请考虑以下示例:
@Cacheable("books")
public Book findBook(ISBN isbn, boolean checkWarehouse, boolean includeUsed)
乍一看,这两个boolean类型的参数虽然会影响书籍的查找方式,但对缓存来说并无用处。此外,如果其中只有一个参数重要,而另一个不重要,那该怎么办呢?
以下示例使用了各种SpEL声明(如果你不熟悉SpEL,请花点时间阅读Spring表达式语言):
@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)
前面的代码片段展示了选择某个参数、其属性之一,甚至是一个任意的(静态的)方法是多么简单。
如果负责生成密钥的算法过于具体,或者需要共享该密钥,你可以在操作上定义一个自定义的keyGenerator。为此,需要指定要使用的KeyGenerator bean实现的名称,如下例所示:
@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:
@Cacheable(cacheNames="books", cacheManager="anotherCacheManager") 1
public Book findBook(ISBN isbn) {...}
指定
anotherCacheManager。
你也可以以类似于替换key生成的方式完全替换CacheResolver。每次缓存操作时都会请求解析,从而让实现根据运行时参数来实际决定使用哪个缓存。以下示例展示了如何指定一个CacheResolver:
@Cacheable(cacheResolver="runtimeCacheResolver") 1
public Book findBook(ISBN isbn) {...}
指定
CacheResolver。
从Spring 4.1开始,缓存注解的value属性不再是必需的,因为无论注解的内容如何,这个特定信息都可以由CacheResolver提供。
与key和keyGenerator类似,cacheManager和cacheResolver参数是互斥的,同时指定这两个参数会引发异常,因为自定义的CacheManager会被CacheResolver实现忽略。这可能不是你所期望的。
同步缓存
在多线程环境中,对于相同的参数,某些操作可能会被并发调用(通常发生在启动时)。默认情况下,缓存抽象不会进行任何锁定操作,因此相同的值可能会被计算多次,这就违背了缓存的初衷。
对于这些特殊情况,你可以使用sync属性来指示底层的缓存提供者在计算值时锁定缓存条目。这样一来,就只有一个线程在忙于计算值,而其他线程则会被阻塞,直到缓存中的条目被更新。以下示例展示了如何使用sync属性:
@Cacheable(cacheNames="foos", sync=true) 1
public Foo executeExpensiveOperation(String id) {...}
使用
sync属性。
这是一个可选功能,您喜欢的缓存库可能不支持它。核心框架提供的所有CacheManager实现都支持此功能。有关更多详细信息,请参阅您的缓存提供者的文档。
使用CompletableFuture和Reactive返回类型进行缓存
从6.1版本开始,缓存注解会考虑CompletableFuture和反应式返回类型(reactive return types),并据此自动调整缓存交互机制。
对于返回CompletableFuture的方法,每当该Future完成时,它所生成的对象就会被缓存起来,而对于缓存命中的查询,将通过一个CompletableFuture来获取:
@Cacheable("books")
public CompletableFuture<Book> findBook(ISBN isbn) {...}
对于一个返回Reactor Mono的方法,该反应式流(Reactive Streams)发布者(publisher)发出的对象在可用时会被缓存,而缓存的查找(cache lookup)如果在缓存中找到,则会以Mono的形式返回(其背后实际是由CompletableFuture支持的):
@Cacheable("books")
public Mono<Book> findBook(ISBN isbn) {...}
对于一个返回 Reactor Flux 的方法,该 Reactive Streams 发布者发射的对象将被收集到一个 List 中,并且每当这个列表完成时就会被缓存起来。当需要查找缓存中的数据时,会以 Flux 的形式获取(该 Flux 实际上是由一个 CompletableFuture 支持的,用于获取缓存的 List 值):
@Cacheable("books")
public Flux<Book> findBooks(String author) {...}
这样的CompletableFuture和反应式适应也适用于同步缓存,在并发缓存未命中时仅计算一次值:
@Cacheable(cacheNames="foos", sync=true) 1
public CompletableFuture<Foo> executeExpensiveOperation(String id) {...}
使用
sync属性。
为了让这样的安排在运行时能够正常工作,配置的缓存需要能够支持基于CompletableFuture的检索。Spring提供的ConcurrentMapCacheManager会自动适应这种检索方式,而CaffeineCacheManager在启用异步缓存模式时也原生地支持这种方式:只需在您的CaffeineCacheManager实例上设置setAsyncCacheMode(true)即可。
@Bean
CacheManager cacheManager() {
CaffeineCacheManager cacheManager = new CaffeineCacheManager();
cacheManager.setCacheSpecification(...);
cacheManager.setAsyncCacheMode(true);
return cacheManager;
}
最后但同样重要的是,要注意,对于涉及组合(composition)和反向压力(back pressure)的复杂反应式交互(reactive interactions),注解驱动的缓存(annotation-driven caching)并不适用。如果你选择在特定的反应式方法上声明@Cacheable,请考虑这种相对粗粒度的缓存机制所带来的影响——它只是简单地存储Mono发出的对象,或者甚至是Flux预先收集的对象列表。
条件缓存
有时,一个方法可能并不总是适合被缓存(例如,它可能会依赖于传入的参数)。缓存注解通过condition参数支持这样的使用场景,该参数接受一个SpEL表达式,该表达式的评估结果为true或false。如果结果是true,那么该方法就会被缓存;否则,该方法的行为就如同没有被缓存一样(也就是说,无论缓存中存储的是什么值或使用了什么参数,该方法都会被每次调用)。例如,以下方法只有当参数name的长度小于32时才会被缓存:
@Cacheable(cacheNames="book", condition="#name.length() < 32") 1
public Book findBook(String name)
为
@Cacheable设置条件。
除了condition参数外,您还可以使用unless参数来阻止将某个值添加到缓存中。与condition不同,unless表达式是在方法被调用之后才进行评估的。以之前的例子为例进行扩展,也许我们只想要缓存平装书,就像下面的例子所示:
@Cacheable(cacheNames="book", condition="#name.length() < 32", unless="#result.hardback") 1
public Book findBook(String name)
使用
unless属性来屏蔽精装书。
缓存抽象支持java.util.Optional返回类型。如果存在Optional值,它将被存储在相关的缓存中;如果不存在Optional值,则会在相关缓存中存储null。#result始终指的是业务实体,而绝不会是任何支持的包装器,因此前面的例子可以重写如下:
@Cacheable(cacheNames="book", condition="#name.length() < 32", unless="#result?.hardback")
public Optional<Book> findBook(String name)
请注意,#result 仍然指的是 Book 而不是 Optional<Book>。由于它可能是 null,我们使用了 SpEL 的安全导航操作符。
可用的Caching SpEL评估上下文
每个SpEL表达式都是在一个专用的上下文中进行的评估。除了内置参数外,该框架还提供了与缓存相关的元数据,例如参数名称。下表描述了可供上下文使用的各项内容,以便您可以利用它们进行键值计算和条件判断:
表1. SpEL表达式中可用的缓存元数据
| 名称 | 位置 | 描述 | 示例 |
|---|---|---|---|
methodName | 根对象 | 被调用的方法的名称 | #root.methodName |
method | 根对象 | 被调用的方法 | #root.method.name |
target | 根对象 | 被调用的目标对象 | #root.target |
targetClass | 根对象 | 被调用目标的类 | #root.targetClass |
args | 根对象 | 用于调用目标的参数(作为对象数组) | #root.args[0] |
caches | 根对象 | 当前方法运行的缓存集合 | #root.caches[0].name |
| 参数名称 | 评估上下文 | 特定方法参数的名称。如果参数名称不可用(例如,因为代码编译时没有使用 -parameters 标志),也可以使用 #a<#arg> 语法来访问各个参数,其中 <#arg> 代表参数索引(从 0 开始)。 | #iban 或 #a0(也可以使用 #p0 或 #p<#arg> 作为别名)。 |
result | 评估上下文 | 方法调用的结果(要缓存的值)。仅在 unless 表达式、cache put 表达式(用于计算 key)或 cache evict 表达式(当 beforeInvocation 为 false 时)中可用。对于支持的包装器(如 Optional),#result 指的是实际对象,而不是包装器。 | #result |
@CachePut 注解
当需要更新缓存而不干扰方法执行时,可以使用@CachePut注解。也就是说,该方法总是会被调用,并且其结果会被放入缓存中(根据@CachePut的选项设置)。它支持与@Cacheable相同的选项,应该用于缓存填充而不是方法执行流程的优化。以下示例使用了@CachePut注解:
@CachePut(cacheNames="book", key="#isbn")
public Book updateBook(ISBN isbn, BookDescriptor descriptor)
通常强烈不建议在同一方法上同时使用@CachePut和@Cacheable注解,因为它们的行为不同。后者通过使用缓存来跳过方法调用,而前者则强制执行调用以便进行缓存更新。这会导致不可预见的行为,除非是特定的特殊情况(例如,这些注解具有相互排除的条件),否则应避免使用此类声明。还要注意,这些条件不应依赖于结果对象(即#result变量),因为这些条件会在前期就被验证以确认是否需要排除。
从6.1版本开始,@CachePut开始支持CompletableFuture和反应式返回类型,只要生成的对象可用,就会执行存储操作。
@CacheEvict 注解
缓存抽象不仅允许向缓存存储中添加数据,还支持数据的移除(即缓存驱逐)。这一过程有助于清除缓存中过时或未使用的数据。与@Cacheable不同,@CacheEvict用于标记执行缓存驱逐的方法(也就是触发从缓存中删除数据的方法)。与它的兄弟注解类似,@CacheEvict也需要指定一个或多个受影响的缓存,允许自定义缓存和键的匹配规则,还可以指定一个条件;此外它还有一个额外的参数(allEntries),用于指示是需要执行全缓存范围的驱逐,还是仅基于某个键来驱逐特定条目。以下示例会从books缓存中移除所有条目:
@CacheEvict(cacheNames="books", allEntries=true) 1
public void loadBooks(InputStream batch)
使用
allEntries属性将所有条目从缓存中清除。
当需要清除整个缓存区域时,这个选项就非常实用。与逐个清除条目(这样会花费很长时间,因为效率低下)不同,如前面的例子所示,所有条目可以一次性被移除。需要注意的是,在这种情况下,框架会忽略任何指定的键,因为这种操作并不适用(清除的是整个缓存,而不仅仅是一个条目)。
您还可以使用beforeInvocation属性来指定在方法调用之后(这是默认设置)还是之前进行缓存清除。前一种情况与其余注释的语义相同:一旦方法成功执行完毕,就会对缓存执行相应的操作(本例中为缓存清除)。如果方法没有执行(因为它可能已被缓存),或者抛出了异常,则不会执行缓存清除。后一种情况(beforeInvocation=true)则会导致缓存清除总是在方法调用之前执行。在缓存清除不需要与方法执行结果相关联的情况下,这种设置非常有用。
请注意,void 方法可以与 @CacheEvict 一起使用——因为这些方法起到触发器的作用,其返回值会被忽略(因为它们不会与缓存进行交互)。而对于 @Cacheable 则不同,它负责将数据添加到缓存中或更新缓存中的数据,因此需要一个结果。
从6.1版本开始,@CacheEvict开始支持CompletableFuture和响应式返回类型,在处理完成后会执行一次清理操作(after-invocation evict operation)。
@Caching 注解
有时,需要指定多个相同类型的注解(例如@CacheEvict或@CachePut)——比如因为不同的缓存之间的条件或键表达式不同。@Caching允许在同一方法上使用多个嵌套的@Cacheable、@CachePut和@CacheEvict注解。以下示例使用了两个@CacheEvict注解:
@Caching(evict = { @CacheEvict("primary"), @CacheEvict(cacheNames="secondary", key="#p0") })
public Book importBooks(String deposit, Date date)
@CacheConfig 注解
到目前为止,我们已经看到缓存操作提供了许多自定义选项,并且可以为每个操作设置这些选项。然而,如果某些自定义选项适用于该类的所有操作,那么配置它们可能会比较繁琐。例如,为类的每个缓存操作指定要使用的缓存名称,可以通过一个类级别的定义来替代。这就是@CacheConfig发挥作用的地方。以下示例使用@CacheConfig来设置缓存名称:
@CacheConfig("books") 1
public class BookRepositoryImpl implements BookRepository {
@Cacheable
public Book findBook(ISBN isbn) {...}
}
使用
@CacheConfig来设置缓存的名称。
@CacheConfig 是一个类级别的注解,它允许共享缓存名称、自定义的 KeyGenerator(键生成器)、自定义的 CacheManager(缓存管理器)以及自定义的 CacheResolver(缓存解析器)。将此注解添加到类上并不会启用任何缓存操作。
操作级别的自定义总是会覆盖在@CacheConfig上设置的自定义。因此,这为每个缓存操作提供了三个级别的自定义:
-
可以通过全局配置来实现,例如使用
CachingConfigurer:请参阅下一节。 -
在类级别,可以使用
@CacheConfig。 -
在操作级别也可以进行配置。
特定提供者的设置通常可以在CacheManagerbean上找到,例如在CaffeineCacheManager上。这些设置实际上也是全局的。
启用缓存注释
需要注意的是,尽管声明缓存注解并不会自动触发它们的功能——这与Spring中的许多特性一样——但这一功能必须通过显式的方式来启用(这意味着,如果你怀疑缓存是问题的根源,你只需移除一条配置语句即可将其禁用,而无需删除代码中的所有注解)。
要启用缓存注解,请在您的@Configuration类之一中添加@EnableCaching注解,或使用XML中的cache:annotation-driven元素:
- Java
- Kotlin
- Xml
@Configuration
@EnableCaching
class CacheConfiguration {
@Bean
CacheManager cacheManager() {
CaffeineCacheManager cacheManager = new CaffeineCacheManager();
cacheManager.setCacheSpecification("...");
return cacheManager;
}
}
@Configuration
@EnableCaching
class CacheConfiguration {
@Bean
fun cacheManager(): CacheManager {
return CaffeineCacheManager().apply {
setCacheSpecification("...")
}
}
}
<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 https://www.springframework.org/schema/beans/spring-beans.xsd
http://www.springframework.org/schema/cache https://www.springframework.org/schema/cache/spring-cache.xsd">
<cache:annotation-driven/>
<bean id="cacheManager" class="org.springframework.cache.caffeine.CaffeineCacheManager">
<property name="cacheSpecification" value="..."/>
</bean>
</beans>
cache:annotation-driven元素和@EnableCaching注解都允许你指定各种选项,这些选项会影响通过AOP将缓存行为添加到应用程序中的方式。这种配置与@Transactional的配置有意地保持相似。
处理缓存注解的默认建议模式是proxy,该模式仅允许通过代理拦截调用。同一类内的局部调用无法通过这种方式被拦截。对于更高级的拦截模式,可以考虑结合编译时或加载时编织(weaving)技术使用aspectj模式。
有关实现CachingConfigurer所需的高级自定义(使用Java配置)的更多详细信息,请参阅javadoc。
表2. 缓存注解设置
| XML属性 | 注解属性 | 默认值 | 描述 |
|---|---|---|---|
cache-manager | N/A(请参阅CachingConfigurer的Javadoc) | cacheManager | 要使用的缓存管理器的名称。默认情况下,会使用此缓存管理者初始化一个CacheResolver(如果未设置cache-manager,则使用系统默认的CacheResolver)。如需更细粒度的缓存解析管理,可以考虑设置cache-resolver属性。 |
cache-resolver | N/A(请参阅CachingConfigurer的Javadoc) | 使用已配置的cacheManager的SimpleCacheResolver。 | 用于解析缓存的后端缓存的CacheResolver的Bean名称。此属性非必需,仅在需要作为cache-manager属性的替代时才需要指定。 |
key-generator | N/A(请参阅CachingConfigurer的Javadoc) | SimpleKeyGenerator | 要使用的自定义键生成器的名称。 |
error-handler | N/A(请参阅CachingConfigurer的Javadoc) | SimpleCacheErrorHandler | 要使用的自定义缓存错误处理器的名称。默认情况下,与缓存相关的操作中抛出的任何异常都会直接返回给客户端。 |
mode | mode | proxy | 默认模式(proxy)通过使用Spring的AOP框架来处理被注解的Bean(遵循前面讨论的代理语义,仅适用于通过代理进入的方法调用)。另一种模式(aspectj)则是将受影响的类与Spring的AspectJ缓存方面(AspectJ caching aspect)结合在一起,修改目标类的字节码以适用于任何类型的方法调用。使用AspectJ编织需要在类路径中包含spring-aspects.jar,并且需要启用加载时编织(或编译时编织)。(有关如何设置加载时编织的详细信息,请参阅Spring配置。) |
proxy-target-class | proxyTargetClass | false | 仅适用于代理模式。控制为带有@Cacheable或@CacheEvict注解的类创建何种类型的缓存代理。如果将proxy-target-class属性设置为true,则会创建基于类的代理;如果proxy-target-class为false或省略该属性,则会创建标准的JDK接口基代理。(有关不同代理类型的详细说明,请参阅代理机制。) |
order | order | `Ordered.LOWEST_PRECEDENCE | 定义应用于带有@Cacheable或@CacheEvict注解的Bean的缓存建议(cache advice)的顺序。(有关AOP建议排序规则的更多信息,请参阅建议排序。如果没有指定顺序,则由AOP子系统决定建议的顺序。 |
<cache:annotation-driven/> 仅在其定义的同一应用程序上下文中的 Bean 上查找 @Cacheable/@CachePut/@CacheEvict/@Caching 注解。这意味着,如果你在 DispatcherServlet 的 WebApplicationContext 中使用 <cache:annotation-driven/>,它只会检查控制器中的 Bean,而不会检查服务中的 Bean。有关更多信息,请参阅 MVC 部分。
Spring建议您仅对具体类(及其方法)使用@Cache*注解,而不是对接口使用这些注解。虽然您确实可以在接口(或接口方法)上添加@Cache*注解,但这只有在您使用代理模式(mode="proxy")时才有效。如果您使用基于织构的切面(mode="aspectj"),那么织构基础设施将不会识别接口级别声明上的缓存设置。
在代理模式(默认模式)下,只有通过代理传入的外部方法调用才会被拦截。这意味着自我调用(即目标对象内部的某个方法调用该对象自身的另一个方法)在运行时不会导致实际缓存,即使被调用的方法被标记为@Cacheable也是如此。在这种情况下,建议使用aspectj模式。此外,为了实现预期的行为,代理必须完全初始化,因此你不应该在初始化代码(即@PostConstruct)中依赖这一特性。
使用自定义注解
缓存抽象允许你使用自己的注解来标识哪些方法会触发缓存的填充或清除。作为一种模板机制,这非常方便,因为它消除了重复声明缓存注解的必要,尤其是在键或条件被明确指定的情况下,或者在代码库中不允许导入外部依赖(如 org.springframework)时。与 stereotype 注解中的其他注解类似,你也可以使用 @Cacheable、@CachePut、@CacheEvict 和 @CacheConfig 作为 meta-annotations(即可以用来标注其他注解的注解)。在下面的例子中,我们用自己自定义的注解替换了常见的 @Cacheable 声明:
@Retention(RetentionPolicy.RUNTIME)
@Target({ElementType.METHOD})
@Cacheable(cacheNames="books", key="#isbn")
public @interface SlowService {
}
在前面的例子中,我们定义了自己的 SlowService 注解,而这个注解本身又被 @Cacheable 注解所标记。现在我们可以替换以下代码:
@Cacheable(cacheNames="books", key="#isbn")
public Book findBook(ISBN isbn, boolean checkWarehouse, boolean includeUsed)
以下示例展示了我们可以用来替换前面代码的自定义注释:
@SlowService
public Book findBook(ISBN isbn, boolean checkWarehouse, boolean includeUsed)
尽管@SlowService不是Spring的注解,但容器在运行时会自动识别它的声明并理解其含义。需要注意的是,如之前所述,需要启用基于注解的行为。