一、什么是缓存穿透?一个“查无此人”的麻烦

想象一下,你开了一家大型图书馆(这代表你的应用),为了快速找到热门书籍,你在门口放了一个智能书架(这就是Redis缓存)。当有人来借《三体》时,你首先看智能书架,有就直接给他,又快又省力。如果书架上没有,你再去庞大的书库(这就是数据库,如MySQL)里找,找到后除了把书给读者,还会在智能书架上放一本副本,方便下一个人。

现在,问题来了。如果来了一个人,他非要借一本根本不存在的书,比如《如何在一周内学会魔法》。你的智能书架(缓存)里肯定没有,于是你每次都得跑去庞大的书库(数据库)里翻个底朝天,结果当然是每次都找不到。这个反复查询一个不存在数据的过程,就是缓存穿透

核心危害:大量的无效查询直接打到数据库上,数据库压力剧增,就像图书馆管理员被无数个借“魔法书”的人搞得筋疲力尽,导致无法服务真正想借《三体》的读者,最终可能拖垮整个系统。

二、方案一:缓存空对象——给“查无此人”做个标记

这是最简单直接的办法。既然数据库里没有这个“魔法书”,那我们就在智能书架(缓存)上,为这个不存在的书名放一个特殊的标记,比如一张写着“此书不存在”的纸条,并设置一个较短的过期时间。

这样,下一个再来借《如何在一周内学会魔法》的人,看到这个纸条,就知道不用去书库折腾了,直接告诉他“没有”就行。数据库的压力瞬间就消失了。

技术栈:Java + Spring Boot + Redis (Jedis/Lettuce)

下面我们看看如何用代码实现这个“缓存空对象”的策略。

// 技术栈:Java + Spring Boot + Redis (Lettuce)
@Service
public class BookService {

    @Autowired
    private RedisTemplate<String, Object> redisTemplate;
    @Autowired
    private BookRepository bookRepository; // 假设是JPA访问MySQL的仓库

    // 统一的缓存键前缀
    private static final String CACHE_KEY_PREFIX = “book:”;
    // 空对象的缓存值,用一个特殊字符串表示
    private static final String CACHE_NULL_VALUE = “NULL_OBJECT”;
    // 空对象的过期时间,比如5分钟,防止长期占用缓存
    private static final long CACHE_NULL_TTL = 300L;

    /**
     * 根据ID查询书籍信息,带有缓存空对象逻辑
     * @param id 书籍ID
     * @return 书籍对象,如果不存在则返回null
     */
    public Book getBookById(Long id) {
        // 1. 构造缓存键
        String cacheKey = CACHE_KEY_PREFIX + id;

        // 2. 先从Redis缓存中尝试获取
        Object value = redisTemplate.opsForValue().get(cacheKey);
        
        // 3. 判断缓存命中情况
        if (value != null) {
            // 3.1 如果命中,且是空对象标记,直接返回null,避免查库
            if (CACHE_NULL_VALUE.equals(value)) {
                System.out.println(“缓存命中空对象,ID: “ + id);
                return null;
            }
            // 3.2 如果命中,且是正常数据,直接返回
            System.out.println(“缓存命中,ID: “ + id);
            return (Book) value;
        }

        // 4. 缓存未命中,查询数据库
        System.out.println(“缓存未命中,查询数据库,ID: “ + id);
        Book book = bookRepository.findById(id).orElse(null);

        // 5. 将数据库查询结果写入缓存
        if (book == null) {
            // 5.1 如果数据库中没有,缓存一个空对象标记
            redisTemplate.opsForValue().set(cacheKey, CACHE_NULL_VALUE, CACHE_NULL_TTL, TimeUnit.SECONDS);
            System.out.println(“数据库无数据,缓存空对象,ID: “ + id);
        } else {
            // 5.2 如果数据库中有,缓存真实数据(这里设置1小时过期)
            redisTemplate.opsForValue().set(cacheKey, book, 1, TimeUnit.HOURS);
            System.out.println(“数据库有数据,写入缓存,ID: “ + id);
        }

        // 6. 返回查询结果
        return book;
    }
}

优缺点分析

  • 优点:实现简单,能有效应对大量不存在的随机ID查询。
  • 缺点
    1. 内存浪费:如果恶意攻击者构造大量不同的无效Key,会导致缓存中塞满大量无意义的空值标记,占用宝贵的内存空间。
    2. 数据短期不一致:如果在空对象缓存有效期内,数据库中真实地添加了这个ID的数据,那么用户在这段时间内看到的依然是“不存在”。需要业务上能容忍这种短暂的不一致,或者通过主动删除缓存来更新。

三、方案二:布隆过滤器——图书馆的“总目录”

有没有一种方法,能在读者开口问之前,就提前知道他问的书名在图书馆里到底“有没有可能存在”呢?图书馆的“总目录”或“索引卡片柜”就是这个角色。布隆过滤器(Bloom Filter)就是我们在缓存系统前加装的一个“超级高效的索引”。

它是一个很长的二进制向量(bit数组)和一系列哈希函数组成。它的特点是:

  • 说“没有”,那一定没有:如果布隆过滤器说某个Key不存在,那数据库里肯定不存在。
  • 说“有”,不一定真有:它说某个Key存在,数据库里可能存在(有很小的误判率)。

我们把所有数据库中存在的合法ID,都预先“登记”到这个布隆过滤器里。当查询请求来时,先问布隆过滤器:“这个ID存在吗?” 如果它说“不存在”,我们就直接返回空结果,连缓存都不查了,彻底保护了缓存和数据库。如果它说“存在”,我们再走正常的“查缓存->查数据库”流程。

结合示例:让我们用Guava库提供的布隆过滤器来实现。

// 技术栈:Java + Spring Boot + Redis + Guava BloomFilter
@Service
public class BookServiceWithBloomFilter {

    @Autowired
    private RedisTemplate<String, Object> redisTemplate;
    @Autowired
    private BookRepository bookRepository;

    private static final String CACHE_KEY_PREFIX = “book:”;
    // 初始化一个布隆过滤器,预计插入100万个元素,期望的误判率为1%
    private BloomFilter<Long> bloomFilter = BloomFilter.create(Funnels.longFunnel(), 1000000, 0.01);

    /**
     * 服务启动时,初始化布隆过滤器(例如从数据库加载所有有效ID)
     */
    @PostConstruct
    public void initBloomFilter() {
        List<Long> allBookIds = bookRepository.findAllIds(); // 假设这个方法获取所有ID
        for (Long id : allBookIds) {
            bloomFilter.put(id);
        }
        System.out.println(“布隆过滤器初始化完成,已加载 ” + allBookIds.size() + “ 个ID。”);
    }

    /**
     * 使用布隆过滤器的查询方法
     */
    public Book getBookByIdWithBloomFilter(Long id) {
        // 1. 先问布隆过滤器
        if (!bloomFilter.mightContain(id)) {
            // 过滤器明确告诉我“这个ID很可能不存在”,直接返回null,拦截无效请求
            System.out.println(“布隆过滤器拦截,ID不存在: “ + id);
            return null;
        }

        // 2. 过滤器说“可能存在”,走正常缓存流程
        String cacheKey = CACHE_KEY_PREFIX + id;
        Object value = redisTemplate.opsForValue().get(cacheKey);

        if (value != null) {
            System.out.println(“缓存命中,ID: “ + id);
            return (Book) value;
        }

        // 3. 缓存未命中,查询数据库
        System.out.println(“缓存未命中,查询数据库,ID: “ + id);
        Book book = bookRepository.findById(id).orElse(null);

        if (book != null) {
            // 4. 数据库有数据,写入缓存
            redisTemplate.opsForValue().set(cacheKey, book, 1, TimeUnit.HOURS);
            System.out.println(“数据库有数据,写入缓存,ID: “ + id);
        } else {
            // 5. 注意:这里数据库查不到,但过滤器却说可能存在,这就是误判的情况。
            // 这种情况很少,但发生了。我们选择不缓存空对象,因为可能是恶意试探的无效ID。
            System.out.println(“[误判情况] 布隆过滤器判断存在,但数据库实际不存在,ID: “ + id);
        }
        return book;
    }

    /**
     * 当新增一本书时,需要更新布隆过滤器
     */
    public void addBook(Book book) {
        // 1. 保存到数据库...
        Book savedBook = bookRepository.save(book);
        // 2. 加入到布隆过滤器
        bloomFilter.put(savedBook.getId());
        // 3. 清除或更新缓存...
        String cacheKey = CACHE_KEY_PREFIX + savedBook.getId();
        redisTemplate.delete(cacheKey);
    }
}

关联技术详解:布隆过滤器 布隆过滤器的核心在于空间效率和查询效率的极致权衡。它用多个哈希函数将同一个元素映射到位数组的多个不同位置,并将这些位置置为1。判断元素是否存在时,只要看这些位置是否都是1。由于哈希冲突,不同元素的位置可能重叠,导致“可能存在”的误判。但它永不漏判(说没有的一定没有),且占用的空间远小于存储所有元素本身,非常适合这种“存在性校验”的场景。Redis 4.0后也提供了BF.ADDBF.EXISTS命令,可以将其功能放在Redis中,实现分布式共享。

优缺点分析

  • 优点:内存占用极低,能从根本上拦截绝大部分无效请求,对数据库保护效果最好。
  • 缺点
    1. 存在误判率:需要根据业务容量和可接受程度,在初始化时权衡“预期元素数量”和“误判率”。
    2. 数据删除/更新困难:传统的布隆过滤器不支持删除(因为不知道某一位是哪几个元素共用的)。如果需要删除,可以考虑使用变种如“计数布隆过滤器”,或定期重建。
    3. 需要预热初始化:需要将历史数据提前加载到过滤器中,对于数据量极大或变化频繁的场景,初始化和更新策略需要精心设计。

四、方案三:请求入口校验——把问题挡在最外面

很多时候,无效的请求本身就带有明显的特征。比如,我们的书籍ID都是正数,而攻击者传入了-10abc这样的参数。我们可以在请求刚到达业务逻辑时,就进行一层校验。

这通常结合在API的控制器层或参数解析层完成,是最轻量、成本最低的第一道防线。

// 技术栈:Java + Spring Boot
@RestController
@RequestMapping(“/api/books”)
public class BookController {

    @Autowired
    private BookService bookService;

    /**
     * 根据ID获取书籍
     * @param id 路径变量,必须是正整数
     */
    @GetMapping(“/{id}”)
    public ResponseEntity<Book> getBook(@PathVariable(“id”) @Min(1) Long id) {
        // Spring MVC的@Min注解会配合@Validated进行校验
        // 如果id不合法,请求在进入Service层之前就会被拦截,返回400 Bad Request
        Book book = bookService.getBookById(id);
        return book != null ? ResponseEntity.ok(book) : ResponseEntity.notFound().build();
    }

    // 或者,更直接地在方法内校验
    @GetMapping(“/simple/{id}”)
    public ResponseEntity<Book> getBookSimple(@PathVariable String id) {
        // 手动校验:ID必须是纯数字且大于0
        if (!id.matches(“\\d+”) || Long.parseLong(id) <= 0) {
            System.out.println(“请求参数非法,直接拦截: “ + id);
            return ResponseEntity.badRequest().body(null); // 返回400错误
        }
        Long bookId = Long.parseLong(id);
        Book book = bookService.getBookById(bookId);
        return book != null ? ResponseEntity.ok(book) : ResponseEntity.notFound().build();
    }
}

优缺点分析

  • 优点:实现简单,零额外开销,对明显无效的请求(如非法参数格式、越界参数)拦截效果立竿见影。
  • 缺点:防御能力有限,只能拦截有明显规则的无效请求。对于符合格式规则(如都是正整数)但数据库里就是不存在的随机ID攻击,无能为力。

五、应用场景、技术选型与总结

应用场景分析

  • 缓存空对象:适用于数据变化不频繁,且空值结果相对有限的场景。例如,查询用户信息,不存在的用户名就那么几个(如系统保留名、已删除的测试账号),可以放心缓存。
  • 布隆过滤器:适用于数据字典明确、总量大、且相对稳定的场景。例如,电商系统中所有有效的商品ID、短链系统中所有已生成的短码。在风控和反垃圾领域,用于判断一个IP或手机号是否在黑名单中,也非常高效。
  • 请求入口校验:适用于所有场景,应作为必备的基础校验层。它成本最低,能过滤掉低级的恶意请求或客户端错误。

技术优缺点与注意事项

  1. 组合使用:在实际项目中,通常不会只采用一种方案。最佳实践是组合拳:先做请求入口校验(如ID格式、范围),再通过布隆过滤器拦截大批量随机攻击,最后对于漏网之鱼或布隆过滤器误判的请求,用缓存空对象兜底。
  2. 空值TTL设置:缓存空对象的过期时间不宜过长,通常几分钟到几十分钟,既要起到保护作用,又要避免长期占用内存和脏数据问题。
  3. 布隆过滤器更新:对于数据频繁增删的业务,需要考虑布隆过滤器的更新策略。对于新增,可以实时put;对于删除,可以标记删除并定期重建,或使用支持删除的变种。
  4. 监控与告警:无论采用哪种方案,都需要监控缓存未命中率(Cache Miss Rate)和数据库QPS。如果发现异常升高,可能是遇到了新的攻击模式或方案有漏洞。

文章总结: 缓存穿透是高性能缓存架构中一个典型且危险的问题。解决它的核心思路是:避免让大量无效查询直达数据库。 我们介绍了三种主要武器:

  • 缓存空对象像是“事后补救”,简单有效但有内存和一致性的代价。
  • 布隆过滤器像是“事前预防”,从源头拦截,效率极高,是防御大规模随机攻击的利器,但需要理解其“可能存在”的误判特性。
  • 请求入口校验则是“守门员”,负责过滤掉明显不合格的请求,是必须有的第一道防线。

没有银弹,在实际架构中,你需要根据业务的数据特性、变化频率和可接受的成本,灵活选择和组合这些策略,构建起一道坚固的缓存防线,确保你的系统在高并发下依然稳健。记住,好的架构总是在空间、时间、复杂度之间做出最合适的权衡。