缓存穿透解决案例

wen java案例 1

本文目录导读:

缓存穿透解决案例

  1. 第一层:接口层校验(前置拦截)
  2. 第二层:缓存空对象(主流方案)
  3. 第三层:布隆过滤器(最强防御)
  4. 第四层:热点数据兜底(限流降级)
  5. 综合架构案例(企业级实践)

缓存穿透是指查询一个不存在的数据,由于缓存中查不到,请求会直接打到数据库,如果并发量高,数据库会压力巨大甚至被打垮,因为缓存没命中,所以也无法回填缓存,导致每次请求都会穿透。

以下是几种经典且可落地的解决案例,按防御层次由浅入深排序:


第一层:接口层校验(前置拦截)

适用场景:最常见的一层保障,ID 为负数、超长字符串、非法字符。

案例描述: 用户查询商品详情,productId 都是正数,如果请求 id=-1id=abc,直接返回参数错误,不进入缓存和数据库查询逻辑。

代码示例(伪代码)

public Product getProduct(String productId) {
    // 1. 前置校验
    if (productId == null || !productId.matches("\\d+")) {
        return null; // 直接返回,拦截非法请求
    }
    long id = Long.parseLong(productId);
    if (id <= 0) {
        return null;
    }
    // 2. 先去缓存查...
    // 3. 再去数据库查...
}

效果:过滤掉大部分恶意攻击或代码 bug 导致的无效请求。


第二层:缓存空对象(主流方案)

适用场景:数据库确实没有数据,但请求量很大(用户查询一个不存在的优惠券码)。

案例描述: 假设用户输入了不存在的优惠券码 COUPON_9999,数据库查无结果,此时将空值(null 或空字符串)也写入缓存,并设置一个较短的过期时间(3~5 分钟),这样后续相同的请求在缓存中就能直接命中,不会打到数据库。

核心步骤

  1. 从缓存取数据。
  2. 如果缓存未命中,查数据库。
  3. 如果数据库查不到,将 null 写入缓存(key 为 COUPON_9999,value 为 null),并设置过期时间(如 300 秒)。
  4. 返回 null 给前端。

代码示例(伪代码)

public String getCoupon(String couponCode) {
    // 1. 查缓存
    Object cacheValue = redis.get("coupon:" + couponCode);
    // 2. 判断是否为“空缓存”
    if (cacheValue != null) {
        // 如果缓存的是空字符串,说明数据库也没有,直接返回空
        if ("EMPTY".equals(cacheValue)) {
            return null;
        }
        return (String) cacheValue;
    }
    // 3. 缓存未命中,去数据库查
    String dbValue = couponMapper.selectByCode(couponCode);
    // 4. 数据库没查到,缓存空值
    if (dbValue == null) {
        redis.set("coupon:" + couponCode, "EMPTY", 300); // 5分钟过期
        return null;
    }
    // 5. 数据库查到了,缓存真实值
    redis.set("coupon:" + couponCode, dbValue, 3600);
    return dbValue;
}

优缺点

  • 优点:实现简单,能有效拦截大量重复的穿透请求。
  • 缺点:消耗部分缓存内存;如果恶意攻击的 key 是随机的(如 id=1id=2...),缓存中会堆积大量空值,占用内存,需要配合下面的布隆过滤器使用。

第三层:布隆过滤器(最强防御)

适用场景:数据量极大,且 key 是随机生成的(如 UUID),缓存空对象会撑爆内存。

案例描述: 用户通过 ID 查询文章,文章 ID 是自增的,但内容被删除或根本不存在,为了防止攻击者用不存在的 ID 随机轰炸,在系统初始化时,将所有存在的文章 ID 提前放入布隆过滤器中。

处理流程

  1. 请求到达,先检查布隆过滤器。
  2. 如果布隆过滤器判定 不存在,直接返回 null不查数据库
  3. 如果布隆过滤器判定 可能存在,再走“缓存 -> 数据库”的流程。

代码实现(以 Redisson 为例)

// 初始化布隆过滤器(系统启动时,将100W个合法ID灌入)
RBloomFilter<Long> bloomFilter = redisson.getBloomFilter("articleIdFilter");
bloomFilter.tryInit(1000000L, 0.01); // 期望100万条数据,误差率1%
// 请求处理
public Article getArticle(Long articleId) {
    // 1. 布隆过滤器拦截
    if (!bloomFilter.contains(articleId)) {
        System.out.println("布隆过滤器拦截不存在ID: " + articleId);
        return null; // 直接返回,不查DB
    }
    // 2. 查缓存 ...
    // 3. 查数据库 ...
}

优缺点

  • 优点:空间占用极小(100万条数据仅占约 1MB 内存),性能极高。
  • 缺点:存在误判率(过滤器说存在,但数据库可能没有,需配合缓存空值兜底);不删除数据(若 ID 被删除,无法从布隆过滤器中移除,需要定期重建或容忍误查)。

第四层:热点数据兜底(限流降级)

适用场景:数据库或缓存被击穿瞬间的压力保护。

案例描述: 如果布隆过滤器或缓存空值都失效,且发生了极端并发(同一时刻 10000 个请求同时穿透),数据库可能瞬间崩溃,此时可以加一个互斥锁,让请求排队。

代码示例(双重检查锁)

public String getData(String key) {
    // 1. 查缓存
    String value = redis.get(key);
    if (value != null) {
        return value;
    }
    // 2. 缓存未命中,加锁
    String lockKey = "lock:" + key;
    boolean lock = redis.setIfAbsent(lockKey, "1", 30, TimeUnit.SECONDS);
    if (!lock) {
        // 拿不到锁,说明有其他线程正在查库,短暂等待后重试
        Thread.sleep(50);
        return getData(key); // 递归重试
    }
    try {
        // 3. 拿到锁,再查一次缓存(可能其他线程已经填入了)
        value = redis.get(key);
        if (value != null) {
            return value;
        }
        // 4. 真正查询数据库
        value = queryDB(key);
        redis.setex(key, 3600, value);
        return value;
    } finally {
        // 释放锁
        redis.delete(lockKey);
    }
}

综合架构案例(企业级实践)

电商系统的商品查询为例,应对恶意攻击的完整链路:

  1. 请求入口Nginx 层做 IP 限流(每 IP 每秒 10 次)。
  2. 应用层:校验 productId 格式是否合法。
  3. 布隆过滤器:判断 productId 是否可能存在于数据库。
    • 不存在 → 返回“商品不存在”
    • 可能存在 → 进入下一步
  4. Redis 缓存:查询是否命中。
    • 命中了“空值标记” → 返回“商品不存在”
    • 命中了真实数据 → 返回商品
    • 未命中 → 进入下一步
  5. 互斥锁:获取锁(防止多个请求同时查库)。
  6. 数据库查询:如果查到就回填缓存;如果没查到,在 Redis 中写入“空值标记”(null 或特殊符号),设置 5 分钟过期。
  7. 返回响应

通过这种多重组合方案,既能防止恶意攻击(用布隆过滤器挡掉大头),又能防止正常业务数据缺失导致的问题(用缓存空值兜底),还能保护数据库不被高并发打崩(用互斥锁降级)。

抱歉,评论功能暂时关闭!