本节目标:让上一节的
remember从「能跑」变成「抗压」。读完本节,你能准确区分穿透、击穿、雪崩三者的成因与表现,能为每一种配上对应的防护代码——布隆过滤器、空值缓存、单飞、逻辑过期、TTL 抖动、熔断降级——并知道这些手段各自的代价与适用边界。
前两节我们把键设计好了、把访问层类型化了。但一个类型完全正确的 remember,在流量尖峰下依然可能把数据库打挂:缓存未命中时所有并发请求一起回源。本节处理的就是这个「最后一公里」。
8.3 穿透·击穿·雪崩防护
三种失效模式的区分
这三个词常被混用,但成因完全不同,防护手段也完全不同:
| 模式 | 成因 | 典型表现 | 主要手段 |
|---|---|---|---|
| 穿透 | 查询根本不存在的数据,缓存永远不命中 | 恶意用不存在的 ID 刷接口,QPS 全落库 | 布隆过滤器、空值缓存、参数校验 |
| 击穿 | 单个热点键过期瞬间,大量并发同时回源 | 某商品页刷新后数据库出现尖刺 | 单飞、逻辑过期、互斥锁 |
| 雪崩 | 大批键同时过期或 Redis 整体故障 | 数据库瞬间被打满,请求超时连锁 | TTL 抖动、多级缓存、熔断降级 |
一句口诀:穿透是「查不存在的」,击穿是「一个热点过期」,雪崩是「一起过期或全挂」。
穿透:布隆过滤器与空值缓存
穿透的典型攻击面是自增 ID:攻击者用 id=99999999 循环请求,缓存里没有、数据库里也没有,每次都要查库。
第一道防线是布隆过滤器——它用极小空间回答「这个 ID 一定不存在」或「可能存在」:
// 用 RedisBloom 模块(生产推荐)
async function productMayExist(id: number): Promise<boolean> {
const r = (await redis.call(
"BF.EXISTS",
"app:bloom:product",
String(id),
)) as number;
return r === 1; // 1 = 可能存在;0 = 一定不存在
}
// 写入商品时同步加入过滤器
await redis.call("BF.ADD", "app:bloom:product", String(newId));
布隆过滤器的关键性质是**「不存在」判断绝对可靠,「存在」判断有假阳性**(约 1%)。所以它只能用来拦截,不能用来确认:返回 0 直接拒绝,返回 1 继续走正常读路径。假阳性只是多查一次库,不影响正确性。
第二道防线是空值缓存——把「查不到」也缓存起来,但要设一个很短的 TTL,并且用哨兵值而不是 null(因为 Redis 里 null 与「键不存在」无法区分):
const NULL_SENTINEL = "__NULL__";
async function getProductSafe(id: number): Promise<Product | null> {
const raw = await redis.get(keys.product.detail(id));
if (raw === NULL_SENTINEL) return null; // 命中空值缓存
if (raw !== null) return productCodec.decode(raw);
const p = await db.product.findUnique({ where: { id } });
if (p === null) {
// 空值缓存 60 秒,挡住重复穿透
await redis.set(keys.product.detail(id), NULL_SENTINEL, "EX", 60);
return null;
}
await productCache.set(p, { ttl: 300 }, id);
return p;
}
空值缓存的两个坑:TTL 不能太长(否则数据补上了还读不到),哨兵值不能与真实数据混淆(__NULL__ 必须是业务不可能产生的值)。若担心哨兵污染,可以把「空值」与「实体」放进不同键前缀。
延伸阅读:布隆过滤器与短链系统 里有位数组与哈希函数的实现细节。
击穿:单飞、逻辑过期与互斥锁
击穿的本质是并发回源。三层防护从轻到重:
第一层:进程内单飞(single-flight)。同一个键在同一进程内只允许一个请求回源,其余请求复用这个 Promise:
const inflight = new Map<string, Promise<unknown>>();
export function singleFlight<T>(key: string, fn: () => Promise<T>): Promise<T> {
const existing = inflight.get(key);
if (existing) return existing as Promise<T>;
const p = fn().finally(() => inflight.delete(key));
inflight.set(key, p);
return p;
}
注意两点:finally 里删除是为了让下一次未命中能重新回源;inflight 必须以键为粒度,否则会把不相关的请求也串在一起。这个模式在 Go 里叫 singleflight,思路完全一致(可参考 singleflight 与缓存击穿
)。
单飞只解决单进程内的并发。多实例部署下,10 个实例仍可能有 10 次回源,因此需要第二层。
第二层:分布式互斥锁。只有一个实例拿到锁去回源,其余短暂等待:
async function loadWithLock<T>(
key: string,
loader: () => Promise<T>,
codec: Codec<T>,
): Promise<T> {
const lockKey = `app:lock:${key}`;
const token = crypto.randomUUID();
const acquired = await redis.set(lockKey, token, "PX", 5000, "NX");
if (!acquired) {
// 没拿到锁:短暂等待后重试读缓存,避免全部涌向数据库
await new Promise((r) => setTimeout(r, 50));
const raw = await redis.get(key);
if (raw !== null) return codec.decode(raw);
return loader(); // 兜底:等待超时后仍回源,避免请求全部失败
}
try {
const value = await loader();
await redis.set(key, codec.encode(value), "EX", 300);
return value;
} finally {
// 用 Lua 保证「只删自己加的锁」,避免误删别人的锁
await redis.eval(
`if redis.call("get", KEYS[1]) == ARGV[1] then
return redis.call("del", KEYS[1])
else return 0 end`,
1,
lockKey,
token,
);
}
}
锁必须设 PX 过期时间,否则持锁进程崩溃会导致死锁。这里用 crypto.randomUUID() 当 token 并在 finally 里比对后删除,是分布式锁的标准写法(更多变体见 Redis 分布式锁
)。
第三层:逻辑过期。让键永不过期,而是在值里带一个「逻辑过期时间」;发现逻辑过期后,立即返回旧值并异步刷新:
interface Envelope<T> {
data: T;
logicalExpireAt: number; // epoch ms
}
async function getWithLogicalExpiry<T>(
key: string,
loader: () => Promise<T>,
ttlMs: number,
): Promise<T> {
const raw = await redis.get(key);
if (raw === null) {
const value = await loader();
await redis.set(key, JSON.stringify({ data: value, logicalExpireAt: Date.now() + ttlMs }));
return value;
}
const env = JSON.parse(raw) as Envelope<T>;
if (env.logicalExpireAt > Date.now()) return env.data;
// 逻辑已过期:返回旧值,后台异步刷新(只有拿到锁的那个请求去刷)
void refreshAsync(key, loader, ttlMs);
return env.data;
}
逻辑过期的代价是读到短暂旧值,换来的是请求永不阻塞。它非常适合首页榜单、推荐位这类「旧一点没关系、慢了不行」的场景。
雪崩:TTL 抖动、多级缓存与熔断
雪崩有两个诱因,要分开治。
诱因一:大批键同时过期。通常是「批量预热时统一写了 300 秒 TTL」造成的。解法是给 TTL 加随机抖动,上一节的 ttlWithJitter 就是干这个的:
export function jitteredTtl(baseSec: number, ratio = 0.1): number {
const delta = baseSec * ratio;
return Math.max(1, Math.floor(baseSec - delta + Math.random() * delta * 2));
}
// 100 个键写 300 秒 TTL,实际落在 270~330 秒之间,峰值被打散
抖动比例建议 10%~20%:太小起不到打散作用,太大会让部分键过早过期、拉低命中率。
诱因二:Redis 整体不可用。这时请求会全部直落数据库。防护有两层:多级缓存与熔断降级。
多级缓存就是在 L2 进程内留一份短 TTL 副本,Redis 挂掉时进程内缓存还能顶一段时间:
const local = new LRUCache<string, unknown>({ max: 1000, ttl: 10_000 });
async function readThrough<T>(key: string, loader: () => Promise<T>): Promise<T> {
const hit = local.get(key) as T | undefined;
if (hit !== undefined) return hit;
try {
const raw = await redis.get(key);
if (raw !== null) {
const value = JSON.parse(raw) as T;
local.set(key, value);
return value;
}
} catch (err) {
// Redis 故障:降级为直接回源,但要限制并发(见下)
logWarn("redis unavailable, fallback to db", { key, err });
}
const value = await loader();
local.set(key, value);
return value;
}
熔断降级是更彻底的兜底:当回源失败率或延迟超过阈值时,直接返回兜底值(缓存旧值、默认值或友好错误),而不是继续压数据库。Node 侧可以用 opossum 之类的库,或按 熔断与限流模式
的思路手写状态机。降级期间必须打点告警,否则会「静默降级」很久没人发现——这正是 17.2 指标与告警
要覆盖的内容。
组合成一条抗压读路径
把上面的手段按顺序串起来,就是一条完整的读路径:
| 顺序 | 检查 | 命中则 | 未命中则 |
|---|---|---|---|
| 1 | 参数校验 / 布隆过滤器 | 直接拒绝 | 继续 |
| 2 | 进程内缓存 L2 | 返回 | 继续 |
| 3 | Redis L3(含空值哨兵) | 返回 | 继续 |
| 4 | 单飞 + 分布式锁 | — | 只放一个请求回源 |
| 5 | 回源数据库 | 写回 L3(抖动 TTL)与 L2 | 失败则熔断降级 |
代码骨架大致如下:
export async function readProduct(id: number): Promise<Product | null> {
if (!Number.isInteger(id) || id <= 0) throw new BadRequestError("invalid id");
if (!(await productMayExist(id))) return null; // 布隆拦截
const key = keys.product.detail(id);
return singleFlight(key, async () => {
const raw = await redis.get(key);
if (raw === NULL_SENTINEL) return null;
if (raw !== null) return productCodec.decode(raw);
const p = await db.product.findUnique({ where: { id } });
const ttl = jitteredTtl(300);
await redis.set(
key,
p === null ? NULL_SENTINEL : productCodec.encode(p),
"EX",
p === null ? 60 : ttl,
);
return p;
});
}
注意这里 singleFlight 包住了整个「读缓存 + 回源 + 写回」过程,而不只是回源那一步——否则多个请求仍会同时读到空缓存。
常见坑
| 现象 | 根因 | 修法 |
|---|---|---|
| 锁永不过期,后续请求全阻塞 | 忘了设 PX 或进程崩溃 | 锁必须带过期时间,且用 token 比对删除 |
| 误删了别人的锁 | 直接 DEL 不校验持有者 | 用 Lua 做 compare-and-delete |
| 空值缓存导致新数据读不到 | 哨兵 TTL 太长 | 空值 TTL 控制在 30~60 秒 |
| 抖动后部分键过早过期 | 抖动比例设得太大 | 控制在 10%~20% |
| 降级后一直没恢复 | 熔断没有半开探测 | 加半开状态与恢复告警 |
| 单飞把不相关请求串起来 | 单飞粒度用了全局键 | 以缓存键为粒度 |
排查线上这类问题时,Redis 侧的延迟分布是关键证据,可参考 Redis 延迟排查 ;而系统性治理思路可对照 分布式缓存故障治理 与 热键与大键 。
与其他机制的衔接
缓存防护不是孤立的。回源失败后的重试要幂等,否则会重复写入——这一点与 9.2 重试、幂等与死信 是同一套思路;限流与熔断常常配合使用,可参考 限流器实现 ;服务关闭时要确保在途的锁与单飞 Promise 正确收敛,这与 5.3 优雅关闭与健康检查 相关。
小结
本节把缓存防护拆成三种模式、六种手段:
- 穿透(查不存在的):布隆过滤器拦截 + 空值哨兵缓存;
- 击穿(热点键过期):进程内单飞 + 分布式锁 + 逻辑过期;
- 雪崩(一起过期或全挂):TTL 抖动 + 多级缓存 + 熔断降级。
三条铁律:锁必须带过期时间且只删自己的;空值缓存 TTL 要短、哨兵值要不可能冲突;降级必须打点告警,不能静默。
到这里,第 8 章的「缓存与性能」三节就完整了:8.1 定层次与键,8.2 定类型边界,8.3 定抗压能力。下一章进入 9.1 BullMQ 队列模型与 payload 泛型 ,把「同步读不到就同步回源」的思路换成异步任务——那才是削峰填谷的终极手段。
阅读导航:上一节:8.2 Redis 类型安全封装 · 下一节:9.1 BullMQ 队列模型与 payload 泛型 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。