2020 年做内容页 SSR 时,我们给页面数据加了 60 秒缓存。平均响应时间立刻下降,压测曲线却每隔一分钟出现一次整齐尖峰:缓存到期的瞬间,几十个并发请求都发现 miss,同时查询接口、拼装页面并写缓存。缓存本来为了保护上游,却把均匀流量压成周期性风暴。
这类问题后来我习惯叫“缓存击穿”或 stampede。关键不是把 TTL 调长,而是规定缓存过期时谁负责刷新,其他请求得到什么。
图 1:数据过期不等于立刻不可用;短时间返回 stale 值,能把刷新成本集中到一个请求。
把缓存状态从有/无变成三段
type CacheEntry<T> = {
value: T;
freshUntil: number;
staleUntil: number;
};
type CacheResult<T> =
| { state: "fresh"; value: T }
| { state: "stale"; value: T }
| { state: "miss" };在 freshUntil 之前直接返回;进入 stale 窗口后仍可返回旧值,同时后台刷新;超过 staleUntil 才是真正 miss。对新闻内容页,晚几十秒通常比所有用户一起等待更可接受,但库存和权限数据不能照搬这套策略。
单进程先做 single flight
const refreshes = new Map<string, Promise<PageData>>();
async function refreshOnce(key: string) {
const existing = refreshes.get(key);
if (existing) return existing;
const promise = loadAndCache(key).finally(() => refreshes.delete(key));
refreshes.set(key, promise);
return promise;
}
async function getPage(key: string): Promise<PageData> {
const cached = await cache.read<PageData>(key);
if (cached.state === "fresh") return cached.value;
if (cached.state === "stale") {
void refreshOnce(key).catch(error => logger.warn("refresh_failed", { key, error }));
return cached.value;
}
return refreshOnce(key);
}这只能合并同一个 Node.js 进程里的请求。多实例部署需要共享锁、CDN 层请求合并,或者让缓存刷新成为独立任务。代码示例如果不写适用范围,很容易制造“已经全局解决”的错觉。
分布式锁要考虑持有者死掉
共享锁至少需要唯一 token 和租约时间。释放时只能删除自己持有的锁,不能简单 DEL key:
SET refresh:page:42 <token> NX PX 10000如果刷新耗时可能超过租约,要么续租,要么让写缓存带版本比较,防止旧刷新覆盖新结果。锁本身故障时系统也要有选择:返回 stale、有限等待,还是降级为直接回源。不能让缓存锁成为比上游更脆弱的单点。
TTL 加随机抖动,避免批量同刻失效
一次发布可能同时写入大量缓存,如果 TTL 都是 3600 秒,它们会在一小时后集体过期。我们把非关键缓存加入正负抖动:
function ttlWithJitter(baseSeconds: number) {
const ratio = 0.15;
return Math.round(baseSeconds * (1 - ratio + Math.random() * ratio * 2));
}抖动只负责摊开时间,不能替代 single flight。热门 key 即使单独过期,仍可能被大量并发击穿。
失败时保留什么,由业务价值决定
| 页面 | stale 允许时间 | 刷新失败策略 |
|---|---|---|
| 公开文章 | 10 分钟 | 返回旧内容并记录年龄 |
| 首页推荐 | 2 分钟 | 旧列表 + 隐藏实时标识 |
| 用户权限 | 0 | 不使用 stale,明确失败 |
| 实时库存 | 很短或 0 | 回源或进入保护性降级 |
我们最终监控 fresh/stale/miss 比例、刷新耗时、锁等待、上游 QPS 和 stale 年龄。只看命中率会掩盖一个问题:命中很多旧数据也可能让用户看到错误结果。
这次事故让我理解,缓存不是一个存取 API,而是数据新鲜度、并发控制和故障策略的组合。TTL 只回答“多久以后重新考虑”,真正的系统设计发生在过期那一刻。