缓存开了,命中率却是 3%:问题出在缓存键上
很多站长给 Nginx 配了 proxy_cache,配置看起来完全正确,目录也生成了,缓存文件确实在写。但是一看命中率就傻眼:
# 在日志里临时加 $upstream_cache_status 字段后统计
awk '{print $NF}' /var/log/nginx/access.log | sort | uniq -c | sort -rn
# 98211 MISS
# 3102 HIT
# 904 EXPIRED命中率 3%。这意味着你的源站几乎承担了全部流量,缓存形同虚设。这时候大多数人会去怀疑"缓存时间设太短"、"磁盘不够"、"Nginx 没重启",然后反复调 proxy_cache_valid,调完发现命中率一点没变。
真相通常是:你缓存了一堆永远不会被复用的东西。因为缓存键(cache key)里混进了每次请求都不一样的东西,同一个页面在缓存看来是一百万个不同的资源。这篇文章专门讲这个——不是讲怎么开缓存,而是讲缓存键怎么设计。
缓存键的本质:一个哈希的输入
Nginx 把缓存键定义为一个字符串,对它做 MD5,得到缓存文件的名字。默认情况下这个键就是:
$scheme$proxy_host$request_uri也就是"协议 + 上游主机名 + 完整请求 URI"。这个默认值对纯静态资源是够用的,但对动态站点问题很大。因为 $request_uri 里包含了查询字符串的全部内容,而查询字符串里往往藏着大量"不影响页面内容"的参数。
场景一:跟踪参数、utm 参数把缓存打散
假设你的页面被外部渠道推广,链接是:
https://example.com/post.html?id=100&utm_source=wechat&utm_medium=social&from=user_8823每一次分享、每一个不同用户点进来,utm_source、from 都可能不同。默认缓存键下,这个页面会生成几十上百个缓存副本,每个副本只被访问一两次——缓存全在浪费磁盘,命中率必然接近零。
这类参数在真实站点上比你想象的多得多:Google Analytics 的 gclid、Facebook 的 fbclid、广告平台的 utm_*、以及各种自研的 from、ref、share_id。
场景二:缓存键里混入了移动端标识
如果你的站点做了移动端适配,用 cookie 或者 UA 来区分,又没在缓存键里处理好,就会出现移动端和 PC 端互相覆盖,或者各自占一份缓存。如果缓存键里直接放了 $http_user_agent,那更糟——UA 的种类以万计,命中率会直接掉到个位数。
场景三:反代的 Host 头不一致导致重复缓存
$proxy_host 默认是 upstream 的名字或者后端地址。如果你的 Nginx 里配置了多个域名指向同一个后端,或者从 www.example.com 和 example.com 都能访问,那么同一份内容在不同 Host 下会被缓存成不同的键。再加上 CDN 回源时 Host 头可能变化,缓存会进一步碎片化。
诊断:先把缓存键"打印"出来看
不要猜。Nginx 允许你把缓存键写进日志,直接看到它长什么样。在 http 块里定义:
log_format cache_debug '$remote_addr - $upstream_cache_status '
'$scheme$proxy_host$request_uri '
'KEY_END uri=$uri args=$args ua_len=${#http_user_agent}';然后在你的站点 server 块里用它:
access_log /var/log/nginx/cache_debug.log cache_debug;重载后抓几十条记录看看:
tail -50 /var/log/nginx/cache_debug.log | awk -F'KEY_END' '{print $1}' | sort | uniq -c | sort -rn | head判据很直接:如果同一份"业务上完全相同"的页面,在日志里出现了大量不同的键,那就是参数污染。比如你把一个文章页 URL 的所有变体都抓出来:
grep "post.html?id=100" /var/log/nginx/cache_debug.log | wc -l
grep "post.html?id=100" /var/log/nginx/cache_debug.log | sed 's/.*MISS //' | sort -u | wc -l第一条命令是访问次数,第二条是"产生了多少个不同的缓存键"。如果访问 500 次产生了 480 个不同的键,答案已经很清楚了。
修复:重写缓存键的三个层次
层次一:用 map 白名单化查询参数(最有效)
不要把 $request_uri(含全部参数)当缓存键,而是只保留真正影响页面内容的参数。做法是用 map 把有效参数重新拼出来。先在 http 块定义:
map $arg_id $cache_key_qs {
default "id=$arg_id";
"" "";
}
# 如果页面还受页码和分类影响,继续拼
map $arg_page $cache_key_page {
default "&page=$arg_page";
"" "";
}然后自定义缓存键时只拼这些白名单参数:
proxy_cache_key "$scheme$host$uri$cache_key_qs$cache_key_page";这样 utm_source、gclid、fbclid 这些参数就直接被丢掉了,它们既不影响页面内容(由前端 JS 读取),也不该影响缓存。
如果参数特别多且难以穷举,还有个更简单粗暴但有效的办法——对不需要的参数做正则剔除:
# 从 $args 中删掉所有 utm_* 和常见跟踪参数
map $args $clean_args {
default $args;
"~(.*)(&?)(utm_[^&]*|gclid|fbclid|from|ref|share_id)(&.*|$)" "$1$2$3";
}注意这个正则写法比较 tricky,实际生产里更推荐逐个 map 白名单,稳定且可读。
层次二:区分 www 和非 www、HTTPS 和 HTTP
$host 比 $proxy_host 更稳定——它是客户端请求的 Host 头,不受上游配置影响。但要注意:
# 在站点入口统一跳转到规范域名,避免同内容多域名缓存
server {
listen 80;
server_name example.com;
return 301 https://www.example.com$request_uri;
}先把域名收敛到唯一一个,是从根上减少缓存碎片的做法,比在缓存键上打补丁管用得多。同时 $scheme 要不要放进键里,取决于你的站点是否是纯 HTTPS。如果 80 端口已经全部 301 到 443,那么 $scheme 永远只有一个值,放不放都无所谓;如果还有 HTTP 直接返回内容的情况,就必须放。
层次三:区分移动端,但不要用 UA 原文
如果站点确实需要"移动端和 PC 端返回不同 HTML",正确做法不是把整个 UA 放进缓存键,而是先把它归一化成一个极小的集合。用 map 把 UA 归成两三类:
map $http_user_agent $device_type {
default "pc";
"~*(Mobile|Android|iPhone|iPad|iPod)" "mobile";
}
proxy_cache_key "$scheme$host$uri$cache_key_qs$device_type";这样缓存键里只有 pc 和 mobile 两种取值,而不是几万种 UA 字符串。如果你用的是响应式布局(CSS 媒体查询适配),那连这一步都不需要,缓存键里根本不该出现设备信息。
进阶:让缓存"按内容而非按 URL"复用
有时候同一个 URL 会因为登录状态而返回不同内容。默认情况下登录用户的请求不应该走缓存,正确做法是直接绕过缓存,而不是把 cookie 放进缓存键(那等于放弃缓存):
# 有登录 cookie 直接不缓存
map $http_cookie $skip_cache {
default 0;
"~*wordpress_logged_in" 1;
"~*comment_author" 1;
"~*phpsessid" 1;
}
proxy_cache_bypass $skip_cache;
proxy_no_cache $skip_cache;理解这两个指令的区别很重要:
proxy_cache_bypass:值为真时,本次请求不去读缓存,直接回源(但它仍然会把回源结果写进缓存,除非同时设置 no_cache)。proxy_no_cache:值为真时,本次回源的结果不写入缓存。
两个一起用,才能实现"登录用户完全绕过缓存体系"。反过来,proxy_cache_bypass 还有个经典用法:配合 Cache-Control: no-cache 实现"前端强制刷新":
proxy_cache_bypass $http_cache_control; # 浏览器按 Ctrl+F5 时绕过缓存这样既不影响普通用户的缓存命中,又给了你一个手工刷新的通道。
验证:命中率必须用数据说话
改完之后,重新统计。建议在日志格式里固定加上 $upstream_cache_status,这是唯一可靠的观测手段:
log_format main '$remote_addr - $remote_user [$time_local] "$request" '
'$status $body_bytes_sent "$http_referer" "$http_user_agent" '
'cache=$upstream_cache_status';
# 统计命中率
awk -F'cache=' '{print $2}' /var/log/nginx/access.log | sort | uniq -c | sort -rn状态值的含义要分清楚:
- HIT:直接命中,最好
- MISS:没有缓存,回源并写入缓存。首访必然 MISS,正常
- EXPIRED:缓存过期,回源并更新。如果 EXPIRED 占比过高,说明
proxy_cache_valid时间太短或者被上游的 Cache-Control 覆盖了 - BYPASS:被
proxy_cache_bypass跳过。如果这个占比高得离谱,回去检查你的 bypass 条件是不是写错了 - STALE:回源失败,返回了过期缓存。这是"优雅降级",正常但应告警
健康的动态站点(比如有较多登录用户),HIT 占比 50%–70% 是合理的;纯内容站(文章、列表页)应该到 80% 以上。如果改完缓存键后 HIT 还是上不去,去看 BYPASS 和 MISS 的分布——问题往往从"参数污染"转移到了"bypass 条件过宽"。
必须注意的三个坑
坑一:不要缓存带 Set-Cookie 的响应
如果后端返回了 Set-Cookie 而你把它缓存了,那么这个 cookie 会被发给所有用户——这是严重的安全问题。务必确认:
# 后端无端设置 cookie 时,别让它污染缓存
proxy_ignore_headers Set-Cookie; # 谨慎使用,它会让 cookie 无法下发
# 或更安全:在 map 里检测到 Set-Cookie 就不缓存
proxy_no_cache $upstream_http_set_cookie;注意 proxy_no_cache 判断的是"上游响应的头",所以用 $upstream_http_set_cookie。
坑二:POST 请求和查询串顺序
proxy_cache 默认只缓存 GET/HEAD。POST 请求不该被缓存,这一点不用额外配置。但要注意 ?a=1&b=2 和 ?b=2&a=1 在默认缓存键下是两个不同的键——如果你的前端有参数顺序不稳定的情况,会白白产生重复缓存。把关键参数用 map 白名单固定顺序重建,顺手就解决了这个问题。
坑三:缓存目录的清理与 inode
缓存键打散最直接的后果是缓存文件数量爆炸。如果之前因为参数污染产生了上百万个小文件,磁盘 inode 可能已经被吃满(是的,这跟 fd 耗尽是两回事,但表现都是"写不进去")。清缓存时用对命令:
# 正确:清空缓存目录内容但保留目录本身
find /var/cache/nginx/proxy_cache -type f -delete
# 或者用 Nginx 官方的 cache manager purge 方式,避免直接删文件
# 也可以直接设置 proxy_cache_path 的 inactive 参数自动淘汰
proxy_cache_path /var/cache/nginx/proxy_cache levels=1:2 keys_zone=my_cache:10m
max_size=2g inactive=24h use_temp_path=off;inactive=24h 表示"24 小时内没被访问过的缓存自动删除",这是控制缓存文件增长的天然闸门。
小结
缓存命中率低,九成以上不是"缓存没开对",而是缓存键切得太碎。排查路径是固定的:先加日志把缓存键打印出来,统计同一业务资源的键数量,然后逐层收窄——用 map 白名单化查询参数、把域名和协议收敛到唯一、把设备信息归一化而不放 UA 原文。最后用 $upstream_cache_status 的分布来验证,而不是凭感觉。
缓存不是"开了就快",它是"以正确的粒度复用才快"。把缓存键从"URL 原文"升级到"业务语义",往往比你升级服务器配置带来的收益大得多。