很多站长在 Nginx 上配过反向代理缓存,加几行 proxy_cache_path 和 proxy_cache 就以为万事大吉,结果上线之后发现两个典型问题:一是缓存命中率始终上不去,二是内容更新了但访问者看到的还是旧页面。前者的根源是缓存策略没设计好,后者则是因为缺少「主动清理」这一环。本文把这套东西从原理到落地完整讲一遍,包括命中率诊断、缓存预热脚本、以及用 proxy_cache_purge 模块做精准清除,最后给一套可以直接抄的生产配置。
一、先搞清楚 Nginx 缓存的三个层次
在动手改配置之前,必须分清 Nginx 缓存到底有哪几种,否则很容易配了 A 却期待 B 的效果。
- 代理缓存(proxy_cache):Nginx 把后端(PHP、Node、Python 等)返回的完整响应体存到磁盘,下次同 URL 请求直接由 Nginx 返回,后端完全不参与。这是本文的主角,也是加速效果最猛的一层。
- FastCGI 缓存(fastcgi_cache):针对 PHP-FPM 场景,原理与 proxy_cache 类似,只是走的是 FastCGI 协议。如果你的站点是 PHP 直接跑在 Nginx 上(而非通过另一层代理),应该用 fastcgi_cache 而不是 proxy_cache。
- 浏览器缓存(expires / Cache-Control):这是让访客浏览器自己存副本,减少重复请求。它和后端无关,只是告诉浏览器「这个东西多少秒内别再问了」。
三者可以叠加使用,但职责不同。本文聚焦代理缓存,因为它是唯一能同时降低后端 CPU 占用和首字节时间(TTFB)的一层。
二、一个最小可用的 proxy_cache 配置
先看基础形态,再谈优化:
# http 块中定义缓存区
proxy_cache_path /var/cache/nginx/proxy levels=1:2
keys_zone=webcache:100m
max_size=5g
inactive=60m
use_temp_path=off;
server {
listen 443 ssl http2;
server_name www.example.com;
location / {
proxy_pass http://127.0.0.1:8080;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_cache webcache;
proxy_cache_key "$scheme$request_method$host$request_uri";
proxy_cache_valid 200 302 10m;
proxy_cache_valid 404 1m;
proxy_cache_use_stale error timeout updating http_500 http_502 http_503 http_504;
add_header X-Cache-Status $upstream_cache_status always;
}
}这段配置逐项拆解:
levels=1:2:缓存目录分两级子目录,避免单目录下文件数过多导致文件系统查找变慢。生产环境基本都要加。keys_zone=webcache:100m:共享内存区,存 key 和元数据(不含响应体)。100MB 大约能放 80 万个 key 的元数据,普通站点 10m 就够,给 100m 是留了很大余量。max_size=5g:磁盘缓存上限。超过后 Nginx 会按 LRU 逐步淘汰旧文件。inactive=60m:60 分钟内没被访问过的缓存文件会被删掉,即使没到proxy_cache_valid的过期时间。这一条经常被忽略,但它决定了「冷数据不会永久占盘」。proxy_cache_use_stale:后端挂了的时候,直接返回过期缓存而不是报 502。这是缓存最被低估的价值——它同时是一层容灾。
三、$upstream_cache_status 与命中率诊断
加了 add_header X-Cache-Status 之后,用 curl 就能看到每次请求的缓存状态:
curl -sI "https://www.example.com/?_=$(date +%s)" | grep -i x-cache-status
返回值含义如下:
- MISS:缓存里没有,回源了。首次访问正常。
- HIT:命中缓存,由 Nginx 直接返回。这是我们追求的状态。
- EXPIRED:缓存有但已过期,回源取新的。正常淘汰过程。
- STALE:后端异常,返回了过期缓存兜底。
- UPDATING:缓存过期但正在后台更新,先给访客旧的(配合
proxy_cache_use_stale updating)。 - BYPASS:被
proxy_cache_bypass规则跳过,强制回源。 - REVALIDATED:带 If-Modified-Since/If-None-Match 回源验证,后端返回 304,缓存被复用。
统计命中率可以直接在访问日志里做。把 cache status 加到 log_format:
log_format cachelog '$remote_addr - $host "$request" '
'status=$status cache=$upstream_cache_status '
'rt=$request_time urt=$upstream_response_time';
access_log /var/log/nginx/cache.log cachelog;然后用一条命令算命中率:
awk '{for(i=1;i<=NF;i++) if($i ~ /^cache=/) print substr($i,7)}' \
/var/log/nginx/cache.log | sort | uniq -c | sort -rn输出形如:
18423 HIT
2107 MISS
512 EXPIRED
88 BYPASS用 HIT / (HIT + MISS + EXPIRED) 算,这个例子里接近 87%,属于健康水平。如果低于 50%,通常说明缓存时间太短、或者 URL 上有随机参数导致 key 不统一。
四、命中率上不去的四个常见原因
1. 带查询参数的 URL 被当成不同的 key
默认 proxy_cache_key 包含完整 $request_uri,所以 /post/1 和 /post/1?from=wechat 会缓存两份。对内容站来说这是巨大的浪费。
解决办法是规范化 key,把无意义的参数剔除:
# 只保留白名单参数,其余忽略
map $args $cache_args {
default "";
"~^(page=\d+)" $1;
"~*[?&]page=(\d+)" "page=$1";
}
proxy_cache_key "$scheme$host$uri$cache_args";
set $cache_args "";更彻底的做法是用 map 把特定追踪参数(utm_source、from、spm 等)从 key 中剥离,这样不管访客从哪个渠道来,都命中同一份缓存。
2. 后端返回了 Set-Cookie
Nginx 默认不会缓存带 Set-Cookie 的响应,因为那通常意味着个性化内容。但很多框架即使对匿名访客也会塞一个 session cookie,导致缓存全军覆没。
处理方式有两种:一是在 Nginx 侧隐藏这个头(仅当该 cookie 对页面渲染无影响时):
proxy_hide_header Set-Cookie;
更稳妥的做法是在后端应用里判断:未登录访客不写 session cookie,只对登录用户写。WordPress 这类 CMS 一般都有插件或配置项控制这一点。
3. 缓存时间设置过短
proxy_cache_valid 200 10m 表示 10 分钟后过期。对于文章页这种一天都不变的内容,完全可以设 1h 甚至 6h,再用主动清理解决更新问题——这比被动等过期高效得多。
4. 请求过于分散(长尾 URL)
如果站点有几万个页面而每天只有几千次访问,每个 URL 都只被访问一两次,命中率天然就低。这种情况下的正解不是调缓存参数,而是做缓存预热。
五、缓存预热:让热门页面一开始就是热的
缓存预热的思路很朴素:主动向后端请求一遍重要页面,把结果灌进缓存,这样真实访客第一次来就是 HIT。对于刚重启 Nginx、清过缓存、或者刚发新文章的场景特别有用。
一个实用的预热脚本:
#!/bin/bash
# /usr/local/bin/cache_warmup.sh
SITE="https://www.example.com"
CONCURRENCY=10
UA="CacheWarmup/1.0"
# 从 sitemap 抓取所有 URL
curl -s "$SITE/sitemap.xml" \
| grep -oE '<loc>[^<]+' | sed 's/<loc>//' > /tmp/urls.txt
TOTAL=$(wc -l < /tmp/urls.txt)
echo "待预热 URL 数量: $TOTAL"
# 用 xargs 并发请求,注意限制并发避免打爆后端
cat /tmp/urls.txt | xargs -P $CONCURRENCY -I {} \
curl -s -o /dev/null -A "$UA" -H "X-Warmup: 1" "{}"
echo "预热完成"几个必须注意的点:
- 控制并发:
-P 10是 10 个并发。低配服务器上并发过高会把 PHP-FPM 打满,反而触发 502。建议先从小值试,观察pm.max_children余量。 - 加自定义头:带上
X-Warmup: 1,然后在 Nginx 里可以针对这个头做特殊处理(比如放宽限流),也方便在日志里区分预热流量和真实流量。 - 预热后复查命中率:跑一遍热身脚本,再看 cache.log 的统计,确认 HIT 比例确实上升。
- 不要预热全部:几万页面的站点全量预热可能跑一两个小时,如果站点有定时任务或高流量,反而会造成负载尖峰。优先预热首页、栏目页、近期文章(按 sitemap 的 lastmod 排序取前 500 条)。
把脚本挂到 crontab,每天凌晨低峰期跑一次:
0 4 * * * /usr/local/bin/cache_warmup.sh >> /var/log/cache_warmup.log 2>&1
六、主动清理:proxy_cache_purge 模块
内容更新后要立刻生效,靠等过期太慢。标准做法是编译 Nginx 的 ngx_cache_purge 模块,然后在收到特殊请求时主动删掉指定缓存。
先确认模块是否已编译进去:
nginx -V 2>&1 | tr ' ' '\n' | grep -i purge
如果没有输出,说明没装。Debian/Ubuntu 需要自己编译,或者直接用带该模块的第三方包(如 nginx-extras,部分发行版自带)。编译方式示意:
./configure --add-module=/usr/local/src/ngx_cache_purge \
--with-http_ssl_module ...(其余参数照抄 nginx -V 的输出)
make && make install配置清理入口。核心是 proxy_cache_purge 指令,它只允许来自可信来源的请求:
# 简单方式:用 secret 路径做保护
location ~ /purge(/.*) {
allow 127.0.0.1;
allow 10.0.0.0/8;
deny all;
proxy_cache_purge webcache "$scheme$host$1";
}这样,在本机执行:
curl -s "http://127.0.0.1/purge/article/123/"
就能把 https://www.example.com/article/123/ 的缓存删掉。注意 proxy_cache_purge 的 key 必须和 proxy_cache_key 完全一致,否则删不掉——这是最容易踩的坑。如果 cache_key 里带了 $cache_args,purge 的 key 里也要带上相同的部分。
更安全的做法是用一段随机串作为前缀,避免路径被猜到:
location ~ ^/purge-8f3a9c/(.*) {
allow 127.0.0.1;
deny all;
proxy_cache_purge webcache "$scheme$host/$1";
}再进一步,可以结合 map 从密钥头校验,只有带正确 X-Purge-Key 的请求才允许清理。对于多机部署,甚至可以写一个内部接口,发布文章后由应用主动调用各个节点的 purge 地址。
七、发布即刷新:把 purge 接进发布流程
对于用脚本或 CI 发布的站点,最顺手的做法是在发布成功后自动 purge 相关页面。以文章发布为例,通常需要清理三个 URL:文章页本身、列表页、首页。伪代码:
def purge_urls(urls):
for u in urls:
path = u.replace("https://www.example.com", "")
r = requests.get(
"http://127.0.0.1/purge" + path,
timeout=5
)
print(path, r.status_code)
purge_urls([
"https://www.example.com/article/123/",
"https://www.example.com/",
"https://www.example.com/category/tech/",
])如果 Nginx 是多机负载均衡,就对每一台内网 IP 都发一遍。也可以给 purge 加一个「批量」路径,一次传入多个 URL,减少请求次数。
八、一套可以直接抄的生产配置
proxy_cache_path /var/cache/nginx/proxy levels=1:2
keys_zone=webcache:100m
max_size=10g
inactive=2h
use_temp_path=off;
# 剔除追踪参数,统一缓存 key
map $args $cache_args {
default "";
"~^(.*)(^|&)page=(\d+)(.*)$" "page=$3";
}
server {
listen 443 ssl http2;
server_name www.example.com;
location / {
proxy_pass http://127.0.0.1:8080;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_cache webcache;
proxy_cache_key "$scheme$host$uri$cache_args";
proxy_cache_valid 200 1h;
proxy_cache_valid 301 302 10m;
proxy_cache_valid 404 1m;
# 单请求穿透,避免并发回源打爆后端
proxy_cache_lock on;
proxy_cache_lock_age 10s;
proxy_cache_lock_timeout 30s;
# 后端故障时兜底
proxy_cache_use_stale error timeout updating
http_500 http_502 http_503 http_504;
proxy_cache_background_update on;
# 登录用户与后台不缓存
proxy_cache_bypass $cookie_logged_in $arg_nocache;
proxy_no_cache $cookie_logged_in $arg_nocache;
add_header X-Cache-Status $upstream_cache_status always;
}
location ~ ^/purge-8f3a9c/(.*) {
allow 127.0.0.1;
deny all;
proxy_cache_purge webcache "$scheme$host/$1";
}
}其中 proxy_cache_lock on 值得单独说:它保证同一 URL 的并发请求只有一个回源,其余等待结果,避免缓存失效瞬间大量请求同时打到后端(缓存雪崩)。proxy_cache_lock_timeout 30s 是等待上限,超时后放行。
九、验证与排错
改完配置后按这个顺序验证:
nginx -t检查语法,通过后systemctl reload nginx。- 连续两次
curl -I同一 URL,第一次应返回 MISS,第二次 HIT。如果第二次还是 MISS,按第四节的原因逐条排查。 - 确认缓存目录有文件产生:
du -sh /var/cache/nginx/proxy。 - 改一篇文章内容,执行 purge,再 curl 应看到内容已更新且状态为 MISS。
- 看一眼
proxy_cache_path所在磁盘的剩余空间,确保max_size不会把磁盘写满。
需要特别提醒:缓存目录不要放在 /tmp 或内存文件系统上,除非你很清楚权衡——/tmp 被清理会导致缓存全丢,而 tmpfs 会挤占物理内存。
十、小结
Nginx 代理缓存的价值在于用磁盘换 CPU 和响应时间。配置本身不难,难的是闭环:合理的 key 设计让命中率上去,预热让冷启动不再是问题,主动 purge 让内容更新即时生效。这三件事做齐,缓存才算真正可用。
对个人站长来说,一台 1C2G 的机器配上完善的代理缓存,扛住每天几千到几万 PV 的静态内容站完全没问题。与其急着升级配置,不如先把这一层缓存打磨到位——这是投入产出比最高的一次优化。