1、概览 {#1概览}
缓存数据意味着我们的应用无需访问速度较慢的存储层,从而提高了性能和响应速度。我们可以使用任何内存实现库(如 Caffeine)来实现缓存。
虽然这样做可以提高数据检索的性能,但如果应用部署在多个副本上,那么实例之间就无法共享缓存。为了解决这个问题,可以引入一个分布式缓存层,所有实例都可以访问它。
本文将带你了解如何在 Spring 中使用 Spring 的缓存支持(spring-cache)实现两级缓存,以及在本地缓存层缓存失效时如何调用分布式缓存层。
2、示例 Spring Boot 应用 {#2示例-spring-boot-应用}
创建一个简单的应用,调用数据库获取一些数据。
2.1、Maven 依赖 {#21maven-依赖}
首先,添加 spring-boot-starter-web 依赖:
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
<version>3.1.5</version>
</dependency>
2.2、实现 Service {#22实现-service}
实现一个 Spring Service,从 Repository 中获取数据。
首先,创建 Customer
实体类:
public class Customer implements Serializable {
private String id;
private String name;
private String email;
// 标准 Getter / Setter
}
然后,实现 CustomerService
类和 getCustomer
方法:
@Service
public class CustomerService {
private final CustomerRepository customerRepository;
public Customer getCustomer(String id) {
return customerRepository.getCustomerById(id);
}
}
最后,定义 CustomerRepository
接口:
public interface CustomerRepository extends CrudRepository<Customer, String> {
}
接下来,实现两级缓存。
3、实现一级缓存 {#3实现一级缓存}
利用 Spring 的缓存支持和 Caffeine
库来实现第一个缓存层。
3.1、Caffeine 依赖 {#31caffeine-依赖}
添加 spring-boot-starter-cache
和 caffeine
依赖:
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-cache</artifactId>
<version>3.1.5</version/
</dependency>
<dependency>
<groupId>com.github.ben-manes.caffeine</groupId>
<artifactId>caffeine</artifactId>
<version>3.1.8</version>
</dependency>
3.2、启用 Caffeine 缓存 {#32启用-caffeine-缓存}
要启用 Caffeine 缓存,需要添加一些与缓存相关的配置。
首先,在 CacheConfig
类中添加 @EnableCaching
注解,并包含一些 Caffeine
缓存配置:
@Configuration
@EnableCaching
public class CacheConfig {
@Bean
public CaffeineCache caffeineCacheConfig() {
return new CaffeineCache("customerCache", Caffeine.newBuilder()
.expireAfterWrite(Duration.ofMinutes(1))
.initialCapacity(1)
.maximumSize(2000)
.build());
}
}
接下来,使用 SimpleCacheManager
类添加 CaffeineCacheManager
Bean,并设置缓存配置:
@Bean
public CacheManager caffeineCacheManager(CaffeineCache caffeineCache) {
SimpleCacheManager manager = new SimpleCacheManager();
manager.setCaches(Arrays.asList(caffeineCache));
return manager;
}
3.3、添加 @Cacheable 注解 {#33添加-cacheable-注解}
要启用上述缓存功能,需要在 getCustomer
方法中添加 @Cacheable
注解:
@Cacheable(cacheNames = "customerCache", cacheManager = "caffeineCacheManager")
public Customer getCustomer(String id) {
}
如前所述,这种方法在单实例部署环境中效果很好,但在应用使用多个副本运行时就不太有效了。
4、实现二级缓存 {#4实现二级缓存}
我们使用 Redis 服务器实现第二级缓存。当然,也可以使用任何其他分布式缓存(如 Memcached)来实现。应用的所有副本都可以访问这层缓存。
4.1、Redis 依赖 {#41redis-依赖}
添加 spring-boot-starter-redis 依赖:
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis</artifactId>
<version>3.1.5</version>
</dependency>
4.2、启用 Redis 缓存 {#42启用-redis-缓存}
需要添加与 Redis 缓存相关的配置。
首先,为 RedisCacheConfiguration
Bean 配置几个属性:
@Bean
public RedisCacheConfiguration cacheConfiguration() {
return RedisCacheConfiguration.defaultCacheConfig()
.entryTtl(Duration.ofMinutes(5))
.disableCachingNullValues()
.serializeValuesWith(RedisSerializationContext.SerializationPair.fromSerializer(new GenericJackson2JsonRedisSerializer()));
}
然后,使用 RedisCacheManager
类启用 CacheManager
:
@Bean
public CacheManager redisCacheManager(RedisConnectionFactory connectionFactory, RedisCacheConfiguration cacheConfiguration) {
return RedisCacheManager.RedisCacheManagerBuilder
.fromConnectionFactory(connectionFactory)
.withCacheConfiguration("customerCache", cacheConfiguration)
.build();
}
4.3、添加 @Caching 和 @Cacheable 注解 {#43添加-caching-和-cacheable-注解}
在 getCustomer
方法中使用 @Caching
和 @Cacheable
注解加入二级缓存:
@Caching(cacheable = {
@Cacheable(cacheNames = "customerCache", cacheManager = "caffeineCacheManager"),
@Cacheable(cacheNames = "customerCache", cacheManager = "redisCacheManager")
})
public Customer getCustomer(String id) {
}
Spring 从第一个可用的缓存中获取缓存对象。如果两个 CacheManager 都未命中,它将运行实际方法。
5、集成测试 {#5集成测试}
创建集成测试,使用嵌入式 Redis 服务器验证二级缓存:
@Test
void givenCustomerIsPresent_whenGetCustomerCalled_thenReturnCustomerAndCacheIt() {
String CUSTOMER_ID = "100";
Customer customer = new Customer(CUSTOMER_ID, "test", "test@mail.com");
given(customerRepository.findById(CUSTOMER_ID))
.willReturn(customer);
Customer customerCacheMiss = customerService.getCustomer(CUSTOMER_ID);<code class="language-java">
assertThat(customerCacheMiss).isEqualTo(customer);
verify(customerRepository, times(1)).findById(CUSTOMER_ID);
assertThat(caffeineCacheManager.getCache("customerCache").get(CUSTOMER_ID).get()).isEqualTo(customer);
assertThat(redisCacheManager.getCache("customerCache").get(CUSTOMER_ID).get()).isEqualTo(customer);
}
运行上述测试用例,运行正常。
接下来,想象一个情景,即由于过期而导致第一级缓存数据被驱逐,然后尝试获取相同的 customer 信息。这时,应该命中第二级缓存 --- Redis。再次查询相同 customer 应该命中一级缓存。
实现上述测试场景,在本地缓存过期后检查二级缓存:
@Test
void givenCustomerIsPresent_whenGetCustomerCalledTwiceAndFirstCacheExpired_thenReturnCustomerAndCacheIt() throws InterruptedException {
String CUSTOMER_ID = "102";
Customer customer = new Customer(CUSTOMER_ID, "test", "test@mail.com");
given(customerRepository.findById(CUSTOMER_ID))
.willReturn(customer);
Customer customerCacheMiss = customerService.getCustomer(CUSTOMER_ID);
TimeUnit.SECONDS.sleep(3);
Customer customerCacheHit = customerService.getCustomer(CUSTOMER_ID);
verify(customerRepository, times(1)).findById(CUSTOMER_ID);
assertThat(customerCacheMiss).isEqualTo(customer);
assertThat(customerCacheHit).isEqualTo(customer);
assertThat(caffeineCacheManager.getCache("customerCache").get(CUSTOMER_ID).get()).isEqualTo(customer);
assertThat(redisCacheManager.getCache("customerCache").get(CUSTOMER_ID).get()).isEqualTo(customer);
}
现在,运行上述测试,发现 Caffeine
缓存对象出现意外断言错误:
org.opentest4j.AssertionFailedError:
expected: Customer(id=102, name=test, email=test@mail.com)
but was: null
...
at com.baeldung.caching.twolevelcaching.CustomerServiceCachingIntegrationTest.
givenCustomerIsPresent_whenGetCustomerCalledTwiceAndFirstCacheExpired_thenReturnCustomerAndCacheIt(CustomerServiceCachingIntegrationTest.java:91)
从上面的日志可以看出,customer 对象在被驱逐后并不在 Caffeine 缓存中,即使再次调用相同的方法,它也不会从二级缓存中恢复。这种情况对于本用例来说并不理想,因为每当一级缓存过期时,在二级缓存也过期之前都不会更新。这会给 Redis 缓存带来额外的负载。
需要注意的是,即使为同一个方法声明了多个缓存,Spring 也不会在多个缓存之间管理任何数据。
这告诉我们,只要再次访问一级缓存,就需要更新它。
6、实现自定义 CacheInterceptor {#6实现自定义-cacheinterceptor}
要更新一级缓存,需要实现一个自定义缓存拦截器,以便在缓存被访问时进行拦截。
添加一个拦截器来检查当前缓存类是否为 Redis 类型,如果本地缓存不存在,就更新缓存值。
自定义 CacheInterceptor
实现,覆写 doGet
方法:
public class CustomerCacheInterceptor extends CacheInterceptor {
private final CacheManager caffeineCacheManager;
@Override
protected Cache.ValueWrapper doGet(Cache cache, Object key) {
Cache.ValueWrapper existingCacheValue = super.doGet(cache, key);
if (existingCacheValue != null && cache.getClass() == RedisCache.class) {
Cache caffeineCache = caffeineCacheManager.getCache(cache.getName());
if (caffeineCache != null) {
caffeineCache.putIfAbsent(key, existingCacheValue.get());
}
}
return existingCacheValue;
}
}
此外,还需要注册 CustomerCacheInterceptor
Bean 以启用它:
@Bean
public CacheInterceptor cacheInterceptor(CacheManager caffeineCacheManager, CacheOperationSource cacheOperationSource) {
CacheInterceptor interceptor = new CustomerCacheInterceptor(caffeineCacheManager);
interceptor.setCacheOperationSources(cacheOperationSource);
return interceptor;
}
@Bean
public CacheOperationSource cacheOperationSource() {
return new AnnotationCacheOperationSource();
}
只要 Spring 代理方法在内部调用获取缓存方法,自定义拦截器就会拦截调用。
再次运行集成测试,看看上述测试用例是否通过。
7、总结 {#7总结}
本文介绍了如何使用 Spring Cache 通过 Caffeine 和 Redis 实现二级缓存,以及如何使用自定义缓存拦截器实现更新一级 Caffeine 缓存。
Ref:https://www.baeldung.com/spring-two-level-cache