一、先看清这是哪一层缓存
缓存命中率掉下来的报警里,绝大多数其实不是「缓存坏了」,而是「本来该被缓存的东西没有被缓存,或者被缓存得太短」。要把这件事查清楚,第一步不是改配置,而是先把这条请求链路上的缓存层数清楚。一个普通的内容站,从浏览器到源站之间至少有五层可能在缓存:浏览器本地、CDN 边缘节点、CDN 的回源层(有些服务商有二级节点)、Nginx 的 FastCGI 缓存或 Proxy 缓存,最后才是 PHP 自己生成的页面。每一层都有自己的 key、自己的 TTL、自己的失效规则,只要其中一层的 key 和另一层不一致,外层看起来就是「没命中」。
判断方法很简单,看响应头里的 X-Cache、CF-Cache-Status、X-Cache-Status 这类字段,不同服务商名字不同,但大部分都会告诉你 HIT、MISS、BYPASS、EXPIRED、DYNAMIC 中的某一个。如果 CDN 报 BYPASS,说明请求被规则跳过,根本不会进缓存;如果报 DYNAMIC,说明这条路径被标记为不缓存;只有 MISS 才是「缓存里没有,去源站取了」,属于正常但可以优化的状态。
curl -s -o /dev/null -D - -A "Mozilla/5.0" "https://www.example.com/post/123"
# 只看关心的几个头
curl -s -o /dev/null -D - "https://www.example.com/post/123" \
| grep -iE '^(x-cache|cf-cache-status|age|cache-control|expires|vary|set-cookie):'这里有一个容易忽略的细节:Age 头。它表示这份响应在缓存里已经存放了多少秒。如果一次请求返回 HIT 但 Age: 0,说明这一份是刚刚从源站取回来放进缓存的,下一次才是真的命中。反过来,如果 AGE 一直接近 TTL 上限,说明缓存时间到了就立刻失效重取,命中率自然上不去。连续打十次请求看 Age 的递增曲线,比看一次结果靠谱得多。
二、命中率的分母到底是什么
很多人盯着后台给的一个百分比,却不知道这个百分比是怎么算的。常见口径有两种:按请求数算,按字节数算。两者的差别非常大。一个页面的 HTML 有 40KB,但它引用了 30 个静态资源,每个资源 500KB 到 2MB。按请求数算,静态资源占了 97% 的请求,只要图片和 JS 命中率 99%,整体命中率就会好看得不得了,把这个数字掩盖了 HTML 全部 MISS 的事实。老板看到的「命中率 98%」和用户实际感受到的「后台页面每次都慢」完全可以同时成立。
所以看命中率之前,先按内容类型拆开。HTML 文档的命中率、静态资源的命中率、API 响应(一般不该缓存)的命中率,要分开统计。一个健康的站,HTML 命中率通常在 90% 以上,静态资源接近 100%,API 不参与统计。判断方法是从日志里按扩展名分桶:
# 从 Nginx 日志按扩展名统计缓存状态($upstream_cache_status)
awk '{print $7}' /var/log/nginx/access.log \
| sed -E 's/.*\.(html|php|css|js|jpg|png|webp|woff2)$/\1/;t;s/.*/other/' \
| sort | uniq -c | sort -rn
# 只看 HTML 的缓存状态分布
grep -E 'GET /[^ ]*\.html' /var/log/nginx/access.log \
| awk '{print $NF}' | sort | uniq -c | sort -rn这需要日志里真的记录了缓存状态。如果 log_format 里没有 $upstream_cache_status,那么现在花一分钟把它加上,未来所有关于缓存的判断成本都会降低一个数量级。这是最值得的一次性投入,比任何监控工具都直接。
三、Key 不一致:最常见也最隐蔽的命中率杀手
Nginx 的 proxy_cache_key 默认是 $scheme$proxy_host$request_uri,注意它不包含 host 和查询串之外的任何东西。这意味着两件事:第一,如果同一份内容能通过 http 和 https 两个协议访问,就会生成两份缓存,命中率凭空砍半;第二,如果站点同时被 www 和裸域访问,而 proxy_host 指向的是同一个 upstream 名字,那么这两者其实共用一份缓存,内容一致时是好事,但一旦其中一个做了重定向,就会互相污染。
CDN 那一层的 key 通常默认包含 host、路径、查询串,但不一定包含某些请求头。这里最容易出事故的是语言和 UA 相关的 Vary。浏览器发 Accept-Encoding: gzip, br,CDN 如果按 Vary 分开存,那么 gzip 和 br 各存一份,回源次数翻倍;如果 CDN 忽略 Vary 又只缓存了一种压缩版本,另一类浏览器拿到的可能就是错的。最稳的做法是在 CDN 侧统一压缩策略,让源站只输出一种编码,把选择权交给边缘节点。
还有一个必须检查的点:响应里带不带 Set-Cookie。绝大多数 CDN 看到响应里有 Set-Cookie 就拒绝缓存,这是硬规则。很多站的页面顶部有一个「欢迎回来」的个性化区块,或者统计脚本回写了一个 cookie,导致整页 HTML 天然不可缓存。这种问题在浏览器里看响应头一眼就能发现,但很多人复查缓存时只看 TTL 不看 cookie。修法是让 cookie 只在真正需要的路径上下发,Path=/api 而不是 Path=/,页面本身就能恢复可缓存。
四、TTL 与失效策略:不是越长越好
命中率低的一个常见原因不是缓存没配,而是 TTL 配得太短。见过有人给 HTML 配 60 秒的 TTL,理由是「要保证内容及时更新」。一个每天只更新十几篇的内容站,60 秒的 TTL 意味着每个页面每天要被回源 1440 次,CDN 在这种配置下基本等于透明代理。合理的做法是按内容更新频率分层:列表页和首页 5 到 10 分钟,文章详情页 1 到 6 小时,静态资源按文件名带 hash 的直接给一年。
但长 TTL 的前提是你能主动失效。如果 CDN 支持按 URL 或按目录刷新,那么 TTL 长一点完全没问题,因为发布新内容时主动推一次刷新就行。如果不支持主动刷新、只能等自然过期,那么 TTL 就要折中到你能接受的最长陈旧时间。这里有个常被忽略的取舍:用户看到一分钟前的旧列表,几乎不会有感知;但用户看到一小时前的旧文章评论数,也不会抱怨。真正不能旧的是价格、库存、登录状态,而这些本来就不该走缓存。
失效还有一条暗线:源站 Nginx 自己的缓存。发布新文章后如果只清了 CDN,源站还在吐旧页面,CDN 会把这个旧页面重新缓存一遍,看起来就像「刷新无效」。正确的顺序是先清源站缓存,再清 CDN,最后用带随机参数的 URL 验证一次源站返回的是新内容。顺序错了就会出现「明明刷新了,过十分钟又变回旧版」的诡异现象。
五、一次完整的排查推演
把上面几条串起来,看一个真实形态的例子。早上九点,后台监控显示某个栏目的命中率从 96% 掉到 61%,同时该栏目的平均响应时间从 180 毫秒涨到 900 毫秒。处理顺序应该是这样的。
第一步,取一条该栏目的 URL,连续打五次,看响应头和 Age。如果全部是 MISS 且 Age 一直是 0,说明缓存根本没写进去,问题在上游而不是命中率计算。第二步,检查响应里有没有 Set-Cookie 和 Cache-Control: no-store, private,这是最常见的「写不进去」原因,通常是昨天上线的一个新功能顺手加了一个 cookie。第三步,如果响应头一切正常,检查 CDN 的规则是否被改动过,尤其是「绕过缓存」类的规则常常在排查其它问题时被人临时加上,之后忘了删。第四步,如果规则也正常,看回源请求量。回源量突然翻倍而请求量没变,说明 key 变了,比如某个参数被加进了 key,或者 UA 白名单把某类客户端单独分流。第五步,看该栏目的 URL 是否被改写过,哪怕只是多了一个 ? 或尾部斜杠,都会生成全新的 key。
这次的真实原因在第二步:前一天给文章页加了一个「已读文章」的本地标记,为了同步状态,脚本往页面里下发了一个跨站的 cookie,Path=/。CDN 看到 Set-Cookie 直接不缓存。改法很简单,把那个 cookie 的限制路径改到 /api/read,页面立刻恢复缓存,命中率在二十分钟内回到 95%。这类问题如果只盯着 CDN 后台找,会找很久,因为缓存规则本身完全没变。
六、把命中率变成可长期观察的指标
排查完之后,值得花一点时间让这个数字以后能自己说话。最省事的做法是每天定时从日志里统计一次,按内容类型分桶,记到一张表里,然后只对「偏离过去三十天均值」的情况报警。不要对绝对阈值报警,因为不同栏目、不同时段的基线差异很大,一个刚上线的栏目命中率天然低。用近三十天同小时段的均值乘以 0.8 做下限,比拍一个「低于 90% 就报警」靠谱得多。
#!/bin/sh
# daily_cache_stat.sh —— 每天 03:10 统计前一天各类型的缓存命中分布
LOG=/var/log/nginx/cache_status.log.1
OUT=/var/log/cache_daily
mkdir -p "$OUT"
for t in html css js img other; do
case "$t" in
html) pat='\.html' ;;
css) pat='\.css' ;;
js) pat='\.js' ;;
img) pat='\.(jpg|jpeg|png|webp|gif)' ;;
other) pat='^$' ;;
esac
hit=$(grep -cE "$pat" "$LOG" 2>/dev/null || echo 0)
miss=$(grep -E "$pat" "$LOG" 2>/dev/null | grep -c 'MISS' || echo 0)
echo "$(date +%F) $t total=$hit miss=$miss" >> "$OUT/$t.txt"
done这段脚本本身没什么技术含量,价值在于「每天留下一个数」。当命中率再次掉下来时,你就有了对照,能一眼看出是缓慢下滑(缓存策略在慢慢失效,比如某个中间层加了新规则)还是断崖下跌(某次发布引入的硬性 no-cache)。缓慢下滑往往更容易被忽略,因为它每天只掉一两个点,两周后才发现时已经很难定位是哪一天改的。
七、几个容易踩的坑
第一,Cache-Control: no-cache 和 no-store 不是一回事。no-cache 允许缓存但每次使用前必须回源验证,no-store 才是完全不存。写了 no-cache 却发现 CDN 完全不缓存,多半是因为 CDN 把它当成了 no-store 处理,或者同时配了其它更严格的头,语义以最严格的那个为准。
第二,301 跳转会被长期缓存。做过一次目录改名,把旧路径 301 到新路径,测试时一切都对。上线后发现有人访问旧路径还是跳到更早的一个旧地址,原因是浏览器缓存了几个月前的 301,而 301 在浏览器里几乎是永久有效的。调整跳转规则时,给缓存加一个短 TTL 或者用 302 过渡一段时间,能省掉很多用户投诉。
第三,后台看到的命中率和真实用户看到的不一致。后台通常是登录态,所有请求都带 cookie,天然不缓存,所以后台的命中率永远偏低。判断真实情况要看未登录的请求,或者直接看 CDN 侧的整体统计,不要拿自己登录的浏览器做基准。
第四,逐出(eviction)造成的假性 MISS。缓存空间有限,热门内容会把冷门内容挤出去。如果一个栏目有三千篇文章,访问量分散,那么每篇都可能刚被缓存就被逐出,命中率天然很低。这种时候提高缓存空间或者只缓存列表页、不缓存详情页,反而更划算。先看清容量限制,再谈优化。
八、小结
缓存命中率是一件「看起来简单、实际上取决于链路每一环是否一致」的事。排查时按「层数清楚、口径分开、key 一致、TTL 合理、有无 cookie/规则干扰」这五步走,绝大多数问题在半小时内就能定位。真正难的不是技术,而是养成「每次改发布流程都顺手确认一遍缓存行为」的习惯,因为绝大多数命中率事故都不是缓存系统本身坏了,而是它旁边的东西被别人动了一下。