在 Spring Webflux 中使用 @Cacheable 注解缓存结果

1、概览

本文将带你了解如何在 Spring WebFlux 中使用 @Cacheable 注解实现缓存,以及一些常见的问题和解决办法。

2、@Cacheable 和响应式类型

在本文撰稿时,@Cacheable 还不能和响应式框架无缝整合。主要问题在于,目前还没有非阻塞式的缓存实现(JSR-107 缓存 API 是阻塞式的)。只有 Redis 提供了响应式驱动。

虽然,仍然可以在方法上使用 @Cacheable。这会缓存封装对象(MonoFlux),但不会缓存方法的实际结果。

2.1、项目设置

创建一个使用响应式 MongoDB 驱动的 Spring WebFlux 项目。并且用 Testcontainers 代替真实运行的 MongoDB 进行测试。

测试类使用 @SpringBootTest 进行注解,如下:

final static MongoDBContainer mongoDBContainer = new MongoDBContainer(DockerImageName.parse("mongo:4.0.10"));

@DynamicPropertySource
static void mongoDbProperties(DynamicPropertyRegistry registry) {
    mongoDBContainer.start();
    registry.add("spring.data.mongodb.uri",  mongoDBContainer::getReplicaSetUrl);
}

这几行代码会启动 MongoDB 实例,并将 URI 传递给 Spring Boot 以自动配置 Mongo Repository。

创建带有保存和获取 Item 方法的 ItemService 类:

@Service
public class ItemService {

    private final ItemRepository repository;

    public ItemService(ItemRepository repository) {
        this.repository = repository;
    }
    @Cacheable("items")
    public Mono<Item> getItem(String id){
        return repository.findById(id);
    }
    public Mono<Item> save(Item item){
        return repository.save(item);
    }
}

application.properties 中,为缓存和 Repository 设置 logger,这样就可以监控测试中发生的情况:

logging.level.org.springframework.data.mongodb.core.ReactiveMongoTemplate=DEBUG
logging.level.org.springframework.cache=TRACE

2.2、初始化测试

就绪后,运行测试并分析结果:

@Test
public void givenItem_whenGetItemIsCalled_thenMonoIsCached() {
    Mono<Item> glass = itemService.save(new Item("glass", 1.00));

    String id = glass.block().get_id();

    Mono<Item> mono = itemService.getItem(id);
    Item item = mono.block();

    assertThat(item).isNotNull();
    assertThat(item.getName()).isEqualTo("glass");
    assertThat(item.getPrice()).isEqualTo(1.00);

    Mono<Item> mono2 = itemService.getItem(id);
    Item item2 = mono2.block();

    assertThat(item2).isNotNull();
    assertThat(item2.getName()).isEqualTo("glass");
    assertThat(item2.getPrice()).isEqualTo(1.00);
}

在控制台中,可以看输出结果如下(为简洁起见,只显示重要部分):

Inserting Document containing fields: [name, price, _class] in collection: item...
Computed cache key '618817a52bffe4526c60f6c0' for operation Builder[public reactor.core.publisher.Mono...
No cache entry for key '618817a52bffe4526c60f6c0' in cache(s) [items]
Computed cache key '618817a52bffe4526c60f6c0' for operation Builder[public reactor.core.publisher.Mono...
findOne using query: { "_id" : "618817a52bffe4526c60f6c0"} fields: Document{{}} for class: class com.baeldung.caching.Item in collection: item...
findOne using query: { "_id" : { "$oid" : "618817a52bffe4526c60f6c0"}} fields: {} in db.collection: test.item
Computed cache key '618817a52bffe4526c60f6c0' for operation Builder[public reactor.core.publisher.Mono...
Cache entry for key '618817a52bffe4526c60f6c0' found in cache 'items'
findOne using query: { "_id" : { "$oid" : "618817a52bffe4526c60f6c0"}} fields: {} in db.collection: test.item

在第一行,我们看到了插入方法。之后,当调用 getItem 时,Spring 会检查缓存中是否有此 Item,但没有找到,因此会访问 MongoDB 来获取此记录。在第二次调用 getItem 时,Spring 会再次检查缓存并找到该 Key 的条目,但仍会访问 MongoDB 获取该记录。

出现这种情况是因为 Spring 缓存了 getItem 方法的结果,即 Mono 封装对象。但是,对于结果本身,它仍然需要从数据库中获取记录。

3、缓存 Mono / Flux 的结果

在这种情况下,可以使用 MonoFlux 的内置缓存机制作为一种变通办法。如前所述,@Cacheable 会缓存封装对象,而有了内置缓存,就可以创建一个指向服务方法实际结果的引用:

@Cacheable("items")
public Mono<Item> getItem_withCache(String id) {
    return repository.findById(id).cache();
}

用这个新的方法运行上一章中的测试。输出结果如下:

Inserting Document containing fields: [name, price, _class] in collection: item
Computed cache key '6189242609a72e0bacae1787' for operation Builder[public reactor.core.publisher.Mono...
No cache entry for key '6189242609a72e0bacae1787' in cache(s) [items]
Computed cache key '6189242609a72e0bacae1787' for operation Builder[public reactor.core.publisher.Mono...
findOne using query: { "_id" : "6189242609a72e0bacae1787"} fields: Document{{}} for class: class com.baeldung.caching.Item in collection: item
findOne using query: { "_id" : { "$oid" : "6189242609a72e0bacae1787"}} fields: {} in db.collection: test.item
Computed cache key '6189242609a72e0bacae1787' for operation Builder[public reactor.core.publisher.Mono...
Cache entry for key '6189242609a72e0bacae1787' found in cache 'items'

你可以看到几乎相似的输出结果。只是这一次,当在缓存中找到一个 Item 时,不需要进行额外的数据库查找。采用这种解决方案后,缓存过期时可能会出现问题。由于使用的是缓存的缓存,因此需要在两个缓存上都设置适当的过期时间。经验法则是 Flux 缓存 TTL 应长于 @Cacheable

4、使用 Caffeine

由于 Reactor 3 addon 在 3.6.0 版本中弃用,因此只使用 Caffeine 来展示缓存的实现。

配置 Caffeine 缓存:

public ItemService(ItemRepository repository) {
    this.repository = repository;
    this.cache = Caffeine.newBuilder().build(this::getItem_withAddons);
}

ItemService 构造函数中,以最低配置初始化 Caffeine 缓存,并在新方法中使用该缓存:

@Cacheable("items")
public Mono<Item> getItem_withCaffeine(String id) {
    return cache.asMap().computeIfAbsent(id, k -> repository.findById(id).cast(Item.class)); 
}

再次运行测试,会看到与上一个示例类似的输出结果。

5、总结

本文介绍了 Spring WebFlux 如何与 @Cacheable 交互,以及常见的一些问题和解决办法。


Ref:https://www.baeldung.com/spring-webflux-cacheable