Web 应用的分层缓存策略
缓存是性能优化的瑞士军刀,但乱用会出脏数据。本文讲清 Web 的多层缓存:浏览器(HTTP 缓存/Service Worker)、CDN、服务端(Redis/内存)、数据库(查询缓存),每一层缓存什么、失效策略、以及缓存一致性怎么保。
本文要点
- 主题聚焦:Next.js、React
- 适合人群:独立开发者、全栈工程师、AI 应用开发者
- 核心价值:快速理解背景、实践路径、常见误区和工程建议
缓存是性能优化里最廉价、也最危险的杠杆。说它廉价,是因为很多时候加一行 Cache-Control 就能把响应时间从 200ms 砍到 5ms;说它危险,是因为一旦失效策略没设计好,用户看到的会是上一次的余额、过期的库存、已经下架的商品。我见过太多团队把缓存当银弹,先无脑 Redis,再无脑 TTL,最后在生产上半夜被叫起来查"为什么订单状态不对"。
这篇文章不讲缓存是什么,讲怎么分层、怎么选、怎么失效、怎么在并发下不翻车。
一、分层缓存全景
一个典型的 Web 请求,从用户点击到数据返回,要穿过好几层。每一层都可以缓存,但缓存的内容、失效成本、命中率完全不同。先看全景:
用户 ── 浏览器(SW / HTTP)── CDN(边缘)── 服务端(内存 / Redis)── 数据库(查询缓存 / MV)
↑ 最便宜、命中率最高 ↑ 最贵、最权威
└──────────── 越靠近用户,收益越大、失效越难 ────────────┘
核心原则一句话:越靠近用户的缓存,命中收益越大,但失效越难、一致性越差。所以分层时永远先问自己:这一层能不能不缓存?能不缓存就别缓存——缓存是负债,不是资产。每多一层缓存,你就多一份"脏数据排查"的账。
二、浏览器层:HTTP 缓存与 Service Worker
浏览器是性价比最高的缓存层——请求根本不出门,带宽、延迟、服务器压力全省了。
HTTP 强缓存靠 Cache-Control(Expires 已过时,别用了)。带 hash 的静态资源(JS / CSS / 图片)直接上 immutable:
Cache-Control: public, max-age=31536000, immutable
immutable 是个被低估的字段,它告诉浏览器"这玩意永远不会变",连条件请求(304 校验)都省了。配合文件名 hash,新版本发上去 hash 变了,旧的自然失效,堪称完美闭环。
协商缓存走 ETag / If-None-Match:服务端比对 hash,一致就返回 304。适合 index.html 这类不能强缓存、但又不想每次都全量传的场景。
Service Worker 是浏览器里的"离线代理",能拦截 fetch。适合 PWA、离线场景。但注意:SW 的缓存策略(CacheFirst / NetworkFirst / StaleWhileRevalidate)直接决定用户看到的是新还是旧。内容型站点用 StaleWhileRevalidate 最舒服——先返旧的,后台再更新。
// sw.js —— API 用 StaleWhileRevalidate,静态资源用 CacheFirst
workbox.routing.registerRoute(
({ url }) => url.pathname.startsWith('/api/'),
new workbox.strategies.StaleWhileRevalidate()
);
三、CDN 层:边缘缓存
CDN 把缓存推到离用户几百公里的节点。它缓存两类东西:静态资源(CSS / JS / 图片 / 字体)和可缓存的动态响应。
后者是常被忽略的金矿。你的 /api/articles 如果是公开内容、更新不频繁,完全可以 s-maxage=60, stale-while-revalidate=600 让 CDN 缓存一分钟——源站压力直接降一个数量级。
Cache-Control: public, s-maxage=60, stale-while-revalidate=600
s-maxage 只对 CDN / 共享缓存生效,不影响浏览器,所以你可以让"浏览器永远强缓存、CDN 缓存一分钟"两套策略并存。CDN 缓存的坑在失效:大多数 CDN 支持 API 主动 purge,但 purge 有传播延迟,别指望它做强一致。
四、服务端层:内存缓存与 Redis
进了服务端,才是缓存真正的主战场。
进程内内存缓存(node-cache、lru-cache):纳秒级访问,零网络开销。适合单进程或计算极昂贵的纯函数结果。坑在哪?多实例部署时,每个进程各缓存各的,一致性和内存占用都会爆炸。所以内存缓存只缓存那些"算了无所谓"的东西(比如编译好的模板、配置快照、enum 字典)。
Redis:跨进程、可共享、可持久化,分布式缓存的默认选择。它适合缓存:
- 计算贵的:聚合统计(今日 PV、热门榜单)、检索结果(ES 查询的二次缓存)
- 访问热的:用户画像、商品详情、首页推荐
- DB 压力大的:列表页、计数器
// Cache-Aside 模式:读时查,写时清
async function getUser(id: string) {
const key = `user:${id}`;
const cached = await redis.get(key);
if (cached) return JSON.parse(cached);
const user = await db.user.findUnique({ where: { id } });
await redis.set(key, JSON.stringify(user), 'EX', 300); // TTL 5 分钟兜底
return user;
}
注意 EX 300 是必须的——TTL 在这里不是性能优化,是一致性兜底。万一主动失效漏了一处,最多脏 5 分钟,而不是脏到天荒地老。
五、数据库层:查询缓存与物化视图
DB 层缓存是最后一道。MySQL 的 query cache 早就被官方劝退(8.0 直接移除),因为它在写多场景下反而是负优化——每次写都让缓存全失效。PostgreSQL 压根没有原生 query cache。
真正的 DB 层缓存是物化视图(Materialized View)和预聚合表:把复杂的 join、window function 提前算好,查询时直接读。报表、大屏、排行榜这类"分钟级延迟可接受"的场景特别适合。
CREATE MATERIALIZED VIEW daily_sales AS
SELECT date_trunc('day', created_at) AS day, SUM(amount) AS total
FROM orders GROUP BY 1;
-- 定时任务:REFRESH MATERIALIZED VIEW CONCURRENTLY daily_sales;
物化视图的失效最简单粗暴:定时刷新,别想着实时。CONCURRENTLY 关键字能让你刷新时不锁表,代价是需要唯一索引。
六、失效策略:缓存的三种武器
缓存写进去容易,清掉难。失效策略决定了你半夜会不会被叫醒。
- TTL(过期时间):被动失效,简单粗暴,兜底用。问题是有时间窗口的脏数据,只能保证"最终一致"。
- 主动失效:数据一变就清(
DEL/PURGE)。准确,但要求应用层在每个写路径上都记得清——漏一处就脏。代码一重构,很容易漏。 - 版本号:缓存 key 带版本(
user:42:v7),数据变了版本 +1,旧 key 自然过期。适合强一致场景(比如配置开关)。 - Cache-Aside:读时查缓存、没有就查 DB 回填;写时更新 DB 再删缓存。工业界事实标准。
实战组合拳:Cache-Aside + TTL 兜底 + 关键路径主动失效。三者缺一不可——只有 TTL 脏数据太多,只有主动失效一旦漏了就永久脏。
七、缓存一致性:并发下的脏数据
这是最容易被面试问到、也最容易在生产翻车的部分。朴素方案:先写 DB,再删缓存。
写线程: UPDATE DB ──► DEL cache
读线程: read cache(miss) ──► read DB ──► SET cache
看起来没问题,但有个经典并发陷阱:
- 读线程 cache miss,从 DB 读到旧值
- 写线程更新 DB,删 cache
- 读线程把旧值写回 cache
- cache 就这么脏到下一个 TTL
解法是延迟双删:写完 DB 后,先删一次,再延迟几百毫秒(覆盖读线程的回填窗口)删第二次。
async function updateUser(id: string, data: UserInput) {
await db.user.update({ where: { id }, data });
await redis.del(`user:${id}`); // 第一次删
await sleep(500); // 等读线程的回填完成
await redis.del(`user:${id}`); // 第二次删
}
延迟多久看你读路径的 P99。500ms 是个比较稳的经验值,代价是写路径变慢、且进程崩溃会丢第二次删。极致场景可以走 binlog 订阅(Canal / Debezium + Kafka),让缓存失效变成异步、可靠、不丢消息——大厂的标配。
八、缓存三大坑:击穿、雪崩、穿透
这块和 Redis 那篇有重合,这里只给结论:
- 击穿(热 key 过期瞬间被打爆):加互斥锁(分布式锁 /
singleflight),只放一个请求回源,其他等结果。 - 雪崩(大量 key 同时过期):TTL 加随机抖动(
ttl + random(0, 60)),别让它们齐刷刷一起挂;或者上多级缓存(本地 + Redis)。 - 穿透(查根本不存在的 key,绕过缓存打 DB):缓存空值(短 TTL,比如 60s),或上布隆过滤器拦一层。
// 击穿的互斥锁示例
async function getWithLock(key: string, loader: () => Promise<any>) {
const cached = await redis.get(key);
if (cached) return JSON.parse(cached);
const lock = await redis.set(`lock:${key}`, '1', 'NX', 'EX', 5);
if (!lock) {
await sleep(50); // 没抢到锁,等一会再读
return getWithLock(key, loader);
}
try {
const val = await loader();
await redis.set(key, JSON.stringify(val), 'EX', 300);
return val;
} finally {
await redis.del(`lock:${key}`);
}
}
九、别过度缓存
最后说个反直觉的:很多时候你不该缓存。
- 时效性强的(余额、库存、订单状态):能走 DB 就走 DB,缓存一致性的成本远大于收益。
- 写多读少的:缓存还没捂热就失效,纯浪费内存和 CPU。
- 一次性计算便宜的:别为了"快"引入一整套失效逻辑,复杂度是利息,迟早要还。
判断标准很简单:先把 DB 查询压到 50ms 以内、加上合理索引、修掉 N+1,再谈缓存。如果索引没建好就上 Redis,你只是把烂问题藏起来,等到缓存击穿那天,问题会十倍奉还。
小结
- 分层思维:浏览器 > CDN > 服务端 > 数据库,越靠用户收益越大、失效越难。
- 每层缓存什么要想清楚:静态资源走 HTTP / CDN,计算贵的走 Redis,复杂聚合走物化视图。
- 失效是核心:
Cache-Aside + TTL 兜底 + 主动失效三件套缺一不可。 - 一致性靠延迟双删或 binlog 订阅,别指望单一手段扛并发。
- 击穿 / 雪崩 / 穿透各有解法,但更重要的是:能不缓存就不缓存。
金句:缓存不是免费的——它用一致性换性能,而这笔一致性的账,迟早要还。
Related