每一次发版,或者每一次重启 PHP-FPM、重启 Nginx,我的站都会经历一段很诡异的十分钟:服务器负载不高、CPU 不高、数据库也没什么慢查询,但页面就是慢,首页能到两三秒,内页普遍一秒多。十分钟之后,一切又自动恢复正常。
前面很长一段时间我都把这个当成「发版后的正常抖动」,直到有一次凌晨发版,恰好赶上一个小规模的推荐流量进来,那十分钟的跳出率是平时的三倍。从那天起我把这件事拎出来单独做,结论是:这不是抖动,这是缓存从零开始重建的那段时间里,整个请求链路上所有层的成本同时暴露了出来。能修,而且值得修。
一、先看清楚「冷」在哪一层
说「缓存没热」太笼统了。一个典型的 Typecho 或 WordPress 站点,请求从进门到出页面,至少有四层缓存会参与,每一层的冷热特性完全不同。
第一层是 Nginx 的整页缓存,也就是 proxy_cache 那一套。它重启之后是彻底空的,文件缓存在磁盘上,命中就是一次 stat 加一次 sendfile,成本极低。第二层是 PHP 的 OPcache,重启 FPM 之后 opcode 缓存清零,所有 PHP 文件都要重新编译,这一层的成本是几百毫秒到两秒不等,取决于文件数量。第三层是应用层的对象缓存,比如模板片段、分类列表、配置项放在 Redis 或 APCu 里。第四层是数据库的查询缓存和 MySQL 自己的 buffer pool,严格说这层不受重启影响(数据在共享内存里,进程重启就不在了,但服务器不重启的话 buffer pool 是热的)。
关键洞察是:这四层的冷热是由不同的事件触发的。重启 Nginx 只冷第一层,重启 FPM 冷第二层和第三层,发版上传新文件会让 OPcache 里那部分失效但其它文件还是热的,而数据库那条链路基本不受这些影响。所以「发版后慢十分钟」这个现象,主因通常不是整页缓存空了,而是 OPcache 重新编译加上应用层缓存重建叠加在一起。
搞混这件事会导致优化白做。我最早的想法是「发版后立刻访问一遍首页把缓存打热」,结果首页确实快了,但内页还是慢,因为首页那一遍只热了首页的整页缓存和公共模板,文章页需要的 OPcache 文件一个都没编译。
二、OPcache 预热:最快见效的一步
OPcache 的预热有一个特别干净的做法:在命令行里用 opcache_compile_file() 遍历所有 PHP 文件,提前把 opcode 编译好写进共享内存。命令行进程和 FPM 进程用的是同一块共享内存(前提是 opcache.file_cache 或者共享内存段配置一致),所以命令行预热完,FPM 侧直接就能命中。
<?php
// warmup.php —— 放在站点根目录之外,只允许命令行执行
if (PHP_SAPI !== 'cli') {
http_response_code(404);
exit;
}
$root = '/var/www/mysite';
$count = 0;
$skip = 0;
$it = new RecursiveIteratorIterator(
new RecursiveDirectoryIterator($root, FilesystemIterator::SKIP_DOTS)
);
foreach ($it as $file) {
if ($file->getExtension() !== 'php') {
continue;
}
$path = $file->getPathname();
// 跳过不该编译的目录:上传目录、缓存目录、vendor 里的测试文件
if (preg_match('#/(uploads|cache|\.git|tests?)/#', $path)) {
$skip++;
continue;
}
if (@opcache_compile_file($path)) {
$count++;
} else {
error_log("warmup failed: $path");
}
}
echo "compiled=$count skipped=$skip\n";跑完之后立刻查一次状态,确认 opcache_statistics.num_cached_scripts 有增长、opcache_statistics.hit_rate 在后续请求里能立刻上去,就说明预热生效了。
# 用 FPM 的 status 页或者一个小脚本读 opcache 状态
php -r '
$s = opcache_get_status(true);
printf("cached=%d used=%.1fMB free=%.1fMB hit=%.1f%%\n",
$s["opcache_statistics"]["num_cached_scripts"],
$s["memory_usage"]["used_memory"]/1048576,
$s["memory_usage"]["free_memory"]/1048576,
$s["opcache_statistics"]["opcache_hit_rate"]);
'这里有两个必须注意的点。第一,内存要给够。如果 opcache.memory_consumption 只有 64MB,而站点编译完需要 200MB,预热到一半就会出现「缓存满了开始踢旧的」,opcache_statistics.oom_restarts 会从 0 一直涨。这种情况预热等于白做,因为被踢掉的脚本下一个请求又要重新编译。判断标准很简单:预热完成后看 used_memory,然后把它乘以 1.5 作为下限去设 memory_consumption。
第二,不要用 HTTP 请求去「假装」预热。有些教程说发版后 curl 一遍 sitemap 里的所有 URL,这确实能编译 PHP 文件,但对一个有几千篇文章的站来说,这意味着几千次完整的请求,每次都要连数据库、渲染模板,代价是真正用户流量的几十倍,而且会把自己的站打到 502。命令行编译文件的方式成本低一到两个数量级,因为它压根不执行业务逻辑。
三、整页缓存的预热要「按访问分布」来
整页缓存不一样,它必须经过真实请求才能生成,因为页面内容是动态的。所以问题变成了:发版后先热哪些页面。
我的做法是从访问日志里算出一个「最近七天真实访问的 URL 分布」,取 Top 200 去打。为什么是 200 而不是全部或者 Top 10?因为流量分布是长尾的,Top 10 只能覆盖大概 25% 的请求,Top 200 能覆盖 70% 以上,再往后每一百个 URL 只多覆盖几个百分点,性价比急剧下降。
#!/bin/bash
# warm_cache.sh —— 发版后调用,串行打 Top 200 URL
# 前提:站点已经能正常响应,且整页缓存已开启
set -euo pipefail
LOG=/var/log/nginx/access.log
URLS=/tmp/warm_urls.txt
HOST=www.example.com
# 从最近一天的日志里取 GET 200 的 HTML 路径,按访问次数排序
awk '$6 ~ /GET/ && $9 == 200 {print $7}' "$LOG" \
| grep -v '\.\(css\|js\|png\|jpg\|jpeg\|gif\|webp\|svg\|ico\|woff2\?\)$' \
| grep -v '^/admin' \
| sort | uniq -c | sort -rn | head -200 | awk '{print $2}' > "$URLS"
# 串行打,间隔 50ms,避免瞬间把自己的 FPM 打满
while IFS= read -r p; do
curl -s -o /dev/null -H "Host: $HOST" -H "X-Warmup: 1" \
"http://127.0.0.1${p}"
sleep 0.05
done < "$URLS"
echo "warmed $(wc -l < "$URLS") urls"这里有三个细节值得说。一是串行打而不是并发打。看起来效率低,但预热本身只需要几十秒到两分钟,而并发打会瞬间占满 FPM 进程池,导致这个时间段进来的真实用户排队。二是加一个 X-Warmup 头,方便在日志里区分真实的预热请求和真实用户请求,后面统计时不会污染数据。三是打 127.0.0.1 而不是公网域名,这样请求不经过 CDN 和公网,速度快而且不会消耗外网带宽。
如果站点前面有 CDN,那么预热还得往上游推一层。CDN 的缓存在源站预热完之后仍然是冷的,用户第一次访问仍然会回源。这时候可以用 CDN 厂商提供的预热接口,或者干脆接受 CDN 边缘冷这件事——因为 CDN 的一个边缘节点冷,只影响那一个机房的用户,而且 CDN 回源到你刚刚热好的源站,速度也是快的。先热源站,再考虑 CDN,顺序不能反。
四、把缓存预热挂进发布流程
预热最大的问题是人容易忘。我的做法是在发布脚本的最后一步固定调用,并且加一个开关允许跳过(比如只改了一行 CSS 的发布,就没必要预热)。
#!/bin/bash
# deploy.sh 的尾部片段
set -euo pipefail
echo "==> reloading php-fpm"
sudo systemctl reload php8.2-fpm
echo "==> warming opcache"
php /var/www/mysite/bin/warmup.php
echo "==> warming page cache"
if [ "${SKIP_WARM:-0}" != "1" ]; then
bash /var/www/mysite/bin/warm_cache.sh
fi
echo "==> smoke test"
code=$(curl -s -o /dev/null -w '%{http_code}' "http://127.0.0.1/")
[ "$code" = "200" ] || { echo "smoke test failed: $code"; exit 1; }
echo "deploy done"systemctl reload 而不是 restart 是有讲究的:reload 会让 FPM 平滑地重新加载配置,老的 worker 处理完手上的请求再退出,不会出现「重启那一瞬间所有请求 502」。但要注意 reload 之后仍然是同一块 OPcache 共享内存,所以如果只是 reload,OPcache 里原有的编译结果还在,预热其实不必要;只有 restart、机器重启、或者上传了新的 PHP 文件才会真正需要预热。这也是我最早理解错的地方——把 reload 当成了冷启动。
五、怎么验证预热真的有用
不要凭感觉,用数据。我在预热前后各跑一次同一条 curl,记录四个时间点,看 TTFB 的变化。
# 预热前
for i in 1 2 3; do
curl -s -o /dev/null -w '%{time_starttransfer}s\n' \
"http://127.0.0.1/some-post.html?_=$RANDOM"
done
# 执行预热
php /var/www/mysite/bin/warmup.php
bash /var/www/mysite/bin/warm_cache.sh
# 预热后
for i in 1 2 3; do
curl -s -o /dev/null -w '%{time_starttransfer}s\n' \
"http://127.0.0.1/some-post.html?_=$RANDOM"
done注意 URL 后面加的 ?_=$RANDOM:不加的话第二次请求直接命中了浏览器或本地代理,测出来永远是几毫秒,什么也证明不了。但加了随机参数之后,如果站点的缓存 key 包含了 Query String,那这个请求永远不会命中缓存——这时候要确认缓存配置里是否 proxy_cache_key 忽略了参数。我的站点是故意让缓存 key 包含参数的(避免串号),所以这条测试必须对同一批 URL 各测一次,不能带随机参数去测缓存命中。
我自己实测的一组数,仅供参考:重启 FPM 后立刻访问一个没热过的内页,TTFB 是 1.4 秒;命令行预热 OPcache 之后同样一个页面,TTFB 降到 0.28 秒;再加上整页缓存热了之后,降到 0.04 秒。三层加起来是 35 倍,而如果只做整页缓存预热不做 OPcache 预热,第一个访问者仍然要等 1.4 秒。这也是为什么两层都必须做。
六、几个反复踩的坑
坑一:预热脚本自己把缓存冲掉了。有些站点的整页缓存 key 里带了 UA 或者 Cookie,预热脚本用的是 curl 默认 UA 和空 Cookie,生成出来的缓存条目被存到一个永远不会被真实用户命中的 key 下。表现是「预热跑完了,但用户访问还是要等」。判断方法是在预热后立刻用一个真实浏览器的 UA 去请求,看响应头里有没有 X-Cache: HIT,没有就是 key 不一致。
坑二:预热把 .gz 预压缩文件覆盖掉了。如果站点用了 gzip_static on,发布新版本时通常要重新生成一批 .gz。如果预热脚本在压缩任务还没跑完时就开始打,Nginx 找不到 .gz 会实时压缩,但请求已经结束了,这个结果不会被保存。结果就是预热期间每个请求都实时压缩一次,反而更慢。正确顺序是:上传文件 → 生成预压缩 → reload → 预热。
坑三:整页缓存预热撞上定时任务。如果预热在凌晨两点跑,恰好和备份、日志切割撞在一起,磁盘 IO 会被抢,预热耗时翻几倍不说,还可能因为 proxy_cache_path 所在分区写满而失败。给预热留一个时间窗,或者干脆在发布流程里同步做,不要放到 cron 里。
坑四:缓存有效期比预热耗时长。这个坑比较隐蔽。假设整页缓存 TTL 是 600 秒,而你预热 200 个 URL 花了 3 分钟,那么最早热的那些页面在你热完最后一页时只剩 7 分钟寿命。如果这会儿正好有个流量高峰,等峰值来的时候前面那批已经过期了。预热耗时必须远小于 TTL,一般要求不超过 TTL 的五分之一。做不到的话,要么缩短预热列表,要么延长 TTL。
七、小结:把「发版慢十分钟」变成一个可度量的过程
这件事真正的价值不在于省了那十分钟,而在于它把一个模糊的体感变成了四层可分别度量的东西。现在我每次发版都会记录三个数:OPcache 预热耗时、整页缓存预热覆盖的 URL 数、预热后的首页 TTFB。这三个数稳定下来之后,任何一次异常都能立刻定位——如果某次预热耗时突然从 8 秒变成 40 秒,那不是发版的问题,是某个 PHP 文件被引进了大量依赖或者 OPcache 内存不够开始 OOM 重启了。
还有一点反直觉的经验:不要把预热做成全自动无缝。留一个必看的输出,让发版的人看到「compiled=1832 skipped=214」这一行。当这个数字突然比上次多出几百,通常意味着有人在 vendor 里装了个新包,或者上传目录里混进了 PHP 文件,都是值得看一眼的信号。全自动跑到看不见,反而会漏掉这些线索。
最后,如果你的站流量很小、一天几百次访问,那预热带来的绝对值收益可能只有几秒乘以几百次,算下来不值得投入。但如果你经历过那种「刚发完版就被分享出去」的时刻,就会知道那几分钟的体验落差有多难看。这个成本,一次就够回本了。