去年的大促活动刚拉开序幕,我们的核心商品微服务就遭遇了毁灭性打击。原因非常戏剧性:为了防止脏数据,开发同学在缓存预热时,把几十万热点商品详情的 Redis Key 全部设置了固定的 2 小时过期时间。结果活动进行到一半,大批热点 key 集中在同一秒集中过期失效。瞬间,百万级并发请求全部穿透 Redis,直接打到后端的 MySQL 数据库上,数据库 CPU 瞬间升天,连接池被死死占满,系统直接崩溃。

这就是经典的**缓存雪崩与击穿(Cache Stampede)**问题。在经典的 Cache-Aside(旁路缓存)策略下,如果某个超热点 Key 突然失效,此时有成千上万个并发线程发现缓存未命中,它们会同时去查 DB 并回写缓存。这种无脑的重入对底层关系型数据库是极其致命的。

为了防止这类大坑再次发生,我们对系统做了一次彻底的防击穿重构。核心策略有两个: 第一,随机化过期时间(Jitter)。设置缓存 TTL 时,在基础时间上随机加减几分钟,防止 Key 集中过期导致雪崩。 第二,使用互斥锁或并发合并(Promise Coalescing/SingleFlight)。当缓存失效时,只允许一个请求穿透到 DB,其他相同的并发请求在内存中排队等待这一个请求返回结果,并共享它写回的数据。

在 Node.js 服务中,我们可以实现一个非常轻量级的请求合并容器(类似于 Go 的 singleflight),配合 Redis 进行双重校验。

来看我们生产环境落地的并发合并获取缓存与 DB 的完整实现:

import { Redis } from 'ioredis';

const redis = new Redis();

// 内存中维护的并发请求合并 Map
const pendingRequests = new Map<string, Promise<any>>();

/**
 * 随机生成带抖动的 TTL 避免雪崩
 * @param baseTtlSecs 基础过期时间(秒)
 * @returns 最终过期时间(秒)
 */
function getRandomTtl(baseTtlSecs: number): number {
  const min = Math.floor(baseTtlSecs * 0.9);
  const max = Math.floor(baseTtlSecs * 1.1);
  return Math.floor(Math.random() * (max - min + 1)) + min;
}

/**
 * 从缓存或 DB 安全读取商品详情(防击穿与雪崩版)
 */
export async function getProductDetailSafe(productId: string): Promise<any> {
  const cacheKey = `product:detail:${productId}`;

  // 1. 尝试从 Redis 读缓存
  const cachedData = await redis.get(cacheKey);
  if (cachedData) {
    return JSON.parse(cachedData);
  }

  // 2. 缓存未命中,进入防击穿合并逻辑
  // 如果当前已有针对此商品 ID 的 DB 查询在处理中,直接复用它的 Promise
  if (pendingRequests.has(cacheKey)) {
    console.log(`[SingleFlight] 合并并发请求,复用 DB 查询: ${cacheKey}`);
    return pendingRequests.get(cacheKey);
  }

  // 3. 创建一个新的 DB 查询 Promise
  const fetchDbPromise = (async () => {
    try {
      // 模拟查询底层 MySQL / PostgreSQL 数据库
      console.log(`[Database] 真实穿透至 DB 查询数据: ${cacheKey}`);
      const product = await queryProductFromDatabase(productId); 
      
      if (!product) {
        // 防止缓存穿透:即便 DB 没有,也缓存一个空对象并设置极短过期时间
        await redis.set(cacheKey, JSON.stringify({}), 'EX', 60);
        return null;
      }

      // 缓存数据,设置随机过期时间(例如基础时间 2 小时,即 7200 秒)
      const finalTtl = getRandomTtl(7200);
      await redis.set(cacheKey, JSON.stringify(product), 'EX', finalTtl);
      
      return product;
    } finally {
      // 无论成功还是失败,执行完毕后必须从 Map 中移除,释放内存
      pendingRequests.delete(cacheKey);
    }
  })();

  // 4. 将 Promise 存入 Map 供后续并发请求共享
  pendingRequests.set(cacheKey, fetchDbPromise);

  return fetchDbPromise;
}

// 模拟数据库查询
async function queryProductFromDatabase(productId: string): Promise<any> {
  return new Promise((resolve) => {
    setTimeout(() => {
      resolve({ id: productId, name: 'iPhone 15 Pro', price: 7999 });
    }, 150); // 模拟 DB 150ms 延迟
  });
}

自打引入了这个 Promise 合并机制后,我们做压测时发现,哪怕在上万并发请求瞬间冲向同一个过期的商品 Key,后端的 MySQL 监控面板里也永远只会出现一条精确的主键查询语句,其余请求在毫秒内全部拿到合并且被 Redis 缓存住的数据,直接搞定了缓存击穿这个老大难问题。

顺便提一句,对于那种恶意攻击拉满的完全不存在的 Key,前置的布隆过滤器(Bloom Filter)也是极其必要的。否则光靠写空对象到缓存,Redis 的内存占用很快就会被垃圾 Key 挤爆。

你们在面临热点 Key 时,是采用我们这种 SingleFlight / 互斥锁拦截的方式,还是简单粗暴地由后台定时任务进行热点数据“永不过期”的强刷更新?