下线 MyBatis 二级缓存后,如何用 Spring Cache + Redis 构建安全可靠的缓存体系?

下线 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 设施。

------ 旷野说

相关推荐
南部余额39 分钟前
深度解析 Spring @Conditional:实现智能条件化配置的利器
java·后端·spring·conditional
凌波粒41 分钟前
Springboot基础教程(6)--整合JDBC/Druid数据源/Mybatis
spring boot·后端·mybatis
计算机毕设指导642 分钟前
基于Springboot+微信小程序流浪动物救助管理系统【源码文末联系】
java·spring boot·后端·spring·微信小程序·tomcat·maven
iナナ1 小时前
Java自定义协议的发布订阅式消息队列(一)
java·开发语言·spring·消息队列·生成消费者模型
shyの同学1 小时前
Spring事务:为什么catch了异常,事务还是回滚了?
数据库·spring·事务·spring事务
在坚持一下我可没意见1 小时前
Spring Boot 实战(一):拦截器 + 统一数据返回 + 统一异常处理,一站式搞定接口通用逻辑
java·服务器·spring boot·后端·spring·java-ee·tomcat
jiayong231 小时前
MyBatis XML Mapper 特殊字符处理方案
xml·mybatis
zore_c2 小时前
【C语言】文件操作详解2(文件的顺序读写操作)
android·c语言·开发语言·数据结构·笔记·算法·缓存