SpringBoot中实现两级缓存

缓存数据意味着我们的应用程序不必访问速度较慢的存储层,从而提高其性能和响应能力。我们可以使用任何内存实现库(例如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 不管理多个缓存之间的任何数据,即使它们是为同一个方法声明的。

这告诉我们,每当再次访问一级缓存时,我们都需要更新一级缓存。