缓存穿透击穿雪崩三件套实战:从 5000 QPS 崩溃到 p99 180ms

双十一压测崩盘复盘:缓存命中率从 99% 掉到 30%,DB CPU 100%。本文讲透缓存穿透/击穿/雪崩三种问题本质差异,给出布隆过滤器、SingleFlight、互斥锁、逻辑过期、多级缓存、jitter TTL、熔断降级 7 种工程实现。附完整 Java 代码 + 压测对比。

2023 年双十一前压测,产品库接口在 5000 QPS 下崩了。Redis 命中率从 99% 掉到 30%,DB CPU 100%,后端连接池打满。事后复盘发现三个问题同时出现:缓存穿透、击穿、雪崩。本文把这三个经典缓存问题彻底讲清楚,带可直接抄走的工程实现。

三种问题的本质区别

缓存穿透 (Cache Penetration)
    现象:大量请求查询根本不存在的 key,缓存里没有,每次都打 DB
    案例:恶意脚本扫描 /api/user/-1, /api/user/-2 ...
    DB 压力:持续高位
    特点:被刷的 key 是无效的

缓存击穿 (Cache Breakdown / Hotspot Invalid)
    现象:某个超热 key 过期的瞬间,所有请求同时打 DB
    案例:首页 banner 缓存 10 分钟过期,过期瞬间 5000 QPS 全打 DB
    DB 压力:瞬间峰值,然后恢复
    特点:被打的 key 是真实热点

缓存雪崩 (Cache Avalanche)
    现象:大量 key 同时过期,或 Redis 整个挂了
    案例:启动时批量预热缓存,设了一样的 30 分钟过期,30 分钟后一起死
    DB 压力:全量流量打到 DB,系统级崩溃
    特点:范围性 / 全局性失效

解决方案 1:缓存穿透 — 布隆过滤器 + 空值缓存

// 方案 A:布隆过滤器,启动时预热所有有效 ID
@Service
public class ProductService {
    private BloomFilter<Long> productIdFilter;

    @PostConstruct
    public void initBloomFilter() {
        // 预期 1000 万,误判率 0.001
        productIdFilter = BloomFilter.create(
            Funnels.longFunnel(),
            10_000_000L,
            0.001
        );
        // 把所有产品 ID 灌进去
        List<Long> allIds = productMapper.selectAllIds();
        allIds.forEach(productIdFilter::put);
        log.info("bloom filter primed: size={} fpp={}", allIds.size(), 0.001);
    }

    public Product getById(Long id) {
        // 第一道防线:布隆过滤器
        if (!productIdFilter.mightContain(id)) {
            return null;     // 一定不存在,不用查 Redis 也不用查 DB
        }
        // 后续走正常缓存查询
        return loadWithCache(id);
    }
}

布隆过滤器优点:空间小(1000 万 ID 只占 ~17MB)、查询 O(1)。缺点:有误判率(本来不存在也可能"mightContain"返回 true,但反过来 mightContain 返回 false 一定不存在)。误判进入下游,只是失去了第一道防线,不会出业务错误。

// 方案 B:空值缓存(简单粗暴)
public Product getById(Long id) {
    String key = "product:" + id;
    String cached = redis.opsForValue().get(key);
    if (cached != null) {
        if ("__NULL__".equals(cached)) return null;
        return JSON.parseObject(cached, Product.class);
    }
    Product p = productMapper.findById(id);
    if (p == null) {
        // 不存在也缓存,但 TTL 短一些(5 分钟)
        redis.opsForValue().set(key, "__NULL__", 5, TimeUnit.MINUTES);
        return null;
    }
    redis.opsForValue().set(key, JSON.toJSONString(p), 30, TimeUnit.MINUTES);
    return p;
}

实际生产建议两层都用:布隆过滤器拦掉 99% 的恶意穿透,空值缓存兜底剩下的。

解决方案 2:缓存击穿 — 互斥锁 / SingleFlight

// 方案 A:Redis 分布式锁(适合分布式场景)
public Product getByIdWithLock(Long id) {
    String key = "product:" + id;
    String cached = redis.opsForValue().get(key);
    if (cached != null) return JSON.parseObject(cached, Product.class);

    // 缓存 miss,尝试拿锁
    String lockKey = "lock:product:" + id;
    String lockValue = UUID.randomUUID().toString();
    Boolean locked = redis.opsForValue().setIfAbsent(lockKey, lockValue, 10, TimeUnit.SECONDS);

    if (Boolean.TRUE.equals(locked)) {
        try {
            // 双重检查
            cached = redis.opsForValue().get(key);
            if (cached != null) return JSON.parseObject(cached, Product.class);

            Product p = productMapper.findById(id);
            if (p != null) {
                redis.opsForValue().set(key, JSON.toJSONString(p), 30, TimeUnit.MINUTES);
            }
            return p;
        } finally {
            // Lua 安全释放
            String script = "if redis.call('get', KEYS[1]) == ARGV[1] then return redis.call('del', KEYS[1]) else return 0 end";
            redis.execute(new DefaultRedisScript<>(script, Long.class), List.of(lockKey), lockValue);
        }
    } else {
        // 没拿到锁,等 50ms 再查缓存(应该已经被刚拿锁的线程填好了)
        try {
            Thread.sleep(50);
        } catch (InterruptedException e) {
            Thread.currentThread().interrupt();
        }
        cached = redis.opsForValue().get(key);
        if (cached != null) return JSON.parseObject(cached, Product.class);
        // 极端情况:还没填好,直接降级查 DB
        return productMapper.findById(id);
    }
}
// 方案 B:本地 SingleFlight(JVM 内去重,适合单机或读多写少)
public class SingleFlight<K, V> {
    private final ConcurrentHashMap<K, CompletableFuture<V>> inFlight = new ConcurrentHashMap<>();

    public V get(K key, Supplier<V> loader) {
        CompletableFuture<V> fut = inFlight.get(key);
        if (fut == null) {
            CompletableFuture<V> newFut = new CompletableFuture<>();
            fut = inFlight.putIfAbsent(key, newFut);
            if (fut == null) {
                fut = newFut;
                try {
                    fut.complete(loader.get());
                } catch (Throwable e) {
                    fut.completeExceptionally(e);
                } finally {
                    inFlight.remove(key);
                }
            }
        }
        try {
            return fut.get(3, TimeUnit.SECONDS);
        } catch (Exception e) {
            throw new RuntimeException(e);
        }
    }
}

// 用法:JVM 内 100 个线程同时 miss,只有 1 个真的查 DB
public Product getByIdSingleFlight(Long id) {
    String key = "product:" + id;
    String cached = redis.opsForValue().get(key);
    if (cached != null) return JSON.parseObject(cached, Product.class);

    return singleFlight.get(id, () -> {
        Product p = productMapper.findById(id);
        if (p != null) redis.opsForValue().set(key, JSON.toJSONString(p), 30, TimeUnit.MINUTES);
        return p;
    });
}

实际生产推荐本地 SingleFlight + 分布式锁兜底。本地能拦掉 99% 单机重复,分布式锁防止多个机器同时打 DB。

解决方案 3:缓存雪崩

策略 A:过期时间加随机抖动

// 错:全部 30 分钟过期 → 30 分钟后一起死
redis.opsForValue().set(key, value, 30, TimeUnit.MINUTES);

// 对:基础 30 分钟 + 0~5 分钟随机抖动
public void setWithJitter(String key, String value, int baseSec, int jitterSec) {
    int ttl = baseSec + ThreadLocalRandom.current().nextInt(jitterSec);
    redis.opsForValue().set(key, value, ttl, TimeUnit.SECONDS);
}

// 应用
setWithJitter(key, value, 1800, 300);     // 30 - 35 分钟随机

策略 B:逻辑过期(Logical Expiration)

// 在 value 里存"逻辑过期时间",Redis key 不设 TTL 或设很长
public class LogicalCache<T> {
    @JsonProperty("data") private T data;
    @JsonProperty("expireAt") private long expireAt;   // 业务上认为的过期时间
}

public Product getProductLogical(Long id) {
    String key = "product:logical:" + id;
    String cached = redis.opsForValue().get(key);
    if (cached == null) {
        return loadAndCacheLogical(id);
    }
    LogicalCache<Product> lc = JSON.parseObject(cached, new TypeReference<LogicalCache<Product>>(){});
    if (lc.expireAt > System.currentTimeMillis()) {
        return lc.data;       // 没逻辑过期,直接用
    }
    // 逻辑过期,异步刷新,返回旧数据(避免击穿)
    asyncExecutor.submit(() -> refreshLogical(id));
    return lc.data;
}

private void refreshLogical(Long id) {
    String lockKey = "lock:refresh:" + id;
    if (!Boolean.TRUE.equals(redis.opsForValue().setIfAbsent(lockKey, "1", 10, TimeUnit.SECONDS))) {
        return;     // 别的线程在刷
    }
    try {
        Product fresh = productMapper.findById(id);
        LogicalCache<Product> lc = new LogicalCache<>(fresh, System.currentTimeMillis() + 1800_000);
        redis.opsForValue().set("product:logical:" + id, JSON.toJSONString(lc));
    } finally {
        redis.delete(lockKey);
    }
}

逻辑过期的好处:缓存永远命中,大不了是稍微旧一点的数据。代价:实现复杂,数据一致性 SLA 变宽松。

策略 C:多级缓存

@Service
public class MultiLayerCache {
    private final Cache<Long, Product> l1 = Caffeine.newBuilder()
        .maximumSize(10_000)
        .expireAfterWrite(Duration.ofMinutes(5))
        .build();
    @Autowired private RedisTemplate<String, String> redis;
    @Autowired private ProductMapper mapper;

    public Product get(Long id) {
        // L1: 本地
        Product p = l1.getIfPresent(id);
        if (p != null) return p;

        // L2: Redis
        String cached = redis.opsForValue().get("product:" + id);
        if (cached != null) {
            p = JSON.parseObject(cached, Product.class);
            l1.put(id, p);
            return p;
        }

        // L3: DB(用 SingleFlight 防击穿)
        return singleFlight.get(id, () -> {
            Product fromDB = mapper.findById(id);
            if (fromDB != null) {
                redis.opsForValue().set("product:" + id, JSON.toJSONString(fromDB), 30, TimeUnit.MINUTES);
                l1.put(id, fromDB);
            }
            return fromDB;
        });
    }
}

策略 D:熔断 / 降级

// Redis 整个挂了的极端情况:直接降级
@HystrixCommand(fallbackMethod = "getFromDBDirectly",
    commandProperties = {
        @HystrixProperty(name = "execution.isolation.thread.timeoutInMilliseconds", value = "1000"),
        @HystrixProperty(name = "circuitBreaker.requestVolumeThreshold", value = "20"),
        @HystrixProperty(name = "circuitBreaker.errorThresholdPercentage", value = "50")
    })
public Product getWithCircuitBreaker(Long id) {
    return cache.get(id);    // 走正常缓存逻辑
}

public Product getFromDBDirectly(Long id) {
    // 降级:跳过缓存,但限流到 DB(避免雪崩 DB)
    if (!rateLimiter.tryAcquire()) {
        throw new ServiceUnavailableException("rate_limited");
    }
    return mapper.findById(id);
}

事故复盘中我们做的具体改造

改造前                           改造后
========================================
直接 Redis get + miss 查 DB       SingleFlight + 分布式锁
所有 key 30min TTL                30min ± 5min 随机
无空值缓存                        负值缓存 5min
无布隆过滤器                      启动预热全部商品 ID
单层 Redis                        Caffeine + Redis 两层
无 Redis 降级                     Hystrix + rate limit 兜底
监控指标:命中率                  + miss 率 + miss 耗时 + 分布式锁等待时间

压测对比

场景:模拟 5000 QPS,缓存命中率 95%,命中率瞬间掉到 30%(雪崩模拟)

改造前:
- DB QPS 峰值: 7000(全部 miss 打到 DB)
- DB CPU: 100%
- 接口 p99: 2000ms
- 错误率: 12%

改造后:
- DB QPS 峰值: 800(SingleFlight 合并)
- DB CPU: 45%
- 接口 p99: 180ms
- 错误率: 0.1%

监控指标

- alert: CacheHitRateLow
  expr: rate(cache_hit_total[5m]) / rate(cache_request_total[5m]) < 0.85
  for: 2m
  annotations:
    summary: '缓存命中率 < 85%'

- alert: CacheBreakdownRisk
  expr: rate(cache_loader_concurrent_calls[1m]) > 10
  annotations:
    summary: '检测到缓存击穿,SingleFlight 并发等待 > 10'

- alert: BloomFilterFppHigh
  expr: bloom_filter_false_positive_rate > 0.01
  annotations:
    summary: '布隆过滤器误判率 > 1%,可能需要扩容'

核对清单

  1. 每个缓存写入是否带 jitter
  2. 每个 loader 是否套了 SingleFlight 或分布式锁
  3. 是否预热布隆过滤器
  4. 是否缓存 null / 空对象
  5. 是否有 L1 本地缓存
  6. 是否有 Redis 全挂的降级方案
  7. 缓存命中率监控有没有报警
  8. 热点 key 是否单独探测 + 提前续期

缓存三件套是面试常考题,但实际生产上能把这 8 条全做到的项目不多。每条都不难,叠加起来才是工程实力。事故让我们一次性把这套基础设施做扎实,后面再压测各种"刻意制造的雪崩"都扛住了,值得。

—— 别看了 · 2026
声明:本站所有文章,如无特殊说明或标注,均为本站原创发布。任何个人或组织,在未征得本站同意时,禁止复制、盗用、采集、发布本站内容到任何网站、书籍等各类媒体平台。如若本站内容侵犯了原著者的合法权益,可联系我们进行处理 邮箱1846861578@qq.com。
技术教程

Redis 分布式锁踩过的 5 个坑:从 SETNX 到 Redlock 到 fencing token

2026-5-19 11:20:07

技术教程

JWT 双 Token 续期 + 多设备登录 + 强制撤销:生产级 Spring Boot 实现

2026-5-19 11:24:09

0 条回复 A文章作者 M管理员
    暂无讨论,说说你的看法吧
个人中心
购物车
优惠劵
今日签到
有新私信 私信列表
搜索