下线 MyBatis 二级缓存后,如何用 Spring Cache + Redis 构建安全可靠的缓存体系?
背景 :在我的高并发服务中,曾长期依赖 MyBatis 二级缓存。但随着业务复杂度上升,其跨节点不一致、缓存穿透难控、序列化不可定制 等问题日益突出。最终,我决定彻底下线 MyBatis 二级缓存 ,全面启用 Spring Cache + Redis 方案。
但切换不是简单加个注解就完事。必须确保:
- 缓存底层是 Redis(而非内存或 Ehcache);
- 序列化方式安全、可读、支持多态;
- 写操作与 DB 强一致,尤其在乐观锁场景下不能翻车。
下面是我的完整实践。
一、核心配置:RedisCacheConfig.java
首先要明确:Spring Cache 是抽象层,必须绑定到 Redis 实现。以下是我在生产环境中稳定运行半年以上的配置:
java
package com.xiaochuan.config;
import com.fasterxml.jackson.annotation.JsonAutoDetect;
import com.fasterxml.jackson.annotation.JsonTypeInfo;
import com.fasterxml.jackson.annotation.PropertyAccessor;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.jsontype.impl.LaissezFaireSubTypeValidator;
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.RedisSerializer;
import org.springframework.data.redis.serializer.StringRedisSerializer;
import java.time.Duration;
@Configuration
@EnableCaching // 启用 Spring Cache 注解
public class RedisCacheConfig {
@Bean
public RedisCacheManager cacheManager(RedisConnectionFactory connectionFactory) {
// 构建默认缓存配置:1 小时过期,禁止缓存 null 值
RedisCacheConfiguration defaultCacheConfig = RedisCacheConfiguration
.defaultCacheConfig()
.entryTtl(Duration.ofHours(1)) // 默认 TTL:1 小时
.disableCachingNullValues() // 不缓存 null,防缓存穿透需另做处理
.serializeKeysWith(RedisSerializationContext.SerializationPair
.fromSerializer(new StringRedisSerializer())) // Key 使用 String 序列化
.serializeValuesWith(RedisSerializationContext.SerializationPair
.fromSerializer(jackson2JsonRedisSerializer())); // Value 使用带类型信息的 JSON
return RedisCacheManager.RedisCacheManagerBuilder
.fromConnectionFactory(connectionFactory)
.cacheDefaults(defaultCacheConfig)
.transactionAware() // 与 Spring 事务同步:事务回滚时避免脏缓存写入
.build();
}
/**
* 配置支持泛型和子类反序列化的 Jackson JSON 序列化器
* - 启用默认类型信息(写入 @class 字段)
* - 允许反序列化为真实子类,而非 LinkedHashMap
*/
private RedisSerializer<Object> jackson2JsonRedisSerializer() {
ObjectMapper objectMapper = new ObjectMapper();
objectMapper.setVisibility(PropertyAccessor.ALL, JsonAutoDetect.Visibility.ANY);
// 激活类型信息写入(非 final 类会写入 @class 字段),支持多态反序列化
objectMapper.activateDefaultTyping(
LaissezFaireSubTypeValidator.instance,
ObjectMapper.DefaultTyping.NON_FINAL,
JsonTypeInfo.As.PROPERTY
);
return new Jackson2JsonRedisSerializer<>(objectMapper, Object.class);
}
}
✅ 为什么这么配?
| 配置项 | 作用 | 风险规避 |
|---|---|---|
| Jackson 序列化 | 生成可读 JSON,支持子类 | 避免 JDK 原生序列化的安全漏洞和版本兼容灾难 |
| 禁用 null 缓存 | 不缓存 null 值 |
防止无效 key 被长期缓存,掩盖"用户不存在"的真实语义 |
transactionAware() |
缓存写入绑定 Spring 事务 | 避免"事务回滚但缓存已更新"的脏数据问题 |
| Key 用 String 序列化 | Redis key 为纯字符串 | 便于运维查看、监控、手动清理 |
💡 穿透处理建议 :
disableCachingNullValues()后,缓存穿透需另解------我用 短 TTL(如 30 秒)的占位符 或 布隆过滤器 拦截无效 ID。
二、UserService:缓存与 DB 的一致性实践
配置只是基础,真正的挑战在写操作的一致性 。以下是我的完整 UserService,特别关注乐观锁 + 缓存更新的协同。
java
@Service
public class UserService {
@Autowired
private UserMapper userMapper;
// 查:命中缓存则跳过 DB
@Cacheable(value = "user:profile", key = "#userId", unless = "#result == null")
public User getUserById(Long userId) {
return userMapper.selectWithVersion(userId);
}
// 改:先更新 DB,再用返回值更新缓存
@CachePut(value = "user:profile", key = "#user.id")
public User updateUser(User user) {
if (userMapper.updateWithVersion(user) == 0) {
throw new OptimisticLockException("用户数据已被他人修改,请重试");
}
// 注意:此处 user 必须包含有效 version!
return user;
}
// 删:先删 DB,再删缓存
@CacheEvict(value = "user:profile", key = "#userId")
public void deleteUser(Long userId) {
userMapper.deleteById(userId);
}
}
🔑 关键细节:乐观锁与 version 字段的生命周期
在我的系统中:
version是数据库乐观锁字段,由系统维护;- 前端 DTO 中不包含
version(对客户端透明); - Java 实体
User仍保留version字段,供 MyBatis 使用。
因此,不能直接将前端 DTO 转为 User 实体后调用 updateUser !否则 version 为 null,乐观锁失效。
✅ 正确调用方式(Service 层封装):
java
// Controller 层
@PostMapping("/user/{id}")
public User update(@PathVariable Long id, @RequestBody UserUpdateDTO dto) {
return userService.updateUserFromDTO(id, dto);
}
// Service 层
public User updateUserFromDTO(Long userId, UserUpdateDTO dto) {
// 1. 从 DB 加载完整实体(含当前 version)
User user = userMapper.selectWithVersion(userId);
if (user == null) throw new UserNotFoundException("用户不存在");
// 2. 仅更新业务字段(保留 version)
user.setName(dto.getName());
user.setEmail(dto.getEmail());
// 3. 调用内部更新方法(此时 user.version 有效)
return updateUser(user); // 触发 @CachePut
}
💡 ORM 回填保障:
- 如果你用 MyBatis-Plus (带
@Version注解),更新成功后user.version会自动回填为新值;- 如果你用 原生 MyBatis ,建议在
updateUser中更新后重新查询,确保缓存对象与 DB 完全一致:
java
@CachePut(value = "user:profile", key = "#userId")
public User updateUser(User user) {
if (userMapper.updateWithVersion(user) == 0) {
throw new OptimisticLockException("数据已被修改,请重试");
}
// 安全兜底:重新加载(适用于无自动 version 回填的场景)
return userMapper.selectWithVersion(user.getId());
}
三、依赖与监控建议
📦 必要依赖
xml
<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>
<!-- Jackson 已由 starter-data-redis 间接引入 -->
🌟 命名规范 :缓存名用
user:profile而非userCache,便于在 Redis 中按业务域隔离,也方便通过KEYS user:*监控或清理。
四、血泪教训:别让"自动"变成"失控"
Spring Cache 注解看似简单,但每一行 @Cacheable 都隐含了一致性承诺。我的原则是:
- 写操作必须同步维护缓存 :用
@CachePut更新,@CacheEvict删除; - 缓存 key 必须稳定无副作用 :用
#userId(标量),别用#user.name(可能变); - 高敏感数据慎用缓存:如余额、权限等,要么强制短 TTL,要么走强一致读路径(绕过缓存);
- 务必开启
transactionAware():否则事务回滚时缓存已更新,数据永久不一致!
✅ 终极口诀(建议背诵)
配置用
RedisCacheManager,序列化选 Jackson;事务感知要开启,空值缓存需谨慎。
更新必带 version,先查后改是铁律;
DTO 无 version,实体加载要牢记。Key 用参数别用对象,命名规范带前缀;
自动缓存虽省力,一致性责任在你肩!
这套方案已在我司多个高并发核心服务(日均千万级请求)稳定运行半年以上,零缓存不一致事故。
关注我,从零开始构建基础 IT 设施。
------ 旷野说