缓存是系统性能的倍增器,但缓存的"故障模式"——击穿、雪崩——会在瞬间把海量请求打到底层数据库,造成级联雪崩。本文深入治理缓存三大故障,重点讲解热点探测、单飞(Singleflight)、限流回源与缓存高可用自动恢复。关于缓存策略的整体框架(穿透/一致性/多级缓存)可先阅读 https://plumephp.com/distributed-cache-strategies/,本文聚焦故障治理的落地机制。
1. 缓存故障三大场景
| 场景 | 触发条件 | 影响 |
|---|---|---|
| 穿透 | 查询不存在的 key,绕过缓存直击 DB | DB 被无效查询打满 |
| 击穿 | 单个热点 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/ 的熔断限流一起,构成了缓存稳定性治理的完整闭环。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。