Nginx proxy_cache 深度实战:缓存预热、主动清理与命中率优化全解析

很多站长在 Nginx 上配过反向代理缓存,加几行 proxy_cache_pathproxy_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 的静态内容站完全没问题。与其急着升级配置,不如先把这一层缓存打磨到位——这是投入产出比最高的一次优化。

Last modification:September 13th, 2026 at 07:56 am

Leave a Comment