MyBatis缓存穿透深度解析:从原理到实战解决方案
引言
作为Java开发中常用的ORM框架,MyBatis的缓存机制(一级缓存、二级缓存)能显著提升查询效率,但实际使用中,“缓存穿透”问题却像个隐藏的“坑”——明明查的是数据库里没有的数据,请求却像潮水一样反复冲向数据库,轻则增加DB压力,重则导致DB崩溃。
今天这篇文章,笔者将从原理到解决方案,带大家彻底搞懂MyBatis缓存穿透!
一、什么是缓存穿透?MyBatis场景下的典型表现
1. 缓存穿透的定义
缓存穿透是指:客户端查询一个数据库中不存在的数据,由于缓存中也无该数据记录,请求绕过缓存直接访问数据库。如果这类无效请求高频发生(比如恶意攻击或参数错误),数据库可能被压垮。
2. MyBatis中的特殊场景
MyBatis的一级缓存(SqlSession级别)和二级缓存(Mapper级别)对“空值”的处理差异,是导致穿透的关键:
- 一级缓存:默认会缓存
null
结果(同一SqlSession中重复查询会直接命中缓存)。但如果SqlSession关闭(比如HTTP请求结束),缓存失效,下次请求仍会穿透。 - 二级缓存:默认不缓存
null
结果(比如PerpetualCache
实现)。即使第一次查询返回null
,缓存中也不会存储该键,后续相同查询依然会穿透到数据库。
举个真实例子:
之前做用户系统时,前端传了一个id=-1
的查询请求(业务中id
是自增正整数)。第一次查询时,MyBatis二级缓存没命中,查数据库返回null
,但缓存不存null
;第二次同样传id=-1
,又穿透到数据库……由于前端埋点错误,这个无效请求被高频触发,DB瞬间压力飙升!
二、MyBatis缓存穿透的根因分析
1. 二级缓存不缓存null
值(核心原因)
MyBatis二级缓存的默认实现(如PerpetualCache
)设计逻辑是“只缓存有效数据”,数据库查不到的结果不会存入缓存。这意味着,同一个无效id
的多次查询,每次都会绕过缓存直接打DB。
2. 数据动态变化导致缓存失效
即使缓存了有效数据,若数据被删除(如用户注销),缓存会被清除。此时查询已删除的id
(数据库无记录),又会触发穿透。
3. 恶意攻击或参数错误
- 恶意用户故意传入不存在的
id
(如id=0
、超大数值)。 - 前端/客户端生成参数时逻辑错误(如循环递增
id
,超出数据库最大值)。
三、实战解决方案:从简单到进阶
针对MyBatis缓存穿透,需要“多层防御”——从缓存策略、前置校验到流量控制,逐层拦截无效请求。以下是我在实际项目中验证过的有效方案:
方案1:缓存空值(Null Caching)—— 最直接的拦截
核心思路:将数据库查询结果为null
的键存入缓存(标记为“不存在”),后续相同请求直接从缓存获取,避免穿透。
实现步骤(以Redis为二级缓存为例)
-
配置MyBatis使用Redis缓存
引入mybatis-redis
依赖,在Mapper接口上添加@CacheNamespace
注解,指定自定义的Redis缓存实现。@CacheNamespace(implementation = CustomRedisCache.class) public interface UserMapper {User selectById(Long id); // 查询方法 }
-
自定义Redis缓存类,处理空值
重写putObject
和getObject
方法,将null
结果序列化为特定标识(如"NULL"
)存入Redis,并设置短过期时间(防止脏数据)。public class CustomRedisCache implements Cache {private final RedisTemplate<String, Object> redisTemplate;private static final long NULL_TTL = 300; // 空值缓存5分钟@Overridepublic void putObject(Object key, Object value) {String cacheKey = "mybatis:cache:" + key.toString(); // 自定义缓存键格式if (value == null) {// 存入"NULL"标识,替代nullredisTemplate.opsForValue().set(cacheKey, "NULL", NULL_TTL, TimeUnit.SECONDS);} else {redisTemplate.opsForValue().set(cacheKey, value);}}@Overridepublic Object getObject(Object key) {String cacheKey = "mybatis:cache:" + key.toString();Object value = redisTemplate.opsForValue().get(cacheKey);// 若缓存值为"NULL",返回null;否则返回实际值return "NULL".equals(value) ? null : value;}// 其他方法(如removeObject、getSize等)按需实现... }
-
验证效果
第一次查询id=-1
时,数据库返回null
,缓存存入"NULL"
;后续相同查询直接从缓存获取null
,不再穿透DB。
方案2:布隆过滤器(Bloom Filter)—— 海量数据的前置拦截
核心思路:在查询数据库或缓存前,先用布隆过滤器判断id
是否存在。若不存在,直接返回,避免访问缓存和DB。
实现步骤(基于Guava BloomFilter)
-
初始化布隆过滤器
启动时加载所有有效id
到布隆过滤器(适用于数据量稳定场景,如用户表、商品表)。@PostConstruct // Spring启动后初始化 public void initBloomFilter() {// 预期插入100万条数据,误判率1%BloomFilter<Long> bloomFilter = BloomFilter.create(Funnels.longFunnel(), 1_000_000, 0.01);// 从数据库加载所有有效id(如用户表的id)List<Long> allUserIds = userMapper.selectAllIds();allUserIds.forEach(bloomFilter::put);// 将布隆过滤器注入到Service中this.bloomFilter = bloomFilter; }
-
在Service层拦截无效请求
查询前通过布隆过滤器校验id
是否存在,不存在则直接返回null
(或抛异常)。public User getUserById(Long id) {// 布隆过滤器前置校验if (!bloomFilter.mightContain(id)) {log.warn("拦截无效id:{}", id);return null;}// 命中缓存或数据库return userMapper.selectById(id); }
注意:布隆过滤器存在误判率(可能将不存在的id
判定为存在),因此即使校验通过,仍需查询缓存/DB二次验证;若数据动态变化(如新增id
),需定期更新布隆过滤器(可通过定时任务重新加载全量数据)。
方案3:热点参数校验+限流—— 拦截恶意请求
核心思路:针对已知非法参数(如负数、超长id
)或高频无效参数,在网关或Service层添加校验,直接拦截。
实现示例
public User getUserById(Long id) {// 校验1:id必须为正数(业务逻辑约束)if (id == null || id <= 0) {log.warn("非法id请求:{}", id);return null;}// 校验2:id不能超过数据库最大可能值(如MySQL的BIGINT最大值)if (id > Long.MAX_VALUE - 1000) { log.warn("id超出合理范围:{}", id);return null;}// 正常查询逻辑...
}
扩展:结合Sentinel等限流工具,对特定无效id
(如id=0
)的请求限流,防止恶意攻击。
方案4:分布式锁—— 防缓存击穿(穿透的极端场景)
核心思路:当缓存未命中时,仅允许一个线程查询数据库,其他线程等待结果,避免大量线程同时穿透到DB(适用于高并发热点id
场景)。
实现步骤(基于Redis分布式锁)
public User getUserById(Long id) {String cacheKey = "user:cache:" + id;// 1. 先查缓存User user = redisTemplate.opsForValue().get(cacheKey);if (user != null) {return user;}// 2. 缓存未命中,尝试加锁(防止大量线程同时查DB)String lockKey = "user:lock:" + id;boolean locked = redisTemplate.opsForValue().setIfAbsent(lockKey, "1", 10, TimeUnit.SECONDS); // 锁10秒if (!locked) {// 加锁失败,等待100ms后重试(避免无限重试)try {Thread.sleep(100);return getUserById(id); // 递归重试(实际项目中建议限制重试次数)} catch (InterruptedException e) {Thread.currentThread().interrupt();return null;}}try {// 3. 加锁成功,查数据库user = userMapper.selectById(id);// 4. 回写缓存(缓存空值或有效数据,设置合理TTL)redisTemplate.opsForValue().set(cacheKey, user != null ? user : "NULL", user != null ? 3600 : 300, // 有效数据缓存1小时,空值缓存5分钟TimeUnit.SECONDS);return user;} finally {// 5. 释放锁(Lua脚本保证原子性)redisTemplate.execute(new DefaultRedisScript<>("if redis.call('get', KEYS[1]) == ARGV[1] then return redis.call('del', KEYS[1]) else return 0 end", Long.class), Collections.singletonList(lockKey), "1");}
}
四、方案对比与选择建议
方案 | 优点 | 缺点 | 适用场景 |
---|---|---|---|
缓存空值 | 实现简单,快速拦截重复无效请求 | 需合理设置TTL,可能存储短时间脏数据 | 无效参数固定、数据量小 |
布隆过滤器 | 内存占用低,适合海量数据 | 存在误判,需二次校验 | 数据量大、动态变化 |
热点参数校验 | 逻辑简单,拦截明显非法请求 | 无法处理合法但数据库无记录的id | 已知非法参数范围 |
分布式锁 | 彻底避免缓存击穿(穿透的极端情况) | 实现复杂,可能影响性能 | 高并发、热点id 场景 |
总结:多层防御,让缓存穿透无处可逃
MyBatis缓存穿透的本质是“无效请求绕过缓存直连DB”,解决思路是拦截无效请求+减少无效查询。实际项目中,建议:
- 优先用缓存空值拦截重复请求(简单高效);
- 海量数据场景补充布隆过滤器前置校验;
- 恶意请求用参数校验+限流精准打击;
- 高并发热点场景用分布式锁防击穿。
记住:没有完美的方案,只有最适合业务的组合!
如果你在实际项目中遇到过更复杂的缓存穿透问题,欢迎在评论区留言讨论~ 😊