云服务器上配 Nginx proxy_cache:`use_temp_path` 决定双倍 IO,Range 请求决定目录爆炸
先把场景钉死
一台 4C8G 的云服务器,Nginx 反代后端(源站可能是 Apache/PHP-FPM,也可能是跨机房的 API),要求是:源站抖动 5 分钟内,前端不能大面积 5xx;同时被打缓存的文件不能把根分区撑爆。
大部分教程到 proxy_cache_path ... keys_zone=xxx:10m; 就结束了。等你真上线,会发现三件事:cache 目录里的文件增长比预期快 10 倍、源站慢的时候后端 QPS 会瞬间飙上去、机器重启后前 3 分钟磁盘 IO 100%。这篇把这三个坑一次讲透。
一、缓存目录落哪:先看 inode 再看吞吐
别急着写配置。先确认两件事:
# 1) 目标挂载点的 inode 余量和文件系统类型
df -i /data
findmnt -T /data -o SOURCE,FSTYPE,OPTIONS
# 2) 如果是云盘,确认是不是 XFS(inode 动态分配,比 ext4 默认更适合百万级小文件)
# ext4 的默认 bytes-per-inode 是 16384,1 亿个缓存文件需要约 1.5TiB 才够 inode
一个 200KB 的图片,缓存文件在磁盘上占一个 inode,加一个目录项。50 万文件看着不多,但如果你的 proxy_cache_key 带上了 $args 里的追踪参数,同一个 URL 能裂出几十份。
目录层级怎么定
levels=1:2 意味着:第一层 1 个十六进制字符 = 16 个目录,第二层 2 个字符 = 256 个目录,叶子目录总数 16×256 = 4096 个。文件名是 cache key 的 MD5,32 位十六进制,Nginx 取末尾字符分层,所以实际路径长这样:
/data/nginx/cache/c/29/b7f54b2df7773722d382f4809d65029c
100 万个缓存文件摊到 4096 个叶子目录,平均每个目录 244 个文件,XFS 完全没问题。levels=1:2:2 是 16×256×256 ≈ 105 万目录,除非你要缓存千万级文件,否则纯属给自己添乱——cache loader 启动扫描会慢到你想砸键盘。
二、`use_temp_path=off` 不是可选项
这是本文最值钱的一段。
默认情况下 proxy_cache_path 用的是 use_temp_path=on,Nginx 会先把响应体写进 proxy_temp_path(默认 /var/lib/nginx/proxy 或编译时的 /var/cache/nginx/proxy_temp),写完再 rename() 到 cache 目录。
问题来了:proxy_temp_path 通常落在根分区,而你把 cache 目录挂在了 /data 这块独立云盘上。跨文件系统的 rename(2) 会返回 EXDEV,Nginx 的 ngx_ext_rename_file 会退化成「读出来 + 写进去 + 删源文件」。
验证一下,起个 worker,追一下系统调用:
NGX_PID=$(cat /run/nginx.pid)
strace -f -e trace=rename,renameat,renameat2,copy_file_range,write \
-p $NGX_PID 2>&1 | grep -Ei 'proxy_temp|EXDEV|cache'
看到 rename(...) = -1 EXDEV (Invalid cross-device link) 就是中招了。后果是:一次缓存回源,写盘数据量翻倍,page cache 占用翻倍,云盘的 IOPS 直接砍半。对于大响应体(视频切片、软件包),这个开销非常明显。
一行修好:
proxy_cache_path /data/nginx/cache
levels=1:2
keys_zone=zone_web:200m
inactive=7d
max_size=200g
min_free=20g
use_temp_path=off
manager_files=1000
manager_sleep=200ms
manager_threshold=500ms
loader_files=2000
loader_sleep=100ms
loader_threshold=300ms;
use_temp_path=off 之后,临时文件直接落在 cache 目录同一层,rename() 变成同文件系统的原子操作,零拷贝。
我们线上这批缓存节点用的是轻云互联的 NVMe 云盘机型,4K 随机写实测能稳在 3 万 IOPS 以上,跑这套配置时 iostat -x 1 里 %util 常年个位数,主要还是靠 use_temp_path=off 把写放大砍掉了。
三、Range 请求:缓存目录的隐形杀手
这个坑极其隐蔽。默认的 proxy_cache_key 是:
$scheme$proxy_host$request_uri
注意,$request_uri 只包含 path + query,不含请求头。所以一个带 Range 头的请求和一个不带 Range 的请求,会命中同一个 cache key——听起来是好事?
坏消息在后端。Nginx 对带 Range 的请求默认是「不回源缓存、直接透传」的,但如果上游自己返回 206 Partial Content,或者你开了 proxy_cache 之后上游返回了 200 全量,缓存里就会存下一份局部内容却被当成完整内容的文件。后续不带 Range 的请求命中的就是这个残缺版本。
更常见的炸法是这样的:有人为了「优化」,在 proxy_cache_key 里加上了 $http_range:
# 千万别这么写
proxy_cache_key "$scheme$proxy_host$request_uri$http_range";
然后视频站每个字节范围都生成一份缓存。30 分钟的 mp4,按 1MB 分片,一个文件能吃出 2000 个缓存条目。一周之后 cache 目录百万文件起步,inode 见底。
正确姿势是三个参数配合:
# 1) cache key 里绝不带 Range
proxy_cache_key "$scheme$proxy_host$uri$is_args$args";
# 2) 超过这个字节偏移的 Range 请求,直接回源且不缓存
proxy_cache_max_range_offset 1m;
# 3) 让 Nginx 自己处理 Range 切片,而不是把 Range 透传给上游
proxy_force_ranges on;
proxy_cache_revalidate on;
另外,proxy_cache_convert_head 默认是 on——Nginx 会把 HEAD 请求转成 GET 去查缓存,然后只回 header。这个行为一般是对的,但如果你的上游对 HEAD/GET 返回不同的 Content-Length,记得关掉它,否则客户端拿到的是 GET 的长度、缺了 body。
四、`proxy_cache_lock` 的雪崩,比不开锁更难看
开锁的本意是「同一个 key 只放一个请求回源」,但这里有两个独立计时器,很多人只配了一个:
proxy_cache_lock_age:锁的持有者已经回源 5 秒了还没结束,允许第二个请求也去回源(默认 5s)proxy_cache_lock_timeout:等待者最多等 5 秒,超时后放行去回源,且响应不写入缓存(默认 5s)
致命的是后者的「不写入缓存」——一旦上游 P99 是 8 秒,第 5 秒时所有排队的请求同时放行。假设此刻有 300 个等待者,上游瞬间收到 300 个并发,本来就慢的上游直接跪,然后 502 覆盖所有缓存,回源进一步放大。
正确配置:
proxy_cache_lock on;
# 锁年龄必须大于上游 P99,否则锁形同虚设
proxy_cache_lock_age 15s;
# 等待超时必须小于 lock_age,让等待者至少能等到一次成功的锁释放
proxy_cache_lock_timeout 10s;
同时把 proxy_read_timeout、proxy_connect_timeout 收紧到业务真实水位,别让 proxy_read_timeout 还是 60s 默认值。上游卡住时,60 秒的悬挂连接会吃掉 worker 的连接槽位。
五、让源站宕 5 分钟也不掉:`stale` 与后台更新
这才是「源站挂了但站点还活着」的关键,配置顺序不能错:
# 必须连带 updating,否则 background_update 不生效
proxy_cache_use_stale error timeout invalid_header updating
http_500 http_502 http_503 http_504;
proxy_cache_background_update on;
机制是这样的:缓存条目过期后,第一个请求会触发后台异步回源(background_update),同时立即把已过期的旧内容返给客户端——前提是 use_stale 列表里有 updating。少了这个词,后台更新期间的前端请求会全部挂住等回源。
补充两个容易被忽略的点:
proxy_cache_use_stale只对「缓存里有旧副本」的 key 生效。从没缓存过的冷 key,源站挂了就是 502,救不了。- 上游返回
Cache-Control: no-cache或者响应里有Set-Cookie时,默认不缓存。需要proxy_ignore_headers Cache-Control Set-Cookie Expires;+proxy_hide_header Set-Cookie;,但绝不要对带用户态的接口这么干,会串号。
六、cache loader / manager 的默认值有多保守
看第一节那段配置里的四行 manager_* / loader_*。默认值是:
loader_files=100 loader_sleep=50ms loader_threshold=200ms
manager_files=100 manager_sleep=50ms manager_threshold=200ms
意思是:cache loader 每次最多处理 100 个文件,处理 200ms 就歇 50ms。如果你有 80 万个缓存文件,Nginx reload 或重启后,loader 要跑十几分钟才把所有索引建完。这段时间缓存命中率极低,全部穿过到后端。
我们踩过一次:滚动发布时后端在 30 秒内被打到 4 倍 QPS,排查半天才发现是 loader 没跑完。
缓存文件数超过 20 万的节点,把上面那四行按第一节的数值改掉,让 loader 在两三分钟内扫完。注意 manager_threshold 别调到 2s 以上,否则 cache manager 长时间占着磁盘队列,会拖累正常的读缓存路径。
七、可观测:没有这行日志,你就是在盲调
log_format cache_log '$remote_addr [$time_local] "$request" '
'$status $body_bytes_sent rt=$request_time '
'urt=$upstream_response_time cache=$upstream_cache_status';
add_header X-Cache-Status $upstream_cache_status always;
跑一天后,一条命令看全局:
awk '{for(i=1;i<=NF;i++) if($i ~ /^cache=/) {split($i,a,"="); c[a[2]]++}}
END{for(k in c) printf "%-12s %8d\n", k, c[k]}' /var/log/nginx/access.log.cache
重点看三个值:MISS(正常冷启动)、EXPIRED(过期回源,配合 background_update 应该是正常的)、BYPASS(被 proxy_cache_bypass 绕过,通常是因为你带了 $cookie_xxx 这类条件,属于配置错误)。
BYPASS 占比高说明你的 bypass 条件写太宽了,缓存等于白开。
八、完整可落地的 server 段
proxy_cache_path /data/nginx/cache
levels=1:2
keys_zone=zone_web:200m
inactive=7d
max_size=200g
min_free=20g
use_temp_path=off
manager_files=1000 manager_sleep=200ms manager_threshold=500ms
loader_files=2000 loader_sleep=100ms loader_threshold=300ms;
server {
listen 443 ssl;
server_name static.example.com;
location / {
proxy_pass http://backend_pool;
proxy_http_version 1.1;
proxy_set_header Connection "";
proxy_set_header Host $host;
proxy_cache zone_web;
proxy_cache_key "$scheme$proxy_host$uri$is_args$args";
proxy_cache_valid 200 302 10m;
proxy_cache_valid 404 1m;
proxy_cache_lock on;
proxy_cache_lock_age 15s;
proxy_cache_lock_timeout 10s;
proxy_cache_use_stale error timeout invalid_header updating
http_500 http_502 http_503 http_504;
proxy_cache_background_update on;
proxy_cache_revalidate on;
proxy_cache_max_range_offset 1m;
proxy_force_ranges on;
proxy_connect_timeout 3s;
proxy_send_timeout 30s;
proxy_read_timeout 30s;
add_header X-Cache-Status $upstream_cache_status always;
}
}
九、上线前必须跑的两个动作
动作一,防雪崩压测。用压测机打同一个冷 key,并发 300,看后端收到的并发峰值:
# 先清掉目标 key
find /data/nginx/cache -type f -delete && nginx -s reload
# 并发打同一个 URL
ab -n 6000 -c 300 -k https://static.example.com/api/big.json
同时在后端机器上跑 ss -s 和 netstat -an | grep -c ESTAB。如果后端 ESTAB 稳定在个位数,说明 proxy_cache_lock 生效;如果看到脉冲式的 300 并发,回头检查 proxy_cache_lock_age 是不是小于上游响应时间。
动作二,验证 rename 是同文件系统。
# 压测期间观察,应该几乎没有磁盘写
iostat -x 1 20 | grep -E 'Device|nvme|vda'
# 正常状态下 %util 应低于 30%,await 低于 5ms
如果 %util 长期贴着 100,先回去确认 use_temp_path=off 是否真的生效——改完这一段必须 nginx -s reload,因为 proxy_cache_path 是共享内存 zone 级别的定义,热改不生效。
最后一句:缓存配置的所有参数都是「按盘和按上游延迟」定的,网上抄来的数字只能当起点。先把 $upstream_cache_status 打出来,再动参数。