Nginx proxy_cache 反向代理缓存实战:动态站缓存键设计、命中率调优与五个必踩的坑

为什么你的 PHP 站明明缓存了还是慢

很多个人站长给网站做了优化:开了 opcache、上了 Redis、把静态资源交给了 CDN,但一打开动态页面,首字节时间(TTFB)还是要一两秒。原因往往不在应用层,而在于每一个请求都老老实实穿透到了后端 PHP-FPM,从头算一遍、查一遍数据库、拼一遍 HTML。对于内容型网站——文章列表、分类页、标签页、详情页——这些页面在几分钟甚至几小时内内容几乎不变,却每次都被完整地重新生成,这是极大的浪费。

Nginx 自带的 ngx_http_proxy_cache_module 就是解决这个问题的钥匙。它能在 Nginx 这一层把后端返回的整页响应缓存到磁盘或内存,后续请求直接由 Nginx 吐出,不再惊动 PHP 和 MySQL。配好之后,一个纯动态的站可以在命中缓存时把 TTFB 从 800 毫秒压到 10 毫秒以内,同时把后端压力砍掉八成以上。本文从一个真实的内容站场景出发,讲清楚 proxy_cache 的完整配置、缓存键设计、命中率调优,以及最容易翻车的几个坑。

proxy_cache 的工作原理:它到底把什么存下来了

Nginx 的 proxy_cache 本质上是一个磁盘缓存系统,配合一块共享内存作为索引。当 Nginx 作为反向代理(proxy_pass)把请求转发给后端时,它会根据你定义的 缓存键(cache key)计算出一个 MD5 哈希,用这个哈希去共享内存里查索引:

  • 如果命中且未过期,Nginx 直接读磁盘上的缓存文件返回,记一次 HIT,完全不联系后端。
  • 如果未命中,Nginx 向后端发起请求,一边把响应发给客户端,一边把响应体写入缓存文件,记一次 MISS。
  • 当多个相同请求同时到达而缓存尚未建立时,Nginx 默认会记录 STALE 或直接放行多个后端请求;生产上通常要靠 proxy_cache_lock 让它们只回源一次。

关键点在于:缓存键决定了"什么算同一个页面"。默认的 $scheme$proxy_host$request_uri 对大多数站长够用,但只要站上有登录态、有移动端和 PC 端两套模板、或者带 ?from= 之类的营销参数,就必须自己重新设计缓存键,否则要么串号,要么命中率极低。

第一步:定义缓存区和路径

缓存配置写在 http 块里。proxy_cache_path 定义缓存在哪、多大、保留多久。下面是一个面向内容站的推荐配置:

http {
    # 注意:以下路径必须由 Nginx 的 worker 用户可写
    proxy_cache_path /var/cache/nginx/proxy
        levels=1:2
        keys_zone=pagecache:64m
        max_size=4g
        inactive=7d
        use_temp_path=off;

    # 一次性把缓存目录建好并授权
    # mkdir -p /var/cache/nginx/proxy
    # chown -R www-data:www-data /var/cache/nginx

    proxy_temp_path /var/cache/nginx/temp;
}

逐项解释:levels=1:2 表示用两级目录(哈希的第一位和接下来两位)分散文件,避免单个目录里塞几十万个文件导致磁盘查找变慢;keys_zone=pagecache:64m 划出 64MB 共享内存存索引,大约能容纳几十万个条目的元数据;max_size=4g 限制缓存文件总大小,超了会按 LRU 淘汰;inactive=7d 表示 7 天没被访问的条目会被清理;use_temp_path=off 让缓存文件直接写进目标目录,少一次磁盘拷贝,对 IO 友好。

第二步:在 server / location 里启用缓存

光定义了缓存区还不够,得在具体的响应位置上打开它。典型的内容站配置如下:

server {
    listen 443 ssl http2;
    server_name www.example.com;

    # 只缓存 200/301/302,其余状态码绝不落盘
    proxy_cache_valid 200 301 302 10m;

    location / {
        proxy_pass http://127.0.0.1:9000;
        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 pagecache;

        # 自定义缓存键:去掉无关查询参数,区分移动端
        proxy_cache_key "$scheme$host$request_uri$is_args$arg_from";

        # 允许给上游返回带 Set-Cookie 的响应做缓存标记
        proxy_ignore_headers Set-Cookie;

        # 后端返回这些标记时强制不缓存
        proxy_no_cache $cookie_loggedin $arg_nocache;

        # 命中时给响应加上状态头,方便调试
        add_header X-Cache-Status $upstream_cache_status;

        # 缓存锁:同一时刻只有一个请求回源,其余等待
        proxy_cache_lock on;
        proxy_cache_lock_timeout 5s;
    }

    # 后台、登录、API 一律不缓存
    location ~ ^/(admin|login|api)/ {
        proxy_pass http://127.0.0.1:9000;
        proxy_cache off;
    }
}

这里有几个地方是站长最常踩的:第一,登录态必须排除。用 proxy_no_cache $cookie_loggedin,让带登录 Cookie 的请求永远回源,否则缓存下来一个"已登录版"页面泄露给所有人,就是安全事故。第二,后台、登录页、API 一定单独 location 关闭缓存,绝不能靠 proxy_cache_valid 一般规则蒙混过去。第三,X-Cache-Status 头是调试的命脉,务必加上,后面靠它看命中率。

第三步:验证命中率,别凭感觉

配置完 reload 一次,然后用 curl 连打两次同一个页面,观察 X-Cache-Status:

curl -sI https://www.example.com/ -H 'Host: www.example.com' | grep -i x-cache
# 第一次:X-Cache-Status: MISS
# 第二次:X-Cache-Status: HIT

MISS 之后紧接着应变为 HIT,说明缓存张开了口。如果第二次还是 MISS,几乎可以断定是缓存键里带了每次变化的东西——最常见的就是后端往响应里发了 Set-Cookie,Nginx 默认遇到它就拒绝缓存。上面那句 proxy_ignore_headers Set-Cookie; 正是为此准备的,但用它之前务必确认你已用 proxy_no_cache 把登录用户排除了,否则会缓存到私有内容。

下一步是统计整体命中率。最直接的办法是从日志里数:

# 假设日志格式里加了 cache 状态字段(见下一节)
awk '{print $NF}' /var/log/nginx/access.log | sort | uniq -c | sort -rn

把 log_format 加一个 upstream_cache_status 字段,长期观察 HIT/MISS 比例:

log_format page '$remote_addr - $upstream_cache_status [$time_local] '
                '"$request" $status $body_bytes_sent "$http_referer" "$http_user_agent"';
access_log /var/log/nginx/access.log page;

健康的内容站缓存命中率应该在 70% 以上。如果低于 40%,回到缓存键检查:是不是带了时间戳参数、是不是 Cookie 没排除干净、是不是 Cache-Control: no-store 之类的响应头把缓存毒死了。

缓存失效:内容更新了怎么让它立刻生效

缓存带来最大便利的同时也带来最大烦恼——发了新文章、改了首页配置,用户却还看到旧页面。Nginx 自己不带主动 purge 功能(开源版),有几个实用做法:

  • 短 TTL 兜底:把 proxy_cache_valid 200 10m 设成 5 到 10 分钟,靠时间自然过期。适合更新不频繁的站,最省心。
  • 带参数绕缓存:在模板里给"最新文章"这类需要实时的区块拼一个变化参数,配合缓存键里保留该参数,实现局部绕过。
  • 发布时主动删缓存文件:用 Nginx 的 ngx_cache_purge(需要第三方模块)或让发布脚本算好哈希、直接删对应文件再 reload。适合对时效要求高的场景。
  • 后台整站清空:内容大改时直接 rm -rf /var/cache/nginx/proxy/*,简单粗暴但对小站完全可用。

进阶:分层缓存与缓存预热

当站点访问量上来之后,单级 proxy_cache 还能再往前推一步。一是把热点页面的缓存文件放到更快的位置——如果服务器有 SSD 甚至把最热的一小部分挂在 tmpfs 内存盘上,命中缓存时的读取几乎没有 IO 等待。二是做缓存预热:在发布新内容或清空缓存之后,用一个脚本主动请求一批最重要的页面(首页、栏目页、热门文章),让缓存提前建好,避免第一批真实用户成为"受害者"撞上 MISS。一个简单的预热脚本大致是这样:

#!/bin/bash
# 发布后预热关键页面,全部走一次请求把缓存建起来
for url in \
  "https://www.example.com/" \
  "https://www.example.com/category/tech/" \
  "https://www.example.com/category/linux/"; do
  curl -s -o /dev/null -w "%{http_code} %{time_total}s  $url\n" \
    -H 'Host: www.example.com' "$url"
done

预热跑完后,用户第一次访问就已经是 HIT,体验和 SEO 都受益。对于更新频繁的栏目,可以把它挂进 crontab,每十分钟预热一次热点列表。

另一个细节是 stale-while-revalidate 式的容错。Nginx 支持在后端短时不可用(比如 PHP-FPM 正在重启)时,用一份稍旧的缓存先顶上,避免用户直接看到 502。配合 proxy_cache_use_stale 可以显著降低发布、重启期间的错误率:

proxy_cache_use_stale error timeout updating http_500 http_502 http_503 http_504;
proxy_cache_background_update on;

第一行表示当后端报错、超时或正在更新缓存时,允许把已过期的旧内容先发给用户;第二行允许 Nginx 在后台悄悄更新缓存,而不用阻塞当前请求。这两行是内容站在"永远别让用户看到错误页"这个目标上非常值得加上的配置。

五个必踩的坑

  1. 目录权限没给对:Nginx worker 用户(www-data 或 nginx)对缓存目录没有写权限,表现为缓存永远 MISS,日志里报 Permission denied。
  2. 缓存了带 Cookie 的私有页面:忘了 proxy_no_cache,结果 A 用户看到了 B 用户的会话,属于严重安全问题。上线前一定要用两个不同账号实测。
  3. CDN 和 Nginx 双重缓存叠加:CDN 层已经缓存了,Nginx 层又被 CDN 的回源请求反复写,逻辑没理清会导致更新后长时间看不到新内容。建议 CDN 只缓存静态资源,动态页缓存在源站 Nginx 处理,职责分开。
  4. max_size 设太小:4G 对小站够用,但图片和附件多了会频繁淘汰,命中率雪崩。用 du -sh /var/cache/nginx/proxy 定期观察实际占用。
  5. 忘了缓存对 SEO 的影响:搜索引擎爬虫也吃缓存,这本身没问题,但要保证首页和文章页的缓存内容是爬虫该看到的公开版本,别把"仅会员可见"的提示缓存进去。

proxy_cache 是个人站长手里性价比极高的性能杠杆:几行配置、零成本,就能把动态站的首屏速度拉到一个完全不同的档次。把缓存键、登录排除和失效策略这三件事做对,剩下的就是长期观察命中率慢慢调优了。

Last modification:October 4th, 2026 at 07:22 pm

Leave a Comment