Spring Cache 以其优雅的注解方式,极大地简化了 Java 应用中缓存逻辑的实现。结合高性能的内存数据库 Redis,我们可以轻松构建出响应迅速、扩展性强的应用程序。然而,在享受便捷的同时,一些常见的"坑"和被忽视的最佳实践可能会悄悄地影响你的应用性能和稳定性。
本文将深入探讨在使用 Spring Cache 结合 Redis 时最需要注意的几个关键点,并提供切实可行的避坑指南和最佳实践,助你用好
1. 序列化陷阱:告别乱码,拥抱 JSON
问题: 当你兴冲冲地配置好 Spring Cache 和 Redis,并缓存了一个 Java 对象后,去 Redis 里查看,可能会看到一堆类似 ¬í\x00\x05sr\x00\x0Ecom.example... 的乱码。这是因为 Spring Boot 默认使用了 JDK 的序列化机制 (JdkSerializationRedisSerializer)。
痛点:
- 可读性为零: 无法直观判断缓存内容,调试极其困难。
- 跨语言障碍: Java 特有格式,其他语言服务无法读取。
- 版本兼容性差: 类结构变更可能导致反序列化失败。
- 潜在安全风险: 反序列化漏洞不容忽视。
最佳实践:使用 JSON 序列化 (Jackson)
JSON 格式是文本格式,具有良好的可读性和跨语言通用性。通过配置 Jackson2JsonRedisSerializer,你可以让缓存在 Redis 中的数据变得清晰可见,例如 {"id":123,"name":"Alice","email":"[email protected]"}。
如何配置? 创建一个 RedisCacheConfiguration Bean:
import com.fasterxml.jackson.annotation.JsonAutoDetect;
import com.fasterxml.jackson.annotation.PropertyAccessor;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.jsontype.impl.LaissezFaireSubTypeValidator;
import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule;
import org.springframework.cache.annotation.EnableCaching;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.data.redis.cache.RedisCacheConfiguration;
import org.springframework.data.redis.cache.RedisCacheManager;
import org.springframework.data.redis.connection.RedisConnectionFactory;
import org.springframework.data.redis.serializer.Jackson2JsonRedisSerializer;
import org.springframework.data.redis.serializer.RedisSerializationContext;
import org.springframework.data.redis.serializer.StringRedisSerializer;
import java.time.Duration;
@Configuration
@EnableCaching // 不要忘记开启缓存
public class CacheConfig {
@Bean
public RedisCacheManager cacheManager(RedisConnectionFactory connectionFactory) {
// 配置 JSON 序列化器
Jackson2JsonRedisSerializer<Object> jacksonSerializer = createJacksonSerializer();
// 默认缓存配置:键用 String 序列化,值用 JSON 序列化,默认 TTL 1 小时
RedisCacheConfiguration defaultCacheConfig = RedisCacheConfiguration.defaultCacheConfig()
.entryTtl(Duration.ofHours(1)) // 设置默认 TTL
.serializeKeysWith(RedisSerializationContext.SerializationPair.fromSerializer(new StringRedisSerializer()))
.serializeValuesWith(RedisSerializationContext.SerializationPair.fromSerializer(jacksonSerializer));
// 可以为特定的 Cache Name 配置不同的 TTL 等
// Map<String, RedisCacheConfiguration> cacheConfigurations = new HashMap<>();
// cacheConfigurations.put("users", defaultCacheConfig.entryTtl(Duration.ofMinutes(30)));
// cacheConfigurations.put("products", defaultCacheConfig.entryTtl(Duration.ofDays(1)));
return RedisCacheManager.builder(connectionFactory)
.cacheDefaults(defaultCacheConfig)
// .withInitialCacheConfigurations(cacheConfigurations) // 启用特定配置
.build();
}
private Jackson2JsonRedisSerializer<Object> createJacksonSerializer() {
ObjectMapper objectMapper = new ObjectMapper();
// 指定要序列化的域、getter/setter 以及修饰符范围,ANY 是包括 private 和 public
objectMapper.setVisibility(PropertyAccessor.ALL, JsonAutoDetect.Visibility.ANY);
// 指定序列化输入的类型,类必须是非 final 修饰的。final 修饰的类,比如 String, Integer 等会抛出异常
objectMapper.activateDefaultTyping(LaissezFaireSubTypeValidator.instance, ObjectMapper.DefaultTyping.NON_FINAL);
// 解决 Jackson2 无法反序列化 LocalDateTime 的问题
objectMapper.registerModule(new JavaTimeModule());
return new Jackson2JsonRedisSerializer<>(objectMapper, Object.class);
}
}
关键: 使用 JSON 序列化能显著提升开发和调试效率,强烈推荐!
2. Key 的艺术:规范命名,动态生成
问题: 缓存的 Key 设计混乱,或者过于简单,可能导致:
- Key 冲突: 不同业务数据使用了相同的 Key,导致缓存覆盖或读取错误。
- 难以理解和管理: 无法通过 Key 快速定位对应的业务数据。
- 批量清除困难: 无法按模块或业务维度精确清理缓存。
最佳实践:规范化、层级化、动态化
-
规范格式: 推荐使用 : 分隔的层级结构,例如 模块名:业务名:唯一标识符。如 user:info:123 或 product:detail:sku1001。
-
利用 SpEL: Spring Cache 的 key 属性支持强大的 SpEL (Spring Expression Language),可以动态地根据方法参数生成 Key。
@Service
public class UserServiceImpl implements UserService {// 使用 SpEL 引用方法参数 id,并结合固定前缀 @Cacheable(value = "user:info", key = "#id") public User getUserById(Long id) { // ... 查询数据库 ... return user; } // 使用 SpEL 引用对象参数的属性 @CachePut(value = "user:info", key = "#user.id") public User updateUser(User user) { // ... 更新数据库 ... return user; } // 引用第一个参数 (p0) 和第二个参数的 email 属性 @Cacheable(value = "user:auth", key = "#p0 + ':' + #p1.email") public String getUserToken(Long userId, LoginRequest request) { // ... 生成 Token ... return token; } @CacheEvict(value = "user:info", key = "#id") public void deleteUser(Long id) { // ... 删除数据库 ... }
}
关键: 设计良好、一致的 Key 命名策略是高效使用缓存的基础。
3. TTL 的守护:设置过期时间,防止内存溢出
问题: 不设置缓存过期时间 (Time-To-Live, TTL),数据将永久存储在 Redis 中,直到手动删除或 Redis 内存耗尽。这会导致:
- 内存溢出风险: Redis 内存持续增长,最终可能导致服务崩溃。
- 数据不一致: 数据库数据已更新,但缓存仍然是旧数据(脏数据)。
最佳实践:合理配置 TTL
- 全局默认 TTL: 在 RedisCacheConfiguration 中设置一个全局的默认过期时间 (entryTtl),作为基础保障。 (见上面配置示例)
- 特定 Cache Name 的 TTL: 可以为不同的 cacheNames (通过 @Cacheable 的 value 或 cacheNames 属性指定) 配置不同的 TTL。例如,用户会话缓存可能只需要 30 分钟,而商品信息缓存可以设置为 1 天。 (见上面配置示例中的注释部分)
- 评估数据变更频率: TTL 的设置需要权衡:TTL 太短,缓存命中率低;TTL 太长,数据一致性风险高。需要根据业务数据的实际更新频率来决定。
关键: 永远不要忘记为你的缓存设置一个合理的过期时间!
4. 事务的纠缠:@Transactional 与缓存注解的顺序迷思
问题: 当 @CachePut 或 @CacheEvict 与 @Transactional 用在同一个方法上时,可能会出现问题。因为 Spring Cache 的 AOP 拦截器通常在事务 AOP 拦截器之前执行。
场景: 一个带有 @Transactional 和 @CachePut 的 updateUser 方法。
- @CachePut 执行,更新 Redis 缓存。
- @Transactional 开始事务。
- 方法体执行,更新数据库。
- 如果此时数据库更新失败,事务回滚。
- 结果: 数据库回滚了,但 Redis 缓存已经被更新为"新"数据,导致数据不一致(脏数据)。
最佳实践:分离关注点或延迟操作
-
分离方法 (推荐): 将数据库操作放在一个纯粹的 @Transactional 方法中,然后在调用该方法的外部、非事务方法中处理缓存更新/清除逻辑。
@Service public class UserFacade { // 无事务 @Autowired private UserService userService; // 包含事务方法 @CachePut(value = "user:info", key = "#user.id") // 缓存操作在事务外部 public User updateUserAndCache(User user) { return userService.updateUserInTransaction(user); // 调用事务方法 } @CacheEvict(value = "user:info", key = "#id") public void deleteUserAndEvictCache(Long id) { userService.deleteUserInTransaction(id); } } @Service public class UserServiceImpl implements UserService { // 纯事务 @Transactional public User updateUserInTransaction(User user) { // ... 更新数据库 ... // if (someError) throw new RuntimeException("DB update failed"); return user; } @Transactional public void deleteUserInTransaction(Long id) { // ... 删除数据库 ... } }
-
事务同步管理器 (较复杂): 使用 TransactionSynchronizationManager.registerSynchronization 注册一个回调,在事务成功提交后才执行缓存操作。这需要更复杂的编码。
关键: 尽量避免在同一个方法上混合 @Transactional 和写操作的缓存注解 (@CachePut, @CacheEvict)。优先选择分离方法。
5. AOP 的限制:内部调用失效之谜
问题: 在同一个 Service 类中,一个没有缓存注解的方法 A 调用了同一个类中带有 @Cacheable 的方法 B,你会发现方法 B 的缓存逻辑没有生效。
@Service
public class MyService {
@Cacheable("myCache")
public String cachedMethod(String key) {
System.out.println("Executing cachedMethod for key: " + key);
return "Data for " + key;
}
public String callingMethod(String key) {
System.out.println("Calling cachedMethod internally...");
// !!! 内部调用,cachedMethod 的缓存注解会失效 !!!
return this.cachedMethod(key);
}
}
原因: Spring AOP (包括缓存) 是通过代理实现的。外部调用 Service Bean 的方法时,访问的是代理对象,代理对象会执行缓存等切面逻辑。但是,当 Bean 的一个方法直接调用同一个 Bean 的另一个方法时 (this.methodB()),它绕过了代理,直接调用了原始对象的方法,导致 AOP 切面(缓存注解)失效。
最佳实践:通过代理调用
-
注入自身 (常用): 将 Service 自身注入到自己中,然后通过注入的实例来调用目标方法。
@Service public class MyService { @Autowired private MyService self; // 注入自身代理 @Cacheable("myCache") public String cachedMethod(String key) { System.out.println("Executing cachedMethod for key: " + key); return "Data for " + key; } public String callingMethod(String key) { System.out.println("Calling cachedMethod via self-proxy..."); // 通过代理调用,缓存注解会生效 return self.cachedMethod(key); } }
注意: 可能需要配置 Spring 允许循环依赖(虽然在新版本 Spring Boot 中,对于单例 Bean 的 Autowired 注入通常是允许的)。
- 移到另一个 Bean (更清晰): 将需要被缓存的方法 (cachedMethod) 移到另一个独立的 Bean 中,然后在 MyService 中注入并调用这个新的 Bean。这是更推荐的解耦方式。
关键: 理解 Spring AOP 代理机制是解决内部调用失效问题的关键。
总结
Spring Cache 与 Redis 的结合为 Java 应用带来了巨大的性能优势和开发便利。然而,魔鬼藏在细节中。关注 序列化选择、Key 的设计、TTL 的设置、事务交互 以及 AOP 代理限制 这些关键点,并遵循相应的最佳实践,将帮助你构建出更加健壮、高效、易于维护的缓存系统。希望这篇避坑指南能让你在未来的开发中更加得心应手!