《TypeScript编程实战》8.3 穿透·击穿·雪崩防护

本节系统拆解缓存的三种失效模式并给出可直接落地的 TypeScript 代码。先对比穿透、击穿、雪崩的成因与线上表现,再分别用布隆过滤器与空值缓存挡穿透、用进程内单飞配合分布式锁与逻辑过期挡击穿、用 TTL 抖动、多级缓存与熔断降级挡雪崩,最后把三者组合成一条抗压读路径,并给出锁超时、哨兵值污染、抖动区间设置等常见坑的排查方法。

本节目标:让上一节的 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返回继续
3Redis 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 泛型 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「typescript」更多文章

  1. 《TypeScript高级编程》11.3 类型驱动架构与团队规范
  2. 《TypeScript高级编程》11.2 渐进式迁移与严格化路径
  3. 《TypeScript高级编程》11.1 TS 版本演进与 breaking changes