当用户分布在多个地域时,把内容缓存在离用户最近的边缘节点,比在源站堆机器更有效:一次回源可以服务成千上万次请求,源站压力下降一到两个数量级,用户的首字节时间也能从几百毫秒降到几十毫秒。Nginx 凭借 proxy_cache 模块,可以自建轻量 CDN 节点或作为 CDN 的回源层。但缓存从来不是「打开开关」那么简单:缓存键设计不当会导致缓存穿透或串号,回源策略不当会在缓存失效瞬间打垮源站,失效机制不当会让用户看到过期内容。本文按边缘节点的完整生命周期展开。
一句话总结: 边缘缓存的核心是三件事——用什么键缓存、什么时候回源、什么时候失效,三者的组合决定了命中率与一致性。
1. 边缘缓存与 CDN 架构概览
一句话总结: CDN 本质是「多级缓存 + 就近接入 + 回源收敛」,Nginx 既可作为边缘节点,也可作为中间层缓存或源站前置缓存。
CDN 的经典拓扑是三层:用户就近接入边缘节点,边缘未命中时回中间层(区域节点),中间层未命中才回源站。层级越多,回源收敛效果越好——假设边缘命中率 90%、中间层命中率 80%,则源站只需承担 10% × 20% = 2% 的请求量。
用户 → 边缘节点(Nginx) → 中间层(Nginx) → 源站(业务服务)
命中率 90% 命中率 80% 仅 2% 流量
Nginx 在这个体系里可以扮演任意一层。作为边缘节点时,它离用户最近,需要极低的单请求开销与极高的并发;作为中间层时,它面对的是边缘节点的回源流量,特点是并发不高但单次回源的响应体可能很大;作为源站前置缓存(Origin Shield)时,它是所有回源流量的唯一入口,起到「削峰」与「合并请求」的作用。
选择自建还是用商业 CDN,关键看三点:内容是否动态、是否需要精细的缓存键控制、是否有合规与数据主权要求。静态资源为主的站点直接用商业 CDN 成本最低;而 API 响应、个性化页面这类需要按业务字段定制缓存键的场景,自建 Nginx 边缘层更灵活。
# 一个最小可用的边缘缓存配置骨架
proxy_cache_path /data/cache levels=1:2 keys_zone=edge:100m
max_size=50g inactive=7d use_temp_path=off;
server {
listen 80;
location / {
proxy_cache edge;
proxy_cache_valid 200 10m;
proxy_pass http://origin;
}
}
2. 缓存键设计
一句话总结: 缓存键决定「什么请求算同一个资源」,默认键忽略 Host 与查询串顺序差异,必须按业务显式定制。
Nginx 的默认缓存键是 $scheme$proxy_host$request_uri,它有三个明显问题:不含 Host(多站点会串号)、包含完整查询串(参数顺序不同会生成不同键)、不含 Vary 维度(移动端与桌面端会拿到同一份缓存)。因此生产环境几乎总是要自定义缓存键。
# 自定义缓存键:规范化查询串 + 站点维度 + 设备维度
proxy_cache_key "$scheme$host$uri$is_args$args$device";
关键设计决策有四个:
第一,是否包含查询串。 对纯静态资源(/static/app.9f3a.js),查询串通常是版本参数,应该包含在键里以保证版本切换后拿到新文件。对 API 响应,查询串往往就是业务参数,必须包含。但如果查询串里有追踪参数(utm_source、gclid),就应该剔除,否则同一个内容会被缓存成上百份:
# 剔除营销追踪参数,只保留业务参数
map $args $normalized_args {
default $args;
"~^(?<a>.*?)&?utm_source=[^&]*" $a;
}
# 更彻底的做法:只保留白名单参数
map $arg_page$arg_size$arg_lang $api_cache_args {
default "$arg_page-$arg_size-$arg_lang";
}
proxy_cache_key "$scheme$host$uri$api_cache_args";
第二,是否包含 Host。 单站点可以省略,多域名共用边缘节点时必须包含,否则 a.example.com 的缓存会被 b.example.com 命中。
第三,Vary 维度。 移动端与桌面端返回不同内容时,用 map 把 UA 归一为设备类型再入键:
map $http_user_agent $device {
default "pc";
"~*Mobile" "h5";
"~*iPad" "pad";
}
第四,是否需要按用户维度隔离。 个性化内容绝不能进共享缓存。带 Cookie 的请求默认应该绕过缓存,只有明确可共享的接口才放行:
# 带会话 Cookie 的请求默认不走缓存
map $http_cookie $skip_cache {
default 0;
"~*session_id=" 1;
"~*auth_token=" 1;
}
proxy_cache_bypass $skip_cache;
proxy_no_cache $skip_cache;
proxy_cache_bypass 表示「跳过读取缓存,直接回源」,proxy_no_cache 表示「不回写缓存」。两者要成对使用:只设 bypass 会导致每次回源结果都覆盖缓存,只设 no_cache 会导致个性化响应被写进共享缓存(更危险)。缓存键一旦上线就极难变更——变更意味着全量缓存失效与回源洪峰,因此设计阶段必须充分评审。
3. proxy_cache_path 分层存储
一句话总结: levels 参数决定缓存文件的目录分层,keys_zone 决定内存索引大小,max_size 与 inactive 决定淘汰策略,三者共同影响磁盘 IO 与内存占用。
proxy_cache_path 是缓存存储的核心配置,每个参数都有明确的工程含义:
proxy_cache_path /data/cache/edge
levels=1:2
keys_zone=edge:100m
max_size=200g
inactive=30d
use_temp_path=off
min_free=5g
manager_files=100
loader_files=200;
levels=1:2 表示缓存文件按两级目录哈希存放,例如 c/29/b7f54b2df7773722d382f4809d65029c。不分层会导致单目录下几十万个文件,文件系统查找性能急剧下降;分层过深(如 2:2:2)则增加 inode 开销与目录遍历成本。1:2 是最常用的平衡点。
keys_zone=edge:100m 是共享内存中的键索引区,只存键与元数据(不存响应体)。1MB 大约能存 8000 个键,100MB 可以容纳约 80 万个缓存条目。注意这是「键数量」上限,不是「缓存大小」上限——键区满了会触发 LRU 淘汰,即使磁盘还有空间。
max_size 是磁盘缓存的总量上限,超限后由 cache manager 按 LRU 淘汰。inactive=30d 表示 30 天未被访问的条目会被清理,它是「时间维度」的淘汰,与 max_size 的「空间维度」淘汰互补。两者必须同时设置,只设 max_size 会让长期不活跃的小文件永久占用索引。
# 观察缓存目录的实际大小与文件数
du -sh /data/cache/edge
find /data/cache/edge -type f | wc -l
# 检查内存中的键区使用情况
curl -s http://127.0.0.1/nginx_status
3.1 按内容类型拆分多个缓存区
一句话总结: 把「小而热」的 API 响应与「大而冷」的文件下载放进不同的 keys_zone,避免冷内容挤掉热内容的索引。
单一缓存区的问题是「冷热混存」:一个 2GB 的安装包文件在索引里占一个条目,却可能因为体积挤掉大量热的小文件。更好的做法是按内容特征拆分:
# 热数据:API 响应,条目多、体积小
proxy_cache_path /data/cache/api levels=1:2 keys_zone=api:50m
max_size=20g inactive=1d use_temp_path=off;
# 冷数据:大文件,条目少、体积大
proxy_cache_path /data/cache/big levels=1:1 keys_zone=big:10m
max_size=500g inactive=60d use_temp_path=off;
location /api/ {
proxy_cache api;
proxy_cache_valid 200 5m;
proxy_pass http://api_origin;
}
location /download/ {
proxy_cache big;
proxy_cache_valid 200 7d;
# 大文件缓存时关闭分片,直接落盘
proxy_cache_lock on;
proxy_pass http://file_origin;
}
proxy_cache_lock on 是回源保护的关键指令,它保证同一时刻对同一个未命中的键只有一个请求回源,其余请求等待并复用结果。没有它的话,缓存失效瞬间会有大量并发请求同时穿透到源站,这就是所谓的「缓存雪崩」。
4. stale-while-revalidate 与 stale-if-error
一句话总结: stale 系列指令让边缘在缓存过期后仍可先返回旧内容再异步刷新,是把「过期瞬间的延迟尖刺」抹平的关键机制。
传统缓存的过期行为是「到点即失效」:过期瞬间第一个请求必须同步回源,用户要等完整的一次回源耗时。如果源站此时慢或抖,用户直接感知到延迟尖刺。stale-while-revalidate 改变这个行为——允许在过期后的一段时间内先返回旧内容,同时后台异步发起刷新。
location /api/ {
proxy_cache api;
# 200 响应缓存 5 分钟
proxy_cache_valid 200 5m;
# 缓存过期后仍可返回旧内容,同时后台异步刷新
proxy_cache_use_stale updating error timeout http_500 http_502 http_503 http_504;
proxy_cache_background_update on;
proxy_cache_lock on;
# 与上游协商 stale 窗口(配合 Cache-Control: stale-while-revalidate=60)
add_header X-Cache-Status $upstream_cache_status;
proxy_pass http://api_origin;
}
proxy_cache_use_stale 的取值分两类:主动 stale(updating,表示「正在后台刷新时,其他请求先拿旧内容」)与被动 stale(error timeout http_50x,表示「回源失败时降级用旧内容」)。前者是性能优化,后者是可用性兜底,两者都建议开启。
proxy_cache_background_update on 让 Nginx 在返回 stale 内容的同时发起异步更新,这是实现 stale-while-revalidate 语义的关键。开启后,用户请求永远不会因为「缓存刚过期」而等待回源——只有在完全没有缓存(首次请求)时才会同步回源。
与 stale-if-error 对应的场景是源站故障。当源站返回 502/504 或连接超时时,如果缓存里有旧内容,返回旧内容比返回错误页对用户友好得多:
# 源站不可用时用旧内容兜底,最多容忍 24 小时
proxy_cache_use_stale error timeout invalid_header http_500 http_502 http_503 http_504;
proxy_cache_valid 200 1h;
需要注意 stale 窗口的边界。proxy_cache_use_stale 本身没有「最长容忍时间」参数(开源版),它依赖 inactive 来清理条目——只要条目还在缓存里且未被淘汰,就可以被 stale 使用。因此 inactive 设得过长会让「源站已经改了但边缘还在返回一周前的旧内容」的风险上升。对一致性要求高的接口,应该用 Cache-Control: stale-while-revalidate=60 这类上游头精确控制窗口。
# 上游通过响应头精确控制缓存与 stale 窗口
# Cache-Control: public, max-age=300, stale-while-revalidate=60, stale-if-error=86400
proxy_cache_valid 200 5m;
proxy_ignore_headers X-Accel-Expires Expires Cache-Control Vary; # 需谨慎使用
proxy_ignore_headers 会忽略上游的缓存控制头,让 Nginx 完全按本地配置决策。只有在源站头不可信或混乱时才使用,否则应该让上游的 Cache-Control 生效——毕竟最了解内容生命周期的是业务方。
5. 缓存预热与 purge
一句话总结: 预热解决「冷启动回源洪峰」,purge 解决「内容变更后的一致性」,两者都需要幂等、可审计、可回滚的接口。
缓存刚上线或刚扩容时,所有节点都是空的,第一批流量会全部打到源站,这就是冷启动雪崩。预热(prewarm)的思路是主动把热点内容灌进缓存。最简单的方式是用脚本按热点列表逐个请求边缘节点:
#!/usr/bin/env bash
# 从日志提取 Top N 热点 URL,逐个预热
EDGE="http://edge-node-1.example.com"
awk '{print $7}' /var/log/nginx/access.log \
| sort | uniq -c | sort -rn | head -1000 | awk '{print $2}' \
| while read -r uri; do
curl -s -o /dev/null -H "X-Prewarm: 1" "$EDGE$uri"
done
预热脚本要控制并发:预热本身也会产生回源流量,如果并发过高会把源站打满,效果适得其反。建议用 xargs -P 4 这类限并发方式,并错峰执行(比如发布后的低峰期)。
purge(缓存清除)是内容变更后的必备能力。Nginx 开源版没有内置 purge 指令,常见实现有三种:用 proxy_cache_purge(第三方模块)、用 OpenResty 的 lua 模块、直接删除缓存文件。
# 方案一:第三方模块 ngx_cache_purge
location ~ /purge(/.*) {
allow 10.0.0.0/8;
deny all;
proxy_cache_purge api "$scheme$host$1$is_args$args";
}
# 方案二:OpenResty 下用 lua 直接删除缓存文件
location ~ ^/purge(/.*) {
allow 10.0.0.0/8;
deny all;
content_by_lua_block {
local f = io.popen("find /data/cache -type f -name '*" ..
ngx.var.arg_key .. "*' -delete")
f:close()
ngx.say("purged")
}
}
直接删文件的方案最危险:它绕过了缓存索引,可能导致内存键区与磁盘文件不一致。生产上更稳妥的是使用带索引的 purge 实现,或者用「版本化缓存键」替代 purge——把内容版本号编进 URL 或缓存键,变更时只需提升版本号,旧缓存自然过期:
# 版本化缓存键:无需 purge,改版本号即可全量失效
# 发布时把 VERSION 从 v3 提到 v4
proxy_cache_key "$scheme$host$uri$is_args$args$cache_version";
# 通过 map 或 map 文件集中管理版本
map $host $cache_version {
default "v4";
}
版本化方案的好处是原子性——新版本内容上线是瞬间生效的,不存在「purge 到一半」的中间态;代价是旧版本的缓存文件会一直占用磁盘直到被 LRU 淘汰,因此需要配合 max_size 与 inactive 做清理。
6. 回源保护与多级缓存协同
一句话总结: 回源保护靠 cache lock、上游连接池与限流三件套;多级缓存协同靠 Cache-Control 头逐层传递与回源收敛。
回源保护是边缘缓存能否扛住流量的决定性因素。缓存命中率再高,只要在失效瞬间有大量并发穿透,源站就会被击穿。三件套是必备配置:
location /api/ {
proxy_cache api;
# 1. 同一键只允许一个请求回源,其余等待
proxy_cache_lock on;
proxy_cache_lock_age 5s;
proxy_cache_lock_timeout 5s;
# 2. 上游连接池复用,避免回源时反复握手
proxy_pass http://api_origin;
proxy_http_version 1.1;
proxy_set_header Connection "";
# 3. 回源限流:即使穿透也限制并发回源数
limit_req zone=origin_req burst=20 nodelay;
}
upstream api_origin {
server 10.0.0.20:8080;
keepalive 64;
keepalive_timeout 60s;
}
limit_req_zone $binary_remote_addr zone=origin_req:10m rate=50r/s;
proxy_cache_lock_timeout 5s 是一个容易被忽略的参数:等待锁的请求超过这个时间还没拿到结果,就会自己去回源(放弃等待)。设得太短会让锁失去意义,设得太长会让用户等待过久。5 秒是个经验值,配合 proxy_cache_lock_age(持锁者超过该时间未完成则允许下一个请求也去回源)使用。
多级缓存协同的核心是让 Cache-Control 头逐层传递。边缘节点的缓存时长通常应短于中间层,中间层短于源站前置缓存,形成「越靠近用户越短」的层级结构,这样内容变更能从内向外逐层收敛:
# 中间层:缓存时间比边缘更长,承担回源收敛
proxy_cache_path /data/cache/mid levels=1:2 keys_zone=mid:100m
max_size=500g inactive=30d;
server {
# 中间层把上游的 Cache-Control 传给下游边缘,并叠加自己的策略
location / {
proxy_cache mid;
proxy_cache_valid 200 1h;
# 向下游(边缘)声明可缓存时长
add_header Cache-Control "public, max-age=600" always;
proxy_pass http://origin;
}
}
这里有一个常见误区:add_header Cache-Control 是在响应给下游时添加的头,而 proxy_cache_valid 是本地的缓存策略,两者互不干扰。边缘节点如果配置了 proxy_ignore_headers Cache-Control,则会忽略中间层下发的头,层级协同就失效了。因此多级缓存必须统一约定:由上游统一下发 Cache-Control,各级 Nginx 尊重该头并只在必要时覆盖。
7. 命中率观测与常见陷阱
一句话总结: 命中率要靠 $upstream_cache_status 分状态统计,常见陷阱包括缓存键冗余、Vary 头缺失与私有内容误缓存。
观测命中率的标准做法是记录 $upstream_cache_status,它区分了 HIT、MISS、EXPIRED、STALE、UPDATING、BYPASS、REVALIDATED 七种状态。只统计 HIT/MISS 会掩盖大量问题:
log_format cache '$remote_addr "$request" $status '
'cache=$upstream_cache_status '
'upstream=$upstream_addr '
'rt=$request_time urt=$upstream_response_time';
access_log /var/log/nginx/cache.log cache;
# 按状态统计缓存效果
awk '{for(i=1;i<=NF;i++) if($i ~ /^cache=/) print $i}' \
/var/log/nginx/cache.log | sort | uniq -c | sort -rn
理想分布是 HIT 占大头(静态资源 90% 以上,API 60% 以上),MISS 与 EXPIRED 合计在 10%~30%,BYPASS 只在个性化路径上出现。如果 BYPASS 比例很高,说明 proxy_cache_bypass 的判定条件过宽;如果 EXPIRED 极高而 HIT 很低,说明 proxy_cache_valid 设得太短。
陷阱一:缓存键包含无用维度导致命中率虚低。 键里包含了 $http_user_agent 或完整 $args,会让同一内容被切成几十份。排查方法是对比「去掉某维度后的键基数」,键基数骤降说明该维度冗余。
陷阱二:Vary 头缺失导致串号。 上游返回 Vary: Accept-Encoding 但 Nginx 未正确识别时,可能把 gzip 内容发给不支持 gzip 的客户端。Nginx 会自动处理 Vary 的部分场景,但自定义维度(如设备类型)必须显式编进缓存键。
陷阱三:私有内容被共享缓存。 带用户信息的响应一旦进了共享缓存,其他用户就会看到别人的数据。除了 proxy_no_cache,还应在源站返回 Cache-Control: private,并在 Nginx 侧用 map 强制识别:
# 上游声明 private 时强制不缓存
map $upstream_http_cache_control $force_no_cache {
default 0;
"~*(private|no-store)" 1;
}
proxy_no_cache $force_no_cache;
proxy_cache_bypass $force_no_cache;
陷阱四:缓存文件写满磁盘。 max_size 未设或设得过大,会让缓存吃光磁盘,进而影响日志写入与系统稳定性。建议在 proxy_cache_path 里加 min_free=5g,保证磁盘始终留有余量;同时用监控对缓存目录做容量告警。
# 缓存目录健康检查脚本
CACHE_DIR=/data/cache/edge
USED=$(du -sm "$CACHE_DIR" | awk '{print $1}')
echo "cache_used_mb $USED"
df -h "$CACHE_DIR" | tail -1 | awk '{print "disk_free " $4}'
8. 总结
| 环节 | 要点 |
|---|---|
| 架构分层 | 边缘 / 中间层 / 源站三级,逐级收敛回源流量 |
| 缓存键 | 显式定制,剔除追踪参数,按设备与站点维度拆分 |
| 存储配置 | levels 分层、keys_zone 索引、max_size + inactive 双淘汰 |
| 分层缓存区 | 热数据与冷数据分开,避免索引互相挤占 |
| stale 策略 | use_stale + background_update 抹平过期尖刺,源站故障时兜底 |
| 预热与 purge | 限并发预热避免二次洪峰,优先用版本化键替代 purge |
| 回源保护 | cache_lock、上游连接池、回源限流三件套 |
| 观测 | 按 upstream_cache_status 分状态统计,警惕四类陷阱 |
边缘缓存的效果取决于设计阶段的决策质量:缓存键一旦上线就难以变更,回源策略一旦定错就会在流量高峰暴露。落地时的建议是「先保守后激进」——先用较短的缓存时长与较严格的 bypass 条件上线,观察命中率与源站压力,再逐步放宽。缓存把内容推到了离用户最近的地方,而配置变更如何在不中断服务的前提下生效,是接入层运营的另一个核心问题,下一篇文章将讨论零停机重载与热升级。
延伸阅读
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。