Nginx proxy_cache 命中率太低排查实战:缓存键碎片化诊断、map 参数白名单与 upstream_cache_status 读法

缓存开了,命中率却是 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 原文"升级到"业务语义",往往比你升级服务器配置带来的收益大得多。

Last modification:September 25th, 2026 at 07:23 pm

Leave a Comment