--- name: caching description: 缓存策略——失效策略、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] 使用统一的键格式:`<实体>::<变体>`(例如 `user:123:profile`、`products: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 缓存 ```ts const cache = new Map(); const MAX_SIZE = 500; export function getOrSet(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 的陈旧时重新验证模式 ```ts import Redis from "ioredis"; const redis = new Redis(process.env.REDIS_URL); export async function swr( key: string, freshSec: number, staleSec: number, fetcher: () => Promise, ): Promise { 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 缓存头 ```ts // 不可变资源(已哈希文件名) 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 缓存配置 ```tsx 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,或通过互斥锁让单个实例刷新。