使用 Spring 实现两级缓存
1. 概述
缓存数据意味着我们的应用不必访问速度较慢的存储层,从而提高了性能和响应能力。我们可以使用任何内存中的实现库来实现缓存,比如 Caffeine。
尽管这样做可以提高数据检索的性能,但如果应用部署到多个副本集,则缓存不会在实例之间共享。为了克服这个问题,我们可以引入一个所有实例都可以访问的分布式缓存层。
本文中,我们将学习如何在 Spring 中实现两级缓存机制。我们将演示如何使用 Spring 的缓存支持实现这两个层,以及在本地缓存层发生缓存丢失时如何调用分布式缓存层。
2. Spring Boot 中的示例应用
想象一下,我们要构建一个简单的应用,该应用调用数据库来获取一些数据。
2.1. Maven 依赖
首先,引入 spring-boot-starter-web 依赖:
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
<version>3.1.5</version>
</dependency>
2.2. 实现 Spring 服务
我们将实现一个从存储库中获取数据的 Spring 服务。
首先,让我们实现 Customer
模型:
public class Customer implements Serializable {
private String id;
private String name;
private String email;
// standard getters and setters
}
然后,我们来实现 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. 实现第一级缓存
我们将利用 Spring的 缓存支持和 Caffeine 库来实现第一个缓存层。
3.1. Caffeine 依赖
让我们引入 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 缓存
要启用 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 注解
要启用以上缓存,我们需要在 getCustomer 方法中添加 @Cacheable 注解:
@Cacheable(cacheNames = "customerCache", cacheManager = "caffeineCacheManager")
public Customer getCustomer(String id) {
}
如前所述,这在单实例部署环境中运行良好,但在应用使用多个副本运行时效果不佳。
4. 实现第二级缓存
我们将使用 Redis 服务实现第二级缓存。当然,也可以用任何其他分布式缓存来实现它,比如 Memcached。我们的应用的所有副本都可以访问此缓存层。
4.1. Redis 依赖
我们来添加 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 缓存
我们需要添加 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 注解
我们将使用 @Caching 和 @Cacheable 注解在 getCustomer 方法引入第二级缓存:
@Caching(cacheable = {
@Cacheable(cacheNames = "customerCache", cacheManager = "caffeineCacheManager"),
@Cacheable(cacheNames = "customerCache", cacheManager = "redisCacheManager")
})
public Customer getCustomer(String id) {
}
我们应该注意,Spring 将从第一个可用的缓存中获取缓存对象。如果两个缓存管理器都未命中,它将运行实际的方法。
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);
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);
}
我们将运行上面的测试用例,并发现它运行良好。
接下来,想象一个场景,其中第一级缓存数据由于过期而被删除,我们将尝试获得相同的客户。那么它应该在第二个缓存级别 Redis 的缓存命中。同一客户的任何后续缓存命中都应该是第一个缓存。
让我们实现上面的测试场景,在本地缓存到期后检查两个缓存:
@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)
从上面的日志中可以明显看出,客户对象在删除后不在 Caffeine 缓存中,即使我们再次调用相同的方法,它也不会从第二个缓存中恢复。对于这个用例来说,这不是一个理想的情况,因为每次一级缓存过期时,它都不会更新,直到第二级缓存也过期。这会将额外的负载放入 Redis 缓存。
我们应该注意,Spring 不管理多个缓存之间的任何数据,即使它们是同一方法声明的。
这告诉我们,每当一级缓存再次被访问时,我们都需要更新它。
6. 实现自定义 CacheInterceptor
要更新第一个缓存,我们需要实现一个自定义的缓存拦截器,以便在访问缓存时进行拦截。
我们将添加一个拦截器来检查当前缓存类是否为 Redis 类型,如果本地缓存不存在,那么我们可以更新缓存值。
让我们通过重写 doGet
方法来实现一个自定义 CacheInterceptor
:
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. 结论
本文中,我们学习了如何使用 Spring 的缓存支持,使用 Caffeine 和 Redis 实现两个级别的缓存。我们还演示了如何使用自定义缓存拦截器实现更新一级 Caffeine 缓存。
和往常一样,示例代码可以在 GitHub 上找到:https://github.com/eugenp/tutorials/tree/master/spring-boot-modules/spring-boot-caching-2