快取資料意味著我們的應用程式不必訪問速度較慢的儲存層,從而提高其效能和響應能力。我們可以使用任何記憶體實現庫(例如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 不管理多個快取之間的任何資料,即使它們是為同一個方法宣告的。
這告訴我們,每當再次訪問一級快取時,我們都需要更新一級快取。