为什么要自己动手做 Nginx 缓存
很多站长一遇到网站变慢,第一反应是"上 CDN"。CDN 确实能解决静态资源的全球分发问题,但它并不便宜,而且对于动态页面、登录态页面、API 接口这类内容,CDN 往往爱莫能助。真正的性价比之王,其实是把 Nginx 自带的 proxy_cache 用起来——它能把后端 PHP、Python、Java 生成的结果缓存在本地磁盘上,命中缓存时直接由 Nginx 返回,连后端进程都不需要唤醒。
本文从零讲清楚 Nginx 磁盘缓存的完整配置:缓存池怎么开、缓存键怎么设计、哪些响应不该缓存、缓存怎么预热和清理,以及一个特别容易踩的坑——后端一抖动就把整个缓存刷成空的问题。
第一步:定义缓存存储区
缓存必须先在 http 块里声明一个存储区(cache zone),告诉 Nginx 缓存放到哪个目录、占用多大内存索引、磁盘上限多少、多久没访问就清理。
# 放在 /etc/nginx/nginx.conf 的 http {} 块内
proxy_cache_path /var/cache/nginx/blog
levels=1:2
keys_zone=blog_cache:100m
max_size=10g
inactive=7d
use_temp_path=off;
逐项解释:
levels=1:2:缓存文件用两级目录散列存放(比如/var/cache/nginx/blog/a/1b/<hash>),避免单个目录下文件过多导致文件系统查找变慢。keys_zone=blog_cache:100m:分配 100MB 共享内存存放缓存键和元数据。经验值是 1MB 大约能存 8000 个缓存条目,100MB 足够存约 80 万条,对绝大多数站点够用。max_size=10g:磁盘缓存总量的硬上限,超了 Nginx 会自己淘汰最旧的。inactive=7d:7 天内没有被访问过的缓存条目会被删除,无论有没有过期。use_temp_path=off:让临时文件直接写到缓存目录,避免跨文件系统 rename,能省掉一次数据拷贝,官方推荐开启。
记得提前建目录并交给 Nginx 用户:
mkdir -p /var/cache/nginx/blog
chown -R www-data:www-data /var/cache/nginx/blog
第二步:最小可用的缓存配置
在站点的 server 或 location 块里引用这个存储区:
server {
listen 443 ssl http2;
server_name www.example.com;
location / {
proxy_pass http://127.0.0.1:9000;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_cache blog_cache;
proxy_cache_valid 200 301 302 10m;
proxy_cache_valid 404 1m;
proxy_cache_key "$scheme$request_method$host$request_uri";
add_header X-Cache-Status $upstream_cache_status;
}
}
其中 add_header X-Cache-Status $upstream_cache_status 是调试神器,它会在响应头里暴露这次请求到底命中了缓存(HIT)、未命中(MISS)、还是被绕过(BYPASS)。上线后先用它确认缓存有没有按预期工作,稳定后再决定要不要保留。
第三步:缓存键(cache key)怎么设计
缓存键决定了"什么算同一个页面"。默认键是 $scheme$proxy_host$request_uri,它忽略了请求方法和 Host,多个域名共用一个后端时可能串页,也会把 POST 请求混进来。推荐显式写成:
proxy_cache_key "$scheme$request_method$host$request_uri";
这里包含了几层考虑:
- 把
$request_method放进去,GET 和 POST 就不会互相覆盖。 - 如果站点区分移动端和 PC 端(
/m/前缀或 UA 判断),需要把$http_user_agent归一化后拼进键,比如用map把 UA 映射成mobile/desktop两个值。 - 如果站点根据 Cookie 返回不同内容(登录态),绝不能把 Cookie 拼进缓存键——正确做法是根本不给这些页面用缓存,见下一节。
第四步:哪些请求不该进缓存
缓存最大的风险是"把不该缓的内容缓了"。以下三类必须明确排除:
# 1. 带登录 Cookie 的请求不走缓存
map $http_cookie $skip_cache {
default 0;
"~*wordpress_logged_in" 1;
"~*PHPSESSID" 1;
"~*sessionid" 1;
}
# 2. 后台、登录、购物车、API 等路径不走缓存
map $request_uri $no_cache_uri {
default 0;
"~*/wp-admin/" 1;
"~*/wp-login.php" 1;
"~*/cart/" 1;
"~*/my-account/" 1;
"~*/api/" 1;
}
server {
# ...
set $bypass 0;
if ($skip_cache) { set $bypass 1; }
if ($no_cache_uri) { set $bypass 1; }
if ($request_method = POST) { set $bypass 1; }
location / {
proxy_pass http://127.0.0.1:9000;
proxy_cache blog_cache;
proxy_cache_bypass $bypass;
proxy_no_cache $bypass;
proxy_cache_valid 200 10m;
add_header X-Cache-Status $upstream_cache_status;
}
}
proxy_cache_bypass 表示"这次请求不去读缓存,直接问后端",proxy_no_cache 表示"这次拿到的结果不要写进缓存"。两者通常成对使用,值非 0 或非空即生效。
第五步:后端抖动会毁掉整个缓存——stale 的正确用法
这是最容易被忽视的坑。假设缓存有效期 10 分钟,10 分钟一到,第一个访问的用户会触发回源。如果此时后端正好因为重启、慢查询、数据库连接池打满而返回 502,Nginx 默认会把 502 当作"正常响应"处理,并且如果配了 proxy_cache_valid 502,它甚至会把错误页缓存下来,让整个站点的访客都看到错误页面,直到缓存过期。
正确姿势是引入 stale 机制:让 Nginx 在缓存过期时继续用旧内容,同时去后台异步更新。
proxy_cache_use_stale error timeout updating http_500 http_502 http_503 http_504;
proxy_cache_background_update on;
proxy_cache_lock on;
proxy_cache_lock_timeout 5s;
proxy_cache_use_stale:后端出错或超时时,直接返回已经过期的旧缓存,用户永远看不到 502。proxy_cache_background_update on:允许用旧缓存响应用户的同时,在后台发起更新请求,用户不用等。proxy_cache_lock on:同一时间只允许一个请求回源,其余请求等待。这能防止缓存刚过期的瞬间几十个并发请求一起压向后端(缓存击穿)。proxy_cache_lock_timeout 5s表示最多等 5 秒,超时后放行让其他请求自己去回源。
再配一条 proxy_cache_valid 200 301 302 10m; 但不要给 5xx 配 valid,让错误响应默认不缓存,配合 stale 才能真正抗抖动。
第六步:缓存预热与主动清理
缓存刚上线或者刚清空时,第一批访客会经历一轮"冷启动"回源。有两个办法缓解:
一是用后台脚本主动预热热门 URL:
# 预热:用 curl 打一遍热门页面,让缓存填满
for u in / /about/ /archives/1/ /archives/2/; do
curl -s -o /dev/null -H "Host: www.example.com" "https://127.0.0.1$u" -k
done
二是内容更新后主动清理对应 URL 的缓存。Nginx 开源版没有原生 purge 指令,常见的做法是用一个"缓存清理键"绕过缓存并刷新。最简单的方案是给内容加版本号:CMS 更新文章时,把 URL 从 /post/123/ 改成 /post/123/?v=1699999999 再回源一次,或者干脆用第三方模块 ngx_cache_purge 提供 PURGE 方法。
# 装了 ngx_cache_purge 之后
location ~ /purge(/.*) {
allow 127.0.0.1;
deny all;
proxy_cache_purge blog_cache "$scheme$request_method$host$1";
}
注意 purge 的键必须和 proxy_cache_key 完全一致,否则清不掉。很多"purge 了但页面没变"的问题,都是键不匹配造成的。
第七步:监控缓存命中率
配置完不是终点,得看命中率。开启 stub_status 或用日志里的 $upstream_cache_status 统计:
log_format cache '$remote_addr "$request" $status '
'cache=$upstream_cache_status '
'upstream=$upstream_response_time';
access_log /var/log/nginx/access.log cache;
然后用统计脚本看 HIT 占比:
awk '{for(i=1;i<=NF;i++) if($i ~ /^cache=/){split($i,a,"="); c[a[2]]++}}
END{for(k in c) print k, c[k]}' /var/log/nginx/access.log | sort
健康站点的静态/伪静态页命中率通常在 90% 以上。如果 HIT 很低、MISS 居高不下,一般是三个原因:缓存键里混进了随机值(比如 $arg 带了跟踪参数)、Cache-Control: no-cache 响应头被后端下发、或者 proxy_no_cache 条件写太宽把所有请求都排除了。
小结
Nginx 磁盘缓存的本质,是用本地磁盘换后端 CPU 和数据库连接。核心只有三件事:定义好 cache zone、设计好 cache key、把不该缓的请求排除干净。再加上 stale 抗抖动和命中率监控,一台普通 VPS 就能扛住平时需要好几倍配置才能承受的流量。先把 X-Cache-Status 打开观察一周,你会发现很多"服务器不够用"的问题,其实只是缓存没配好。
补充一:gzip 与缓存的配合,别让缓存把压缩吃掉
很多站长配好缓存后发现,缓存命中率上去了,但流量费用反而涨了——原因往往是压缩和缓存的顺序没理顺。Nginx 的 gzip on 默认只压缩动态生成的响应;如果后端已经返回了 Content-Encoding: gzip,Nginx 不会重复压缩,也不该重复压缩。
正确的做法是:让缓存存"未压缩"的原始响应,在 Nginx 出站时统一压缩。这样不同客户端(有的支持 brotli、有的只支持 gzip)都能拿到合适的编码,而缓存本身只存一份。配置要点:
gzip on;
gzip_comp_level 5;
gzip_min_length 1024;
gzip_vary on;
gzip_types text/plain text/css application/json application/javascript text/xml application/xml image/svg+xml;
# 关键:告诉后端不要压缩,由 Nginx 统一压缩
proxy_set_header Accept-Encoding "";
其中 proxy_set_header Accept-Encoding ""; 这一行极其重要。它的作用是把客户端发来的 Accept-Encoding 头清空后再转发给后端,后端就会返回未压缩内容,缓存里存的也是未压缩内容;然后在 Nginx 出站时才根据客户端实际支持的能力去压缩。gzip_vary on 会补上 Vary: Accept-Encoding 响应头,避免 CDN 或中间代理把 gzip 内容错误地发给不支持压缩的客户端。如果少了传空 Accept-Encoding 这一步,缓存里可能存进一份 gzip 内容,之后所有客户端拿到的都是它,遇到不支持 gzip 的爬虫或老旧客户端就会出现"乱码"或下载损坏。
补充二:缓存目录放内存盘还是磁盘
缓存目录放在哪种存储上,直接影响命中时的响应速度。常见做法有两种:
- 放 SSD 磁盘:容量大、成本低,命中一次大约需要一次磁盘随机读,SSD 上通常在 0.1ms 级别,对绝大多数站点已经足够快。
- 放 tmpfs 内存盘:把
/var/cache/nginx挂到 tmpfs,命中时纯内存读取,快到极致,但代价是重启即丢、且占用物理内存。适合缓存体积不大(几 GB)、内存充足的机器。
注意,把缓存放 tmpfs 之后,max_size 要小于 tmpfs 的大小,否则写满会报错。/etc/fstab 里的挂载示例:
tmpfs /var/cache/nginx tmpfs size=2G,mode=0755,uid=33,gid=33 0 0
一个折中方案是把"元数据"和"数据体"分开——但那需要更复杂的模块支持,对个人站长来说,先按 SSD 跑,等确实遇到磁盘 IO 瓶颈再换 tmpfs 即可,不要过早优化。
补充三:避免缓存放大的三个纪律
缓存用错方向,会把小问题放大成大事故,记住三条纪律:
第一,不要在缓存键里放会无限增长的参数。比如有些统计脚本会给每个 URL 拼上 ?utm_source=...、?from=... 之类,结果同一个页面被存成成百上千份,缓存目录迅速膨胀却什么都命中不了。正确做法是在 map 层把这些无意义的参数剔除:
map $request_uri $cache_uri {
default $request_uri;
"~^(?<path>[^?]*)\?(.*)$" $path;
}
# 然后 proxy_cache_key 用 $cache_uri
第二,不缓存的响应要明确用 Cache-Control: no-store 或从 Nginx 侧排除,别指望后端会自觉地设对头。第三,上线新缓存规则前先加白名单小范围验证,确认登录态、购物车、后台这些页面没有被误缓——一旦误缓,访客可能看到别人的账户信息,这是安全事故,不是性能问题。