缓存击穿/雪崩治理

缓存击穿/雪崩治理:热点探测、单飞(singleflight)、限流回源、缓存高可用与自动恢复

缓存是系统性能的倍增器,但缓存的"故障模式"——击穿、雪崩——会在瞬间把海量请求打到底层数据库,造成级联雪崩。本文深入治理缓存三大故障,重点讲解热点探测、单飞(Singleflight)、限流回源与缓存高可用自动恢复。关于缓存策略的整体框架(穿透/一致性/多级缓存)可先阅读 https://plumephp.com/distributed-cache-strategies/,本文聚焦故障治理的落地机制。

1. 缓存故障三大场景

场景触发条件影响
穿透查询不存在的 key,绕过缓存直击 DBDB 被无效查询打满
击穿单个热点 key 过期,瞬间大量并发回源单个 key 的 DB 峰值暴增
雪崩大量 key 同时过期 / 缓存集群宕机全局流量打到 DB,级联故障
穿透:请求不存在 key ──► 缓存 miss ──► 每次查 DB(无 value 可缓存)
击穿:热点 key 过期 ──► 并发全部 miss ──► 同时回源 DB
雪崩:海量 key 同时过期 ──► 全量回源 ──► DB 过载 ──► 上游超时 ──► 级联雪崩

治理思路分层:穿透用空值缓存 + 布隆过滤器;击穿用热点探测 + 单飞 + 互斥重建;雪崩用过期打散 + 限流回源 + 多级缓存 + 缓存高可用。

2. 缓存击穿治理

2.1 热点探测

治理击穿的前提是提前识别热点 key。热点探测方法:

方法原理特点
访问计数统计本地/集中统计 key 访问频率,超阈值标记热点实现简单,有统计窗口延迟
滑动窗口计数用滑动窗口统计近 N 秒访问量比固定窗口更准
自适应识别动态调整热点阈值,访问量突增即标记适配流量波动
代理层探测在网关/Proxy 层聚合 key 访问无侵入业务,全局视角
// 本地滑动窗口热点探测(基于时间槽)
public class HotspotDetector {
    private static final int SLOT_MS = 1000;
    private static final int WINDOW_SLOTS = 10;          // 10 秒窗口
    private final ConcurrentHashMap<String, int[]> counters = new ConcurrentHashMap<>();

    public boolean isHot(String key) {
        int now = (int) (System.currentTimeMillis() / SLOT_MS);
        int[] slots = counters.computeIfAbsent(key, k -> new int[WINDOW_SLOTS]);
        synchronized (slots) {
            slots[now % WINDOW_SLOTS]++;
            int sum = 0;
            for (int s : slots) sum += s;
            return sum >= HOT_THRESHOLD;    // 近 10s 超过阈值视为热点
        }
    }

    public void reset(String key) {
        counters.remove(key);
    }
}

热点识别结果用于:对热点 key 预加载(主动重建)、延长过期时间、独立单飞保护。

2.2 单飞(Singleflight)

单飞:当多个并发请求同时回源同一个 key 时,只让其中一个真正回源,其余请求等待并复用其结果。这能把"N 次并发回源"收敛为"1 次"。

无单飞:
  100 并发 miss ──► 100 次 DB 查询 ──► DB 峰值 100
有单飞:
  100 并发 miss ──► 1 次 DB 查询 + 99 个等待复用 ──► DB 峰值 1

Go 标准库 golang.org/x/sync/singleflight 的用法:

func (c *CacheService) GetWithSingleFlight(ctx context.Context, key string) (string, error) {
    // 1) 先读缓存
    if v, ok := c.cache.Get(ctx, key); ok {
        return v, nil
    }
    // 2) 单飞:同 key 并发只执行一个 fn,其余等待
    v, err, _ := c.group.Do(key, func() (any, error) {
        // 再次检查缓存(双检),防止已重建
        if vv, ok := c.cache.Get(ctx, key); ok {
            return vv, nil
        }
        value, err := c.loadFromDB(ctx, key)   // 真正回源
        if err != nil {
            return nil, err
        }
        c.cache.Set(ctx, key, value, c.expire(key))
        return value, nil
    })
    if err != nil {
        return "", err
    }
    return v.(string), nil
}

Java 中可用 Caffeine 的 loadingCache.get 并发去重,或自实现"单 key 互斥锁 + 双检":

public String getWithLock(String key) throws Exception {
    // 1) 缓存命中直接返回
    String value = cache.get(key);
    if (value != null) return value;

    // 2) 单 key 级联锁:只有拿到锁的线程回源,其余等待后重读缓存
    Lock lock = keyLocks.computeIfAbsent(key, k -> new ReentrantLock());
    lock.lock();
    try {
        // 3) 双检:拿到锁后再次读缓存(可能已被重建)
        value = cache.get(key);
        if (value != null) return value;
        // 4) 真正回源 + 写回
        value = db.load(key);
        cache.set(key, value, expireTime);
        return value;
    } finally {
        lock.unlock();
        keyLocks.remove(key);   // 避免 key 锁泄漏
    }
}

2.3 互斥重建与锁粒度

  • 单飞/互斥锁只锁"回源动作",不锁"整个请求生命周期",避免长时间锁等待
  • 锁粒度到 key,而不是全局锁,否则热点 key 之间互相拖累
  • 回源失败时不写缓存,允许下一次请求重试(或短暂缓存失败标记,防止击穿变穿透)

3. 缓存雪崩治理

3.1 过期时间打散

大量 key 设置相同过期时间会同步失效,必须打散:

// 过期时间 = 基础时间 + 随机偏移
long expire = 3600 * 1000 + ThreadLocalRandom.current().nextLong(0, 600 * 1000);
cache.set(key, value, expire);

对"同一批同时写"的缓存(如商品列表),可在固定时间基础上叠加随机抖动,把失效错开。

3.2 多级缓存

本地缓存(Caffeine)+ Redis + 数据库三级缓存,雪崩时本地缓存仍能抗住:

请求 ──► 本地缓存(进程内,ms 级)── miss ──► Redis ── miss ──► 数据库
              ▲                                     ▲
         Redis 宕机时本地仍命中             Redis 未宕机时兜住大部分

本地缓存命中率高时,即使 Redis 集群整体故障,本地缓存 + 限流回源也能让服务不直接挂掉。

3.3 限流回源

当大量请求同时回源(击穿/雪崩触发时),必须对回源做限流:

public String getGuarded(String key) {
    String v = cache.get(key);
    if (v != null) return v;
    // 回源限流:令牌桶,超出容量的回源请求降级
    if (!originLimiter.tryAcquire()) {
        return fallbackValue(key);   // 降级:返回默认值 / 过期数据 / 提示稍后
    }
    v = db.load(key);
    if (v != null) cache.set(key, v, expireWithJitter());
    return v;
}
回源流量控制:
  并发回源 10万 ──► 令牌桶限流 ──► 允许 2000 次回源 ──► DB 压力可控
                     其余 ──► 降级(默认值/旧数据/等待重试)

4. 热点探测与本地缓存

4.1 热点 key 的本地缓存放大

对热点 key 在每台机器本地缓存一份,显著减少对 Redis 的访问:

// Caffeine 本地缓存,热点 key 短 TTL
LoadingCache<String, String> local = Caffeine.newBuilder()
    .maximumSize(10_000)
    .expireAfterWrite(30, TimeUnit.SECONDS)   // 本地缓存 TTL 更短,容忍脏读
    .build(key -> remoteCache.get(key));       // miss 时走 Redis

// 请求路径:本地缓存 → Redis → DB

本地缓存解决的是"热点 key 高频访问打满 Redis"的问题;单飞解决的是"热点 key 过期瞬间打满 DB"的问题;两者结合治理击穿。

4.2 热点 key 更新策略

热点 key 更新要避免"重建风暴":

  • 主动重建:在过期前(如 TTL 1/3 处)提前异步刷新热点 key
  • 版本号控制:热点 key 带版本号,版本变化才重写,避免无意义覆盖
  • 预加载:热点探测识别出的 key,在业务低谷批量预热
@Scheduled(fixedRate = 60_000)
public void refreshHotspots() {
    for (String key : hotspotDetector.currentHotKeys()) {
        executor.submit(() -> {
            String v = db.load(key);
            cache.set(key, v, hotspotExpire(key));   // 热点 key 更长 TTL
        });
    }
}

5. 缓存高可用与自动恢复

5.1 Redis 集群高可用

缓存集群自身故障会直接引发雪崩,必须构建高可用:

层级手段说明
主从复制Sentinel 监控 + 自动故障转移主节点宕机秒级切换从节点
集群分片Redis Cluster 多分片 + 副本单分片故障不影响全局
跨地域异地多活缓存核心场景容灾
客户端容错读写分离、失败降级客户端感知故障快速切换

5.2 客户端容错与降级

缓存客户端要做到"缓存不可用,业务不完全不可用":

public String getWithFallback(String key) {
    try {
        return cache.get(key);          // Redis
    } catch (RedisConnectionException e) {
        // 1) 本地缓存兜底
        String local = localCache.getIfPresent(key);
        if (local != null) return local;
        // 2) 缓存整体不可用时,限流回源 + 短熔断,防止打爆 DB
        if (cacheBreaker.isOpen()) {
            return fallbackValue(key);
        }
        String v = db.load(key);
        // 3) 回源成功后写本地缓存,不写 Redis(避免重建风暴)
        localCache.put(key, v);
        return v;
    }
}

缓存熔断:连续多次连接失败后打开熔断器,直接走"本地缓存 + 限流回源",避免每次请求都重试失效的 Redis。参考 https://plumephp.com/rate-limiting-circuit-breaker/ 的熔断三态模型。

5.3 自动恢复与预热

缓存集群恢复后,不能瞬间放量(否则重建风暴 + DB 过载):

恢复流程:
  1. Redis 故障转移完成,客户端熔断器进入半开探测
  2. 小流量验证缓存可用,逐步关闭熔断
  3. 缓存 key 逐步回填(不做全量重写,靠请求 miss 渐进重建)
  4. 热点 key 优先预热:从 DB 批量加载高频 key
// 半开探测:允许少量请求试读缓存
public String getRecovering(String key) {
    if (cacheBreaker.allowHalfOpenProbe()) {
        try {
            String v = cache.get(key);
            cacheBreaker.recordSuccess();
            return v;
        } catch (Exception e) {
            cacheBreaker.recordFailure();
            return localCache.getIfPresent(key);
        }
    }
    return localCache.getIfPresent(key);   // 熔断打开,走本地
}

5.4 缓存故障的监控指标

指标含义告警阈值示例
Redis 错误率客户端请求失败比例> 1%
回源 QPS打到 DB 的缓存回源量突增 3 倍
热点 key 数量当前热点探测数突增告警
本地缓存命中率本地兜底效果下降告警
缓存重建延迟回源 + 写回耗时P99 > 200ms

6. 治理方案对比

场景治理手段失效点兜底
穿透空值缓存 / 布隆过滤器布隆误判回源限流
击穿热点探测 + 单飞单飞遗漏非热点 key互斥重建
雪崩过期打散 + 多级缓存缓存集群整体宕机限流回源 + 降级
缓存集群故障主从 + 分片 + 熔断全地域故障本地缓存 + DB 限流
重建风暴主动预热 + 渐进回填预热数据过期请求 miss 渐进重建

总结

治理维度关键机制目的
热点识别滑动窗口计数 / 自适应探测提前发现击穿风险
回源收敛Singleflight / 单 key 互斥并发回源 N→1
流量保护令牌桶限流回源 / 降级防 DB 过载
雪崩预防过期打散 / 多级缓存 / 本地缓存错峰与兜底
高可用主从 / 集群 / 客户端熔断缓存故障不拖垮业务
自动恢复半开探测 / 渐进预热平滑恢复避免二次雪崩

缓存故障治理的本质是"为最坏情况设计":假设缓存会失效、集群会宕机,那么在失效瞬间如何让系统优雅降级而不是级联崩溃。它和 https://plumephp.com/distributed-cache-strategies/ 的整体策略、https://plumephp.com/rate-limiting-circuit-breaker/ 的熔断限流一起,构成了缓存稳定性治理的完整闭环。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「distributed-systems」更多文章

  1. 分布式数据库前沿深度解析:TiDB、Spanner 与 CockroachDB 的共识与事务实现
  2. 异地多活与容灾架构深度解析:同城双活、两地三中心与多活设计
  3. 幂等设计与消息可靠性:不丢不重、防止重复消费的分布式基石