SpringBoot中實現兩級快取

banq發表於2024-03-22

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

這告訴我們,每當再次訪問一級快取時,我們都需要更新一級快取。

相關文章