在同一个 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