缓存策略
在正确层(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、错键空间)数据库扛全负载。