Skip to content
返回质量保障

缓存策略

在正确层(CDN、应用、DB)用正确 TTL 和失效缓存,避免陈旧数据和缓存风暴。

为什么

缓存是单最有效性能优化——缓存命中 1ms 返回数据库查询 100ms 返回的,100 倍改善。但缓存也是一些最难 bug 源——陈旧数据(更新后缓存显旧价)、缓存风暴(缓存过期 1000 请求同时打数据库)、不一致(写缓存和读缓存不一致)。缓存策略非"加 Redis 祈祷";是刻意选缓存什么、哪层、什么 TTL、如何失效。做对系统快且正确;做错快且错,比慢且正确更糟。

何时使用

查询或计算慢(>50ms)且数据不常变(分钟级非秒级)时加缓存。从静态资源 CDN 开始,然后半静态响应 HTTP 缓存,然后贵计算应用缓存。

如何做

1. 为每数据类型选缓存层
   - CDN(Cloudflare、Fastly): 静态资源(图、CSS、JS),哈希文件名不可变
   - CDN: 半静态 HTML(商品页)短 TTL(60s)+ stale-while-revalidate
   - 应用缓存(Redis): 贵查询结果(商品目录、推荐)
   - 数据库缓存: 查询计划缓存(内置),聚合用物化视图
   - 内存(LRU): 每实例热数据(用户会话、功能标志)

2. 按数据可陈旧度设 TTL
   - 静态资源: 1 年(不可变,哈希文件名 bust)
   - 商品目录: 5 分钟(不常变,5 分钟陈旧可接受)
   - 用户档案: 60 秒(更常变但 60 秒陈旧显示可)
   - 实时数据(股价、库存数): 不缓存或仅缓存 1 秒

3. 写时失效缓存
   - 写穿: 数据库写后立即更新缓存
   - 事件: 写时发消息,缓存订阅者失效键
   - 标签: 带标签缓存(如 "product:42"),相关写时按标签失效
   - 版本: 缓存键含版本,schema 变时 bump 版本

4. 防缓存风暴(惊群)
   - 键过期时 1000 请求同时 miss 打 DB
   - 用锁: 首 miss 获锁、从 DB 取、写缓存、释放
   - 其他请求服务陈旧值(stale-while-revalidate)直到新鲜就绪
   - 或用请求合并: 同键所有并发 miss 共享一次 DB 取

5. 监控缓存命中率和陈旧度
   - 命中率 < 90% 意味 TTL 太短或键空间太大
   - 追踪陈旧读(用户见旧数据)——若频繁失效太慢
   - 缓存故障告警(Redis 挂)——系统应降级到 DB 非崩

示例

// Redis 缓存带 stale-while-revalidate 和风暴保护
import Redis from "ioredis";
const redis = new Redis();

async function cached<T>(
  key: string,
  ttl: number,           // 新鲜 TTL 秒
  staleTtl: number,      // 陈旧 TTL(重验时服务陈旧)
  fetch: () => Promise<T>,
): Promise<T> {
  const raw = await redis.get(key);
  if (raw) {
    const { value, expiresAt } = JSON.parse(raw);
    if (Date.now() < expiresAt) return value; // 新鲜命中
    // 陈旧——服务它但后台重验(stale-while-revalidate)
    if (await redis.setnx(`${key}:lock`, "1")) {
      redis.expire(`${key}:lock`, 5); // 锁 5 秒防风暴
      fetch().then((fresh) => {
        redis.set(key, JSON.stringify({ value: fresh, expiresAt: Date.now() + ttl * 1000 }), "EX", ttl + staleTtl);
        redis.del(`${key}:lock`);
      });
    }
    return value; // 重验时服务陈旧
  }
  // 冷 miss——取并缓存
  const fresh = await fetch();
  await redis.set(key, JSON.stringify({ value: fresh, expiresAt: Date.now() + ttl * 1000 }), "EX", ttl + staleTtl);
  return fresh;
}

// 用法: 商品目录缓存 5 分钟,陈旧再 1 分钟
const products = await cached("catalog:electronics", 300, 60, () =>
  db.query("SELECT * FROM products WHERE category = 'electronics'"),
);

// 商品更新时失效
async function updateProduct(id: string, data: Partial<Product>) {
  await db.query("UPDATE products SET ... WHERE id = $1", [id]);
  await redis.del("catalog:electronics"); // 失效缓存的目录
}

常见问题

  • ⚠无失效策略缓存——管理员更新后缓存服务旧价,客户见错价,bug 间歇因依赖缓存 TTL。
  • ⚠所有缓存数据设单一长 TTL——商品目录(周变)和库存数(时变)都缓存 10 分钟,故库存总陈旧。
  • ⚠TTL 过期时缓存踩踏——1000 并发请求全 miss 缓存,全同时打数据库,数据库在突增负载下崩。
  • ⚠用共享键缓存用户特定数据——用户 A 档案缓存在 "profile",用户 B 请求打同缓存见 A 数据,隐私 breach。

最佳实践

  • ✓用户特定数据缓存键含用户 ID 或会话 ID——"profile:42" 非 "profile",使不同用户永不共享缓存条目。
  • ✓可容忍短暂陈旧的数据用 stale-while-revalidate——缓存即时服务陈旧值后台重验,消除延迟和缓存风暴。
  • ✓写时用标签或事件失效——商品更新时失效 "catalog" 标签非仅一键,使所有派生缓存一致。
  • ✓监控缓存命中率低于 90% 告警——低命中意味缓存无效(错 TTL、错键空间)数据库扛全负载。