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为二级缓存为例)
  1. 配置MyBatis使用Redis缓存
    引入mybatis-redis依赖,在Mapper接口上添加@CacheNamespace注解,指定自定义的Redis缓存实现。

    @CacheNamespace(implementation = CustomRedisCache.class)
    public interface UserMapper {
        User selectById(Long id); // 查询方法
    }
    
  2. 自定义Redis缓存类,处理空值
    重写putObjectgetObject方法,将null结果序列化为特定标识(如"NULL")存入Redis,并设置短过期时间(防止脏数据)。

    public class CustomRedisCache implements Cache {
        private final RedisTemplate<String, Object> redisTemplate;
        private static final long NULL_TTL = 300; // 空值缓存5分钟
    
        @Override
        public void putObject(Object key, Object value) {
            String cacheKey = "mybatis:cache:" + key.toString(); // 自定义缓存键格式
            if (value == null) {
                // 存入"NULL"标识,替代null
                redisTemplate.opsForValue().set(cacheKey, "NULL", NULL_TTL, TimeUnit.SECONDS);
            } else {
                redisTemplate.opsForValue().set(cacheKey, value);
            }
        }
    
        @Override
        public 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等)按需实现...
    }
    
  3. 验证效果
    第一次查询id=-1时,数据库返回null,缓存存入"NULL";后续相同查询直接从缓存获取null,不再穿透DB。

方案2:布隆过滤器(Bloom Filter)—— 海量数据的前置拦截

核心思路:在查询数据库或缓存前,先用布隆过滤器判断id是否存在。若不存在,直接返回,避免访问缓存和DB。

实现步骤(基于Guava BloomFilter)
  1. 初始化布隆过滤器
    启动时加载所有有效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;
    }
    
  2. 在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”,解决思路是拦截无效请求+减少无效查询。实际项目中,建议:

  1. 优先用缓存空值拦截重复请求(简单高效);
  2. 海量数据场景补充布隆过滤器前置校验;
  3. 恶意请求用参数校验+限流精准打击;
  4. 高并发热点场景用分布式锁防击穿。

记住:没有完美的方案,只有最适合业务的组合!

如果你在实际项目中遇到过更复杂的缓存穿透问题,欢迎在评论区留言讨论~ 😊

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

码不停蹄的玄黓

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值