前言: 在构建高性能的“黑马点评”商铺查询功能时,我们面临的一个严峻挑战是缓存穿透。当黑客利用脚本发起海量不存在的 Key(如 id = -1)进行攻击时,请求会瞬间穿透 Redis 直接打在数据库上,极易导致数据库宕机。本文将深入探讨缓存穿透的原理,对比主流解决方案,并重点解析我们采用的“缓存空对象”方案及其兜底逻辑。

一、 核心概念:什么是缓存穿透?

缓存穿透 (Cache Penetration) 是指客户端请求的数据在缓存中不存在,同时在数据库中也不存在

由于数据库中没有数据,所以我们无法将数据写入缓存。这就导致了每次针对该 Key 的请求,都会绕过 Redis,直接打到数据库。

它与缓存击穿、缓存雪崩的区别在于:

  • 击穿:Key 对应的数据存在,只是突然过期了(点)。
  • 雪崩:大量的 Key 同时过期(面)。
  • 穿透:数据根本就不存在(空)。

二、 解决方案大比拼:布隆过滤器 vs 缓存空对象

针对缓存穿透,业界主要有两种主流的解决方案,它们各有优劣:

方案

原理简述

优点

缺点

方案一:缓存空对象 (Caching Null Object)

当 DB 查不到数据时,往 Redis 存一个空值(如 ""

),并设置较短的 TTL。

实现简单,维护方便,数据一致性好(配合 TTL)。

1. 内存消耗:如果黑客随机生成海量 Key,Redis 会存海量空值。


 

2. 短期不一致:若 DB 后来真的有了数据,缓存里短暂还是空值。

方案二:布隆过滤器 (Bloom Filter)

在访问 Redis 前,通过一个巨大的位图(Bitmap)判断 Key 是否可能存在

内存占用极少(只存 0/1 位)。

1. 实现复杂:需要引入额外组件。


 

2. 存在误判:它说“存在”不一定真存在,但说“不存在”一定不存在。


 

3. 删除困难:很难从过滤器中删除数据。

三、 技术选型:为什么我们选择了“缓存空对象”?

虽然布隆过滤器在内存占用上具有绝对优势,但在现阶段,我们最终选择了看似“笨重”的缓存空对象方案。主要基于以下三点深层思考:

  1. 开发成本与维护性

项目处于快速迭代期,引入布隆过滤器意味着需要维护一个新的组件(或 Redis 的 Bitmap 结构),增加了系统的复杂度。而“缓存空对象”仅需在原有逻辑上增加几行代码,性价比极高。

     2.兜底逻辑解决了“内存消耗”痛点

大家最担心的就是黑客刷几亿个随机 Key 把 Redis 内存撑爆。为了解决这个问题,我们在代码中设置了较短的 TTL(过期时间)(例如 2 分钟)。

        限制内存:即使攻击流量进来,产生的空对象也会在 2 分钟后自动销毁,Redis 会自动清理这些垃圾数据,保证内存占用维持在一个动态平衡的范围内,不会无限膨胀。

     3.数据一致性兜底

如果数据库随后真的插入了该数据(比如运营新建了 id=1 的店铺),TTL 保证了 Redis 里的空对象不会永久存在。最长等待 2 分钟,用户就能查到最新的数据。

四、 代码落地与实战

基于上述思考,我们在 ShopServiceImpl 中实现了如下逻辑。

代码实现:

Java

public Shop queryWithPathThrough(Long id){
    String key = CACHE_SHOP_KEY + id;
    
    // 1. 从 redis 查询商铺缓存
    String shopJson = stringRedisTemplate.opsForValue().get(key);
    
    // 2. 判断是否存在(StrUtil.isNotBlank 过滤了 null 和 "")
    if (StrUtil.isNotBlank(shopJson)) {
        // 3. 如果命中真实数据 - 直接返回
        Shop shop = BeanUtil.toBean(shopJson, Shop.class);
        return shop;
    }

    // 🚩 核心逻辑 1:拦截空值
    // 如果 shopJson 不为 null(说明是 ""),则直接返回错误,不再查库
    if(shopJson != null ){
        return null;
    }

    // 4. 如果不存在(shopJson == null) - 根据 id 查询数据库
    Shop shop = getById(id);
    
    // 5. 数据库不存在
    if (shop == null) {
        // 🚩 核心逻辑 2:缓存空对象 + 兜底 TTL
        // 将空值写入 redis,并设置较短的过期时间(CACHE_NULL_TTL),防止内存爆炸
        stringRedisTemplate.opsForValue().set(key, "", CACHE_NULL_TTL, TimeUnit.MINUTES);
        return null;
    }

    // 6. 数据库存在 - 写入 redis 并设置正常过期时间
    stringRedisTemplate.opsForValue().set(key, JSONUtil.toJsonStr(shop), CACHE_SHOP_TTL, TimeUnit.MINUTES);
    
    return shop;
}

关键代码解析:

  • if (shopJson != null):这是防止穿透的第一道防线。既然 isNotBlank 没通过,且对象不为 null,那它只能是我们在步骤 5 存入的空字符串 ""。此时直接返回,保护数据库。
  • set(..., "", CACHE_NULL_TTL):这是防止穿透的第二道防线。当数据库查不到时,我们主动存入空值。注意这里的 TTL 通常设置得比较短(如 2-5 分钟),这正是我们对抗“内存消耗”的杀手锏。

五、进阶思考:主动防御 vs 被动防御

布隆过滤器 和 缓存空对象 本质上是一种 Redis 层面的被动防御(见招拆招)。

但我们能做的远不止于此,我们可以在业务层“主动出击”。

  • 增强 ID 复杂度
  • 问题:如果使用数据库自增 ID(如 1, 2, 3...),攻击者很容易推测出规律,编写脚本遍历所有 ID 进行攻击。
  • 对策:采用 Snowflake(雪花算法) 或 UUID 生成复杂的非连续 ID。让攻击者无法猜测 ID 规律,从源头掐断攻击脚本的生成。
  • 基础格式校验:
  • 对策:在 Controller 层或网关层对参数进行严格校验。例如 ID 必须是 Long 类型且大于 0,或者 ID 的长度必须符合特定规则。如果是 id=-1id=abc 这种显然非法的请求,直接驳回,根本不需要去烦劳 Redis 和数据库。
  • 加强用户权限校验:
  • 对策:绝大多数系统查询都需要登录。在网关层强制校验 User Token,未登录用户或权限不足的用户直接拦截。这就过滤掉了绝大部分无成本的脚本攻击。
  • 热点参数限流:
  • 对策:利用 Sentinel 或 Nginx 对同一 IP、同一用户的请求频率进行限制。如果检测到某个 IP 在 1 秒内发起了 1000 次查询不存在 Key 的请求,直接将其拉黑。

六、 总结

通过“缓存空对象”配合“TTL 过期策略”,我们以极低的开发成本,有效解决了缓存穿透问题。虽然它在极端攻击下会短暂占用内存,但对于目前的业务量级和 Redis 集群能力来说,这是一个性价比极高的选择。

解决了“穿透”问题后,如果一个存在的热点 Key 突然过期,导致万级并发瞬间打向数据库(缓存击穿),我们又该如何应对?下一篇我们将探讨互斥锁逻辑过期的实战应用。

Logo

汇聚全球AI编程工具,助力开发者即刻编程。

更多推荐