Redis 缓存实战:从缓存模式到三大经典问题

缓存是后端性能优化里性价比最高的手段之一,但也是线上事故的高发区。缓存穿透、击穿、雪崩这三大经典问题,几乎每个团队都踩过。本文从最常用的缓存读写模式讲起,逐一拆解这三大问题的成因与可落地的解决方案,最后给出一套生产环境的缓存设计清单。

一、两种基础缓存模式

Cache-Aside(旁路缓存)

这是使用最广泛的模式:读请求先查缓存,未命中则查数据库并回填缓存;写请求先更新数据库,再删除缓存。

typescript
import Redis from "ioredis";

const redis = new Redis();

async function getUser(id: string) {
  const key = `user:${id}`;
  const cached = await redis.get(key);
  if (cached) return JSON.parse(cached);

  const user = await db.user.findUnique({ where: { id } });
  if (user) {
    // 设置过期时间,避免脏数据永久残留
    await redis.set(key, JSON.stringify(user), "EX", 300);
  }
  return user;
}

async function updateUser(id: string, data: Partial<User>) {
  // 先更新数据库,再删除缓存(而不是更新缓存)
  await db.user.update({ where: { id }, data });
  await redis.del(`user:${id}`);
}

为什么写操作是「删除缓存」而不是「更新缓存」?两个原因:一是更新缓存在并发写场景下容易出现两个写请求交叉执行,导致缓存里留下旧值;二是如果缓存值需要复杂计算(如联表聚合),写时计算可能白白浪费——删除策略把计算推迟到真正有人读的时候(lazy loading)。

至于「先更新数据库还是先删缓存」的经典争论:先删缓存再更新数据库,在「删缓存成功但数据库更新慢」的窗口期,并发读请求可能读到旧值并回填旧缓存。先更新数据库再删缓存只存在极短的不一致窗口(缓存旧值 vs 数据库新值),且缓存本身有过期时间兜底,是工程上更稳妥的选择。要求更高的场景可以再加「延迟双删」:更新数据库后隔几百毫秒再删一次缓存。

Read/Write-Through(读写穿透)

应用只和缓存层打交道,由缓存层自己负责与数据库同步。读时缓存未命中由缓存层回源数据库;写时同步写入缓存和数据库。这个模式对应用代码更透明,但要求缓存组件支持回源逻辑,在 Redis 生态中通常需要借助 RedisGears 或自研中间层,国内实践中远不如 Cache-Aside 普及,本文后续讨论都以 Cache-Aside 为背景。

二、缓存穿透:查询不存在的数据

成因:恶意攻击或业务缺陷导致大量请求查询数据库中根本不存在的数据(如负数 ID、随机 UUID),缓存永远不会命中,压力全部打到数据库。

方案一:缓存空值

数据库查不到也往缓存里写一个空标记,设置较短的过期时间:

typescript
async function getUserSafe(id: string) {
  const key = `user:${id}`;
  const cached = await redis.get(key);
  if (cached !== null) {
    // 空值标记,直接返回 null,不再查库
    return cached === "NULL" ? null : JSON.parse(cached);
  }

  const user = await db.user.findUnique({ where: { id } });
  if (user) {
    await redis.set(key, JSON.stringify(user), "EX", 300);
  } else {
    // 空值缓存 60 秒,挡住针对不存在 ID 的重复请求
    await redis.set(key, "NULL", "EX", 60);
  }
  return user;
}

优点是实现简单;缺点是会占用额外内存,且在「数据后来真的被创建」的场景下存在短暂不一致(可用短 TTL 缓解,或在创建数据时主动删除空值缓存)。

方案二:布隆过滤器

在缓存之前加一层布隆过滤器,把所有合法 Key 的指纹存进去。查询先过布隆过滤器:过滤器说「不存在」就一定不存在,直接返回;说「可能存在」才走缓存和数据库。

typescript
// 使用 RedisBloom 模块
async function initBloom() {
  const ids = await db.user.findMany({ select: { id: true } });
  for (const { id } of ids) {
    await redis.call("BF.ADD", "user_bloom", id);
  }
}

async function getUserWithBloom(id: string) {
  const exists = await redis.call("BF.EXISTS", "user_bloom", id);
  if (!exists) return null; // 确定不存在,直接拦截
  return getUserSafe(id);
}

布隆过滤器空间效率极高(千万级 Key 只需十几 MB),但要注意两点:它有一定的误判率(会把不存在的 Key 误判为存在,但不会漏判);且默认不支持删除,数据删除频繁的场景需要用布谷鸟过滤器或定期重建。

三、缓存击穿:热点 Key 过期瞬间被打爆

成因:某个热点 Key(如秒杀商品、热搜榜单)在过期的一瞬间,成千上万个并发请求同时未命中,全部涌向数据库回源。

方案:互斥锁(Mutex)

只允许一个请求回源数据库并回填缓存,其余请求等待后读缓存:

typescript
async function getHotProduct(id: string) {
  const key = `product:${id}`;
  const cached = await redis.get(key);
  if (cached) return JSON.parse(cached);

  const lockKey = `lock:${key}`;
  // SET NX PX:原子地尝试获取锁,10 秒自动释放防死锁
  const locked = await redis.set(lockKey, "1", "PX", 10000, "NX");

  if (locked) {
    try {
      const product = await db.product.findUnique({ where: { id } });
      if (product) {
        await redis.set(key, JSON.stringify(product), "EX", 600);
      }
      return product;
    } finally {
      await redis.del(lockKey);
    }
  } else {
    // 没抢到锁:短暂等待后重试,此时缓存通常已被回填
    await new Promise((r) => setTimeout(r, 50));
    return getHotProduct(id);
  }
}

对于读多写极少的超热点数据,还有两种更激进的思路:逻辑过期——缓存不设置物理 TTL,把过期时间存在 value 里,发现逻辑过期后由一个线程异步重建,其余请求先返回旧数据,保证永不回源风暴;永不过期 + 主动更新——后台任务定时刷新热点 Key,彻底绕开过期这个触发点。代价是实现复杂度和数据时效性的牺牲,只适合确认的热点场景。

四、缓存雪崩:大量 Key 同时失效

成因:两种典型情况。一是大量 Key 设置了相同的过期时间,在同一时刻集体失效;二是 Redis 实例宕机,整个缓存层消失。

针对集体过期:TTL 加随机抖动

最简单也最有效的手段——给每个 Key 的过期时间加一个随机偏移,把过期时刻打散:

typescript
function randomTtl(base: number, jitter = 60) {
  // 基础 300 秒 + 0~60 秒随机抖动
  return base + Math.floor(Math.random() * jitter);
}

await redis.set(key, value, "EX", randomTtl(300));

如果是定时批量预热缓存的场景(如每天凌晨刷新榜单),还要避免「预热完成后所有 Key 一起开始倒计时」的隐患,预热时就该使用带抖动的 TTL。

针对缓存宕机:高可用与降级

五、生产环境缓存设计清单

把上面的经验浓缩成一份上线前的检查清单:

总结

缓存设计的核心矛盾,是在「性能」与「一致性、可用性」之间做取舍。Cache-Aside 模式配合「先更库后删缓存」是绝大多数业务的正确起点;穿透用空值缓存或布隆过滤器挡住不存在的查询;击穿用互斥锁或逻辑过期保护热点 Key;雪崩用 TTL 抖动防集体失效,用高可用与降级防缓存宕机。没有银弹,关键是理解每种方案牺牲了什么,再结合自己业务的读写比例、数据时效性要求做选择。