从零开始 Spring Boot 47:缓存
Spring 提供一个简单但使用的缓存(Cache)机制,我们可以利用它来优化代码执行效率。
简单示例
老规矩,我们从一个简单示例开始:
@Service
public class FibonacciService2 {
@Clock
public long fibonacci(int n) {
return doFibonacci(n);
}
private long doFibonacci(int n) {
if (n <= 0) {
throw new IllegalArgumentException("n不能小于等于0");
}
if (n <= 2) {
return 1;
}
return this.doFibonacci(n - 2) + this.doFibonacci(n - 1);
}
}
FibonacciService2
用于计算斐波那契数列,具体采用递归方式进行计算,这很消耗时间。
@Clock
是一个自定义的注解,用一个自定义 AOP 切面来处理,以统计方法的执行时长,感兴趣的可以查看完整代码。- 这里将实际的计算逻辑拆分为
dodoFibonacci
方法(与外部调用方法fibonacci
分开),是因为方便统计方法执行时长,以及后期的缓存优化。
编写一个测试用例并执行:
@SpringJUnitConfig(classes = {
CacheApplication.class})
@Import(ClockConfig.class)
public class FibonacciService2Tests {
@Autowired
FibonacciService2 fibonacciService;
@Test
void testFibonacci() {
var result = fibonacciService.fibonacci(40);
Assertions.assertEquals(102334155L, result);
}
}
执行结果:
com.example.cache.FibonacciService2.fibonacci() is called, use 211 mills.
执行时长有211毫秒,并且随着计算位数的增加,计算时长会指数增加。
这个问题很明显可以通过缓存来进行优化,因为计算一个高位斐波那契数会涉及低位斐波那契数的重复计算,只要将这些计算缓存起来,就会很快得出一个高位斐波那契数。
要使用 Spring 缓存,需要添加依赖:
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-cache</artifactId>
</dependency>
这是 Spring Boot 的方式,如果是 Spring,需要添加不同的依赖,具体可以参考这篇文章。
其次需要给配置类(@Configuration
)添加上@EnableCaching
注解以启用缓存功能:
@Configuration
@EnableCaching
public class WebConfig {
// ...
}
要使用缓存,还需要添加一个CacheManager
类型的 bean,默认情况下 Spring Boot 会创建一个ConcurrentMapCacheManager
作为CacheManager
bean,因此一般不需要手动添加。
如果我们要修改默认创建的ConcurrentMapCacheManager
,可以通过定义一个或多个CacheManagerCustomizer<ConcurrentMapCacheManager>
类型的 bean 来实现:
@Configuration
@EnableCaching
public class WebConfig {
@Bean
CacheManagerCustomizer<ConcurrentMapCacheManager> cacheManagerCustomizer() {
return cacheManager -> {
cacheManager.setCacheNames(List.of("fibonacci", "cache2"));
};
}
// ...
}
Spring 会获取这些CacheManagerCustomizer
类型的 bean,并利用它们对CacheManager
进行初始化。
这个过程由自动配置类
CacheAutoConfiguration
实现。
可以利用CacheManagerCustomizer
设置CacheManager
的(多个)缓存名称:
cacheManager.setCacheNames(List.of("fibonacci", "cache2"));
现在修改代码,给需要进行缓存的方法添加上@Cacheable
注解:
@Service
public class FibonacciService3 {
// ...
@Cacheable("fibonacci")
protected long doFibonacci(int n) {
if (n <= 0) {
throw new IllegalArgumentException("n不能小于等于0");
}
if (n <= 2) {
return 1;
}
return this.doFibonacci(n - 2) + this.doFibonacci(n - 1);
}
}
要注意的是,@Cacheable
只能作用于public
或protected
方法,对于private
方法是不起作用的。原因也很简单,和之前介绍过的异步执行(@Async
)类似,它们都是通过AOP 实现的,而 AOP 又是通过代理(JDK或CGLIB)实现的。而这里FibonacciService3
没有接口,所以显然是使用 CGLIB 代理实现(类继承),因此存在这样的限制。
- 关于 AOP 实现原理及相应的限制,可以阅读我的这篇文章。
- 如果对
protected
方法使用@Cacheable
,idea 会有错误提示——@Cacheable只能错用于 public 方法。但实际上在通过 CGLIB 进行代理的情况下,是的确可以对protected
方法缓存的,且会正常通过编译并执行。所以这大概是 idea 的一种“粗鲁”提示。
我们可以给@Cacheable
注解指定一个(或多个)使用的缓存(@Cacheable("fibonacci")
),这里使用的是之前通过CacheManager
设置的名称为fibonacci
的缓存。
现在是不是可以利用缓存提升代码执行效率了?并不会!
实际运行测试用例就会发现,时间几乎一致,并没有显著提升。
原因是这里进行缓存的方法进行了自调用,我们之前在介绍 AOP 的时候提到过,因为 Spring AOP 通过代理实现,所以默认情况下不能处理“自调用”。
具体到我们这里的示例,fibonacci
自调用了doFibonacci
,而doFibonacci
又对自己进行了递归调用,所以实际上不会触发任何缓存。
当然,我们可以对外部调用方法fibonacci
进行缓存:
@Service
public class FibonacciService3 {
@Clock
@Cacheable("fibonacci")
public long fibonacci(int n) {
return doFibonacci(n);
}
}
但这样用处不大,仅仅可以缩减“重复获取斐波那契数”的执行效率:
@SpringJUnitConfig(classes = {
CacheApplication.class})
@Import(ClockConfig.class)
public class FibonacciService3Tests {
@Autowired
FibonacciService3 fibonacciService;
@Test
void testFibonacci() {
var result = fibonacciService.fibonacci(40);
Assertions.assertEquals(102334155L, result);
var result2 = fibonacciService.fibonacci(40);
Assertions.assertEquals(result2, result);
fibonacciService.fibonacci(39);
}
}
执行结果:
com.example.cache.FibonacciService3.fibonacci() is called, use 214 mills.
com.example.cache.FibonacciService3.fibonacci() is called, use 0 mills.
com.example.cache.FibonacciService3.fibonacci() is called, use 126 mills.
所以,我们要想办法让“中间斐波那契数”的计算能够被缓存。通过之前的文章我们知道,可以通过手动调用代理的方式让“自调用”也能够触发 AOP 的 advice,因此我们可以修改代码:
@Service
public class FibonacciService {
@Clock
public long fibonacci(int n)