缓存是 Web 性能优化的杠杆之王:一次命中省掉的不只是源站的计算,还有整条链路的网络传输。一个成熟的站点会在三个位置分别放置缓存——浏览器缓存、CDN 缓存与源站 Nginx 缓存——它们各司其职,共同把「绝大多数请求挡在源站之外」。但多级缓存也意味着多级复杂度:缓存键怎么设计、失效如何触发、回源如何控制、数据一致性如何保证,每一个问题都直接影响命中率与用户体验。本文从三层缓存架构讲起,逐步深入到缓存键、失效策略、回源控制与命中率优化,给出可落地的多级缓存设计。
一句话总结: 多级缓存把请求层层拦截在浏览器与 CDN 边缘,源站 Nginx 缓存兜底,设计核心是缓存键、失效策略与回源控制三件事。
1. 三层缓存架构总览
一句话总结: 浏览器缓存省网络传输、CDN 缓存省跨网回源、源站缓存省应用计算,三层各解决一段问题,配置要逐层配合。
多级缓存的每一层解决不同的问题。浏览器缓存让「同一用户重复访问」不产生任何网络请求——资源直接从本地磁盘/内存取;CDN 缓存让「不同用户访问同一资源」从最近的边缘节点取,省掉跨地域的回源传输;源站 Nginx 缓存让「回源请求」不必打到应用服务器,直接从 Nginx 的磁盘缓存返回。三层叠加后,真正到达应用层的请求只剩下「缓存未命中」的那一小部分。
客户端 ──▶ 浏览器缓存(本地,命中即无网络请求)
│ 未命中
▼
CDN 边缘节点(就近命中,省跨网传输)
│ 未命中,回源
▼
源站 Nginx(proxy_cache 磁盘缓存,省应用计算)
│ 未命中
▼
应用服务器(真实计算)
三层缓存的生效机制相互依赖:CDN 与浏览器靠源站下发的 Cache-Control 头决定缓存时长,源站 Nginx 靠 proxy_cache_valid 决定缓存有效期。因此设计顺序是「从源站到边缘」:先在源站 Nginx 定好缓存规则,再通过响应头把这些规则传达给下游。各层的角色分工决定了配置重点:浏览器层关注 Cache-Control/ETag,CDN 层关注缓存头与回源策略,源站层关注 proxy_cache 的键与失效。
# 源站 Nginx:定义缓存区与过期规则
http {
proxy_cache_path /var/cache/nginx levels=1:2 keys_zone=static_cache:100m
max_size=10g inactive=60m use_temp_path=off;
server {
location /static/ {
proxy_cache static_cache;
proxy_pass http://origin_cluster;
}
}
}
三层缓存最容易犯的错误是「层级错配」:把动态接口放在浏览器长期缓存、把静态资源设成不缓存、把个性化的数据缓存到 CDN。正确的做法是给每类资源设计「缓存层级」:公开静态资源三层全缓存,动态接口只做源站缓存(且短 TTL),用户私有数据不做 CDN 缓存。设计多级缓存前,先给站点的资源分类,明确每类资源该被哪几层缓存。
2. 浏览器缓存
一句话总结: 浏览器缓存用 Cache-Control 控制强制缓存时长、用 ETag/Last-Modified 实现协商缓存,静态资源应配版本化 URL + 长缓存。
浏览器缓存是所有缓存里离用户最近的,命中时零网络开销。它由响应头控制:Cache-Control 控制强制缓存(指定时长内直接使用本地副本,不发请求),ETag/Last-Modified 控制协商缓存(本地副本过期后发请求询问,304 则继续用本地副本)。静态资源的标准配置是「版本化 URL + 长缓存 + ETag」:
location /static/ {
# 静态资源:长缓存(版本化 URL 保证更新即时生效)
add_header Cache-Control "public, max-age=31536000, immutable";
# ETag 由 Nginx 自动生成(基于 mtime 与 size)
etag on;
alias /srv/web/static/;
}
max-age=31536000(一年)意味着浏览器在一年内都不会重新请求该资源,这对「文件名含版本号」的静态资源完全正确——资源内容变了,URL 就变(如 app.1a2b3c.js),浏览器自然加载新版本。immutable 告诉浏览器该资源在有效期内绝对不变,连重新验证都省了。但如果静态资源没有版本化(文件名固定),一年长缓存会导致用户永远看不到更新——此时要么改成短缓存 + 协商缓存,要么强制推行版本化。
动态页面的浏览器缓存要谨慎。对公开的、不依赖登录态的页面,可以用短缓存或协商缓存:
# 动态页面:协商缓存优先,配合短强制缓存
location /news/ {
add_header Cache-Control "public, max-age=60, must-revalidate";
proxy_pass http://origin_cluster;
}
must-revalidate 是关键:它告诉浏览器「缓存过期后必须重新验证」,防止陈旧的动态页面被长期使用。对依赖登录态的私有数据(用户中心、购物车),浏览器缓存应设置为 private 或 no-store,避免敏感数据留在浏览器缓存:
# 私有数据:不缓存
location /api/user/ {
add_header Cache-Control "no-store";
proxy_pass http://api_cluster;
}
浏览器缓存设计的核心是「按资源类型分配缓存语义」:公开静态资源长缓存 + 版本化,公开动态页面短缓存 + 协商,私有数据不缓存。浏览器缓存还能通过 Etag 配合 304 减少带宽——Nginx 的 etag on 自动生成,无需额外配置。检查浏览器缓存是否生效,用开发者工具的网络面板看资源是 from disk cache 还是 from memory cache,以及是否出现 304 请求。
3. CDN 缓存
一句话总结: CDN 把资源缓存到就近边缘节点,源站通过 Cache-Control 与 CDN 专用头控制 CDN 的缓存时长与回源策略。
CDN 缓存位于浏览器与源站之间,解决的是「跨地域回源」的传输成本。CDN 边缘节点会按照源站下发的 Cache-Control 决定缓存时长,因此源站配置 CDN 缓存的本质是「给 CDN 写缓存指令」。大多数 CDN 遵循 Cache-Control: max-age 作为默认缓存时长,同时提供自己的扩展头(如 CDN-Cache-Control、X-Cache)做精细控制。
# 源站给 CDN 的缓存指令:静态资源长缓存
location /static/ {
add_header Cache-Control "public, max-age=86400";
add_header CDN-Cache-Control "public, max-age=604800"; # CDN 缓存一周
proxy_pass http://origin_cluster;
}
静态资源在 CDN 的缓存时间可以比浏览器更长(浏览器一年,CDN 也可以一年),因为 CDN 的失效可控——发布时用 CDN 的 purge 接口主动清除,不必等 TTL 自然过期。动态接口的 CDN 缓存要区分「可公开聚合」与「个性化」:公开的列表页、聚合数据可以短缓存(如 30~60 秒),个性化数据不应进 CDN。判断依据是「同一 URL 对不同用户是否返回相同内容」。
# 公开聚合接口:CDN 短缓存
location /api/hotlist {
add_header Cache-Control "public, max-age=30";
proxy_pass http://api_cluster;
}
CDN 缓存生效后会回传缓存标识头,排错时靠它判断命中位置:
# 检查 CDN 命中情况
curl -sI https://cdn.example.com/static/app.js | grep -iE 'x-cache|cache-control'
# X-Cache: HIT from CDN → CDN 命中
# X-Cache: MISS from CDN → 未命中,已回源
CDN 层的常见问题:一是源站忘了下 Cache-Control,CDN 按默认策略可能不缓存或乱缓存;二是 Set-Cookie 导致 CDN 不缓存(带 Set-Cookie 的响应 CDN 通常拒绝缓存,源站要确保公开资源不写 Cookie);三是 CDN 缓存了不该缓存的个性化数据,导致用户看到彼此的数据。CDN 配置要与源站缓存头联动:源站改 TTL,CDN 的行为随之变化,任何缓存参数调整都要先评估对下游的影响。
4. 源站 Nginx 缓存
一句话总结: 源站 proxy_cache 把应用响应缓存到磁盘,是「回源请求」的最后一道拦截,配置要点是缓存区、缓存键与过期规则。
即使有浏览器与 CDN 缓存,源站仍会收到两类回源请求:CDN 未命中的、以及绕开 CDN 直达源站的。源站 Nginx 的 proxy_cache 把这些请求拦截在应用之前,直接返回磁盘上的缓存副本。它是三层缓存中源站可控性最强的一层,也是命中率优化的主战场。
http {
proxy_cache_path /var/cache/nginx levels=1:2 keys_zone=api_cache:100m
max_size=20g inactive=24h use_temp_path=off;
server {
location /api/list/ {
proxy_cache api_cache;
proxy_cache_valid 200 60s; # 2xx 缓存 60 秒
proxy_cache_valid 404 5s; # 4xx 缓存 5 秒(防风暴)
proxy_cache_valid any 10s;
proxy_set_header Host $host;
proxy_pass http://api_cluster;
# 缓存命中时回传标识,便于观测
add_header X-Cache-Status $upstream_cache_status;
}
}
}
proxy_cache_path 定义缓存存储:levels=1:2 是目录分层(避免单目录文件过多),keys_zone 是共享内存中缓存键索引的大小,max_size 是磁盘上限,inactive 是「多久未被访问就淘汰」。use_temp_path=off 让缓存直接写入目标目录而非先写临时目录,减少一次文件复制。proxy_cache_valid 按状态码分设过期时间——404 也缓存 5 秒是为了防止「某个 key 的后端临时 404」反复回源打爆应用。
$upstream_cache_status 是观测命中率的关键变量:HIT(命中)、MISS(未命中回源)、EXPIRED(过期重新验证)、UPDATING(正在更新)。把该变量写入 access_log 与 X-Cache-Status 响应头,就能统计源站缓存的真实命中率:
# 在日志中记录缓存状态,按接口统计命中率
log_format cache '$remote_addr [$time_local] "$request" '
'$status $upstream_cache_status';
server {
location /api/ {
access_log /var/log/nginx/api_cache.log cache;
proxy_cache api_cache;
proxy_pass http://api_cluster;
}
}
源站缓存的粒度还可以分层:把「高频但变化慢」的聚合数据与「低频变化快」的业务数据分开配置缓存区与 TTL,避免一份缓存区里混合多种过期策略导致淘汰互相干扰。分层缓存中源站缓存常被忽略,但它是纯内网成本最低的一层:磁盘缓存不需要额外机器,命中时连网络带宽都省了。缓存区大小与命中率要联动评估:inactive 淘汰太快会降低命中率,max_size 太小会频繁淘汰热点,应根据实际缓存占用调整。
5. 缓存键与失效策略
一句话总结: 缓存键决定「什么算同一份内容」,默认键含域名与 URI,个性化内容要用 Cookie/参数扩展键,失效靠 TTL、purge 与版本化三种手段。
缓存键(cache key)决定两个请求「是否共享同一份缓存」。Nginx 的默认缓存键是 $scheme$host$request_uri(协议 + 域名 + URI),这意味着同一 URL 的所有请求共享一份缓存。对大多数公开内容这是正确的,但遇到「URL 相同、内容按用户或参数变化」的场景就需要扩展键。
location /api/me/ {
proxy_cache api_cache;
# 按用户维度扩展缓存键:不同用户缓存不同副本
proxy_cache_key "$scheme$request_method$host$request_uri$cookie_sessionid";
proxy_pass http://api_cluster;
}
扩展缓存键要谨慎:$cookie_sessionid 进键意味着每个用户一份缓存,命中率会骤降且缓存膨胀。更合理的做法是「公开数据用默认键,私有数据不缓存」而非把用户维度塞进键。缓存键还与 Vary 头相关:如果后端按 Accept-Encoding 返回不同内容,Nginx 的缓存键默认已包含编码相关处理,但代理场景要确认 proxy_cache_key 没有丢失必要维度。
失效策略有三种手段,按场景组合使用。TTL(proxy_cache_valid)是自动失效,适合内容会自然变化的场景;purge 是主动清除,适合「内容变了、等不了 TTL」的发布场景——需要第三方模块或 Lua 实现 purge 接口:
-- OpenResty purge 接口示意:按 key 清除缓存
location = /purge {
content_by_lua_block {
local key = ngx.var.arg_key
local cache = ngx.shared.api_cache
cache:delete(key) -- 或调用 proxy_cache purge 模块
ngx.say("purged: " .. key)
}
}
版本化 URL 是第三种失效手段:内容变更时生成新 URL(如 /static/app.abc123.js),旧缓存自然过期淘汰,无需主动清除。三种手段的选型:静态资源用版本化 + 长 TTL,动态列表用短 TTL,发布敏感内容用 purge + TTL 兜底。失效策略要配套「观测」——每次发布后确认新内容已生效、旧缓存已清除,避免「发布了但用户还看到旧内容」的经典事故。
6. 回源控制与一致性
一句话总结: 回源控制决定「缓存过期后如何更新」,revalidate 与 stale 机制在一致性与可用性之间权衡,防雪崩还要锁回源。
缓存过期后的行为由回源控制决定,直接关系用户体验与源站安全。Nginx 的 proxy_cache_revalidate 让 Nginx 在过期后用条件请求(If-Modified-Since)向后端验证,后端返回 304 则续用缓存并续期——这比重新拉取完整响应省带宽。proxy_cache_use_stale 允许「后端不可用或正在更新时返回过期缓存」,用一致性换可用性:
location /api/list/ {
proxy_cache api_cache;
proxy_cache_valid 200 60s;
proxy_cache_revalidate on;
# 后端故障或正在更新时,返回过期缓存
proxy_cache_use_stale error timeout updating http_500 http_502 http_503;
# 防雪崩:同一 key 同时只有一次回源,其余请求等待或取 stale
proxy_cache_lock on;
proxy_cache_lock_timeout 5s;
proxy_pass http://api_cluster;
}
proxy_cache_lock on 是防缓存雪崩的关键:当某个 key 过期且有大量请求同时到达时,默认所有请求都会回源(缓存击穿);开启锁后只有第一个请求回源,其余请求等待或直接复用 stale。proxy_cache_use_stale 里的 updating 让「正在更新中的过期缓存」直接返回,进一步削峰。这两个指令组合是「高并发热点接口」的标配。
一致性权衡的原则是「按数据容忍度分级」:强一致数据(订单、余额)不做缓存或只做极短缓存;弱一致数据(商品列表、新闻流)可以接受 60 秒陈旧;可容忍更陈旧的聚合数据可以配合 stale 长时间兜底。每个接口的缓存设计都要回答「用户能接受多旧的数据」这个问题。一致性还会被多级缓存放大:源站缓存 60 秒 + CDN 缓存 30 秒 + 浏览器缓存 60 秒,用户看到的最旧数据可能累积到 2 分钟以上——设计 TTL 时要考虑整个链路的叠加。
7. 缓存命中率优化
一句话总结: 提升命中率的三板斧是静态与动态分离、缓存预热、以及「缓存键去个性化」,配合命中率观测持续校准。
缓存设计的最终目标是命中率。三层各自的命中率含义不同:浏览器命中率看「本地取用比例」,CDN 命中率看「边缘命中比例」,源站命中率看「回源被拦截比例」。提升命中率有三个最有效的手段。第一是动静分离:把高频动态接口按「可公开 + 变化慢」拆出可缓存子集,与完全动态的接口分开路由。
# 动静分离:可缓存的聚合接口与动态接口分开
location /api/hotlist/ {
proxy_cache api_cache;
proxy_cache_valid 200 60s;
proxy_pass http://api_cluster;
}
location /api/dynamic/ {
proxy_cache off; # 完全动态,不缓存
proxy_pass http://api_cluster;
}
第二是缓存预热:发布后或缓存清空后,主动用脚本请求热点 URL,让缓存立即填充,避免「冷启动期」大量请求直通应用。预热脚本按热点列表循环请求,配合 X-Cache-Status: MISS → HIT 验证填充效果:
# 预热脚本示意:请求热点 URL 填充缓存
while read -r url; do
curl -s -o /dev/null "https://api.example.com$url"
done < hot_urls.txt
第三是「缓存键去个性化」:检查缓存键里是否有不必要的个性化维度(如把用户 id 塞进公开接口的键),去掉后命中率可能成倍提升。但要警惕过度共享导致的「用户间串数据」——去个性化必须保证内容确实对所有用户一致。命中率的持续优化依赖观测:把 $upstream_cache_status 按接口维度统计,定期复盘命中率偏低的接口,判断是键的问题、TTL 的问题还是内容本身不适合缓存。
8. 总结
| 层级 | 作用 | 关键配置 | 命中率观测 |
|---|---|---|---|
| 浏览器缓存 | 省网络请求 | Cache-Control、ETag、版本化 URL | from disk/memory cache、304 |
| CDN 缓存 | 省跨网回源 | Cache-Control + CDN 扩展头 | X-Cache: HIT/MISS |
| 源站缓存 | 省应用计算 | proxy_cache + proxy_cache_valid | $upstream_cache_status |
| 缓存键 | 定义内容边界 | proxy_cache_key | 键的个性化程度 |
| 失效策略 | 内容更新手段 | TTL、purge、版本化 URL | 发布后旧缓存清除确认 |
| 回源控制 | 过期后的行为 | revalidate、use_stale、lock | 缓存击穿/雪崩告警 |
| 一致性 | 数据容忍度分级 | 按接口调整 TTL 与 stale | 用户可见陈旧度评估 |
多级缓存的设计本质是「把请求逐层拦截,把数据按容忍度分级」。浏览器、CDN、源站三层各有分工,通过缓存头把策略从源站传达给边缘;缓存键决定共享边界,失效策略决定更新时机,回源控制决定过期行为。落地时按顺序推进:先给资源分类设计缓存层级,再配置源站 proxy_cache 并观察 $upstream_cache_status,然后通过缓存头联动 CDN 与浏览器,最后用预热与去个性化提升命中率。每一层的改动都要用命中率与一致性指标验证,形成「设计 → 观测 → 校准」的循环。
延伸阅读
- Nginx 缓存与压缩优化 — proxy_cache 键与有效期深度实践
- Nginx 静态资源与页面加速 — 浏览器缓存与版本化资源
- Nginx 监控与可观测性 — 缓存命中率指标与告警
- Nginx 反向代理与负载均衡 — 回源与上游连接池
- Nginx HTTPS 与 TLS 加固 — 缓存链路上的传输安全
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。