Spring Cache 使用教程之二

大纲

前言

官方文档

使用 @Cacheable 注解

@Cacheable 可以将方法运行的结果进行缓存,在缓存时效内再次调用该方法时不会调用方法本身,而是直接从缓存获取结果并返回给调用方。

属性介绍

属性名描述
value / cacheNames 指定缓存的名称,Spring Cache 使用 CacheManage 管理多个缓存组件 Cache,这里的 Cache 组件就是根据该名称进行区分的,它负责对缓存执行真正的 CRUD 操作
key 缓存数据时 Key 的值,默认是使用方法参数的值,可以使用 SpEL 表达式计算 Key 的值
keyGenerator 缓存 Key 的生成策略,它和 key 属性互斥使用(只能二选一)
cacheManager 指定缓存管理器(如 ConcurrentHashMap、Redis 等)
cacheResolver 作用和 cacheManager 属性一样,两者只能二选一
condition 指定缓存的条件(满足什么条件才缓存),可用 SpELl 表达式(如 #id>0,表示当入参 id 大于 0 时才缓存)
unless 否定缓存,即满足 unless 指定的条件时,方法的结果不进行缓存,使用 unless 时可以在调用的方法获取到结果之后再进行判断(如 #result == null,表示如果结果为 null 时不缓存)
sync 是否使用异步模式进行缓存,默认值是 false

在一个多线程的环境中,某些操作可能会被相同的参数并发地调用,同一个 value 值可能被多次计算(或多次访问数据库),这样就达不到缓存的目的。针对这些可能高并发的操作,可以使用 sync 属性来告诉底层的缓存提供者将缓存的入口锁住,这样在同一时刻就只能有一个线程计算操作的结果值,而其它线程则需要等待。当 sync 的值为 true 时,相当于同步操作,可以有效地避免出现缓存击穿的问题,关于缓存击穿的介绍可以点击 这里

特别注意

  • 1、即满足 condition 又满足 unless 条件的情况下,不会缓存数据
  • 2、使用异步模式(sync=true)进行缓存时,unless 条件将不会生效
  • 3、condition 不指定相当于 true,而 unless 不指定相当于 false
  • 4、condition 属性使用的 SpEL 表达式只有 #root 和获取方法参数类的 SpEL 表达式,不能使用带返回结果的表达式(如 #result),因此 condition = "#result != null" 会导致所有对象都不写入缓存,每次都要查询数据库。

使用案例

1
2
3
4
@Cacheable(value="users", key="#id")
public User find(Integer id) {
return null;
}

指定 Key

@Cacheable 注解有一个属性 key 可以用于直接定义缓存 Key,该属性不是必填项。如果为空,则会使用默认的 Key 生成器进行生成。默认的 Key 生成器要求方法参数具有有效的 hashCode()equals() 方法实现。值得一提的是,key 属性的值支持使用 SpEL 表达式。使用方法参数作为 Key 时,可以直接使用 #参数名 或者 #p参数索引 的 SpEL 表达式来引用,以下的写法都是合法的。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
@Cacheable(value="users", key="#id")
public User find(Integer id) {
return null;
}

@Cacheable(value="users", key="#p0")
public User find(Integer id) {
return null;
}

@Cacheable(value="users", key="#user.id")
public User find(User user) {
return null;
}

@Cacheable(value="users", key="#p0.id")
public User find(User user) {
return null;
}

使用 @CachePut 注解

@Cacheable 注解不同的是使用 @CachePut 注解标注的方法,在执行前不会去检查缓存中是否存在之前执行过的结果,而是每次都会执行该方法,并将执行结果以键值对的形式写入指定的缓存中。@CachePut 注解一般用于更新缓存数据,相当于缓存使用的是写模式中的双写模式。

属性介绍

@CachePut 注解所具有的属性与 @Cacheable 注解相同,这里不再累述。

使用案例

1
2
3
4
5
@CachePut(value = "users", key="#user.id")
public User updateUser(User user) {
userMapper.updateUser(user);
return user;
}

使用 @CacheEvict 注解

标注了 @CacheEvict 注解的方法在被调用时,会从缓存中移除已存储的数据。@CacheEvict 注解一般用于删除缓存数据,相当于缓存使用的是写模式中的失效模式。

属性介绍

属性名描述
value / cacheNames 缓存的名称
key 缓存的键
allEntries 是否根据缓存名称清空所有缓存数据,默认值为 false,当值指定为 true 时,Spring Cache 将忽略注解上指定的 key 属性
beforeInvocation 是否在方法执行之前就清空缓存,默认值为 false

清除缓存的操作默认是在对应方法成功执行之后才触发的,即方法的执行如果因为抛出异常而未能成功返回时也不会触发清除操作。使用 beforeInvocation 属性可以改变触发清除操作执行的时机,当指定该属性的值为 true 时,Spring 会在调用该方法之前清除缓存中的数据。值得一提的是,在方法调用之前还是之后清除缓存的区别在于方法调用时是否会出现异常,若不出现异常,这两者之间没有区别,若出现异常,设置为在方法调用之后清除缓存将不起作用,因为方法调用失败了。

使用案例

1
2
3
4
@CacheEvict(value = "users", key = "#id")
public void deleteUserById(Long id) {
userMapper.deleteUserById(id);
}

使用 @Caching 注解

@Caching 注解用于在一个方法或者类上,同时指定多个 Spring Cache 相关的注解。

属性介绍

属性名描述
cacheable 用于指定 @Cacheable 注解
put 用于指定 @CachePut 注解
evict 用于指定 @@CacheEvict 注解

使用案例

1
2
3
4
5
6
7
8
@Caching(cacheable = {@Cacheable(value = "stu", key = "#userName")}, put = {
@CachePut(value = "stu", key = "#result.id"), @CachePut(value = "stu", key = "#result.age")})
public Student getStuByUserName(String userName) {
StudentExample studentExample = new StudentExample();
studentExample.createCriteria().andUserNameEqualTo(userName);
List<Student> students = studentMapper.selectByExample(studentExample);
return Optional.ofNullable(students).orElse(null).get(0);
}

使用 @CacheConfig 注解

@CacheConfig 注解标注在类上,用于抽取 Spring Cache 相关注解的公共配置,可抽取的公共配置包括缓存名称、主键生成器、缓存管理器。比如,在每个 Spring Cache 缓存注解中,往往都指定了缓存名称(value = "stu" 或者 cacheNames = "stu")。此时 @CacheConfig 注解可以将它抽离出来,并在整个类上添加 @CacheConfig(value = "stu") 注解之后,每个方法默认都会使用指定的缓存名称 stu

属性介绍

属性名描述
value / cacheNames 指定缓存的名称,Spring Cache 使用 CacheManage 管理多个缓存组件 Cache,这里的 Cache 组件就是根据该名称进行区分的,它负责对缓存执行真正的 CRUD 操作
cacheManager 缓存管理器
keyGenerator 主键生成器

使用案例

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
@Service
@CacheConfig(value = "stu")
public class StudentServiceImpl implements StudentService {

@Resource
private StudentMapper studentMapper;

@Override
@CachePut(key = "#result.id")
public Student updateStu(Student student){
studentMapper.updateByPrimaryKey(student);
return student;
}

@Override
@CacheEvict(key = "#id")
public void delSut(Integer id) {
studentMapper.deleteByPrimaryKey(id);
}

}

使用 SpEL 表达式

SpEL 表达式的语法

Spring Cache 也提供了 root 对象,可以在 SpEL 表达式中直接使用

名称位置描述示例
methodName 根对象要调用的方法的名称#root.methodName
method 根对象正在调用的方法#root.method.name
target 根对象正在调用的目标对象#root.target
targetClass 根对象要调用的目标的类#root.targetClass
args 根对象用于调用目标的参数(作为数组)#root.args[0]
caches 根对象正在调用的方法使用的缓存列表(如 @Cacheable(value={"cache1", "cache2"})),则有两个缓存)#root.caches[0].name
argument name 评估背景方法参数名,可以直接使用 #参数名,也可以使用 #p0#a0 的形式,0 代表参数的索引#iban、#a0、#p0
result 评估背景方法执行后的返回值,仅当方法执行之后的判断有效,如 unlesscache putcache evict (当 beforeInvocation = false) 的表达式#result

SpEL 表达式的使用案例

1
2
3
4
5
6
7
8
9
@Cacheable(value="users", key="#p0.id")
public User find(User user) {
return null;
}

@Cacheable(value="users", key="#root.method.name")
public User find(User user) {
return null;
}

当需要使用 root 对象的属性作为 Key 时,还可以将 #root 省略掉,因为 Spring Cache 默认使用的就是 root 对象的属性

1
2
3
4
@Cacheable(value={"users", "members"}, key="caches[0].name")
public User find(User user) {
return null;
}

当需要调用目前类里的方法动态生成 Key 时,在 SpEL 表达式内拼接字符串时,必须使用单引号将字符串包裹起来

1
2
3
4
5
6
7
8
9
10
11
12
@Cacheable(value="users", key="#root.target.getDictTableName() + '_' + #root.target.getFieldName()")
public User find(User user) {
return null;
}

public String getDictTableName(){
return "";
}

public String getFieldName(){
return "";
}

自定义缓存配置

防止缓存穿透

为了避免出现缓存穿透,建议让 Spring Cache 将空值也写入缓存,关于缓存穿透的介绍可以点击 这里

1
2
3
4
5
6
spring:
cache:
type: redis
redis:
# 是否缓存空值,防止缓存穿透
cache-null-values: true

指定有效时间

指定缓存数据的有效时间,这样可以让缓存数据过期被删除后,触发主动更新(基于缓存的读模式)。

1
2
3
4
5
6
spring:
cache:
type: redis
redis:
# 有效时间,单位为毫秒
time-to-live: 3600000

提示

Spring Cache 的注解不支持给缓存单独设置不同的有效时间,若希望像 Redis 一样设置缓存的有效时间,可以参考这篇 博客

指定 Key 前缀

在不指定 Key 前缀时,Spring Cache 默认会使用缓存的名称作为 Key 前缀。

1
2
3
4
5
6
7
8
spring:
cache:
type: redis
redis:
# Key 的前缀,建议不配置,让它默认使用缓存的名称作为前缀
key-prefix: CACHE_
# 是否使用前缀
use-key-prefix: true

指定 Key 生成器

缓存的本质就是键值对存储模式,每一次方法的调用都需要生成相应的 Key,这样才能操作缓存。若没有给 Spring Cache 的注解(如 @Cacheable)设置属性 key,缓存抽象默认会使用 SimpleKeyGenerator 来自动生成 Key,具体源码如下:

  • 如果没有方法参数,则直接返回 SimpleKey.EMPTY
  • 如果只有一个方法参数,则直接返回该方法参数
  • 若有多个方法参数,则返回包含多个方法参数的 SimpleKey 对象

Spring Cache 也考虑到需要自定义 Key 的生成方式,只需要实现 org.springframework.cache.interceptor.KeyGenerator 接口,然后通过 @Cacheable 注解的 keyGenerator 属性指定 Key 生成器即可。值得一提的是,默认的 Key 生成器要求方法参数具有有效的 hashCode()equals() 方法实现。

1
2
3
4
5
6
7
8
9
10
11
12
/**
* 自定义 Key 生成器
*/
@Component
public class CustomKeyGenerator implements KeyGenerator {

public Object generate(Object target, Method method, Object... params) {
String key = target.toString() + ":" + method.getName() + ":" + Arrays.toString(params);
return key;
}

}
1
2
3
4
5
6
7
/**
* 指定自定义的 Key 生成器
*/
@Cacheable(value="users", keyGenerator="customKeyGenerator")
public User find(User user) {
return null;
}

指定序列化机制

默认情况下,Spring Cache 会使用 JDK 的序列化机制将缓存数据写入 Redis,这样存储在 Redis 里面的就是二进制数据。若希望 Spring Cache 将数据序列化成 JSON 数据再写入 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
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
import org.springframework.boot.autoconfigure.cache.CacheProperties;
import org.springframework.boot.context.properties.EnableConfigurationProperties;
import org.springframework.cache.annotation.CachingConfigurerSupport;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.data.redis.cache.RedisCacheConfiguration;
import org.springframework.data.redis.serializer.GenericJackson2JsonRedisSerializer;
import org.springframework.data.redis.serializer.RedisSerializationContext;
import org.springframework.data.redis.serializer.StringRedisSerializer;

import java.time.Duration;

@Configuration
@EnableCaching
@EnableConfigurationProperties(CacheProperties.class)
public class SpringCacheConfig extends CachingConfigurerSupport {

/**
* Spring Cache 的 Redis 配置
*/
@Bean
public RedisCacheConfiguration redisCacheConfiguration(CacheProperties cacheProperties) {
// 默认配置
RedisCacheConfiguration config = RedisCacheConfiguration.defaultCacheConfig();

// 设置随机的有效时间,若不设置,默认是永久有效
// Random random = new Random();
// config = config.entryTtl(Duration.ofHours(random.nextInt(24)));

// Key的序列化机制
config = config.serializeKeysWith(RedisSerializationContext.SerializationPair.fromSerializer(new StringRedisSerializer()));

// Value的序列化机制
config = config.serializeValuesWith(RedisSerializationContext.SerializationPair.fromSerializer(new GenericJackson2JsonRedisSerializer()));

// 加载配置文件的内容
CacheProperties.Redis redisProperties = cacheProperties.getRedis();
if (redisProperties.getTimeToLive() != null) {
config = config.entryTtl(redisProperties.getTimeToLive());
}
if (redisProperties.getKeyPrefix() != null) {
config = config.prefixCacheNameWith(redisProperties.getKeyPrefix());
}
if (!redisProperties.isCacheNullValues()) {
config = config.disableCachingNullValues();
}
if (!redisProperties.isUseKeyPrefix()) {
config = config.disableKeyPrefix();
}
return config;
}

}

Spring Cache 加载 Redis 缓存配置的流程

  • CacheAutoConfiguration --> RedisCacheConfiguration --> 自动配置了 RedisCacheManager --> 初始化所有缓存 --> 每个缓存决定使用什么配置内容 --> 如果 RedisCacheConfiguration 有就用已经有的,没有就用默认的 Redis 配置
  • 所以如果想自定义 Redis 缓存配置,只需要在 Spring 容器中放一个 RedisCacheConfiguration,它就会应用到当前 RedisCacheManager 管理的所有缓存分区中

常见问题总结

缓存不生效

在有些情形下,Spring Cache 注解式缓存是不起作用的。比如在同一个 Bean 里的内部方法调用,又或者是子类调用父类中有缓存注解的方法等。后者不起作用是因为缓存切面必须走代理才有效,这时候可以手动使用 CacheManager 来获得缓存效果。

Spring Cache 的不足

  • 读模式

    • 读模式下,可能会出现缓存失效的问题,Spring Cache 的解决方案如下
      • 缓存穿透:查询一个不存在的数据(Null),解决方案是缓存空数据,配置内容是 cache-null-values: true
      • 缓存雪崩:大量缓存同时过期,解决方案是给缓存设置过期时间(或者是随机的过期时间),配置内容是 time-to-live: 3600000
      • 缓存击穿:大量并发请求进来同时查询一个正好过期的数据,解决方案是使用 @Cacheable(sync = true) 来实现同步模式的缓存写入,底层是基于 JDK 的 synchronized
  • 写模式

    • 双写模式或者失效模式下,可能会出现缓存数据一致性问题(读取到脏数据),Spring Cache 暂时没办法解决,其他的解决方案如下
      • 加分布式锁(读写锁),只适用于读多写少的业务场景
      • 直接查询数据库,不再从缓存获取数据,只适用于读多写多的业务场景
      • 使用 Canal 中间件,实时将数据库的数据更新到缓存,会增加系统的复杂性
  • 总结

    • 常规数据(读多写少、即时性与一致性要求不高的数据)完全可以使用 Spring Cache,至于写模式下缓存数据一致性问题的解决,只要缓存数据有设置过期时间就足够了
    • 特殊数据(读多写多、即时性与一致性要求非常高的数据),不能使用 Spring Cache,建议考虑特殊的设计(例如使用 Cancal 中间件等)

提示

更多关于缓存写模式、缓存失效、缓存数据一致性、分布式锁的介绍,请点击 这里

参考博客