Skip to content

前言

我们一定听说过 缓存无敌 的话,特别是在大型互联网公司,查多写少 的场景屡见不鲜。网络上查到的很多诸如系统吞吐量提升 50%、接口耗时降低 80%、一个分钟级别的程序优化到毫秒级别等,多多少少和缓存有关。

举个例子:在我们程序中,很多配置数据(例如一个商品信息、一个白名单、一个第三方客户的回调接口),这些数据存在我们的DB上,数据量比较少,但是程序访问很频繁,这种情况下,将数据放一份到我们的内存缓存中将大大提升我们系统的访问效率,因为减少了数据库访问,有可能减少了数据库建连时间、网络数据传输时间、数据库磁盘寻址时间。

总的来说,下面这些场景都可以考虑使用缓存优化性能:

  • 查数据库
  • 读取文件
  • 网络访问,特别是调用第三方服务查询接口

SpringCache 是 Spring 提供的一个缓存框架,在 Spring3.1 版本开始支持将缓存添加到现有的 Spring 应用程序中,在 4.1 开始,缓存已支持 JSR-107 注释和更多自定义的选项。

Spring Cache 利用了 AOP,实现了基于注解的缓存功能,并且进行了合理的抽象,业务代码不用关心底层是使用了什么缓存框架,只需要简单地加一个注解,就能实现缓存功能了,做到了对代码侵入性做小。

由于市面上的缓存工具实在太多,SpringCache 框架还提供了 CacheManager 接口,可以实现降低对各种缓存框架的耦合。它不是具体的缓存实现,它只提供一整套的接口和代码规范、配置、注解等,用于整合各种缓存方案,比如 Caffeine、Guava Cache、Ehcache。

深入

在 SpringCache 官网中,有一个缓存抽象的概念,其核心就是将缓存应用于 Java 方法中,从而减少基于缓存中可用信息的执行次数。换句话来说。就是每次调用目标方法前,SpringCache 都会先检查该方法是否正对给定参数执行,如果已经执行过,就直接返回缓存的结果。(通俗的讲,就是查看缓存里面是否有对应的数据,如果有就返回缓存的数据),而无需执行实际方法、如果该方法上位执行。则执行该方法(缓存中没有对应的数据就执行方法获取对应数据,并进行缓存),并缓存结果并返回给用户。这样就不用多次去执行数据库操作,减少 CPU 和 IO 的消耗。

两个接口

SpringCache 为我们提供了两个接口:

org.springframework.cache.Cache:Cache 接口为缓存的组件规范定义,包含缓存的各种操作集合。

org.springframework.cache.CacheManager:CacheManager 接口下 Spring 提供了各种 xxxCache 的实现;如RedisCache、EhCacheCache、ConcurrentMapCache 等。

SpringCache 概念

  • Cache 接口:缓存接口,定义缓存操作。实现有 如 RedisCache、EhCacheCache、ConcurrentMapCache 等

  • CacheResolver:指定获取解析器

  • CacheManager:缓存管理器,管理各种缓存(Cache)组件;如:RedisCacheManager,使用 Redis 作为缓存。指定缓存管理器

  • @Cacheable:在方法执行前查看是否有缓存对应的数据,如果有直接返回数据,如果没有调用方法获取数据返回,并缓存起来。

  • @CacheEvict:将一条或多条数据从缓存中删除

  • @CachePut:将方法的返回值放到缓存中

  • @EnableCaching:开启缓存注解功能,在 SpringBoot 启动类上使用

  • @Caching:组合多个缓存注解

  • @CacheConfig:统一配置 @Cacheable 中的 value 值

在开发中,常用的是 @Cacheable@CacheEvict@CachePut 三个注解,分别:

  • @Cacheable 查询数据库后,将得到的数据进行缓存
  • @CacheEvict 更新数据库的数据时,把更新的旧数据从缓存中删除
  • @CachePut 更新数据库的数据时,顺便在缓存里也进行更新

注解属性

开发常用的几个注解,介绍内部的属性。

cacheNames

每个注解中都有自己的缓存名字。该名字的缓存与方法相关联,每次调用时,都会检查缓存以查看是否有对应 cacheNames 名字的数据,避免重复调用方法。名字可以可以有多个,在这种情况下,在执行方法之前,如果至少命中一个缓存,则返回相关联的值。(Springcache 提供两个参数来指定缓存名:value、cacheNames,二者选其一即可,因为功能一样,每一个需要缓存的数据都需要指定要放到哪个名字的缓存,缓存的分区,按照业务类型分)

java
@Cacheable(cacheNames = "uuid)
public String getUuid() {
  return UUID.randomUUID().toString().replace("-", "");
}

key

缓存的 key,如果是 Redis,则相当于 Redis 的 key。

可以为空,如果需要可以使用 SpEL 表达式进行表写。如果为空,则缺省默认使用 key 表达式生成器进行生成。默认的 key 生成器要求参数具有有效的 hashCode()equals() 方法实现。key 的生成器。key / keyGenerator 二选一使用。

KeyGenerator

这是 key 生成器,缓存的本质是 key-value 存储模式,每一次方法的调用都需要生成相应的 Key, 才能操作缓存。

通常情况下,@Cacheable 有一个属性 key 可以直接定义缓存 key,开发者可以使用 SpEL 语言定义 key 值。若没有指定属性 key,缓存抽象提供了 KeyGenerator 来生成 key

具体源码如下:

java
public class SimpleKeyGenerator implements KeyGenerator {

	@Override
	public Object generate(Object target, Method method, Object... params) {
		return generateKey(params);
	}

	/**
	 * Generate a key based on the specified parameters.
	 */
	public static Object generateKey(Object... params) {
		if (params.length == 0) {
			return SimpleKey.EMPTY;
		}
		if (params.length == 1) {
			Object param = params[0];
			if (param != null && !param.getClass().isArray()) {
				return param;
			}
		}
		return new SimpleKey(params);
	}

}

可看出

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

当然 Spring Cache 也考虑到需要自定义 Key 生成方式,需要我们实现 org.springframework.cache.interceptor.KeyGenerator 接口。

默认的 key 生成器要求参数具有有效的 hashCode()equals() 方法实现。

自定义 key 生成器

java
@Component
public class MyKeyGenerate implements KeyGenerator {
  @Override
  public Object generate(Object target, Method method, Object... params) {
    String s = target.toString()+":"+method.getName()+":"+ Arrays.toString(params);
    return s;
  }
}

@Cacheable(cacheNames = "test", keyGenerator = "myKeyGenerate")
public User getUserById(Long id,String username){
  User user = new User();
  user.setId(id);
  user.setUsername(username);
  return user;
}

condition

缓存的条件,对入参进行判断,符合条件的缓存,不符合的不缓存。

可以为空,如果需要指定,需要使用 SpEL 表达式,返回 true/false,只有返回 true 的时候才会对数据源进行缓存/清除缓存。在方法调用之前或之后都能进行判断。

condition=false 时,不读取缓存,直接执行方法体,并返回结果,同时返回结果也不放入缓存。

condition=true 时,读取缓存,有缓存则直接返回。无则执行方法体,同时返回结果放入缓存(如果配置了 result,且要求不为空,则不会缓存结果)。

注意:

condition 属性使用的 SpEL 语言只有 #root 和获取参数类的 SpEL 表达式,不能使用返回结果的 #result。所以 condition = "#result != null" 会导致所有对象都不进入缓存,每次操作都要经过数据库。

java
@Cacheable(value = "uuid", key = "#key", condition = "#root.args[0] != null")
public String getUuid(String key) {
  return UUID.randomUUID().toString().replace("-", "");
}

unless

和 condition 相反,符合条件的不缓存,不符合的缓存。

既可以使用 #root,也可以使用 #result 表达式。

效果: 缓存如果有符合要求的缓存数据则直接返回,没有则去数据库查数据,查到了就返回并且存在缓存一份,没查到就不存缓存。

java
@Cacheable(value = "uuid", key = "#key", unless = "#result != null")
public String getUuid(String key) {
  return UUID.randomUUID().toString().replace("-", "");
}
  • condition 不指定相当于 true,unless 不指定相当于 false

  • condition = false,一定不会缓存

  • condition = true,且 unless = true,不缓存

  • condition = true,且 unless = false,缓存

Sync

是否使用异步,默认是 false

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

allEntries

@CacheEvict 特有的属性:是否清空左右缓存。默认为 false,当指定了allEntries为true时,Spring Cache将忽略指定的key

beforeInvocation

@CacheEvict 特有的属性:是否在方法执行前就清空,默认为 false

清除操作默认是在对应方法成功执行之后触发的,即方法如果因为抛出异常而未能成功返回时也不会触发清除操作。使用 beforeInvocation 可以改变触发清除操作的时间,当我们指定该属性值为 true 时,Spring 会在调用该方法之前清除缓存中的指定元素。

Spel 表达式

SpEL(Spring Expression Language),即 Spring 表达式语言,是比 JSP 的 EL 更强大的一种表达式语言。为什么要总结 SpEL,因为它可以在运行时查询和操作数据,尤其是数组列表型数据,因此可以缩减代码量,优化代码结构。

具体使用文档后续再出。

SpringCache 也提供了 root 对象,具体功能使用如下:

名字位置描述例子
methodName根对象要调用的方法名称#root.methodName
method根对象正在调用的方法#root.method.name
target根对象正在调用的目标对象#root.target
targetClass根对象要调用的目标类#root.targetClass
args根对象用于调用目标的参数(数组)#root.args[0]
caches根对象对其执行当前方法的缓存集合#root.caches[0].name
result返回值方法的返回值(要缓存的值)#result

SpEL 例子

使用参数作为 key:使用方法参数时我们可以直接使用 #参数名 或者 #p + 参数 index

java
@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;
}

当我们要使用 root 对象的属性作为 key 时我们也可以将 #root 省略,因为 Spring 默认使用的就是 root 对象的属性。如:

java
@Cacheable(value={"users", "xxx"}, key="caches[1].name")
public User find(User user) {
  returnnull;
}

要调用类里面的其他方法:

java
@Cacheable(value={"chartList"}, key="#root.target.getDictTableName() + '_' + #root.target.getFieldName()")
public List<Map<String, Object>> getChartList(Map<String, Object> paramMap) {
}

public String getDictTableName(){
  return "";
}

public String getFieldName(){
  return "";
}

@CacheConfig

这是抽取缓存的公共配置,因为常用的注解有很多频繁使用的属性,所以我们不希望每个注解都写一遍这些常用的属性,如:

java
@Cacheable(cacheNames = "uuid", key = "#key")
public String getUuid(String key) {
  return UUID.randomUUID().toString().replace("-", "");
}

@CacheEvict(cacheNames = "uuid", key = "#name")
public String getNewUuid(String name) {
  return UUID.randomUUID().toString().replace("-", "");
}

@CachePut(cacheNames = "uuid", key = "#age")
public String updateUuid(String age) {
  return UUID.randomUUID().toString().replace("-", "");
}

所以可以使用 @CacheConfig 注解作用在类上:

java
@CacheConfig(cacheNames = "uuid")
@Service
public class Test {
  @Cacheable( key = "#key")
  public String getUuid(String key) {
    return UUID.randomUUID().toString().replace("-", "");
  }

  @CacheEvict(key = "#key")
  public String getNewUuid(String key) {
    return UUID.randomUUID().toString().replace("-", "");
  }

  @CachePut(key = "#key")
  public String updateUuid(String key) {
    return UUID.randomUUID().toString().replace("-", "");
  }

  @Caching(
    cacheable = {@Cacheable(key = "#userName")},
    put = {@CachePut(key = "#result.id"),
           @CachePut(key = "#result.age")
          }
  )
  public Student getStuByStr(String userName) {
    return UUID.randomUUID().toString().replace("-", "");
  }
}

整合 Redis

说到缓存,我们会优先考虑到 Redis,Spring Cache 也支持 Redis,所以我们以 Redis 作为例子。

引入依赖:

xml
<dependencies>
  <dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-web</artifactId>
  </dependency>
  <dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-test</artifactId>
  </dependency>
  <dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-data-redis</artifactId>
  </dependency>
  <dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-cache</artifactId>
  </dependency>
</dependencies>

因为都是 SpringBoot 的 Started,所以版本就会自动跟随 SpringBoot 的版本。

配置 Redis:

java
@Configuration
public class RestTemplateConfig {
    private final RedisConnectionFactory redisConnectionFactory;

    public RestTemplateConfig(RedisConnectionFactory redisConnectionFactory) {
        this.redisConnectionFactory = redisConnectionFactory;
    }

    @Bean
    public RedisTemplate<Object, Object> redisTemplate() {
        RedisTemplate<Object, Object> redisTemplate = new RedisTemplate<>();
        redisTemplate.setConnectionFactory(redisConnectionFactory);

        // 使用 Jackson2JsonRedisSerialize 替换默认序列化
        Jackson2JsonRedisSerializer jackson2JsonRedisSerializer = new Jackson2JsonRedisSerializer(Object.class);

        ObjectMapper objectMapper = new ObjectMapper();
        objectMapper.setVisibility(PropertyAccessor.ALL, JsonAutoDetect.Visibility.ANY);
        objectMapper.enableDefaultTyping(ObjectMapper.DefaultTyping.NON_FINAL);

        jackson2JsonRedisSerializer.setObjectMapper(objectMapper);

        /*
         * 设置 value 的序列化规则和 key 的序列化规则
         * RedisTemplate 默认序列化使用的 jdkSerializeable, 存储到 Redis 会变成二进制字节码,有风险!
         * 所以官网建议转成其他序列化
         */
        redisTemplate.setKeySerializer(new StringRedisSerializer());
        redisTemplate.setValueSerializer(jackson2JsonRedisSerializer);
        redisTemplate.setHashKeySerializer(new StringRedisSerializer());
        redisTemplate.setHashValueSerializer(jackson2JsonRedisSerializer);
        redisTemplate.afterPropertiesSet();

        return redisTemplate;
    }

    /**
     *  支持 Spring 事务
     * @return redisTemplate
     */
    @Bean
    public RedisTemplate<Object, Object> tranRedisTemplate() {
        RedisTemplate<Object, Object> redisTemplate = new RedisTemplate<>();
        redisTemplate.setConnectionFactory(redisConnectionFactory);

        // 使用 Jackson2JsonRedisSerialize 替换默认序列化
        Jackson2JsonRedisSerializer jackson2JsonRedisSerializer = new Jackson2JsonRedisSerializer(Object.class);

        ObjectMapper objectMapper = new ObjectMapper();
        objectMapper.setVisibility(PropertyAccessor.ALL, JsonAutoDetect.Visibility.ANY);
        objectMapper.enableDefaultTyping(ObjectMapper.DefaultTyping.NON_FINAL);

        jackson2JsonRedisSerializer.setObjectMapper(objectMapper);

        /*
         * 置 value 的序列化规则和 key 的序列化规则
         * RedisTemplate 默认序列化使用的 jdkSerializeable, 存储到 Redis 会变成二进制字节码,有风险!
         * 所以官网建议转成其他序列化
         */
        redisTemplate.setKeySerializer(new StringRedisSerializer());
        redisTemplate.setValueSerializer(jackson2JsonRedisSerializer);
        redisTemplate.setHashKeySerializer(new StringRedisSerializer());
        redisTemplate.setHashValueSerializer(jackson2JsonRedisSerializer);
        redisTemplate.afterPropertiesSet();

        // 支持 spring 事务,如 @Transactional 注解
        redisTemplate.setEnableTransactionSupport(true);
        return redisTemplate;
    }
}

Controller

java
@RestController
public class RedisController {
    
    private final RedisService redisService;

    public RedisController(RedisService redisService) {
        this.redisService = redisService;
    }

    @GetMapping("/getUUID")
    public Response<String> getUuid() {
        return HttpResult.okMessage(redisService.getUuid("a"));
    }

    @GetMapping("/getNewUuid")
    public Response<String> getNewUuid() {
        return HttpResult.okMessage(redisService.getNewUuid());
    }

    @GetMapping("/updateUuid")
    public Response<String> updateUuid() {
        return HttpResult.okMessage(redisService.updateUuid());
    }    
}

Service

java
@Service
public class RedisService {

    private final RedisTemplate<String, Object> redisTemplate;
    
    private final static String CACHE_NAMES = "uuid";
    private final static String CACHE_KEY = "test";

    public RedisService(RedisTemplate redisTemplate) {
        this.redisTemplate = redisTemplate;
    }
    
    public String setRredisString(String key, String value) {
        return "存储成功";
    }

    @Cacheable(cacheNames = CACHE_NAMES, key = "#key")
    public String getUuid(String key) {
        return UUID.randomUUID().toString().replace("-", "");
    }
    
    @CacheEvict(cacheNames = CACHE_NAMES, key = "'test'")
    public String getNewUuid() {
        return UUID.randomUUID().toString().replace("-", "");
    }

    @CachePut(cacheNames = CACHE_NAMES, key = "'test'")
    public String updateUuid() {
        return UUID.randomUUID().toString().replace("-", "");
    }
}

当我们重复去调用 Controller 的 /getUUID 接口时,并去 Redis 数据库查看,第二次以上的调用,数据都来自 Redis。

因为第一次生成随机 UUID 后,Spring Cache 会将 UUID 存入 Redis,下次请求的时候,直接去 Redis 读取。

存在 Redis 的 key 并不是 @Cacheable 的 key,而是 CACHE_NAMES:key,即如果传入的参数 key 为 kbt,则 Redis 存入的 key 就是 uuid:kbt,value 则是 UUID。

当调用 /getNewUuid 时,发现返回了一个新的 UUID,同时 Redis 存储的 UUID 被删除。

当调用 /updateUuid 时,发现返回了一个新的 UUID,同时 Redis 存储的 UUID 被替换为新的 UUID。