缓存数据意味着我们的应用程序不必访问速度较慢的存储层,从而提高其性能和响应能力。我们可以使用任何内存实现库(例如Caffeine )来实现缓存。
虽然这样做提高了数据检索的性能,但如果应用程序部署到多个副本集,则实例之间不会共享缓存。为了克服这个问题,我们可以引入一个可以被所有实例访问的分布式缓存层。
在这篇文章中,我们将学习如何在Spring中实现二级缓存机制。我们将展示如何使用 Spring 的缓存支持来实现这两个层,以及如果本地缓存层发生缓存未命中,如何调用分布式缓存层。
首先,让我们包含spring-boot-starter-web 依赖项:
<dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-web</artifactId> <version>3.1.5</version> </dependency>
|
我们将实现一个从存储库获取数据的 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> { }
|
接下来,我们来实现两级缓存。实现一级缓存
我们将利用 Spring 的缓存支持和 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>
|
要启用咖啡因缓存,我们需要添加一些与缓存相关的配置。
首先,我们在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; }
|
要启用上述缓存,我们需要在getCustomer方法中添加@Cacheable注解 :
@Cacheable(cacheNames = "customerCache", cacheManager = "caffeineCacheManager") public Customer getCustomer(String id) { }
|
正如前面所讨论的,这在单实例部署环境中效果很好,但当应用程序运行多个副本时,效果就不那么有效了。实现二级缓存
我们将使用Redis服务器实现第二级缓存。当然,我们可以使用任何其他分布式缓存(例如Memcached)来实现它。我们应用程序的所有副本都可以访问这一层缓存。
<dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-data-redis</artifactId> <version>3.1.5</version> </dependency>
|
启用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(); }
|
我们将使用@Caching和@Cacheable注释在getCustomer方法中包含第二个缓存:
@Caching(cacheable = { @Cacheable(cacheNames = "customerCache", cacheManager = "caffeineCacheManager"), @Cacheable(cacheNames = "customerCache", cacheManager = "redisCacheManager") }) public Customer getCustomer(String id) { }
|
我们应该注意到Spring 将从第一个可用的缓存中获取缓存对象。如果两个缓存管理器都未命中,它将运行实际的方法。实现自定义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 代理方法内部调用 get 缓存方法时,自定义拦截器都会拦截该调用。
实施集成测试
为了验证我们的设置,我们将实施一些集成测试并验证两个缓存。
首先,让我们创建一个集成测试来使用嵌入式 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); }
|
我们将运行上面的测试用例,发现效果很好。接下来,我们想象一个场景,第一级缓存数据因过期而被逐出,我们尝试获取相同的客户。然后,应该是对第二级缓存——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 不管理多个缓存之间的任何数据,即使它们是为同一个方法声明的。
这告诉我们,每当再次访问一级缓存时,我们都需要更新一级缓存。