Spring 实现两级缓存

1、概览

缓存数据意味着我们的应用无需访问速度较慢的存储层,从而提高了性能和响应速度。我们可以使用任何内存实现库(如 Caffeine)来实现缓存。

虽然这样做可以提高数据检索的性能,但如果应用部署在多个副本上,那么实例之间就无法共享缓存。为了解决这个问题,可以引入一个分布式缓存层,所有实例都可以访问它。

本文将带你了解如何在 Spring 中使用 Spring 的缓存支持(spring-cache)实现两级缓存,以及在本地缓存层缓存失效时如何调用分布式缓存层。

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、实现 Service

实现一个 Spring Service,从 Repository 中获取数据。

首先,创建 Customer 实体类:

public class Customer implements Serializable {
    private String id;
    private String name;
    private String email;
    // 标准 Getter / Setter
}

然后,实现 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、Caffeine 依赖

添加 spring-boot-starter-cachecaffeine 依赖:

<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、启用 Caffeine 缓存

要启用 Caffeine 缓存,需要添加一些与缓存相关的配置。

首先,在 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 和 @Cacheable 注解

getCustomer 方法中使用 @Caching@Cacheable 注解加入二级缓存:

@Caching(cacheable = {
  @Cacheable(cacheNames = "customerCache", cacheManager = "caffeineCacheManager"),
  @Cacheable(cacheNames = "customerCache", cacheManager = "redisCacheManager")
})
public Customer getCustomer(String id) {
}

Spring 从第一个可用的缓存中获取缓存对象。如果两个 CacheManager 都未命中,它将运行实际方法。

5、集成测试

创建集成测试,使用嵌入式 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);
}

运行上述测试用例,运行正常。

接下来,想象一个情景,即由于过期而导致第一级缓存数据被驱逐,然后尝试获取相同的 customer 信息。这时,应该命中第二级缓存 — Redis。再次查询相同 customer 应该命中一级缓存。

实现上述测试场景,在本地缓存过期后检查二级缓存:

@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)

从上面的日志可以看出,customer 对象在被驱逐后并不在 Caffeine 缓存中,即使再次调用相同的方法,它也不会从二级缓存中恢复。这种情况对于本用例来说并不理想,因为每当一级缓存过期时,在二级缓存也过期之前都不会更新。这会给 Redis 缓存带来额外的负载。

需要注意的是,即使为同一个方法声明了多个缓存,Spring 也不会在多个缓存之间管理任何数据。

这告诉我们,只要再次访问一级缓存,就需要更新它。

6、实现自定义 CacheInterceptor

要更新一级缓存,需要实现一个自定义缓存拦截器,以便在缓存被访问时进行拦截。

添加一个拦截器来检查当前缓存类是否为 Redis 类型,如果本地缓存不存在,就更新缓存值。

自定义 CacheInterceptor 实现,覆写 doGet 方法:

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 代理方法在内部调用获取缓存方法,自定义拦截器就会拦截调用。

再次运行集成测试,看看上述测试用例是否通过。

7、总结

本文介绍了如何使用 Spring Cache 通过 Caffeine 和 Redis 实现二级缓存,以及如何使用自定义缓存拦截器实现更新一级 Caffeine 缓存。


Ref:https://www.baeldung.com/spring-two-level-cache