Spring Cache 性能分析

1. 概述

缓存是我们经常在提高应用性能的手段,Spring 对缓存的支持也非常好,我们通常只需要添加 <cache:annotation-driven /> 配置,选择合适的 CacheManager 及底层的缓存实现框架,即可在需要缓存的方法中通过 @Cacheable 来完成缓存接入了,如果你使用 SpringBoot 那么整个接入过程则更加的简单,只需要在 maven 中添加缓存相关的 starter 即可(具体的接入过程可以参考 Spring Cache)。

本文主要关注 Spring Cache 通用的基于注解的的解决方案和手动的缓存操作之间的性能对比。

2. 测试用例——简单KEY

2.1 用例场景

为了测试几种场景,我们写了一个简单的基于 SpringBoot 的测试,代码如下所示:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
@Component("service")
public class CacheService {

private final Cache cache;

public CacheService(CacheManager cacheManager) {
cache = cacheManager.getCache("time");
}

public long noCache(String dummy) {
return System.currentTimeMillis();
}

@Cacheable("time")
public long annotationBased(String dummy) {
return System.currentTimeMillis();
}

public long manual(String dummy) {
Cache.ValueWrapper valueWrapper = cache.get(dummy);
long result;
if (valueWrapper == null) {
result = System.currentTimeMillis();
cache.put(dummy, result);
} else {
result = (long) valueWrapper.get();
}
return result;
}
}

注意:这个例子(获取当前时间)并不是真实的业务场景,也完全不符合缓存的使用原则,仅仅只是用来作为演示的目的

分别采用 3 种不同的方法来测试我们的性能,分别是:

  • noCache:无缓存
  • annotationBased:基于注解的缓存
  • manual:手动操作缓存

2.2 基准测试

然后我们针对这3种分别进行基准测试,关于基准测试的知识,如果不了解的可以查阅我的另外一篇博客:性能测试利器:JMH,基准测试类如下所示:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
@State(Scope.Thread)
public class CacheBenchmark {

private ConfigurableApplicationContext context;
private CacheService service;

@Setup
public void prepare() {
if (null == context) {
context = SpringApplication.run(SpringbootDemoApplication.class);
service = context.getBean("service", CacheService.class);
}

}

@TearDown
public void shutdown() {
if (null != context) {
context.close();
}
}

@Benchmark
@BenchmarkMode(Mode.AverageTime)
@OutputTimeUnit(TimeUnit.MILLISECONDS)
public long nocache() {
return service.noCache("const");
}

@Benchmark
@BenchmarkMode(Mode.AverageTime)
@OutputTimeUnit(TimeUnit.MILLISECONDS)
public long annotationBased() {
return service.annotationBased("const");
}

@Benchmark
@BenchmarkMode(Mode.AverageTime)
@OutputTimeUnit(TimeUnit.MILLISECONDS)
public long manual() {
return service.manual("const");
}

public static void main(String[] args) throws Exception {
Options opt = new OptionsBuilder()
.include(CacheBenchmark.class.getSimpleName())
.timeUnit(TimeUnit.NANOSECONDS)
.threads(1)
.forks(2)
.warmupIterations(5)
.measurementIterations(10)

.shouldFailOnError(true)
.shouldDoGC(true)
.build();

new Runner(opt).run();
}
}

2.3 结果分析

这个用例在我本机的测试结果如下:

1
2
3
4
5
6
# Run complete. Total time: 00:02:42

Benchmark Mode Cnt Score Error Units
CacheBenchmark.annotationBased avgt 20 337.636 ± 24.020 ns/op
CacheBenchmark.manual avgt 20 20.028 ± 0.697 ns/op
CacheBenchmark.nocache avgt 20 19.153 ± 1.465 ns/op

注意:由于机器的性能各不相同,这里并不比较绝对值

由于我们的用例仅仅只是返回当前的时间,执行时间几乎可以忽略,所以可以看到我们的 无缓存 方案的性能是最好的,该方案仅仅用作基准,不参与比较。

但是,对于有缓存的情况(时间单位采用的是 NANOSECOND),结果可能令大多数人吃惊,手动操作缓存与基于Spring注解的缓存方案差距非常大,有 337.636/19.153 = 17.628 倍之多(本机测试数据而言)。

3. 测试用例——SpEL

3.1 用例

这次我们测试一下带有 SpEL 的缓存 KEY 场景,代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
public long manual(String dummy, String dummy2) {
String key = dummy + dummy2;
Cache.ValueWrapper valueWrapper = cache.get(key);
long result;
if (valueWrapper == null) {
result = System.currentTimeMillis();
cache.put(key, result);
} else {
result = (long) valueWrapper.get();
}
return result;
}

@Cacheable(value = "time", key = "#p0.concat(#p1)")
public long annotationWithSpel(String dummy1, String dummy2) {
return System.currentTimeMillis();
}

@Cacheable(value = "time")
public long annotationBased(String dummy1, String dummy2) {
return System.currentTimeMillis();
}

同样是 3 种方法:

  • manual:完全手动的完成 KEY 的拼接及缓存的控制
  • annotationBased:基于注解,并使用默认的 KEY 生成机制
  • annotationWithSpel:基于注解,并通过 SpEL 指定 KEY 的生成方式

3.2 基准测试

测试过程和之前一样,代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
@Benchmark
@BenchmarkMode(Mode.AverageTime)
@OutputTimeUnit(TimeUnit.MILLISECONDS)
public long manual() {
return service.manual("const", "const");
}

@Benchmark
@BenchmarkMode(Mode.AverageTime)
@OutputTimeUnit(TimeUnit.MILLISECONDS)
public long annotationBased() {
return service.annotationBased("const", "const");
}

@Benchmark
@BenchmarkMode(Mode.AverageTime)
@OutputTimeUnit(TimeUnit.MILLISECONDS)
public long annotationWithSpel() {
return service.annotationWithSpel("const", "const");
}

3.3 结果分析

测试的结果如下:

1
2
3
4
Benchmark                           Mode  Cnt     Score     Error  Units
CacheBenchmark2.annotationBased avgt 20 380.550 ± 39.698 ns/op
CacheBenchmark2.annotationWithSpel avgt 20 1582.346 ± 122.755 ns/op
CacheBenchmark2.manual avgt 20 35.999 ± 0.598 ns/op

同样的,手动操作缓存要比基于注解的方式快 (380.550 / 35.999 = 10.571) 倍,相比 SpEL 要快 (1582.346 / 35.999 = 43.955) 倍。

4. 总结

如上分析所见,针对特定问题的自定义解决方案比通用解决方案快 15 - 45 倍。当然,这绝不是指责 Spring 是一个缓慢的框架,或者大部分场景下,几百纳秒的差距并不会对应用有实质性的影响!

实际上,对于一个如此通用的框架而言,Spring 要做的绝不是在每一个点上都完美的提供既兼容易用性又能完全保证性能优势的方案,而是在满足绝大多数应用场景的情况下提供极高的开发效率与易用性的平衡。

但是,我们仍然会说几百纳秒,这在大多数情况下可能是微不足道的时间差,但在极少数情况下,仍然有一些优化的意义,如果我们的应用有着分毫必究的性能要求,那么不妨试试手动的缓存操作。