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:
RedisCacheConfigurationbean 是所有缓存的默认配置。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}"
注意: 当你自己声明
RedisCacheConfigurationbean 时,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。
- 小心自调用问题。
做好这三点将帮助你在将缓存投入生产时避免大多数问题。