1 分钟快速上手 Spring Cache

如果你现在有一个现成的工程,你想给你工程的某个接口增加缓存,再不可以分布式缓存的情况下,你可以通过以下两步完成 Spring Cache 接入:

1、引用依赖

1
2
3
4
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-cache</artifactId>
</dependency>

2、给你需要增加缓存的接口或者方法加上注解

1
2
3
4
5
6
7
8
9
10
11
12
13
14
@Service
@CacheConfig(cacheNames = "myCache")
public class MyCacheService {

@CachePut(key = "#key", unless="#result == null")
public String save(String key) {
// do something
}

@Cacheable(key = "#key")
public String find(String key) {
// do something
}
}

当你完成这两步时,支持 local 缓存的方案已经完成了。

基本使用(默认配置)

快速开始部分,我们仅引入了一个依赖,然后对需要缓存的接口加了注解,其他什么配置都没有,所以这种方式使用的都是 Spring Cache 的默认配置。Spring Cache 的默认配置类是 CacheProperties,简单看下有哪些配置属性:

属性 子属性 描述
type 缓存类型,根据环境自动检测(auto-detected)
cacheNames 如果底层缓存管理器支持的话,要创建的以逗号分隔的缓存名称列表。通常,这将禁用动态创建额外缓存的能力。
caffeine spec:是创建缓存规范,具体见 CaffeineSpec 类 Caffeine 作为缓存
couchbase expiration:描述过期时间,默认情况下,内部 entries 不会过期 Couchbase 作为缓存
ehcache config: 用于创建 ehcache 所提供的配置文件 EhCache 作为缓存
infinispan config:用于创建 Infinispan 所提供的配置文件 Infinispan 作为缓存
jcache config:用于初始化缓存管理器的配置文件的位置。配置文件依赖于底层缓存实现。 Jcache 作为缓存
provider:CachingProvider 实现的完全限定名,用于检索符合JSR-107的缓存管理器。仅当类路径上有多个JSR-107实现可用时才需要。
redis timeToLive:缓存过期时间 Redis 作为缓存
cacheNullValues:是否允许缓存 null 值
keyPrefix:key 前缀
useKeyPrefix:写入时是否使用 前缀
enableStatistics:是否开启缓存指标统计能力

Spring Cache 没有使用上表中的缓存,上表中所提到的缓存类型是在指定 type 时,对应所需的配置,默认情况下,在没有明确指定 type 时,使用的是 SIMPLECacheType 所有枚举类型如下:

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
public enum CacheType {

/**
* Generic caching using 'Cache' beans from the context.
*/
GENERIC,

/**
* JCache (JSR-107) backed caching.
*/
JCACHE,

/**
* EhCache backed caching.
*/
EHCACHE,

/**
* Hazelcast backed caching.
*/
HAZELCAST,

/**
* Infinispan backed caching.
*/
INFINISPAN,

/**
* Couchbase backed caching.
*/
COUCHBASE,

/**
* Redis backed caching.
*/
REDIS,

/**
* Caffeine backed caching.
*/
CAFFEINE,

/**
* Simple in-memory caching.
*/
SIMPLE,

/**
* No caching.
*/
NONE

}

SIMPLE 对应的缓存器是基于内存的,其底层存储基于 ConcurrentHashMap

使用 redis 作为缓存

上述快速开始部分实现缓存存储是基于内存的,对于单体应用解决小流量接口缓存问题不大,但是在分布式环境和大流量接口场景下,是不行的。下面来对快速开始部分进行改造,实现目前常用的基于 Spring Cache + Redis 的方案。

1、引入依赖

1
2
3
4
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>

注意:网上一些时间较久的文章使用的是 spring-boot-starter-redis,这个依赖在 Spring Boot 1.4 版本之后被弃用了,改为使用 spring-boot-starter-data-redis 了,官方有明确说明,详见:https://mvnrepository.com/artifact/org.springframework.boot/spring-boot-starter-redis

2、指定 cache typeredis

1
spring.cache.type=redis

完成 1-2 时,就完成了基于 redis 默认配置的集成,此时连接的 redis 地址是 localshot:6379;当然也可以通过配置文件来定制 redis 的配置,

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
#redis配置
#Redis数据库索引(缓存将使用此索引编号的数据库)
spring.redis.database=0
#Redis服务器地址
spring.redis.host=localhost
#Redis服务器连接端口
spring.redis.port=6379
#Redis服务器连接密码(默认为空)
spring.redis.password=
#连接超时时间 毫秒(默认2000)
#请求redis服务的超时时间,这里注意设置成0时取默认时间2000
spring.redis.timeout=2000
#连接池最大连接数(使用负值表示没有限制)
#建议为业务期望QPS/一个连接的QPS,例如50000/1000=50
#一次命令时间(borrow|return resource+Jedis执行命令+网络延迟)的平均耗时约为1ms,一个连接的QPS大约是1000
spring.redis.pool.max-active=50
#连接池中的最大空闲连接
#建议和最大连接数一致,这样做的好处是连接数从不减少,从而避免了连接池伸缩产生的性能开销。
spring.redis.pool.max-idle=50
#连接池中的最小空闲连接
#建议为0,在无请求的状况下从不创建链接
spring.redis.pool.min-idle=0
#连接池最大阻塞等待时间 毫秒(-1表示没有限制)
#建议不要为-1,连接池占满后无法获取连接时将在该时间内阻塞等待,超时后将抛出异常。
spring.redis.pool.max-wait=2000

此外,还可以通过创建缓存配置文件类可以设置缓存各项参数,比如缓存key 的过期时间,使用 key 前缀等,如下:

定义缓存过期时间

1
2
3
4
5
6
@Bean
public RedisCacheConfiguration cacheConfiguration() {
return RedisCacheConfiguration.defaultCacheConfig()
// 过期时间
.entryTtl(Duration.ofMinutes(60)));
}

自定义 key 前缀

key 前缀默认是 cacheName,比如你的 key 是 test,你的 cacheName 是 myCache,则默认情况下存入的 key 为:”myCache::test”, 如果需要调整,可以通过如下方式调整

1
2
3
4
5
6
@Bean
public RedisCacheConfiguration cacheConfiguration() {
return RedisCacheConfiguration.defaultCacheConfig()
// 增加前缀
.prefixCacheNameWith("my-prefix::")));
}

修改之后,key 为 "my-prefix::myCache::glmapper"

除了这些 redis 配置之外,通过 @CacheConfig 注解可以看到,还有 keyGenerator、cacheManager 和 cacheResolver,这些也可以通过自己实现来完成定制化。

自定义 KeyGenerator

顾名思义,keyGenerator 是用来生成 key 的,如上面例子中的

1
2
3
4
@Cacheable(key = "#key")
public String find(String key) {
// do something
}

这里的 key 是通过 Spel 表达式从参数中获取的,当 Spel 表达式不能满足我们需求时,则可以使用自定义缓存 key 来实现,只需指定 KeyGenerator 接口的实现类的 bean 名称即可,如下

1
2
3
4
5
6
7
8
@Component
public class MyKeyGenerator implements KeyGenerator {
@Override
public Object generate(Object target, Method method, Object... params) {
String key = params[0] + "-glmapper";
return key;
}
}

此时存储的 key 为:"my-prefix::myCache::glmapper-glmapper"

需要注意的是,keyGenerator 和 key 不能同时存在,比如:

1
2
3
4
5
@Cacheable(key = "#key", keyGenerator = "myKeyGenerator")
public String find(String key) {
System.out.println("execute find...");
return this.mockDao.find(key);
}

如果同时存在,则会抛出如下异常:

1
Both 'key' and 'keyGenerator' attributes have been set. These attributes are mutually exclusive: either set the SpEL expression used tocompute the key at runtime or set the name of the KeyGenerator bean to use.

自定义 CachManager

自定义 CacheManager 就是实现 CacheManager 接口即可,一般情况下,如果我们需要自定义 RedisConnectionFactory 和 RedisCacheConfiguration 的话,会用到自定义 CacheManager

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
@Bean(name = "myCacheManager")
public CacheManager myCacheManager(RedisConnectionFactory redisConnectionFactory) {
RedisCacheConfiguration defaultConfiguration = RedisCacheConfiguration
.defaultCacheConfig()
.disableCachingNullValues()
.entryTtl(Duration
.ofSeconds(600L))
.serializeKeysWith(SerializationPair.fromSerializer(new StringRedisSerializer()))
.serializeValuesWith(
SerializationPair.fromSerializer(new GenericJackson2JsonRedisSerializer()));

return RedisCacheManager.builder(redisConnectionFactory)
.cacheDefaults(defaultConfiguration)
.build();
}

使用时,可以指定具体的 cacheManager

1
2
3
4
5
@Cacheable(keyGenerator = "myKeyGenerator", cacheManager = "myCacheManager")
public String find(String key) {
System.out.println("execute find...");
return this.mockDao.find(key);
}

自定义CacheResolver

CacheResolver 是缓存解析器,默认的 Cache 解析实现类是org.springframework.cache.interceptor.SimpleCacheResolver,自定义 Cache 解析器需要实现CacheResolver 接口,使用方式和前面自定义 KeyGenerator 类似,即在注解属性 cacheResolver 配置自定义Bean名称。

CacheResolver 解析器的目的是从 CacheOperationInvocationContext 中解析出 Cache,

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
@Component
public class MyCacheResolver implements CacheResolver {
private final CacheManager cacheManager;
public MyCacheResolver(CacheManager cacheManager) {
this.cacheManager = cacheManager;
}
@Override
public Collection<? extends Cache> resolveCaches(CacheOperationInvocationContext<?> context) {
Cacheable annotation = context.getMethod().getAnnotation(Cacheable.class);
BasicOperation operation = context.getOperation();
if (operation instanceof CacheableOperation) {
// do something
}
Collection<Cache> ret = new ArrayList<>();
// 根据注解 或 方法得到的 cacheName 去 getCache,再返回不同的过期时间的 Cache
String[] cacheNames = annotation.cacheNames();
for (String cacheName : cacheNames) {
ret.add(cacheManager.getCache(cacheName));
}
return ret;
}
}

条件缓存 condition 和 unless

最后再来关注下常见的条件缓存问题;有时候,一些值不适合缓存,可以使用 @Cacheable 的 condition 属性判读那些数据不缓存,它接收的是一个 Spel 表达式,该表达式的值是 true 或 false;true,数据被缓存,false不被缓存。

1
2
3
4
5
@Cacheable(key = "#key", condition = "#key.startsWith('glmapper::')")
public String find(String key) {
System.out.println("execute find...");
return this.mockDao.find(key);
}

key 必须是 "glmapper::" 开头的才允许缓存。

@Cacheable#unless 一般是对结果条件判读是否进行缓存使用的,这个示例使用的是入参作为判断条件,各位可以自己写一个根据结果进行缓存的示例,切记满足条件是不缓存。Spel #result变量代表返回值。

1
2
3
4
5
6
@CachePut(unless="#result == null", keyGenerator = "myKeyGenerator")
public String save(String model) {
System.out.println("execute save...");
this.mockDao.save(model, model);
return model;
}

如果返回结果是 null,则不缓存。

beforeInvocation 可能导致潜在的缓存不一致问题

beforeInvocation 是 CacheEvict 注解的属性,默认值为false,表示在调用方法之后进行缓存清理;如果设置true,表示在调用方法之前进行缓存清理。一般情况下推荐使用默认配置即可,如果设置成 true,有两种可能导致一致性问题:

  • 在清理之后,执行方法执行,并发设置缓存。
  • 注解的方法本身内部如果调用了填充缓存的方法。

总结

整体来看,Spring Cache 的上手难度不算大,其提供的注解能够覆盖大多数使用 cache 的场景,对于业务逻辑基本无侵入性。同时,Spring 也秉持了其一贯的作风,就是提供灵活的扩展机制,使得你可以自由的定制自己的各种功能。

本篇简单介绍 Spring Cache 的基本使用方式,下面将会从源码进行分析 Spring Cache 的基本工作原理

基本原理

如果你对 spring 事务模块比较熟悉的话,那么理解 Spring Cache 会简单很多,在官方文档中就有提到这个观点,Spring Cache 的实现从框架 API 抽象层面来看,和事物基本一样的,都是通过 AOP 来实现,以达到最小化业务侵入。

Similar to the transaction support, the caching abstraction allows consistent use of various caching solutions with minimal impact on the code.

本篇将从注解解析、执行拦截、方法执行过程对 Spring Cache 基本原理进行分析。

注解解析

1 分钟快速上手 Spring Cache中,需要关注几个注解:@CacheConfig,@CachePut,@Cacheable,@CacheEvict,此外也需要关注这些注解的属性;这里我们来看下这些注解是如何被解析,如何生效的。

@CacheConfig

属性 含义 案例
cacheNames/value 缓存的名称 例如: @CachePut(value=”mycache”) @CachePut(cacheNames={”cache1”,”cache2”}
keyGenerator key 生成器
cacheManager 缓存管理器
cacheResolver 用于拦截方法调用的缓存实例

@CachePut

属性 含义 案例
cacheNames/value 缓存的名称 例如: @CachePut(value=”mycache”) @CachePut(value={”cache1”,”cache2”}
key 缓存的 key 例如: @CachePut(value=”testcache”,key=”#userName”)
condition 缓存的条件 例如: @CachePut(value=”testcache”,condition=”#userName.length()>2”)

@Cacheable

属性 含义 案例
cacheNames/value 缓存的名称 每一个缓存名称代表一个缓存对象。当一个方法填写多个缓存名称时将创建多个缓存对象。当多个方法使用同一缓存名称时相同参数的缓存会被覆盖。所以通常情况我们使用“包名+类名+方法名”或者使用接口的RequestMapping作为缓存名称防止命名重复引起的问题。单缓存名称:@Cacheable(value=”mycache”) 多缓存名称:@Cacheable(value={”cache1”,”cache2”}
key 缓存的 key key标记了缓存对象下的每一条缓存。如果不指定key则系统自动按照方法的所有入参生成key,也就是说相同的入参值将会返回同样的缓存结果。如果指定key则要按照 SpEL 表达式编写使用的入参列表。如下列无论方法存在多少个入参,只要userName值一致,则会返回相同的缓存结果。@Cacheable(value=”testcache”,key=”#userName”)
condition 缓存的条件 满足条件后方法结果才会被缓存。不填写则认为无条件全部缓存。条件使用 SpEL表达式编写,返回 true 或者 false,只有为 true 才进行缓存如下例,只有用户名长度大于2时参会进行缓存 @Cacheable(value=”testcache”,condition=”#userName.length()>2”)

@CacheEvict

属性 含义 案例
cacheNames/value 缓存的名称 删除指定名称的缓存对象。必须与下面的其中一个参数配合使用例如: @CacheEvict(value=”mycache”) 或者 @CacheEvict(value={”cache1”,”cache2”}
key 缓存的 key 删除指定key的缓存对象例如: @CacheEvict(value=”testcache”,key=”#userName”)
condition 缓存的条件 删除指定条件的缓存对象例如: @CacheEvict(value=”testcache”,condition=”#userName.length()>2”)
allEntries 方法执行后清空所有缓存 缺省为 false,如果指定为 true,则方法调用后将立即清空所有缓存。例如: @CacheEvict(value=”testcache”,allEntries=true)
beforeInvocation 方法执行前清空所有缓存 缺省为 false,如果指定为 true,则在方法还没有执行的时候就清空缓存,缺省情况下,如果方法执行抛出异常,则不会清空缓存。例如: @CacheEvict(value=”testcache”,beforeInvocation=true)

属性解析

Spring Cache 生效需要通过 @EnableCaching 注解来开启,这种做法和在 Spring 项目中非常常见;通过 @EnableCaching ,导入 CachingConfigurationSelector 类,进而根据EnableCaching 的值选择应该使用 AbstractCachingConfiguration 的哪个实现。Spring Cache 对业务接口拦截有两种模式,PROXY 和 ASPECTJ,默认情况下是 PROXY。每个模式提供一个
Configuration,如 PROXY 对应的是 ProxyCachingConfiguration。这里已 ProxyCachingConfiguration 为例,ProxyCachingConfiguration 中会声明 BeanFactoryCacheOperationSourceAdvisor、CacheOperationSource 和 CacheInterceptor 三个 bean。其中 CacheOperationSource 会绑定一个 parser,用来解析注解。

1
2
3
4
5
6
7
8
9
10
11
12
public class SpringCacheAnnotationParser implements CacheAnnotationParser, Serializable {

private static final Set<Class<? extends Annotation>> CACHE_OPERATION_ANNOTATIONS = new LinkedHashSet<>(8);
// 这里即为我们前面所提到的四个注解
static {
CACHE_OPERATION_ANNOTATIONS.add(Cacheable.class);
CACHE_OPERATION_ANNOTATIONS.add(CacheEvict.class);
CACHE_OPERATION_ANNOTATIONS.add(CachePut.class);
CACHE_OPERATION_ANNOTATIONS.add(Caching.class);
}
// ... 省略其他代码
}

以 @CachePut 为例

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
private CacheOperation parsePutAnnotation(
AnnotatedElement ae, DefaultCacheConfig defaultConfig, CachePut cachePut) {

CachePutOperation.Builder builder = new CachePutOperation.Builder();

builder.setName(ae.toString());
builder.setCacheNames(cachePut.cacheNames());
builder.setCondition(cachePut.condition());
builder.setUnless(cachePut.unless());
builder.setKey(cachePut.key());
builder.setKeyGenerator(cachePut.keyGenerator());
builder.setCacheManager(cachePut.cacheManager());
builder.setCacheResolver(cachePut.cacheResolver());

defaultConfig.applyDefault(builder);
CachePutOperation op = builder.build();
validateCacheOperation(ae, op);

return op;
}

每个注解解析之后会对应一个 CacheOperation;@CachePut 对应的是 CachePutOperation,CachePutOperation 主要是来描述缓存 “put” 这个操作,实际执行缓存动作的并不是这个 CachePutOperation。

执行拦截

所有 Spring Cache 切面入口是 CacheInterceptor 这个类

image.png

上图是我用例执行的一个堆栈信息,拦截到的注解是 @CachePut。CacheAspectSupport 是 CacheInterceptor 的父类,它包括了执行拦截的所有核心逻辑。有兴趣的可以自己阅读代码,不是很复杂。

方法执行

拦截到方法之后就是执行具体的动作,前面提到所有的执行动作都是在 CacheAspectSupport 中,以 @CachePut 为例

1
2
3
4
5
6
7
// 收集 @CachePuts
collectPutRequests(contexts.get(CachePutOperation.class), cacheValue, cachePutRequests);

// 处理任何已收集的 put 请求,无论是来自 @CachePut 还是 @Cacheable
for (CachePutRequest cachePutRequest : cachePutRequests) {
cachePutRequest.apply(cacheValue);
}

apply 中执行具体的 put 动作

1
2
3
4
5
6
7
8
public void apply(@Nullable Object result) {
if (this.context.canPutToCache(result)) {
for (Cache cache : this.context.getCaches()) {
// 将 key result ,写入到 cache 中
doPut(cache, this.key, result);
}
}
}

除了这些常规流程之外,Sring Cache 也提供了对于异常场景的关注,可以通过子定义 CacheErrorHandler 来完成对异常场景的处理。

总结

这种接口+实现分离的设计带来的好处是,我们可以做到存储平台无关,对于后续的存储迁移和替换会非常方便。

参考链接

作者

卫恒

发布于

2022-02-21

更新于

2022-04-24

许可协议

评论