Files
2026-07-13 21:36:47 +08:00

6.2 KiB
Raw Permalink Blame History

name, description
name description
caching 缓存策略——失效策略、TTL 指导原则、缓存键、缓存层级,以及何时不应使用缓存。适用于实现或审查缓存逻辑时使用。

适用场景

  • 在为实现 API 或服务添加缓存层(内存缓存、Redis、CDN)时。
  • 在为缓存数据选择 TTL 值或失效策略时。
  • 在设计缓存键模式以避免冲突或脏数据 bug 时。
  • 在审查从缓存读取或写入缓存的代码时。
  • 在调试脏数据、缓存雪崩或不一致响应时。
  • 在配置 TanStack Query 的 staleTime/gcTime 以进行客户端缓存时。

失效策略

  • [P0-MUST] 为每一个缓存定义失效策略。脏数据比没有缓存更糟糕。
  • [P0-MUST] 当底层数据发生变化时,必须使缓存失效——不要仅依赖 TTL 过期。
  • [P1-SHOULD] 优先选择事件驱动的失效策略(写入/更新/删除时触发),而非单纯依赖基于时间的过期。
  • [P1-SHOULD] 当数据模式发生变化时,使用缓存版本化(包含版本键)。

TTL 指导原则

  • [P1-SHOULD] 根据数据变化频率设置 TTL:静态配置(小时/天)、用户资料(分钟)、实时数据(秒级或不缓存)。
  • [P1-SHOULD] 使用陈旧时重新验证策略:立即返回陈旧数据,同时在后台刷新。
  • [P2-MAY] 在开发环境中使用较短的 TTL,在生产环境中使用较长的 TTL。

缓存键

  • [P0-MUST] 缓存键必须包含所有影响查询结果的条件参数。
  • [P1-SHOULD] 使用统一的键格式:<实体>:<ID>:<变体>(例如 user:123:profileproducts:list:page=2)。
  • [P1-SHOULD] 按服务或模块对键进行命名空间划分,防止冲突。
  • [P2-MAY] 对较长或较复杂的键进行哈希处理,以保持存储效率。

缓存层级

  • [P1-SHOULD] 根据使用场景选择合适的缓存层级:
层级 最适合的场景 TTL 范围
内存缓存(Map、LRU 热点数据、单实例应用 秒到分钟
Redis / Memcached 跨实例的共享缓存、会话 分钟到小时
CDN / 边缘节点 静态资源、公开 API 响应 小时到天
HTTP 缓存头 浏览器缓存、API 响应 因资源而异
  • [P1-SHOULD] 分层使用缓存:检查内存 → Redis → 源服务器。未命中时回填写入。

何时不应使用缓存

  • [P0-MUST] 不得在共享缓存中缓存用户特定的敏感数据(身份令牌、支付信息)。
  • [P1-SHOULD] 不应缓存变化频繁且过时数据会导致错误行为的数据(库存数量、实时定价)。
  • [P1-SHOULD] 不应缓存错误响应——使用较短的 TTL,或在失败时跳过缓存。
  • [P2-MAY] 当计算开销小且数据量不大时,应避免使用缓存。

代码示例

带 TTL 的内存 LRU 缓存

const cache = new Map<string, { value: unknown; expires: number }>();
const MAX_SIZE = 500;

export function getOrSet<T>(key: string, ttlMs: number, compute: () => T): T {
  const entry = cache.get(key);
  if (entry && entry.expires > Date.now()) return entry.value as T;

  const value = compute();
  if (cache.size >= MAX_SIZE) {
    // 淘汰最旧的条目(最先插入的)
    const oldest = cache.keys().next().value!;
    cache.delete(oldest);
  }
  cache.set(key, { value, expires: Date.now() + ttlMs });
  return value;
}

使用 ioredis 的陈旧时重新验证模式

import Redis from "ioredis";
const redis = new Redis(process.env.REDIS_URL);

export async function swr<T>(
  key: string,
  freshSec: number,
  staleSec: number,
  fetcher: () => Promise<T>,
): Promise<T> {
  const raw = await redis.get(key);
  if (raw) {
    const { value, createdAt } = JSON.parse(raw) as { value: T; createdAt: number };
    const ageMs = Date.now() - createdAt;
    if (ageMs < freshSec * 1000) return value; // 新鲜——直接返回
    if (ageMs < staleSec * 1000) {
      // 陈旧——返回缓存值,后台刷新
      fetcher().then((v) =>
        redis.set(key, JSON.stringify({ value: v, createdAt: Date.now() }), "EX", staleSec),
      );
      return value;
    }
  }
  const value = await fetcher();
  await redis.set(key, JSON.stringify({ value, createdAt: Date.now() }), "EX", staleSec);
  return value;
}

Express/Hono 中的 HTTP 缓存头

// 不可变资源(已哈希文件名)
app.use("/assets", (_, res, next) => {
  res.setHeader("Cache-Control", "public, max-age=31536000, immutable");
  next();
});

// API 响应——短缓存 + 重新验证
app.get("/api/products", (_, res) => {
  res.setHeader("Cache-Control", "public, max-age=60, stale-while-revalidate=300");
  res.json(products);
});

TanStack Query 缓存配置

import { QueryClient, QueryClientProvider } from "@tanstack/react-query";

const queryClient = new QueryClient({
  defaultOptions: {
    queries: {
      staleTime: 5 * 60 * 1000, // 数据在 5 分钟内视为新鲜
      gcTime: 30 * 60 * 1000,   // 30 分钟后进行垃圾回收
      retry: 2,
      refetchOnWindowFocus: false,
    },
  },
});

// 在组件中使用
const { data } = useQuery({
  queryKey: ["products", { page, category }], // 缓存键包含参数
  queryFn: () => fetchProducts({ page, category }),
});

反模式

  • 缓存后不闻不问——缓存数据但没有失效策略。数据永久变脏。

    • 替代做法:为每个缓存键定义显式的失效策略(写入时事件驱动,或设定有边界的 TTL)。
  • 统一 TTL——无论数据变化频率如何,对所有数据使用相同的 TTL(例如 1 小时)。

    • 替代做法:使 TTL 匹配数据变化频率——价格用秒级,用户资料用分钟级,配置用小时级。
  • 缺少关键参数——缓存键缺少用户 ID、语言环境或查询参数,导致返回错误数据。

    • 替代做法:包含所有影响结果的条件参数:products:list:page=2:locale=en
  • 缓存错误响应——将错误响应(500 错误、超时)以较长 TTL 存储。

    • 替代做法:失败时跳过缓存,或使用极短的 TTL(5-10 秒)以便快速重试。
  • 缓存雪崩——当某个热门键过期时,所有实例同时请求源服务器。

    • 替代做法:使用陈旧时重新验证、带有抖动的 TTL,或通过互斥锁让单个实例刷新。