使用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.咖啡因依賴性
讓我們包含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.啟用咖啡因快取
要啟用咖啡因緩存,我們需要添加一些與快取相關的配置。
首先,我們在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 a
@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", "[email protected]");
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", "[email protected]");
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 protected])
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 代理方法內部呼叫 get 快取方法時,自訂攔截器都會攔截該呼叫。
我們將重新運行整合測試,並查看上述測試案例是否通過。
七、結論
在本文中,我們學習如何使用 Spring 的快取支援透過 Caffeine 和 Redis 實現兩層快取。我們還了解如何使用自訂快取攔截器實作來更新一級 Caffeine 快取。
與往常一樣,範例程式碼可以在 GitHub 上找到。