很多站长给站点加了 CDN 之后,发现源站的 CPU 还是常年 100%,一查日志才发现:CDN 只缓存了静态文件,所有动态页面的请求依然原封不动打到 PHP-FPM 上。而动态页面恰恰是数据库压力最大的部分——一个首页要跑十几条查询,一篇热门文章被搜索引荐一小时几千次访问,数据库就跟着抖一小时。
这种情况下最省钱的一招不是加内存、不是换更好的服务器,而是在 Nginx 前面再加一层自己的反向代理缓存,也就是常说的 proxy_cache。它把 PHP 吐出来的 HTML 整页存到本地磁盘,下一个请求直接由 Nginx 返回,连 PHP-FPM 都不进。配置得当的话,同样的机器能扛的并发可以翻好几倍。
但这一招也是最容易出事的一招:缓存键设计错了,A 用户看到 B 用户的后台;过期时间设错了,编辑改了文章前台半小时不更新;缓存目录没清理,磁盘被悄悄占满。下面按我实际踩过的顺序讲一遍。
一、先确认这个站的页面真的适合整页缓存
整页缓存的前提是「同一个 URL,对不同人返回同样的内容」。判断起来很简单:打开浏览器无痕模式,登录与不登录各访问一次你要缓存的页面,把两次的 HTML 存下来做对比。如果只有 CSRF token、用户名、购物车数量这几个位置不同,那说明主体内容是一致的,可以做缓存,只要把那几个位置摘出去;如果内容结构完全不同(比如登录后首页变成「我关注的人的动态」),那这个页面就不能整页缓存,最多缓存给未登录访客的那一份。
我通常把页面分成四类来定策略。第一类是纯静态资源:图片、CSS、JS、字体,这些交给 CDN 就好,不必自己再缓存一遍。第二类是「内容型页面」:文章页、分类页、标签页、列表分页,它们的更新由编辑动作驱动,非常适合整页缓存,也是收益最大的部分。第三类是「半动态页面」:首页如果带着最新评论、热门排行,缓存 30 到 60 秒问题不大,读者晚半分钟看到新评论完全可以接受。第四类是「绝对不可缓存」:后台、登录、下单、支付回调、任何带会话的接口,必须显式排除。
还有一个容易被忽略的点:搜索引擎爬虫和真实用户看到的应该是同一份 HTML。如果你为了性能做了「爬虫来就给完整页面、用户来就异步补数据」,短期看着快,长期是拿收录换速度。整页缓存反而是对 SEO 友好的方案,因为爬虫拿到的就是一份已经在本地生成好的完整页面,响应时间极短。
二、缓存键:设计错了就是事故的开始
Nginx 默认的缓存键是 $scheme$proxy_host$request_uri,看起来够用,实际有三个坑。
第一,它包含 $proxy_host,如果你用 upstream 名字,不同 upstream 会算出不同的键,改一次配置全部缓存失效。我一般显式改写:
proxy_cache_key "$scheme$request_method$host$request_uri";第二,$request_uri 包含查询字符串,这本来是好事——?page=2 和 ?page=3 应该分开缓存。但问题在于,一旦带上 utm_source、fbclid、gclid 这类渠道参数,同一个页面就会被缓存成几十份,命中率直接崩掉,磁盘还被垃圾副本塞满。处理办法不是简单粗暴地「忽略所有参数」(那会让分页全部串成第一页),而是用 map 只剥掉明确的追踪参数:
map $args $cache_args {
default $args;
"~^utm_" "";
"~gclid=" "";
"~fbclid=" "";
}
proxy_cache_key "$scheme$request_method$host$uri$cache_args";注意这里用了 $uri 而不是 $request_uri,再手动拼上清洗过的 $cache_args。不然 $request_uri 里已经带着原始参数了,map 出来的东西根本没生效。这个细节我见过不止一个人写错。
第三,也是最危险的一点:如果站点是多语言的,或者会根据 Accept-Language、Cookie 返回不同内容,而缓存键里没有体现这些维度,就会出现「中文用户打开看到英文页」的经典事故。这时候 Vary 头不是可选项,而是必须项,同时缓存键里也要加对应变量。如果你不确定站点有没有这种分流,用一个反证法:访问同一个 URL 时抓两次响应头,看 Vary 和 Set-Cookie 有没有差异,有差异就别急着上整页缓存。
三、最小可用配置:从一段能跑的定义开始
下面是它在我几台机器上稳定跑了两年的核心片段,可以直接抄过去改域名:
proxy_cache_path /var/cache/nginx/zz levels=1:2 keys_zone=zz_cache:20m
max_size=4g inactive=60m use_temp_path=off;
server {
listen 80;
server_name www.example.com;
set $skip_cache 0;
if ($request_method = POST) { set $skip_cache 1; }
if ($http_cookie ~* "PHPSESSID|admin") { set $skip_cache 1; }
if ($request_uri ~* "/admin/|/login|/cart|/api/") { set $skip_cache 1; }
if ($query_string ~* "nocache=1") { set $skip_cache 1; }
location / {
proxy_pass http://127.0.0.1:8080;
proxy_cache zz_cache;
proxy_cache_bypass $skip_cache;
proxy_no_cache $skip_cache;
proxy_cache_valid 200 301 302 10m;
proxy_cache_valid 404 1m;
proxy_cache_valid any 0;
proxy_cache_use_stale error timeout updating http_500 http_502 http_503 http_504;
proxy_cache_background_update on;
proxy_cache_lock on;
add_header X-Cache-Status $upstream_cache_status always;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
}
}
这一段里有几个参数值得单独说。
levels=1:2 是把缓存文件打散成两级目录,避免一个目录里堆几十万个文件导致 ls 都卡死。inactive=60m 的意思是「60 分钟内没人访问就清理掉」,注意它跟 proxy_cache_valid 的过期时间不是一回事:后者决定「内容还算不算新鲜」,前者决定「多久没人读就淘汰」。很多人只看到磁盘一直在涨,就是因为只调了 valid 没调 inactive。
proxy_cache_lock on 是我认为最被低估的一个开关。没有它的时候,一个热门页面缓存失效的瞬间,如果同时进来 200 个请求,这 200 个会全部穿透到 PHP,也就是俗称的「缓存击穿」,源站可能直接被自己人打死。打开 lock 之后,Nginx 只放一个请求回源,其余排队等结果。
proxy_cache_use_stale 配合 background_update 是另一层保险:当源站返回 502 或者响应超时的时候,Nginx 会把「已经过期但还能用」的旧副本先发出去,同时在后台悄悄更新。用户看到的是一份十秒前的页面,而不是一个 502 错误页。对内容站来说,这几乎等于免费的高可用。
四、绕开缓存:四种手段的正确用法
第一种是 proxy_cache_bypass:条件成立时,不去读缓存,直接回源。它解决的是「我自己想看最新版」的需求,比如编辑在 URL 后面加 ?nocache=1。第二种是 proxy_no_cache:条件成立时,回源的结果不写进缓存。它解决的是「这份响应不能给别人看」。这两个必须成对出现,只写前者会留下一份旧副本给别人,只写后者会让下次请求还读到旧内容。
第三种是从后端控制。很多时候判断逻辑放在 PHP 里更合适,因为只有应用知道「这个用户是不是管理员」。这时候让 PHP 输出一个响应头 X-Accel-Expires: 0,Nginx 就完全跳过这份响应的缓存。同理,X-Accel-Expires: 60 可以按单篇覆盖全局的过期时间——比如新闻页给 60 秒,关于页面给 1 小时。
第四种是发布后主动清缓存。这里有个常见的误区:很多教程教你「发新文章后把整个缓存目录删掉」,看起来简单,实际非常伤——因为 rm -rf /var/cache/nginx/zz/* 之后,所有页面同时变冷,下一次流量高峰会齐刷刷回源,等于自己制造一次 DDoS。更温和的做法是只清掉相关路径,或者干脆靠较短的过期时间自然过期。如果确实需要精确清除,用 ngx_cache_purge 模块配一个内部 location,只允许本地调用:
location ~ /purge(/.*) {
allow 127.0.0.1;
deny all;
proxy_cache_purge zz_cache "$scheme$request_method$host$1$is_args$args";
}发文章时,curl -X PURGE http://127.0.0.1/purge/新文章路径,再清一下首页和对应分类页就够了。三个 PURGE 请求,代价可以忽略。
五、验证命中率,别靠感觉
配置上之后必须看数字,否则你永远不知道它到底有没有在工作。最直接的办法是加 X-Cache-Status 响应头,然后用 curl 打几次:
curl -sI "http://127.0.0.1/123.html" | grep -i x-cache-statusMISS 表示第一次回源,HIT 表示命中,BYPASS 表示被 proxy_cache_bypass 跳过,EXPIRED 表示有旧副本但已过期,UPDATING 表示正在后台更新。正常情况下,第一次 MISS、后面连续 HIT 才对。如果同一秒内连着打五次都是 MISS,那基本可以断定 proxy_no_cache 的条件被误触发了,最常见的就是 Cookie 匹配写得太宽——比如 if ($http_cookie ~* "admin") 把访客的 _ga、统计脚本的 cookie 全匹配上了。
长期统计不要靠头字段,直接看日志。在 log_format 里加上 $upstream_cache_status,一天下来算一下 HIT 占比:
awk '{print $NF}' /var/log/nginx/access.log | sort | uniq -c | sort -rn内容型站点的健康值是 HIT 在 70% 以上。如果你看到 BYPASS 占了 40%,先别怀疑 Nginx,去查那 40% 的请求 URL 长什么样——多半是后台接口或者某个被误判的路径。如果 MISS 占大头而 HIT 很少,检查两件事:站点是不是被 CDN 挡住了,你都收不到真实用户请求;以及 max_size 是不是太小导致缓存刚写就被淘汰。
六、磁盘与权限:两个最无聊也最致命的问题
缓存目录放在 /var/cache 还是根分区,取决于你的磁盘布局。我强烈建议单独挂一个分区或者至少用 tmpfs 的目录结构承载,因为 max_size 只是 Nginx 自己管理的上限,如果目录被别的进程写脏、或者日志和缓存混在一个分区里,磁盘满的时候整个站点会一起挂。曾经有一次,机器上 /var/log 和 /var/cache 同分区,缓存写满之后 MySQL 无法写临时表,报了一堆看不懂的错误,排查了两小时才发现是磁盘 100%。
权限方面,Nginx 的 worker 进程通常是 www-data 或 nginx,缓存目录的属主必须是它,否则会静默失败——注意是静默:Nginx 只会写一条 warn 级别日志,页面照常返回,只是永远不 HIT。所以配置完的第一件事是 chown -R www-data:www-data /var/cache/nginx,然后 ls -l 确认目录里有新的子目录被创建出来。
另外 use_temp_path=off 这行别删。默认情况下 Nginx 先把响应写到 /var/lib/nginx/tmp,写完再 rename 到缓存目录。如果这两个路径不在同一个文件系统,rename 就退化成复制加删除,大文件(比如一篇带内联图片的页面)会额外消耗一倍 IO 和空间。开了这个参数就直接写进缓存目录,省一次搬运。
七、什么时候该放弃它
最后说个反面的话:不是所有站都适合自己搭一层 proxy_cache。如果站点访问量不大、随便加 2G 内存就能解决问题,那配缓存带来的复杂度(缓存键、绕开规则、清缓存流程、调试时的困惑)可能大于收益,直接用 CDN 的缓存规则更省事。如果有大量个性化内容、频繁的实时数据、或者干脆是单页应用,整页缓存也会很别扭。
它真正发光的场景很明确:内容型站点、突发流量、源站数据库吃紧、或者你想在低配机器上稳住几千的并发。判断标准就一条——先看 upstream_response_time 的 p95,如果这个数在 300 毫秒以上并且大部分时间花在 PHP 与数据库上,那整页缓存能把这 300 毫秒压到 5 毫秒以内,这笔账就划算。