MyBatis 二级缓存深度实战——缓存穿透、与 Redis 二级缓存的集成方案
MyBatis 二级缓存深度实战——缓存穿透、与 Redis 二级缓存的集成方案
适读人群:使用 MyBatis 的后端工程师,想深入理解缓存机制并避免踩坑的开发者 | 阅读时长:约15分钟 | 核心价值:搞清楚 MyBatis 缓存的工作原理和局限性,知道什么时候该用 Redis 替换它
一次"缓存了假数据"的线上故障
两年前,我的一个学员小吴负责公司的商品中心服务,用的是 MyBatis,开启了二级缓存做查询加速。
某天下午,运营修改了一批商品的价格。改完之后,他们去前台核实,发现商品价格还是老的。刷新页面,还是老的。运营以为是前端缓存,强制刷新还是老的。
最后排查了两个小时,才发现是 MyBatis 的二级缓存没有及时失效。商品信息被缓存在内存里,而价格更新走的是另一个 Mapper,没有触发对应 Mapper 的缓存清除。
更糟糕的是:系统部署了 3 个实例,每个实例各自有一份二级缓存,三个实例展示的价格都不一样,有的是新价格,有的还是旧价格。
"我以为开了缓存就是在加速,没想到是在存假数据。"小吴后来跟我说。
今天把 MyBatis 缓存的工作原理、使用限制、以及如何正确集成 Redis 作为二级缓存,系统梳理一遍。
一、MyBatis 缓存的两个层次
1.1 一级缓存(SqlSession 级别)
一级缓存是 MyBatis 默认开启的,作用范围是一个 SqlSession(一次数据库连接会话)。同一 SqlSession 内,对同一 SQL + 参数的查询会返回缓存结果,不再查数据库。
// 一级缓存生效示例
SqlSession session = sqlSessionFactory.openSession();
// 第一次查询,查数据库
UserMapper mapper = session.getMapper(UserMapper.class);
User user1 = mapper.selectById(1L); // 查数据库,结果存入一级缓存
// 第二次查询,命中一级缓存
User user2 = mapper.selectById(1L); // 直接从缓存返回,不查数据库
// user1 == user2(同一对象引用)
System.out.println(user1 == user2); // true
session.close(); // SqlSession 关闭,一级缓存销毁在 Spring 集成 MyBatis 的场景中,每次请求通常使用不同的 SqlSession(由 SqlSessionTemplate 管理),一级缓存实际上在同一方法的多次查询间才有效。跨方法调用一般不同 SqlSession,一级缓存没有收益。
踩坑一:一级缓存导致事务内读到"幻影数据"
现象:在同一个事务里,先查了一条记录,另一个事务修改了这条记录,再查,还是旧值。
原因:一级缓存在事务结束前不失效(事务内复用同一 SqlSession),第二次查询命中缓存而不是数据库。
解法:如果事务内必须读最新数据,可以在 Mapper 方法上用 @Options(flushCache = FlushCachePolicy.TRUE) 强制清除缓存,或者直接用 sqlSession.clearCache()。
1.2 二级缓存(Mapper/namespace 级别)
二级缓存的作用范围是一个 Mapper namespace,不同 SqlSession 之间共享。
<!-- 在 Mapper.xml 中开启二级缓存 -->
<cache
eviction="LRU" <!-- 淘汰策略:LRU(最近最少使用) -->
flushInterval="60000" <!-- 刷新间隔:60 秒自动清空整个缓存 -->
size="512" <!-- 最多缓存 512 个对象 -->
readOnly="false" <!-- false: 返回缓存对象的副本(线程安全);true: 返回引用(更快但线程不安全) -->
/>同时,在全局配置中开启:
mybatis:
configuration:
cache-enabled: true # 全局开启二级缓存(默认 true,但需要 Mapper.xml 里的 <cache/> 才真正生效)二、二级缓存的致命局限性
2.1 分布式环境下数据不一致
这是文章开头小吴踩到的坑。二级缓存存在 JVM 内存中,多个应用实例各自独立,没有数据同步机制。
实例 A 更新了数据,清除了自己的缓存;实例 B 的缓存没有失效,继续提供旧数据。
结论:多实例部署时,绝对不要使用 MyBatis 默认的内存二级缓存。
2.2 Mapper 粒度的缓存失效太粗
MyBatis 二级缓存的失效是以 Mapper 为单位的。一旦这个 Mapper 有任何写操作(INSERT/UPDATE/DELETE),整个 Mapper 的缓存全部清空。
// 假设 UserMapper 有二级缓存
// 查询 user_id=1 的用户信息 → 缓存
// 查询 user_id=2 的用户信息 → 缓存
// 更新 user_id=1 的用户 → UserMapper 全部缓存清空!
// 查询 user_id=2 → 缓存失效,需要重新查数据库这种一刀切的失效策略,在写操作频繁的场景下,缓存命中率极低,反而增加了开销(序列化/反序列化)。
三、Redis 二级缓存:正确的分布式缓存方案
3.1 自定义 Cache 接口
MyBatis 允许通过实现 org.apache.ibatis.cache.Cache 接口来自定义缓存后端,把缓存存到 Redis。
/**
* MyBatis Redis 二级缓存实现
* 支持分布式环境下的缓存一致性
*/
public class RedisMybatisCache implements Cache {
private static final Logger log = LoggerFactory.getLogger(RedisMybatisCache.class);
private final String id; // Mapper namespace 作为缓存命名空间
private final RedisTemplate<String, Object> redisTemplate;
private final long ttlSeconds;
// Spring 容器初始化后注入
private static RedisTemplate<String, Object> staticRedisTemplate;
@Autowired
public void setRedisTemplate(RedisTemplate<String, Object> redisTemplate) {
RedisMybatisCache.staticRedisTemplate = redisTemplate;
}
public RedisMybatisCache(String id) {
this(id, 300); // 默认缓存 5 分钟
}
public RedisMybatisCache(String id, long ttlSeconds) {
this.id = id;
this.redisTemplate = staticRedisTemplate;
this.ttlSeconds = ttlSeconds;
}
@Override
public String getId() {
return id;
}
@Override
public void putObject(Object key, Object value) {
try {
String cacheKey = buildKey(key);
redisTemplate.opsForHash().put(id, cacheKey, value);
// 设置整个 Hash 的过期时间(每次写入都刷新)
redisTemplate.expire(id, ttlSeconds, TimeUnit.SECONDS);
} catch (Exception e) {
log.warn("MyBatis Redis 缓存写入失败,将降级为无缓存,key={}", key, e);
}
}
@Override
public Object getObject(Object key) {
try {
return redisTemplate.opsForHash().get(id, buildKey(key));
} catch (Exception e) {
log.warn("MyBatis Redis 缓存读取失败,将直接查数据库,key={}", key, e);
return null; // 缓存失败时降级到数据库,不影响业务
}
}
@Override
public Object removeObject(Object key) {
try {
return redisTemplate.opsForHash().delete(id, buildKey(key));
} catch (Exception e) {
log.warn("MyBatis Redis 缓存删除失败,key={}", key, e);
return null;
}
}
@Override
public void clear() {
// 清空这个 Mapper 下的所有缓存
try {
redisTemplate.delete(id);
} catch (Exception e) {
log.warn("MyBatis Redis 缓存清空失败,namespace={}", id, e);
}
}
@Override
public int getSize() {
try {
Long size = redisTemplate.opsForHash().size(id);
return size == null ? 0 : size.intValue();
} catch (Exception e) {
return 0;
}
}
private String buildKey(Object key) {
return key.toString();
}
}在 Mapper.xml 中使用自定义缓存:
<!-- 使用 Redis 作为二级缓存 -->
<cache type="com.example.cache.RedisMybatisCache">
<property name="ttlSeconds" value="600"/> <!-- 缓存 10 分钟 -->
</cache>3.2 细粒度缓存失效(Spring Cache 方案)
对于需要细粒度缓存控制的场景,我更推荐直接用 @Cacheable、@CacheEvict 注解,而不是 MyBatis 的二级缓存机制:
@Service
public class ProductService {
@Autowired
private ProductMapper productMapper;
// 查询时缓存,key 精确到 productId
@Cacheable(value = "product", key = "#productId", unless = "#result == null")
public Product getProduct(Long productId) {
return productMapper.selectById(productId);
}
// 更新时只失效这一个 key,不影响其他商品的缓存
@CacheEvict(value = "product", key = "#product.id")
@Transactional
public void updateProduct(Product product) {
productMapper.updateById(product);
}
// 批量失效(当分类下所有商品都可能受影响时)
@CacheEvict(value = "product", allEntries = true)
@Transactional
public void updateCategoryProducts(Long categoryId, BigDecimal discount) {
productMapper.batchUpdateByCategory(categoryId, discount);
}
}踩坑二:缓存穿透
现象:商品 ID 不存在的查询大量打到数据库,没有缓存保护。
原因:unless = "#result == null" 条件阻止了空结果被缓存,每次查询都透传到数据库。
解法:对空结果也缓存,但设置更短的 TTL(比如 30 秒),防止长期缓存不存在的 key:
@Cacheable(value = "product", key = "#productId")
public Optional<Product> getProduct(Long productId) {
Product product = productMapper.selectById(productId);
// 返回 Optional,空结果包装为 Optional.empty() 也会被缓存
// 配合 Redis 的 TTL,30 秒后过期,避免无效 key 长期占内存
return Optional.ofNullable(product);
}踩坑三:缓存雪崩
现象:Redis 重启后,大量缓存同时失效,数据库 QPS 瞬间飙升,数据库被压垮。
原因:所有缓存同时设置了相同的 TTL,同时过期。
解法:TTL 加随机抖动,避免同时过期:
@Configuration
public class CacheConfig {
@Bean
public RedisCacheManager cacheManager(RedisConnectionFactory factory) {
Map<String, RedisCacheConfiguration> configs = new HashMap<>();
// product 缓存:10分钟 ± 2分钟随机偏移
configs.put("product", RedisCacheConfiguration.defaultCacheConfig()
.entryTtl(Duration.ofMinutes(10 + ThreadLocalRandom.current().nextInt(-2, 3))));
return RedisCacheManager.builder(factory)
.withInitialCacheConfigurations(configs)
.build();
}
}四、选型建议
- 读多写少、数据一致性要求低:MyBatis 二级缓存(仅单实例部署时)或 Redis
@Cacheable - 多实例部署:必须用 Redis,绝对不用 MyBatis 默认内存缓存
- 写操作频繁:慎用 MyBatis 二级缓存,写操作触发整个 namespace 失效,缓存命中率低
- 需要细粒度失效控制:用
@CacheEvict+ Redis,而不是 MyBatis 缓存
缓存是一把双刃剑,加快了读速度,但引入了数据一致性的复杂性。用之前要想清楚:数据的更新频率、一致性要求、部署方式,再做决策。
