想象你每天必喝的咖啡馆,店员发现你固定点冰美式后,提前把配方贴在收银台背后。这本是为提升效率的好意,但如果遇到三种情况就会翻车:

  1. 有人非要喝不存在的「珍珠奶茶」(店员每次都查配方本却发现没有)
  2. 今天唯一会做冰美式的店员请假(其他店员挤在操作台前不知所措)
  3. 所有咖啡配方表同时被风吹走(整个店铺陷入混乱)

这就是缓存穿透、击穿和雪崩的生动写照。我们来看看Node.js中如何用代码应对这些场景。


1. 缓存穿透:如何应对「找不存在」的请求

1.1 现象诊断

当查询数据库中不存在的数据时,每次请求都穿透缓存直达数据库,就像不断问店员要不存在商品的顾客。

1.2 解决方案示例

(Node.js + Redis)

方案一:布隆过滤器把关

// 初始化布隆过滤器
const { BloomFilter } = require('bloom-filters');
const filter = BloomFilter.create(1000000, 0.01); // 百万数据量,1%误判率

// 预热阶段加载有效ID
const validIds = await db.query('SELECT id FROM products');
validIds.forEach(id => filter.add(id));

// 查询拦截
app.get('/product/:id', async (req, res) => {
  const id = req.params.id;
  
  // 优先检查布隆过滤器
  if (!filter.has(id)) {
    return res.status(404).json({ error: '商品不存在' });
  }

  // 后续查询流程...
});

方案二:缓存空对象

const redis = require('ioredis');
const client = new redis();

app.get('/product/:id', async (req, res) => {
  const id = req.params.id;
  let data = await client.get(`product:${id}`);

  // 缓存命中特殊空值
  if (data === 'NULL') return res.status(404).send();
  if (data) return res.json(JSON.parse(data));

  // 数据库查询
  const product = await db.query('SELECT * FROM products WHERE id = ?', [id]);
  
  if (!product) {
    // 缓存空值5分钟
    await client.setex(`product:${id}`, 300, 'NULL');
    return res.status(404).send();
  }

  // 正常缓存数据
  await client.setex(`product:${id}`, 3600, JSON.stringify(product));
  res.json(product);
});

2. 缓存击穿:热点数据失效的连锁反应

2.1 危机时刻

就像网红奶茶店突然排起长队,而此时收银系统故障,所有顾客都要手动登记订单。

2.2 互斥锁实现

(Node.js + Redis)

const acquireLock = async (key, ttl = 10) => {
  const result = await client.set(key, 'LOCK', 'EX', ttl, 'NX');
  return result === 'OK';
};

app.get('/hot-product/:id', async (req, res) => {
  const cacheKey = `product:${req.params.id}`;
  let data = await client.get(cacheKey);

  if (data) return res.json(JSON.parse(data));

  // 尝试获取分布式锁
  const lockKey = `${cacheKey}:lock`;
  const isLocked = await acquireLock(lockKey);
  
  if (!isLocked) {
    // 未获得锁时的降级策略
    await new Promise(resolve => setTimeout(resolve, 50));
    return this.get('/hot-product/' + req.params.id)(req, res);
  }

  try {
    // 二次检查缓存(可能在等待锁期间数据已重建)
    data = await client.get(cacheKey);
    if (data) return res.json(JSON.parse(data));

    // 数据库查询
    const product = await db.query('SELECT * FROM products WHERE id = ?', [id]);
    await client.setex(cacheKey, 3600, JSON.stringify(product));
    res.json(product);
  } finally {
    await client.del(lockKey);
  }
});

3. 缓存雪崩:当集体失效引发灾难

3.1 预防策略

为不同的缓存键设置随机过期时间,避免同时失效:

// 设置缓存时加入随机抖动
const setCacheWithJitter = async (key, value, baseTTL = 3600) => {
  const jitter = Math.floor(Math.random() * 600); // 0-10分钟随机值
  await client.setex(key, baseTTL + jitter, value);
};

// 缓存预热时使用
await setCacheWithJitter('product:1001', productData);

4. 混合战术:实战中的组合拳

4.1 多级缓存架构示例

// 第一层:内存缓存
const LRU = require('lru-cache');
const memoryCache = new LRU({ max: 500 });

// 第二层:Redis集群
const getProduct = async (id) => {
  // 优先读取内存缓存
  if (memoryCache.has(id)) return memoryCache.get(id);
  
  // 读取Redis缓存
  const redisData = await client.get(`product:${id}`);
  if (redisData) {
    memoryCache.set(id, redisData);
    return redisData;
  }

  // 数据库查询并回填
  const dbData = await fetchFromDB(id);
  setCacheWithJitter(`product:${id}`, dbData);
  memoryCache.set(id, dbData);
  return dbData;
};

5. 应用场景与选型指南

5.1 典型场景对比

场景特征 适用方案
频繁查询不存在ID 布隆过滤器 + 空值缓存
秒杀活动热点商品 互斥锁 + 逻辑过期时间
大促期间全站缓存刷新 多级缓存 + 随机过期时间

6. 经验总结:缓存管理四要诀

  1. 容量规划:Redis内存使用不超过70%,设置淘汰策略
  2. 监控报警:关注缓存命中率、穿透率等核心指标
  3. 降级预案:在缓存集群故障时快速切到本地缓存
  4. 版本管理:缓存键包含版本号以便灰度更新