网站改完模板、发了新文章,按理说应该立刻能看到的变化,结果刷新还是旧页面——这是每个站长都遇过的场景。等一小时、等一天,有时甚至一周,最后靠「清一下 CDN 缓存」蒙过去。但清缓存这个动作本身往往没起效,或者起效了却把整站打慢。缓存预热这件事,真正难的不是「怎么推一把」,而是搞清楚「哪一层还没有那个键」。
一、先建一张缓存层地图,否则永远在打错靶子
一个典型的个人站长站点,从用户浏览器到硬盘上的 MySQL,至少有五层缓存可能存着你那份旧内容:
- 浏览器本地缓存:
Cache-Control: max-age决定的,你无法远程清,只能改 URL 或降 max-age。 - CDN 边缘节点:URL 为键,TTL 由回源头的 Cache-Control 或 CDN 后台的规则决定。
- Nginx 的 fastcgi_cache 或 proxy_cache:磁盘上的整页缓存,存在
keys_zone指定的共享内存里索引。 - PHP 应用层缓存:APCu、Redis、Memcached 里的片段或数据,键名由代码决定。
- 数据库自身的缓冲池与查询缓存:这部分不受你控制,也不该去清。
关键判断:清缓存是「删除键」,预热是「重建键」。 如果你不知道该在哪一层重建,清完之后必然是一次回源尖峰:所有用户同时来要这份还不在缓存里的内容,全部打到 PHP 和 MySQL 上。日 PV 两万的小站,平时 PHP 只扛几百并发,清缓存瞬间可能出现所有访客同时回源,这就是「清完缓存站点打不开」的原始机理。
二、判断键是否已存在的三种探针
预热之前必须先知道现状。直接看响应头是最省事的方式:
curl -sI "https://www.example.com/1234.html" | grep -iE 'age|cache|via|x-cache|cf-cache'读法:Age: 0 或没有 Age 头,说明是刚回源的新副本,或者该层根本没缓存;Age: 3600 且接近 max-age,说明快过期了,此刻预热正是时候。X-Cache: HIT 或 cf-cache-status: HIT 是 CDN 或反代自己写的标记,各家的头名不一样,先在浏览器开发者工具网络面板里看一遍自己站点实际返什么,别照抄别人的文章。
第二类探针是 Nginx 自己的状态变量。给缓存命中的响应加一个头,是排查缓存问题最值得的一次性投入:
add_header X-Cache-Status $upstream_cache_status always;七个取值的含义要分清:HIT 命中;MISS 没命中且已回源并写入;BYPASS 被规则绕过(常见于带 Cookie 的请求);EXPIRED 过期回源;STALE 用了过期副本兜底;UPDATING 正在后台更新;REVALIDATED 用 304 确认了副本还新鲜。如果你发现大量请求是 BYPASS,那问题不是没预热,而是缓存规则压根没让这些请求进缓存,预热再多次都是白做。
第三类探针是应用层。APCu 或 Redis 里的键是否有值,用命令行直接看:redis-cli --scan --pattern 'post:*' | head 或者写个五行的 PHP 脚本调 apcu_exists($key)。这一步别省,很多「缓存没生效」的案例,最后发现是应用层压根没写这个键,或者写的键名里带了时间戳。
三、预热该推哪些 URL:不是全站,是热集合
全站预热是个陷阱。一个一千多篇的博客,把所有文章 URL 都拉一遍,等于让服务器做一次全量回源,CPU 和数据库连接都会被拉满,而其中 90% 的页面一天只有个位数访问。真正该预热的是一个「热集合」:
- 首页、分类首页、标签聚合页——访问最密集的少数几十个 URL。
- 最近发布或更新的 20~50 篇文章——刚发的文章通常会有站内推荐、RSS、搜索爬虫同时来拉。
- 从访问日志里按 PV 排出来的前 100 个 URL——这是最可靠的口径,比任何猜测都准。
从日志里提取热 URL 的一行命令(combined 格式,$request 在第一个引号对里):
awk -F'"' '{print $2}' /var/log/nginx/access.log \
| awk '{print $2}' | grep '\.html' | sort | uniq -c | sort -rn | head -100注意 grep -c 与 wc -l 的区别在这里不涉及,但如果你的日志已经被轮转,记得读 access.log.1 而不是半截的当天文件,否则统计出来的排名会被当天没跑完的流量污染。
四、预热脚本:串行、限速、绕过 CDN 的取舍
预热脚本最容易出的事故是「自己把自己打死」。三条纪律:
第一,串行或低并发。 用 xargs -P 2,不要上 -P 50。预热的目的是把内容温和地填进缓存,不是压测。并发 50 的预热等于对自家站点发起一次小型 CC 攻击。
第二,请求要打到哪一层要想清楚。 想让 CDN 缓存,就必须请求公网域名;但如果是要验证源站本身是否能正常生成页面,加 --resolve 直连源站 IP 更可靠,因为 CDN 可能返回它自己的一份缓存,你误以为源站是通的。
while read -r u; do
curl -s -o /dev/null -w "%{http_code} %{time_total} $u\n" \
--max-time 20 "https://www.example.com$u"
sleep 0.5
done < /tmp/hot_urls.txt-w 里打印状态码和耗时,跑完直接能看出哪些页面预热失败或者特别慢。第三,预热结果要留档。 把输出重定向到带日期的文件,下次出问题可以对比:同一个 URL 昨天预热耗时 0.2 秒,今天 8 秒,说明那个页面本身的性能退化了,缓存只是把问题盖住了。
五、预热之后仍然看到旧页面的六种原因
这是最让人抓狂的部分。按发生频率从高到低排:
1)浏览器自己的缓存。 你按 F5 是「带条件请求刷新」,很多情况下仍会用到本地副本;必须 Ctrl+Shift+R 强制刷新,或者用无痕窗口、或用 curl 从命令行验证。这一步能排除掉大概三成的「假故障」。
2)CDN 后台的缓存规则覆盖了源站的 Cache-Control。 很多 CDN 默认「忽略源站,按路径后缀设 TTL」,你改了源站头其实毫无作用。检查 CDN 的缓存键配置里有没有把 Cache-Control 的优先级设为尊重源站。
3)缓存键里混进了会变的东西。 最常见的是 utm_source、fbclid、?_= 时间戳这类查询参数被计入缓存键,导致每个带参数的 URL 都是一个新的键。你的预热请求不带参数,用户从公众号、广告点进来带了一堆参数,自然命中不了。解决办法是在 Nginx 或 CDN 侧把这些参数从缓存键里剔除。
4)Vary 头与 Cookie 的干扰。 如果响应带了 Vary: Cookie,那么每个不同 Cookie 的用户都会得到一个独立的缓存副本,命中率会低到可以忽略。论坛、登录态站点尤其如此,正确做法是对未登录用户单独用一套不带 Cookie 的缓存策略。
5)应用层的片段缓存没被清。 整页缓存清了,但页面里某个区块(比如侧边栏最新文章)还从 APCu 里读旧数据。这就是「整页看起来是新的,局部还是旧的」的典型症状。解决办法是建立「版本号」机制:把所有片段缓存的键前缀加一个全局版本号,改内容时只改这个版本号,所有旧键自然失效。
6)多台后端机器之间不一致。 如果你有两台 PHP 机器,各自有本地的 fastcgi_cache 或 APCu,清缓存时只清了一台,用户每次请求落到哪台就是哪份内容,表现出来就是「刷新几次,有时新有时旧」。此时必须逐台清,或者把缓存集中到 Redis 这类共享存储上。
六、把预热做进发布流程,而不是事后救火
上面这些手段如果只在出事时手动执行,实际效果通常只发挥一半。真正省心的做法是把预热写进发布脚本,固定成四步:
- 发布前先取一次基线:首页 TTFB、首页响应体长度、三个热文章的状态码。
- 部署完成、PHP 与 Nginx 重启后,先
curl --resolve直连源站验证页面正常,再去清 CDN 缓存。 - 清完立刻跑预热脚本,只推热集合的 100~150 个 URL,串行、每个之间 sleep 半秒。
- 预热跑完再取一次基线,与发布前对比。TTFB 上升超过 50% 或者出现非 200 状态码,直接触发回滚流程。
这套流程的价值不在于「更快」,而在于把「发布后多久恢复正常」从一件靠运气的事,变成一个有数字、可对比、可回滚的过程。对单人维护的站点尤其重要:你不需要 24 小时盯着,因为脚本会替你把那段最容易出事的窗口期守住。
七、什么时候不该预热
最后说一点反面。预热不是所有场景都值得做:内容更新极其频繁的站点(比如聚合类、新闻转载站),缓存本身命中率就很低,预热成本大于收益;有大量个性化内容的站点(登录后千人千面),整页缓存本身就不该开,预热更无从谈起;服务器资源本来就吃紧的站点,先解决容量问题再谈预热,否则预热脚本自己就是压垮骆驼的最后一根稻草。
判断标准很简单:如果你的访问日志里,同一个 URL 在十分钟内被重复请求的次数很多,那预热有意义;如果大部分 URL 一天只有一次访问,那缓存和预热都只是心理安慰,把精力放在页面本身的速度上更实在。