CDN 边缘节点缓存失效导致静态资源 404 雪崩:我用 Vary 头 + 缓存键规则 5 分钟止血
上周四下午 3 点,监控群里突然炸了。核心页面的静态资源(JS/CSS)大面积 404,用户侧白屏率飙升到 40%。排查了 5 分钟,发现问题根本不在源站——CDN 边缘节点的缓存策略把我们坑惨了。
事情是怎么发生的
我们前端团队刚上线了一个新版本,部署流程大概是这样的:
- 构建产物打到 Nginx 静态资源目录
- CDN 刷新缓存,等待边缘节点回源
- 用户访问时从就近节点拉取
听起来没毛病对吧?但这次的坑在于:我们改了文件名哈希策略。
老版本用的是main.a3f2b1c.js这种带 hash 的文件名,新版本改成了main-[contenthash].js。问题就出在这个切换过程中——CDN 边缘节点缓存了一部分旧 hash 的 301 跳转规则,又缓存了新 hash 的 200 响应。结果用户请求main.a3f2b1c.js时,CDN 返回了 404(因为源站已经删了这个文件),而不是预期的 301 到新文件。
更蛋疼的是,这个 404 响应被 CDN 缓存了 24 小时。
定位过程:为什么不是源站的问题
一开始我也以为是部署漏了文件,直接 ssh 到源站查看:
ls-la/usr/share/nginx/html/static/js/# main.8d9e4f2.js 存在# main.a3f2b1c.js 不存在(这是正常的,旧文件本来就该被清理)源站没问题。那问题只能在 CDN 层。
我抓了一个用户的请求链路:
curl-I-H"Host: static.example.com"\"https://cdn-edge-node.example.com/static/js/main.a3f2b1c.js"HTTP/2404age:8473x-cache: HIT from cdn-node-bj-03划重点:age: 8473说明这个 404 已经在边缘节点缓存了 2 个多小时。x-cache: HIT表示后续请求都会直接走缓存,不会回源。
这时候我意识到:CDN 把 404 当成正常响应缓存了,而且缓存时间按我们之前配的max-age=86400走的。
止血:5 分钟内的应急操作
第一步:清除 CDN 缓存(2 分钟)
先不管根因,把症状止住。登录 CDN 控制台,批量刷新/static/js/*和/static/css/*的缓存。
但这里有个坑:CDN 刷新是异步的,边缘节点可能不会立即生效。所以我们同时做了第二步。
第二步:源站兜底,恢复旧文件(1 分钟)
让运维临时把旧 hash 文件从备份恢复回源站:
# 从上个版本的构建产物里恢复rsync-av/backup/build-20260424/static/js/ /usr/share/nginx/html/static/js/# reload Nginx(不要 restart,避免连接断开)nginx-sreload这样即使 CDN 还有缓存,回源时也能拿到 200 响应而不是 404。
第三步:调整缓存键规则,防止再次缓存 404(2 分钟)
这一步是关键。我们需要让 CDN 根据响应状态码区分缓存行为。
在 CDN 控制台修改缓存键规则(Cache Key + 状态码过滤):
# Nginx 源站侧:给错误响应加 no-cache 头 location /static/ { expires 1y; add_header Cache-Control "public, immutable"; # 关键:4xx/5xx 不缓存 error_page 404 = @no_cache; } location @no_cache { add_header Cache-Control "no-store, no-cache, must-revalidate"; return 404; }CDN 侧的配置(以阿里云 CDN 为例):
缓存配置 > 自定义缓存键 - 状态码 200/301/302:缓存 1 年 - 状态码 404/500/502:缓存 0 秒(不缓存)根因修复:Vary 头 + 正确的缓存键策略
止血之后,开始想怎么彻底避免这个问题。
问题 1:文件名 hash 变更时,CDN 缓存了错误的跳转关系
我们之前部署时,Nginx 配了 301 跳转到最新 hash 文件:
# 这是有问题的配置 location /static/js/main.js { return 301 /static/js/main.8d9e4f2.js; }CDN 缓存了这个 301 响应,但缓存键里没有包含目标文件名的信息。当源站删了旧文件、改了跳转目标时,CDN 还在返回缓存的 301,导致用户被跳转到不存在的文件。
修复方案:用 Vary 头区分缓存版本
正确的做法是在 301 响应里加Vary头,让 CDN 根据请求路径或版本参数区分缓存:
location /static/js/main.js { # 重写到当前版本 rewrite ^/static/js/main\.js$ /static/js/main.8d9e4f2.js last; }更好的方案是完全不用跳转,直接让构建工具生成不带 hash 的 entry 文件(如果用了 webpack/vite):
// vite.config.jsexportdefault{build:{rollupOptions:{output:{entryFileNames:'js/[name].js',// 入口文件不带 hashchunkFileNames:'js/[name]-[hash].js',// chunk 带 hashassetFileNames:(assetInfo)=>{constinfo=assetInfo.name.split('.');constext=info[info.length-1];return`assets/[name]-[hash][extname]`;},},},},}这样main.js永远是最新入口,内部引用的 chunk 自带 hash,CDN 缓存策略就简单多了。
问题 2:404 响应不应该被缓存
很多 CDN 默认配置是"所有响应都缓存",包括错误响应。这非常危险。
我们在 Nginx 层加了错误响应的缓存控制头:
# 在 server 块里统一处理 map $status $cache_control { ~^200 "public, max-age=31536000, immutable"; ~^301 "public, max-age=3600"; ~^302 "public, max-age=3600"; ~^404 "no-store, no-cache, must-revalidate"; ~^500 "no-store, no-cache, must-revalidate"; default "no-cache"; } server { location /static/ { add_header Cache-Control $cache_control always; # 对静态资源,只有 200 才允许 CDN 缓存 location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2)$ { expires 1y; add_header Cache-Control "public, immutable"; # 关键:404 不缓存 try_files $uri $uri/ =404; } } }同时在 CDN 控制台配置了状态码缓存分层:
状态码 200:缓存 365 天 状态码 301/302:缓存 1 小时 状态码 404/500/502/503:不缓存监控脚本:防止下次被坑
为了避免下次部署时再踩这个坑,我写了两个监控脚本。
脚本 1:部署后自动验证 CDN 缓存状态
#!/bin/bash# cdn-cache-check.shCDN_HOST="cdn.example.com"ORIGIN_HOST="origin.example.com"STATIC_FILES=("/static/js/main.js""/static/css/main.css""/static/js/chunk-vendors.js")echo"=== CDN 缓存一致性检查 ==="forfilein"${STATIC_FILES[@]}";do# 检查 CDN 节点响应cdn_status=$(curl-s-o/dev/null-w"%{http_code}"\-H"Host:$CDN_HOST"\"https://$CDN_HOST$file")# 检查源站响应origin_status=$(curl-s-o/dev/null-w"%{http_code}"\-H"Host:$ORIGIN_HOST"\"https://$ORIGIN_HOST$file")if["$cdn_status"!="$origin_status"];thenecho"⚠️ 不一致:$file"echo" CDN:$cdn_status| 源站:$origin_status"# 自动刷新 CDN 缓存echo" 正在刷新 CDN 缓存..."# 这里调用 CDN API 刷新(以阿里云为例)# aliyun cdn RefreshObjectCache --ObjectPath "https://$CDN_HOST$file"elseecho"✅ 一致:$file(状态码:$cdn_status)"fidoneecho"=== 检查 404 是否被缓存 ==="# 请求一个肯定不存在的文件,检查 CDN 是否缓存了 404fake_file="/static/js/fake-$(date+%s).js"curl-I-s-H"Host:$CDN_HOST""https://$CDN_HOST$fake_file"|grep-E"(HTTP/|cache|age)"脚本 2:实时监控静态资源 404 率
#!/usr/bin/env python3# static-404-monitor.pyimportrequestsimportjsonfromdatetimeimportdatetime,timedelta# 假设你用了 Prometheus + AlertmanagerPROMETHEUS_URL="http://prometheus:9090"QUERY=''' sum(rate(nginx_http_requests_total{status=~"404",path=~"/static/.*"}[5m])) / sum(rate(nginx_http_requests_total{path=~"/static/.*"}[5m])) * 100 '''defcheck_404_rate():end=datetime.now()start=end-timedelta(minutes=5)params={'query':QUERY,'start':start.timestamp(),'end':end.timestamp(),'step':'60s'}resp=requests.get(f"{PROMETHEUS_URL}/api/v1/query_range",params=params)data=resp.json()ifdata['status']=='success'anddata['data']['result']:values=data['data']['result'][0]['values']latest_rate=float(values[-1][1])print(f"静态资源 404 率:{latest_rate:.2f}%")iflatest_rate>5:print(f"🚨 告警: 404 率超过阈值 (5%),触发 CDN 缓存刷新")# 触发刷新逻辑# refresh_cdn_cache()returnFalsereturnTrueif__name__=="__main__":healthy=check_404_rate()exit(0ifhealthyelse1)写在最后
这次故障虽然只持续了不到 10 分钟,但让我意识到一个被忽视的问题:CDN 不是魔法,它也会缓存错误响应。
几个血泪教训:
- 不要假设 CDN 是透明的。它有自己的缓存逻辑,默认配置不一定符合你的预期。
- 404 必须禁止缓存。这是底线,否则一次小错误会被放大成全局故障。
- 部署后一定要有验证步骤。哪怕只是跑一个 curl 检查几个关键文件的状态码。
- 静态资源尽量用 immutable 策略。入口文件不带 hash,chunk 文件带 hash 且永不修改,这样 CDN 缓存最安全。
对了,我们现在每次部署后都会跑那个cdn-cache-check.sh脚本。5 秒钟能做完的事,能避免一次 P1 故障。
如果你也在用 CDN + 静态资源部署,建议检查一下你的缓存配置。404 被缓存这件事,真的防不胜防。