一秒空窗,一秒洪水
击穿只发生在热点 key 上。某爆款商品详情缓存了 30 分钟,到期那一秒,QPS 五千的详情接口同时发现缓存没了——五千个请求拿着同一个问题涌向数据库:把这条商品再查一遍。DB 瞬间被同一份数据的重复重建打爆,缓存还没来得及填上,超时与重试已经跟上,雪崩链(限流系列第 1 篇)的引信就此点燃。
普通 key 过期,一两个请求回源,无伤大雅;热点 key 过期,是一次小规模的定向洪水。治法的核心就一句话:同一时刻,只允许一次回源。
解法一:互斥重建
未命中的请求先抢锁,抢到的去查库重建,没抢到的稍等片刻再读缓存——重建者几十毫秒内就会把缓存填好,等待者直接命中:
public ItemDetail getHotItem(Long id) {
String key = "item:detail:" + id;
ItemDetail d = redis.get(key);
if (d != null) return d;
String lockKey = "lock:rebuild:" + id;
if (redis.setnx(lockKey, "1", 10, SECONDS)) { // 只有一个线程拿到重建权
try {
d = itemDao.loadDetail(id);
redis.setex(key, 1800, toJson(d));
} finally {
redis.del(lockKey);
}
return d;
}
Thread.sleep(50); // 没抢到的稍等再读缓存
return redis.get(key); // 重建者很快填上,这里大概率命中
}三个工程细节:锁必须带 TTL 防死锁(重建者宕机锁就悬了);等待要设上限并给兜底动作(等两轮还没等到,降级返回默认值或旧快照);批量重建别串行——多个 key 同时过期时逐个抢锁反而拖长整体恢复时间。这套互斥的细节,分布式锁系列已经整套讲过,拿来即用。
解法二:逻辑过期
另一个思路釜底抽薪:热点 key 干脆不设 TTL,把过期时间写进 value 里。读到没过期的直接返回;发现逻辑上过期了,先返回旧值保住可用性,同时异步触发重建——重建期间所有请求照常返回旧数据,没有空窗:
// 写入:不设 TTL,过期时间进 value
redis.set(key, toJson(new CacheWrapper(detail, now() + 1800_000L)));
// 读取:逻辑判断,过期也先返回旧值
CacheWrapper w = parse(redis.get(key));
if (w.expireAt > System.currentTimeMillis()) {
return w.data; // 未过期:正常返回
}
tryAcquireThenSubmit(key); // 过期:抢到执行权的任务异步重建
return w.data; // 所有请求:先返回旧值,绝不阻塞| 维度 | 互斥重建 | 逻辑过期 |
|---|---|---|
| 一致性 | 重建后即为新值 | 过期后短暂旧值 |
| 可用性 | 未命中请求需短暂等待 | 永不阻塞 |
| 实现成本 | 低(加锁即用) | 较高(value 包装 + 异步任务) |
| 适合 | 一般热点 key | 头部爆款、不可阻塞的场景 |
怎么选看业务对「准」和「稳」的取舍:价格类数据要准,互斥重建;爆款流量要稳,逻辑过期——旧半分钟的价格,好过一秒钟的页面打不开。而击穿只盯一个 key,雪崩盯的是同一夜集体失效的所有 key——下一篇把战线拉大。
评论 (0)