Spring Boot 中的 @Cacheable 和 Redis 缓存

1. 概述

许多应用程序反复调用相同的慢查询。例如获取产品信息、配置或汇率。Spring Cache Abstraction 通过几个注解来解决这个问题。Redis 充当多个实例共享的缓存存储的角色。

阅读本文后,你将能够:

  • 配置 Redis 作为缓存提供者。
  • 使用 @Cacheable、@CachePut、@CacheEvict。
  • 为每个缓存设置 TTL 并以 JSON 格式存储数据。
  • 使用 JUnit 5 和 Testcontainers 测试缓存行为。

2. Maven 依赖

你需要两个 starter:一个用于 Spring Cache,一个用于 Redis。

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-cache</artifactId>
</dependency>
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>

测试部分需要添加 Testcontainers。Spring Boot 已管理这些依赖的版本:

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-testcontainers</artifactId>
    <scope>test</scope>
</dependency>
<dependency>
    <groupId>org.testcontainers</groupId>
    <artifactId>junit-jupiter</artifactId>
    <scope>test</scope>
</dependency>

3. Redis 连接配置

在 application.yml 中声明 Redis 地址并选择 Redis 作为缓存提供者:

spring:
  data:
    redis:
      host: localhost
      port: 6379
  cache:
    type: redis

每个属性的含义:

  • spring.data.redis.* 配置连接。Spring Boot 默认使用 Lettuce 客户端。
  • spring.cache.type: redis 明确指定提供者。如果 classpath 中有 Redis,Spring Boot 也会自动选择 Redis。明确指定有助于避免 classpath 中有多个提供者时的混淆。

你可以使用 Docker 快速运行本地 Redis:

docker run -d --name redis -p 6379:6379 redis:7-alpine

4. @Cacheable 基础示例

4.1. Model 和 repository 模拟慢查询

我们使用一个简单的 record 作为产品:

public record Product(Long id, String name, BigDecimal price) {}

下面的 repository 模拟数据库速度慢。它还计算被调用的次数以供我们验证缓存:

@Repository
public class ProductRepository {

    private final Map<Long, Product> store = new ConcurrentHashMap<>(Map.of(
            1L, new Product(1L, "Nón lá Huế", new BigDecimal("150000")),
            2L, new Product(2L, "Áo bà ba", new BigDecimal("320000"))
    ));
    private final AtomicInteger queryCount = new AtomicInteger();

    public Optional<Product> findById(Long id) {
        queryCount.incrementAndGet();
        sleep(1000); // 模拟查询耗时 1 秒
        return Optional.ofNullable(store.get(id));
    }

    public Product save(Product product) {
        store.put(product.id(), product);
        return product;
    }

    public void deleteById(Long id) {
        store.remove(id);
    }

    public int getQueryCount() { return queryCount.get(); }

    public void resetQueryCount() { queryCount.set(0); }

    private static void sleep(long millis) {
        try {
            Thread.sleep(millis);
        } catch (InterruptedException e) {
            Thread.currentThread().interrupt();
        }
    }
}

4.2. 启用缓存并标记方法

使用 @EnableCaching 启用缓存机制。将此注解放在 @Configuration 类上(将在第 5 部分编写)或主类上。然后标记需要缓存的方法:

@Service
public class ProductService {

    private final ProductRepository productRepository;

    public ProductService(ProductRepository productRepository) {
        this.productRepository = productRepository;
    }

    @Cacheable(cacheNames = "products", key = "#id")
    public Product findById(Long id) {
        return productRepository.findById(id)
                .orElseThrow(() -> new ProductNotFoundException(id));
    }
}

处理流程如下:

  • cacheNames = "products" 是缓存名称。在 Redis 中,键的默认格式为 products::<key>。
  • key = "#id" 是 SpEL 表达式,以参数 id 作为键。如果方法只有一个参数,你可以省略此属性。
  • 第一次调用时,Spring 在缓存中找不到键。它运行该方法,然后将结果存储到 Redis。
  • 后续使用相同 id 的调用,Spring 从 Redis 返回结果。该方法不被执行。
  • 如果该方法抛出异常(ProductNotFoundException),Spring 不会 在缓存中存储任何内容。

调用 findById(1L) 两次,然后检查 Redis:

redis-cli KEYS '*'
redis-cli TTL products::1
1) "products::1"
(integer) 1798

第一次调用耗时约 1 秒。第二次调用只需几毫秒。TTL 约为 1800 秒,因为我们将在下一部分为 products 缓存配置 30 分钟。

5. 配置 RedisCacheManager:TTL 和序列化器

默认情况下,RedisCacheManager 使用 JDK 序列化。这种方式有三个缺点:

  • 类必须实现 Serializable。
  • Redis 中的数据是二进制的,难以阅读。
  • 当你更改类的结构时,数据容易损坏。

默认情况下,缓存也没有 TTL,意味着条目永久存活。我们修复这两个问题:

@Configuration
@EnableCaching
public class CacheConfig {

    @Bean
    public RedisCacheConfiguration defaultCacheConfig() {
        return RedisCacheConfiguration.defaultCacheConfig()
                .entryTtl(Duration.ofMinutes(10))
                .disableCachingNullValues()
                .serializeValuesWith(RedisSerializationContext.SerializationPair
                        .fromSerializer(new GenericJackson2JsonRedisSerializer()));
    }

    @Bean
    public RedisCacheManagerBuilderCustomizer productCacheCustomizer() {
        return builder -> builder.withCacheConfiguration("products",
                RedisCacheConfiguration.defaultCacheConfig()
                        .entryTtl(Duration.ofMinutes(30))
                        .disableCachingNullValues()
                        .serializeValuesWith(RedisSerializationContext.SerializationPair
                                .fromSerializer(new Jackson2JsonRedisSerializer<>(Product.class))));
    }
}

解释每个 bean:

  • RedisCacheConfiguration bean 是所有缓存的默认配置。Spring Boot 自动识别此 bean。每个条目的 TTL 为 10 分钟,不存储 null,值以 JSON 格式存储。
  • RedisCacheManagerBuilderCustomizer 覆盖 products 缓存的特定配置。此缓存的 TTL 为 30 分钟,并使用类型特定的序列化器 Product.class。

为什么 products 使用 Jackson2JsonRedisSerializer<>(Product.class)?GenericJackson2JsonRedisSerializer 需要将类型信息(@class)保存到 JSON。对于 record 这样的 final 类,这种机制容易返回 LinkedHashMap 而不是正确的类型,你会遇到 ClassCastException。类型特定的序列化器完全避免了这个问题。

Redis 中的数据现在可以直接阅读:

redis-cli GET products::1
"{\"id\":1,\"name\":\"Nón lá Huế\",\"price\":150000}"

注意: 当你自己声明 RedisCacheConfiguration bean 时,application.yml 中的 spring.cache.redis.time-to-live、spring.cache.redis.key-prefix… 等属性将被忽略。选择这两种配置方式中的一种,不要混合使用。

6. 更新和删除缓存:@CachePut 和 @CacheEvict

缓存只有在其中的数据正确时才有用。当产品更改时,你必须更新或删除相应的条目。

@CachePut(cacheNames = "products", key = "#product.id()")
public Product update(Product product) {
    return productRepository.save(product);
}

@CacheEvict(cacheNames = "products", key = "#id")
public void delete(Long id) {
    productRepository.deleteById(id);
}

@CacheEvict(cacheNames = "products", allEntries = true)
public void clearAll() {
    // 示例:在批量导入产品后调用
}

注解之间的区别:

  • @CachePut 总是运行该方法,然后将返回结果写入缓存,键为 #product.id()。因此该方法必须返回需要缓存的正确对象。如果返回 void,缓存将不会有正确的数据。
  • @CacheEvict 在方法成功运行后删除该键的条目。如果需要在方法运行前删除,请添加 beforeInvocation = true。
  • allEntries = true 删除整个 products 缓存。在 Redis 中,此操作按模式扫描键。你应该仅在真正需要时使用它。

7. 控制缓存:condition、unless 和 sync

@Cacheable 还有其他有用的属性:

@Cacheable(cacheNames = "productSearch",
           key = "#keyword.toLowerCase()",
           condition = "#keyword.length() >= 3",
           unless = "#result.isEmpty()")
public List<Product> search(String keyword) {
    // 缓慢的查询...
    return List.of();
}

@Cacheable(cacheNames = "products", key = "#id", sync = true)
public Product findHotProduct(Long id) {
    return findFromDatabase(id); // 同一类中的 private 方法,直接读取数据库
}

每个属性的含义:

  • condition 在运行方法之前被评估。这里,少于 3 个字符的关键字不会被搜索,也不会被缓存。
  • unless 在运行方法之后被评估,可以使用 #result。这里,空列表不会被存储。
  • key = "#keyword.toLowerCase()" 帮助“Nón”和“nón”共享同一条目。
  • sync = true 防止缓存惊群。当多个线程同时 miss 一个键时,只有一个线程运行该方法,其他线程等待结果。此机制仅在一个 JVM 内同步,不是实例之间的分布式锁。

productSearch 缓存没有特定配置,因此使用第 5 部分的默认配置,即 TTL 10 分钟和 GenericJackson2JsonRedisSerializer。对于包含 record 的 List<Product> 值,你应该声明类型特定的序列化器,就像使用 products 所做的那样。这样你将避免第 5 部分提到的 ClassCastException 错误。

8. 测试缓存行为

最可靠的方法是通过 Testcontainers 使用真实的 Redis 进行测试。注解 @ServiceConnection 自动为 Spring Boot 配置主机和端口,你不需要修改 application.yml。

@SpringBootTest
@Testcontainers
class ProductServiceCacheTest {

    @Container
    @ServiceConnection(name = "redis")
    static GenericContainer<?> redis =
            new GenericContainer<>(DockerImageName.parse("redis:7-alpine"))
                    .withExposedPorts(6379);

    @Autowired
    private ProductService productService;

    @Autowired
    private ProductRepository productRepository;

    @Autowired
    private CacheManager cacheManager;

    @BeforeEach
    void setUp() {
        cacheManager.getCache("products").clear();
        productRepository.resetQueryCount();
    }

    @Test
    void whenFindByIdCalledTwice_thenRepositoryIsQueriedOnce() {
        Product first = productService.findById(1L);
        Product second = productService.findById(1L);

        assertThat(first).isEqualTo(second);
        assertThat(productRepository.getQueryCount()).isEqualTo(1);
        assertThat(cacheManager.getCache("products").get(1L, Product.class))
                .isEqualTo(first);
    }

    @Test
    void whenProductUpdated_thenCacheReturnsNewValue() {
        productService.findById(2L);

        productService.update(new Product(2L, "Áo bà ba lụa", new BigDecimal("450000")));
        Product cached = productService.findById(2L);

        assertThat(cached.name()).isEqualTo("Áo bà ba lụa");
        assertThat(productRepository.getQueryCount()).isEqualTo(1);
    }
}

这两个测试验证了以下几点:

  • 第一个测试:调用两次,但 repository 仅被调用一次。条目确实存在于具有正确类型 Product 的缓存中。这确认了序列化器工作正确。
  • 第二个测试:@CachePut 写入新值,因此后续读取从缓存获取,不再额外调用 repository。
  • @BeforeEach 清理缓存和计数器,帮助测试相互独立。Container 是 static,因此对于整个类只启动一次。

9. 常见错误和何时不应使用缓存

自调用问题。 Spring Cache 通过代理工作。如果 ProductService 中的一个方法调用 this.findById(id),调用直接进入对象,不通过代理,所以缓存不会被应用。这也是为什么第 7 部分的 findHotProduct 示例直接读取数据库,而不是调用 findById。在 private 方法上的注解也没有效果。解决方案是将需要缓存的方法分离到另一个 bean 中。

当已禁用 null 时缓存 null 值。 使用 disableCachingNullValues(),如果方法返回 null 会导致错误:

java.lang.IllegalArgumentException: Cache 'products' does not allow 'null' values;
Avoid storing null via '@Cacheable(unless="#result == null")' ...

你有两种处理方式:添加 unless = "#result == null",或像第 4 部分的示例那样抛出异常。

更改类结构。 你在 Product 中添加或重命名字段,但 Redis 仍然存储旧格式的 JSON。结果可能是反序列化错误或缺少字段的数据。你应该在部署时更改缓存名称(例如 products-v2)或清理缓存。

缓存 JPA entity。 Entity 有懒惰代理和双向关系,序列化时很容易出错。缓存 DTO 或 record 代替。

注意: 不应在需要绝对一致性的数据上使用缓存,例如账户余额或支付时的库存。当数据频繁更改时也不应使用缓存,因为命中率会很低,你只会多浪费一次网络调用到 Redis。如果只运行一个实例且数据较小,Caffeine 等内存缓存通常比 Redis 更快更简单。

10. 结论

你已经配置了 Redis 作为 Spring Boot 的缓存提供者。你使用 @Cacheable 读取,@CachePut 更新,@CacheEvict 删除条目。你还为每个缓存设置 TTL 和 JSON 序列化器,然后通过 Testcontainers 使用真实 Redis 验证行为。

三件要记住的事情:

  • 总是设置 TTL。
  • 缓存 DTO 而不是 entity。
  • 小心自调用问题。

做好这三点将帮助你在将缓存投入生产时避免大多数问题。

spring-bootrediscachespring-cachetestcontainers