赵赛晗我爱你

缓存穿透、击穿、雪崩:三个概念傻傻分不清楚?

这三个概念面试必考,但很多人记混了。

一句话区分:

  • 穿透: 查的数据根本不存在
  • 击穿: 热点数据的缓存突然过期
  • 雪崩: 大量缓存同时过期

缓存穿透

场景: 查询一个数据库里根本不存在的数据。

请求 id=-1 → 查缓存(没有)→ 查数据库(也没有)→ 返回null
下次再请求 id=-1 → 查缓存(还是没有)→ 查数据库(还是没有)

每次请求都打到数据库,缓存完全失效。恶意攻击者可以用这种方式打垮数据库。

解法一:缓存空值

查数据库发现没有,就在缓存里存一个空值,设置较短的过期时间(比如5分钟)。

Object data = cache.get(key);
if (data == null) {
    data = db.query(key);
    if (data == null) {
        cache.set(key, "NULL", 5, TimeUnit.MINUTES);  // 缓存空值
    } else {
        cache.set(key, data, 30, TimeUnit.MINUTES);
    }
}
return "NULL".equals(data) ? null : data;

缺点:如果攻击者用不同的不存在的key,缓存里会存大量空值,占用内存。

解法二:布隆过滤器(推荐)

布隆过滤器是一个位数组+多个哈希函数,可以判断一个元素"一定不存在"或"可能存在"。

// 启动时,把所有合法的id加入布隆过滤器
bloomFilter.add(id);

// 请求进来时,先过布隆过滤器
if (!bloomFilter.mightContain(requestId)) {
    return null;  // 布隆过滤器说不存在,直接返回,不查缓存和数据库
}
// 布隆过滤器说可能存在,再查缓存和数据库

布隆过滤器有一定的误判率(说存在但实际不存在),但不会漏判(说不存在就一定不存在)。

布隆过滤器误判率与位数组大小的关系: 误判率由位数组长度 m 和哈希函数个数 k 决定,元素越多、位数组越小则误判率越高。Guava实现中可在创建时指定期望误判率:

// Guava BloomFilter:期望插入100万元素,误判率1%
BloomFilter<Long> bloomFilter = BloomFilter.create(
    Funnels.longFunnel(),
    1_000_000,   // 预期元素数量
    0.01         // 期望误判率(1%)
);
// Guava会自动计算最优的位数组大小和哈希函数个数

// Redis实现(RedisBloom模块)
// BF.RESERVE key error_rate capacity
// BF.RESERVE user:bloom 0.01 1000000
// BF.ADD user:bloom 12345
// BF.EXISTS user:bloom 12345

数据更新时布隆过滤器的维护: 布隆过滤器不支持删除操作。新增数据时向过滤器添加即可;删除数据时无法从过滤器中移除(若需要支持删除,可使用 Counting Bloom Filter)。可以定期重建过滤器解决过时数据问题。

缓存空值方案的注意事项:

// ⚠️ 边界场景:数据库后续真正新增了该数据,空值缓存会导致短暂不一致
// 解决:新增数据时主动删除空值缓存
public void createUser(User user) {
    userMapper.insert(user);
    // 主动删除可能存在的空值缓存,防止缓存与数据库不一致
    cache.delete("user:" + user.getId());
}

缓存击穿

场景: 一个热点数据(比如明星微博、秒杀商品)的缓存突然过期,大量请求同时涌入,全部打到数据库。

缓存过期的那一刻:
1000个并发请求 → 查缓存(全部miss)→ 全部打到数据库 → 数据库可能崩

解法一:互斥锁

缓存miss时,只让一个线程去查数据库,其他线程等待。

public Object getData(String key) {
    Object data = cache.get(key);
    if (data != null) return data;

    // 缓存miss,尝试获取互斥锁
    String lockKey = "lock:" + key;
    if (tryLock(lockKey)) {
        try {
            // 双重检查,防止其他线程已经重建了缓存
            data = cache.get(key);
            if (data != null) return data;

            // 查数据库,重建缓存
            data = db.query(key);
            cache.set(key, data, 30, TimeUnit.MINUTES);
            return data;
        } finally {
            unlock(lockKey);
        }
    } else {
        // 没拿到锁,等一会儿重试
        Thread.sleep(50);
        return getData(key);
    }
}

缺点:等待期间请求被阻塞,响应变慢。

解法二:逻辑过期(热点数据永不过期)

不设置TTL,而是在数据里存一个"逻辑过期时间"。发现逻辑过期时,异步更新缓存,同时返回旧数据。

public Object getData(String key) {
    CacheData cacheData = cache.get(key);

    // 缓存存在且未逻辑过期
    if (cacheData != null && !isExpired(cacheData)) {
        return cacheData.getData();
    }

    // 逻辑过期,异步重建
    if (tryLock("lock:" + key)) {
        // 异步线程去更新缓存
        executor.submit(() -> {
            Object newData = db.query(key);
            cache.set(key, new CacheData(newData, System.currentTimeMillis() + 30 * 60 * 1000));
            unlock("lock:" + key);
        });
    }

    // 返回旧数据(允许短暂不一致)
    return cacheData != null ? cacheData.getData() : null;
}

适合读多写少、允许短暂不一致的热点数据。


缓存雪崩

场景: 大量缓存在同一时间段过期,或者Redis服务宕机,导致所有请求全部打到数据库。

解法:

1. 过期时间加随机值

// ❌ 所有缓存同时过期
cache.set(key, data, 30, TimeUnit.MINUTES);

// ✅ 过期时间加随机扰动,避免同时过期
int randomOffset = new Random().nextInt(300);  // 0-300秒随机
cache.set(key, data, 30 * 60 + randomOffset, TimeUnit.SECONDS);

2. Redis高可用

部署Redis集群(主从+哨兵,或者Cluster),避免单点故障导致全量缓存失效。

3. 服务降级

Redis挂了,不要让请求直接打到数据库,而是返回降级数据(比如空数据、本地缓存、兜底数据)。

try {
    data = cache.get(key);
    if (data == null) {
        data = db.query(key);
    }
} catch (RedisException e) {
    // Redis挂了,返回降级数据
    data = getDefaultData(key);
    log.error("Redis异常,走降级", e);
}

4. 限流

即使缓存雪崩发生了,通过限流保护数据库不被打垮。


三种问题对比

问题触发条件核心解法
穿透 查不存在的数据 布隆过滤器
击穿 热点数据缓存过期 互斥锁 / 逻辑过期
雪崩 大量缓存同时过期 / Redis宕机 过期时间加随机值 / 高可用 / 限流

面试时把这三个概念的区别和对应解法说清楚,不要混淆,基本就过了。

posted on 2026-11-07 11:07  37.2°  阅读(8)  评论(0)    收藏  举报

导航

赵赛晗我爱你