「加个缓存吧」是性能优化的万能答案,也是线上事故的常见来源。这篇把模式讲清楚,再把坑标出来。

五种读写模式

1. Cache-Aside(旁路缓存)—— 最常用

读:先查缓存,没有就查数据库并回写。写:先更新数据库,再删除缓存。

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

  const user = await db.query('SELECT * FROM users WHERE id = ?', [id]);
  if (user) await redis.set(`user:${id}`, JSON.stringify(user), 'EX', 300);
  return user;
}

async function updateUser(id, data) {
  await db.query('UPDATE users SET name = ? WHERE id = ?', [data.name, id]);
  await redis.del(`user:${id}`);     // 删除而不是更新
}

为什么是「删除」而不是「更新」? 因为两个并发写请求更新缓存的顺序可能和数据库提交顺序不一致,删除则天然幂等,下次读会拿到最新值。

2. Read-Through

应用只跟缓存打交道,缓存自己负责回源数据库。很多框架的二级缓存就是这个模式,代码更干净,但可观测性差一些。

3. Write-Through

写的时候同时写缓存和数据库,缓存层保证两者都成功。适合读多写少、对一致性要求高的场景。

4. Write-Behind(回写)

写只落缓存,异步批量刷到数据库。性能最好,风险最高——缓存宕机会丢数据。适合点赞数、播放量这类可容忍少量丢失的计数场景。

5. Refresh-Ahead(预刷新)

缓存快过期时后台主动刷新,让请求永远命中新鲜数据。适合热点数据,代价是多消耗一点计算资源。

三种典型故障

故障一:缓存穿透(查不存在的数据)

攻击者用一个不存在的 ID 高频查询,缓存永远不命中,请求全部打到数据库。

方案 A:缓存空值

if (!user) {
  await redis.set(key, '', 'EX', 60);   // 短 TTL 的空值
  return null;
}

方案 B:布隆过滤器

const exists = await bloom.mightContain(id);   // 内存级判断,O(1)
if (!exists) return null;                       // 一定不存在,直接返回

方案 C:参数校验前置。ID 必须是正整数,长度超限直接拒绝——能挡掉一大半爬虫。

故障二:缓存雪崩(大量 key 同时失效)

所有 key 都设了 300 秒 TTL,整点一到集体失效,数据库瞬间被打满。

// ❌ 固定 TTL
await redis.set(key, value, 'EX', 300);

// ✅ 加随机抖动
const ttl = 300 + Math.floor(Math.random() * 120);
await redis.set(key, value, 'EX', ttl);

再加一层互斥重建,保证同一个 key 只有一个请求去回源:

async function getWithLock(key, loader) {
  const cached = await redis.get(key);
  if (cached) return JSON.parse(cached);

  const lock = await redis.set(`${key}:lock`, '1', 'EX', 10, 'NX');
  if (!lock) {
    await sleep(50);
    return getWithLock(key, loader);     // 稍后重试,读别人写好的缓存
  }
  try {
    const value = await loader();
    await redis.set(key, JSON.stringify(value), 'EX', 300);
    return value;
  } finally {
    await redis.del(`${key}:lock`);
  }
}

故障三:缓存一致性丢失

经典并发场景:

T1 写请求:更新数据库 → (还没删缓存)
T2 读请求:读缓存(旧值)→ 写回缓存(旧值)
T1 写请求:删除缓存
结果:缓存里留下了旧值,且要等到 TTL 过期才恢复

缓解手段:

  1. 延迟双删:删除缓存 → 更新数据库 → 延迟 500ms 再删一次
  2. 订阅 binlog 异步失效(Canal 那套),把缓存失效和数据库提交解耦
  3. 给缓存加上版本号,写入时带版本,读到旧版本丢弃

但最重要的一句是:如果你的业务不能容忍短暂不一致,就不要把强一致的读放到缓存上。

三个容易被忽略的实践

1. 缓存 key 一定要有命名空间和版本

const KEY = (id) => `v2:user:${id}`;

改了数据结构只改 v2 → v3,不用去线上删一堆 key。

2. 大 key 是运维灾难

一个 key 存了几 MB 的 JSON,网络传输、序列化、淘汰都变慢,还会阻塞单线程的 Redis。

// ❌ 一次缓存整个列表
await redis.set('post:all', JSON.stringify(allPosts));

// ✅ 按需缓存单个对象 + 分页 key
await redis.set(`post:${id}`, JSON.stringify(post), 'EX', 600);
await redis.set(`post:list:page:${page}`, JSON.stringify(ids), 'EX', 60);

3. 监控命中率,而不只是看响应时间

// 每次访问都记录命中/未命中
metrics.counter('cache_access', { hit: cached ? 'yes' : 'no' });

命中率低于 70% 就要看看是不是 key 设计有问题,或者 TTL 太短导致缓存根本没用上。

一张决策表

场景 建议
用户信息、文章详情 Cache-Aside + 300s TTL + 随机抖动
首页列表 Cache-Aside + 60s TTL,允许短暂陈旧
点赞/播放计数 Write-Behind,接受少量丢失
配置、字典表 Refresh-Ahead,几乎不过期
强一致要求的余额 不要缓存,直接查数据库

小结

缓存本质上是用一致性换性能。所以先回答一个问题:这部分数据,旧 5 秒能接受吗?

  • 能接受 → 放心加缓存,配合 TTL 抖动 + 互斥重建
  • 不能接受 → 不要缓存,或者只缓存「不会变」的部分

把这个问题想清楚,上面的模式和故障处理都是自然推论。