一、爬虫被 CDN 缓存了首页:事故是这样发生的
这是一次真实事故的复盘,值得原样写下来,因为它同时踩中了缓存、日志与推荐位三个坑,而每一个坑单独看都不算严重。
起因很平常:站点上了 CDN,为了降低回源压力,给 HTML 也开了缓存,规则是「全部内容缓存 5 分钟」。上线两周后,运营发现首页推荐位一直在推同一篇文章,手动在后台换了推荐位,刷新前台还是旧的;同时 Google 收录的新文章数量明显变慢。运维第一反应是「CDN 没刷新」,于是去控制台刷新了首页 URL,看起来好了——但第二天又回到旧内容。
真正的根因在第三个星期才查出来:首页 HTML 被缓存的那 5 分钟里,缓存键没有区分用户身份,也就是说,登录状态的后台用户、匿名访客、以及搜索引擎爬虫拿到的都是同一份缓存副本。而那一份副本恰好是某个后台用户访问首页时生成的——里面含有一个随机生成的 CSRF token,以及当时那一位用户的登录态片段。
事故的三层后果依次暴露:一是推荐位改了不生效,因为改的是后台数据而缓存的是 HTML;二是新文章收录变慢,因为爬虫抓到的是 5 分钟前的旧首页,短期内看到的「最新文章」没有更新;三是缓存里那份带 token 的 HTML 被匿名访客拿到,虽然 token 本身不能直接冒用,但页面上出现了「欢迎回来,某某」的字样。
下面的复盘不讨论「该不该缓存 HTML」这种非此即彼的问题,而是拆开说明三件事:缓存键怎么设计、动态片段怎么从缓存里拿出去、以及这件事怎么在日志里被提前发现。
二、缓存键决定了谁能拿到同一份内容
缓存的本质是「用一组输入去换一份输出」。输入就是缓存键,键相同就会命中同一份内容。所以「为什么张三看到了李四的页面」这个问题,答案永远是「他们的缓存键算出来一样」。理解了这一点,几乎所有缓存事故都能推导出来。
默认的缓存键通常只有 URL。这在纯静态资源上没问题,但在 HTML 上至少有五类差异被忽略:登录态、语言或地区、设备类型(移动端和桌面端返回不同模板)、A/B 实验分组、以及 Cookie 里的个性化偏好。只要这些差异真实存在而缓存键里没有,就一定会串。
# CDN 侧的缓存键设计(以常见配置项为例,字段名各家不同,语义一致)
# 1. 主机名 + 路径 + 查询串(去掉 utm_* 之类的高基数参数)
# 2. 增加一个由源站设置的 Cookie 作为键:只有明确分组的 Cookie 才参与
# 3. 增加 Accept-Language 作为分区键(如果做了多语言)
# 源站配合:只在确实需要分版时下发这个 Cookie
add_header Set-Cookie "site_variant=cn; Path=/; Max-Age=3600" always;
# 明确告知 CDN:页面级 Cookie 不参与缓存键,但也不要缓存
add_header Cache-Control "private, no-store" always;
这里的关键判断是「这份内容对谁而言是同一份」。对匿名访客的首页,内容完全一样,可以缓存;对登录用户,内容里必然有个性化部分,不能共用同一份缓存。两种内容的 Cache-Control 写反了,就是事故。
一个实用规则:凡是响应里出现过 Set-Cookie,或者 HTML 里嵌入了会话相关的值,就一律用 private, no-store。整页缓存的适用对象是「完全匿名、完全一致」的页面,比如文章详情页、分类列表页、静态帮助页,而不是首页与后台。
# nginx 侧:按登录态分流,是整页缓存的地基
map $http_cookie $cache_state {
default "anon";
"~(^|;\s*)(PHPSESSID|TYPEcho_uid|wordpress_logged_in)" "auth";
}
map $cache_state $cache_control {
"anon" "public, max-age=300, s-maxage=300";
"auth" "private, no-store";
}
server {
location / {
add_header Cache-Control $cache_control always;
add_header X-Cache-State $cache_state always; # 方便在响应头里核对
# ...
}
}
X-Cache-State 这个自定义响应头看着多余,但它在事后复盘时价值很大:你不需要翻日志就能确认某个请求走的是哪条分支。所有关键的分流判断都应该留下这种可观测的痕迹。
三、动态片段:把不该缓存的部分拿出去
整页缓存一旦开了,下一个问题就是「页面上总有几块是动态的」。这时有三种主流做法,各有其代价。
第一种是骨架加异步。整页 HTML 里只留占位,动态部分由浏览器再发一次 XHR 取回。代价是多一次请求,以及首屏会有一瞬间的空位,处理不好会贡献 CLS。收益是缓存最干净,HTML 可以缓存很久。
第二种是片段缓存加服务端拼装。用 ESI(Edge Side Includes)或自研的片段占位,在 CDN 或 Nginx 层把片段拼进页面。Nginx 可以用 ssi on 配合 proxy_pass 实现,代价是回源次数变多,且拼装失败时页面会出现破洞。
# nginx 片段拼装:主文档缓存 5 分钟,推荐位片段缓存 60 秒
location = / {
ssi on;
proxy_pass http://backend;
proxy_cache pagecache;
proxy_cache_valid 200 5m;
proxy_cache_key "$scheme$host$request_uri$cache_state";
proxy_cache_bypass $cache_state; # 登录用户直接回源
}
location = /fragment/recommend {
proxy_pass http://backend/recommend;
proxy_cache pagecache;
proxy_cache_valid 200 60s;
add_header X-Fragment-Cache $upstream_cache_status;
}
第三种是接受 5 分钟的陈旧。如果动态块本身不敏感(比如「今日热门」这种榜单),旧 5 分钟完全无所谓,那就别折腾,直接把 TTL 设成业务能接受的最长值。这是最省事的选择,前提是你真的要确认「不敏感」。
三种做法的分界线是响应的语义,不是实现难度:必须实时的(用户自己的信息、库存、价格)用异步;可以稍微陈旧的(榜单、评论数、推荐位)用片段缓存;完全无关的(文章正文、静态页)整页缓存。混着来就会出现「推荐位换了不生效」这种事。
四、改了不生效的四层排查顺序
「后台改了前台没变」是一个高频报障。它的排查必须有固定顺序,否则就会陷入「刷新 CDN → 没好 → 重启服务 → 没好 → 怀疑人生」的循环。
第一层,确认数据真的改了。直接查库比看后台页面可靠,因为后台可能有自己的页面缓存或者表单没提交成功。这一步十次里能抓到两次问题。
# 第一层:绕过所有缓存直接看数据
$ mysql -e "SELECT id,title,status,updated_at FROM contents ORDER BY updated_at DESC LIMIT 5"
# 第二层:带时间戳直接打源站,绕开 CDN
$ curl -sI "https://origin.example.com/?_=$(date +%s%N)" | grep -Ei 'age|cache-control|x-cache'
第二层,绕过 CDN 直接打源站。看响应头里的 Age:如果是 0 或没有,说明命中的是新鲜内容;如果有值,说明这份是从缓存里拿的。X-Cache、CF-Cache-Status、X-Via 这类头各 CDN 叫法不同,但语义一致。
第三层,确认 CDN 的刷新语义。这里有两个高频误解:刷新一个 URL 只对这个 URL 精确生效,带参数的变体(?utm=x)是另一个缓存对象;刷新目录只在部分 CDN 上支持,且很多 CDN 的「目录刷新」实际上是列出该目录下的已知对象而不是通配。如果你的页面 URL 带参数,刷新不带参数的那个没有任何作用。
第四层,确认浏览器端也已更新。max-age=300 意味着用户的浏览器在这 5 分钟内压根不会发请求,无论你在服务端做什么都影响不到他。要强制更新只能改 URL(加版本号)或者把 max-age 降到很短并配合 stale-while-revalidate。
# 逐层排查的一行版:同时看 CDN 层与源站层
$ for u in "https://www.example.com/" "https://origin.example.com/"; do
printf "%-40s " "$u"
curl -sI "$u" | tr -d '\r' | awk -F': ' '
/^(HTTP|Age|Cache-Control|X-Cache|CF-Cache-Status|Via)/ {printf "%s | ", $0} END {print ""}'
done
这个命令一次性把 CDN 与源站两边的缓存状态并排打出来,比在不同工具间来回切快得多。做运维久了会发现,排查效率的差异主要来自「能不能把多个信息源合并到一次观察里」。
五、爬虫视角:缓存对收录的两种相反影响
缓存对 SEO 的影响经常被说成「有好有坏」,但这种说法无法指导决策。更精确的说法是:缓存对收录的影响取决于爬虫的抓取预算与内容更新频率之间的关系。
当你的站点更新频率高于缓存 TTL 时,爬虫每次来都只能看到旧内容,这会降低它对你「新鲜度」的判断,长期看会减少抓取频次。这时候缓存是负面的。反之,当站点更新频率低于 TTL,或者你遇到流量尖峰时,缓存让爬虫的请求更快返回、更少 5xx,这会提升抓取效率。
因此在实践中应该为两类页面设置相反的缓存策略:文章详情页内容一旦发布就基本不变,可以缓存很久(甚至配合 CDN 的 stale-while-revalidate 设到一天);列表页与首页是「发现路径」的枢纽,新文章必须尽快在这里出现,TTL 应该短(60~300 秒),或者用「主动刷新」代替被动等待。
# 发布后主动刷新首页与分类页,比等 TTL 到期可靠
$ cat publish_hook.sh
#!/bin/bash
set -euo pipefail
for u in "https://www.example.com/" \
"https://www.example.com/category/tech/" \
"https://www.example.com/sitemap.xml"; do
curl -s -X POST "https://cdn-provider.example.com/api/purge" \
-H "Authorization: Bearer ${CDN_TOKEN}" \
-H "Content-Type: application/json" \
-d "{\"url\":\"${u}\"}" -o /dev/null -w "%{http_code} ${u}\n"
done
把刷新动作挂在发布流程里,比在 CDN 控制台手工点要有保障得多。上面这个脚本还顺手刷新了 sitemap,因为 sitemap 被缓存是最隐蔽的问题之一——它被缓存一周,搜索引擎就一周看不到新链接,而你在后台看 sitemap 一切正常,因为你请求时带了随机参数或者命中了新副本。
# sitemap 是否被缓存:看 Age 与 Last-Modified 的差距
$ curl -sI https://www.example.com/sitemap.xml | grep -Ei 'age|last-modified|cache-control'
# Age 若大于你预期的 TTL,说明 CDN 忽略或覆盖了源站缓存头
六、事故之后我们改了什么
复盘的价值在于落到具体改动。这次事故之后做了五件事,都算不上复杂,但每一条都对应着事故中的一个具体环节。
第一,给 HTML 分了类。匿名页面(文章、分类、标签、静态页)允许整页缓存,登录态页面与后台一律 private, no-store,并在 Nginx 层用一个 map 强制下发,而不是依赖应用代码自觉。应用代码里漏一次,就意味着一次事故,所以这层判断应该落在基础设施层。
第二,把 Set-Cookie 变成了缓存禁用的硬信号。CDN 侧配置了「响应含 Set-Cookie 则不缓存」,并加了监控:一旦有页面级请求响应里出现 Set-Cookie,立刻上报。这一条能防住绝大多数串号问题。
第三,首页与列表页的 TTL 从 5 分钟降到 60 秒,同时加上发布钩子主动刷新。这两个动作组合起来的效果是「正常情况下最多旧 60 秒,发布时立刻更新」。
第四,给每个响应加了 X-Cache-State 与 X-Fragment-Cache 头,并在日志里记录 $upstream_cache_status。现在任何一次「改了不生效」都能在 30 秒内定位到是哪一层。
# 缓存状态进日志:这是事后定位的基础
log_format cache '$remote_addr [$time_local] "$request" $status '
'cache=$upstream_cache_status age=$sent_http_age '
'state=$sent_http_x_cache_state "$http_user_agent"';
# 每天看一眼命中率,比看绝对值更有意义
$ awk '{split($0,a,"cache="); split(a[2],b," "); hit[b[1]]++}
END {for (k in hit) printf "%-12s %d\n", k, hit[k]}' /var/log/nginx/cache.log
第五,把「后台改数据 → 前台不生效」写进了值班清单,并且明确了四层排查顺序。这一条改动看起来最轻,实际收益最大:它把一次可能耗时两小时的排查压缩到了十分钟,也让新接手的人不用重新摸索。
七、几条可以带走的判断
把这次事故抽象一下,能提炼出几条通用判断,适用于任何一次缓存相关的问题。
缓存键是唯一的解释入口。任何「A 看到了 B 的内容」或者「改了不生效」,都要先画出缓存键包含哪些字段,再问「这两次请求的键是否相同」。
缓存策略应该写在基础设施层,而不是应用层。应用层的判断会随着代码演进被遗忘,而 Nginx 与 CDN 的规则是声明式的、集中可见的,改一次就全站生效。
动态内容不进整页缓存,但也不是一律禁止缓存。分歧点在于「旧多少秒是业务能接受的」,这个问题产品能回答,技术只需要给出可选的档位。
所有分流判断都要留下可观测的痕迹。一个自定义响应头的成本是零,但它把「猜」变成了「看」,这在事后复盘与日常排查里都是最划算的投入。
最后,别把缓存当成纯粹的加速手段。它同时改变了「谁在什么时刻看到什么内容」,这是产品语义层面的改动。上线整页缓存之前,先定义清楚每种页面的「可接受的陈旧程度」,比事后追查串号省力十倍。