在同一个 Bean 中调用另一个 @Cacheable 方法时缓存失效
1、简介
在 Spring 中可以使用 @Cacheable
注解通过 AOP 技术地轻松为 Bean 中的方法启用缓存功能。但是,当你尝试在 Bean 的其他方法中直接调用 @Cacheable
方法时,缓存功能会失效。
本文将会解释这种情况下缓存功能失效的原因以及解决办法。
2、复现问题
首先,初始化一个启用缓存的 Spring Boot 应用。然后创建一个 MathService
,其中的 square
方法注解了 @Cacheable
:
@Service
@CacheConfig(cacheNames = "square")
public class MathService {
private final AtomicInteger counter = new AtomicInteger();
@CacheEvict(allEntries = true)
public AtomicInteger resetCounter() {
counter.set(0);
return counter;
}
@Cacheable(key = "#n")
public double square(double n) {
counter.incrementAndGet();
return n * n;
}
}
接着,再在 MathService
中创建一个 sumOfSquareOf2
方法,调用两次 square
方法:
public double sumOfSquareOf2() {
return this.square(2) + this.square(2);
}
然后,为 sumOfSquareOf2
方法创建一个测试,检查 square
方法被调用的次数:
@SpringBootTest(classes = Application.class)
class MathServiceIntegrationTest {
@Resource
private MathService mathService;
@Test
void givenCacheableMethod_whenInvokingByInternalCall_thenCacheIsNotTriggered() {
AtomicInteger counter = mathService.resetCounter();
assertThat(mathService.sumOfSquareOf2()).isEqualTo(8);
assertThat(counter.get()).isEqualTo(2);
}
}
由于在同一类中的调用不会触发缓存,因此 counter
的值等于 2
。也就是说 square
方法被调用了两次(两次调用传递的参数相同,都是 2
),缓存未生效。这和我们想象中不一样。
3、问题分析
@Cacheable
方法的缓存功能是通过 Spring AOP 实现的。你如果在 IDE 中 Debug 这段代码的,你会发现 MathServiceIntegrationTest
中的 mathService
指向 MathService$$EnhancerBySpringCGLIB$$5cdf8ec8
的实例,而 MathService
中的 this
指向 MathService
的实例。
MathService$$EnhancerBySpringCGLIB$$5cdf8ec8
是 Spring 生成的代理类。它拦截所有对 MathService
的 @Cacheable
方法的请求,并且会缓存方法的返回值,作为返回结果。
而 MathService
类本身不具备缓存功能,因此同一类中的内部调用不会获得缓存值。
知道了原因后,解决办法就简单了。显然,最简单的方式就是将 @Cacheable
方法封装到另一个 Bean 中。但是,如果出于某种原因,必须将这些方法定义在同一个 Bean 中,那么还有三种可能的解决方案:
- 自注入
- 编译时织入
- 加载时织入
官方文档(中文)详细介绍了面向切面编程(AOP)和不同的织入方法。织入是将源码编译成 .class
文件时插入代码的一种方法。它包括 AspectJ 中的编译时织入、编译后织入和加载时织入。由于编译后织入是用于第三方库的织入,而我们的情况并非如此,因此我们只关注编译时织入和加载时织入。
4、方案一:自注入
自注入 是绕过 Spring AOP 限制的常用解决方案。这种方式可以获取 Spring 增强 Bean 的引用,并通过该 Bean 调用方法。在我们的例子中,可以将 mathService
Bean 自动注入到一个名为 self
的成员变量,然后通过 self
而不是使用 this
引用来调用 square
方法:
@Service
@CacheConfig(cacheNames = "square")
@Scope(proxyMode = ScopedProxyMode.TARGET_CLASS)
public class MathService {
@Autowired
private MathService self;
// 其他代码
public double sumOfSquareOf3() {
return self.square(3) + self.square(3);
}
}
由于存在循环引用,@Scope
注解有助于创建和注入一个指向自己的的代理。然后, self
字段被注入相同的 MathService
实例。测试表明,square 方法只执行了一次:
@Test
void givenCacheableMethod_whenInvokingByExternalCall_thenCacheIsTriggered() {
AtomicInteger counter = mathService.resetCounter();
assertThat(mathService.sumOfSquareOf3()).isEqualTo(18);
assertThat(counter.get()).isEqualTo(1);
}
5、方案二:编译时织入
顾名思义就是在编译时进行织入。这是最简单的织入方法。当我们同时拥有切面的源码和应用切面的代码时,AspectJ 编译器就会从源码开始编译,并生成、输出织入后的 class 文件。
在 Maven 项目中,我们可以使用 Mojo 的 AspectJ Maven 插件,使用 AspectJ 编译器将 AspectJ 切面织入到我们的类中。对于 @Cacheable
注解,切面的源码由 spring-aspects
库提供,因此我们需要添加其 Maven 依赖,并将该切面(Aspect)库配置为 AspectJ Maven 插件的依赖。
启用编译时织入需要三个步骤。首先,通过在任何配置类上添加 @EnableCaching
注解来启用 AspectJ 模式的缓存:
@EnableCaching(mode = AdviceMode.ASPECTJ)
其次,需要添加 spring-aspect
依赖:
<dependency>
<groupId>org.springframework</groupId>
<artifactId>spring-aspects</artifactId>
</dependency>
最后为 compile
goal 定义 aspectj-maven-plugin
:
<plugin>
<groupId>org.codehaus.mojo</groupId>
<artifactId>aspectj-maven-plugin</artifactId>
<version>${aspectj-plugin.version}</version>
<configuration>
<source>${java.version}</source>
<target>${java.version}</target>
<complianceLevel>${java.version}</complianceLevel>
<Xlint>ignore</Xlint>
<encoding>UTF-8</encoding>
<aspectLibraries>
<aspectLibrary>
<groupId>org.springframework</groupId>
<artifactId>spring-aspects</artifactId>
</aspectLibrary>
</aspectLibraries>
</configuration>
<executions>
<execution>
<goals>
<goal>compile</goal>
</goals>
</execution>
</executions>
</plugin>
如上,AspectJ Maven 插件将在执行 mvn clean compile
时织入各个切面。
有了编译时织入,不需要修改代码,执行测试,square
方法只执行了一次:
@Test
void givenCacheableMethod_whenInvokingByInternalCall_thenCacheIsTriggered() {
AtomicInteger counter = mathService.resetCounter();
assertThat(mathService.sumOfSquareOf2()).isEqualTo(8);
assertThat(counter.get()).isEqualTo(1);
}
6、方案三:加载时织入
加载时织入(Load-time weaving)是一种简单的二进制织入,它推迟到类加载器加载类文件并将类定义到 JVM 时才进行织入。可以使用 AspectJ Agent 启用 AspectJ 加载时织入功能,让它参与类加载过程,并在虚拟机定义任何类型之前对其进行织入。
启用加载时织入也有三个步骤。首先,通过在任何配置类上添加两个注解,使用 AspectJ 模式和加载时织入器(load-time weaver)启用缓存:
@EnableCaching(mode = AdviceMode.ASPECTJ)
@EnableLoadTimeWeaving
然后,添加 spring-aspects
依赖:
<dependency>
<groupId>org.springframework</groupId>
<artifactId>spring-aspects</artifactId>
</dependency>
最后,通过 JVM 的 javaagent
选项:-javaagent:path/to/aspectjweaver.jar
,或使用 Maven 插件配置 javaagent
:
<build>
<plugins>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-surefire-plugin</artifactId>
<version>${maven-surefire-plugin.version}</version>
<configuration>
<argLine>
--add-opens java.base/java.lang=ALL-UNNAMED
--add-opens java.base/java.util=ALL-UNNAMED
-javaagent:"${settings.localRepository}"/org/aspectj/aspectjweaver/${aspectjweaver.version}/aspectjweaver-${aspectjweaver.version}.jar
-javaagent:"${settings.localRepository}"/org/springframework/spring-instrument/${spring.version}/spring-instrument-${spring.version}.jar
</argLine>
<useSystemClassLoader>true</useSystemClassLoader>
<forkMode>always</forkMode>
<includes>
<include>com.baeldung.selfinvocation.LoadTimeWeavingIntegrationTest</include>
</includes>
</configuration>
</plugin>
</plugins>
</build>
使用加载时织入,上述测试方法 givenCacheableMethod_whenInvokingByInternalCall_thenCacheIsTriggered
也通过了。
7、总结
本文解释了为什么在一个 Bean 中调用 @Cacheable
方法时,缓存失效。然后,介绍了如何通过自注入、编译时织入和加载时织入的方式来解决这个问题。
参考:https://www.baeldung.com/spring-invoke-cacheable-other-method-same-bean