小站最常见的性能焦虑是「数据库压力大」「PHP 每次都查库」。一提优化,很多人第一反应是上 Redis、加索引、换更好的服务器。但对内容站来说,收益最大、成本最低的一步其实是页面静态化——把动态渲染的结果落成 HTML 文件,让绝大部分访问根本不进 PHP。这篇文章讲的是静态化的几种层次、具体实现,以及静态化之后才会出现的那些新问题。
一、静态化的三个层次,别一步跳到最难
「静态化」不是一个开关,而是一条连续谱:
- 页面缓存(半静态):PHP 层把渲染结果序列化存起来,命中直接输出。数据库不查了,但仍是 PHP 进程在处理请求。
- Nginx fastcgi_cache:PHP 算出结果后由 Nginx 存成磁盘缓存文件,后续请求 Nginx 直接返回,完全不进 PHP-FPM。
- 真静态 HTML:文章发布时生成
/post/123.html,Nginx 按普通静态文件处理,甚至可以前置 CDN 全缓存。
我建议顺序也是这样推进:先做 1,验证缓存失效逻辑对不对;再做 2,性能立刻上涨一大截而改动最小;最后才考虑 3,因为它引入了「文件和数据库两份真相」的一致性负担。很多站停在 2 就完全够了——fastcgi_cache 命中时一次请求大约 1~3ms,和读静态文件差别已经小到可以忽略。
二、Nginx fastcgi_cache:改动最小的方案
fastcgi_cache_path /var/cache/nginx/site levels=1:2 keys_zone=SITE:100m inactive=7d max_size=2g use_temp_path=off;
fastcgi_cache_key "$scheme$request_method$host$request_uri";
server {
listen 443 ssl http2;
server_name www.example.com;
set $skip_cache 0;
if ($request_method = POST) { set $skip_cache 1; }
if ($query_string != "") { set $skip_cache 1; }
if ($request_uri ~* "/admin/|/login|/search|/preview") { set $skip_cache 1; }
location ~ \.php$ {
include fastcgi_params;
fastcgi_pass unix:/run/php/php8.1-fpm.sock;
fastcgi_cache SITE;
fastcgi_cache_valid 200 301 302 30m;
fastcgi_cache_bypass $skip_cache;
fastcgi_no_cache $skip_cache;
fastcgi_cache_use_stale error timeout updating http_500 http_503;
fastcgi_cache_lock on;
add_header X-Cache $upstream_cache_status always;
}
}
逐条说清楚为什么这么写:
keys_zone=SITE:100m:100MB 的键区大约能记 80 万条缓存索引(每个 key 约 100 字节),小站完全够。fastcgi_cache_key里带$scheme和$host:如果站同时响应 http 和 https、或者有 www 与裸域两套入口,不带这两项会把 https 的内容缓存给 http 请求,出现「混合内容」告警。fastcgi_cache_bypass与fastcgi_no_cache的区别:前者是「本次请求不读缓存」,后者是「本次请求不写缓存」。登录用户需要两者都设,否则他们会看到别人(或未登录)的页面。fastcgi_cache_lock on:缓存过期瞬间如果来了 100 个请求,不加这个会全部穿透到 PHP 同时渲染;加了之后只有一个请求去生成,其余等待。这是防止「缓存雪崩」最便宜的一招。fastcgi_cache_use_stale ... updating:允许在缓存更新的那几毫秒里继续返回旧内容。用一点点「陈旧」换稳定,内容站完全可接受。
三、命中率才是验收标准,不是「配了就算完」
X-Cache 响应头会返回 HIT / MISS / BYPASS / EXPIRED。上线后必须实测命中率:
for i in $(seq 1 200); do
curl -s -o /dev/null -w "%{http_code} %{time_total}\n" \
"https://www.example.com/article-$((i % 20)).html"
done | awk '{print $2}' | sort -n | awk '{a[NR]=$1} END{print "p50="a[int(NR*0.5)], "p95="a[int(NR*0.95)]}'
如果 p95 还是几十毫秒,说明命中率有问题,最可能的原因有三个:
- 缓存被
Set-Cookie干掉了:Typecho、WordPress 这类程序即使对访客也会输出setcookie(评论者信息、测试 cookie)。Nginx 默认不因为Set-Cookie拒绝缓存,但 CDN 通常会。要根治就在 PHP 层去掉访客的无用 cookie,或对静态内容add_header Set-Cookie ""清空。 - URL 带随机参数:统计代码、分享链接常常带
?from=xxx,上面配置里if ($query_string != "")会让它全部绕过缓存。改为只对特定参数绕缓存:if ($args ~* "(preview|nocache)=")。 - 缓存区域太小:
inactive=7d但max_size=2g,文章多、图多的话缓存会频繁淘汰。看/var/cache/nginx/site的实际占用与 nginx 的cache manager日志就能确认。
四、主动清理:静态化之后真正的难点
文章更新了,缓存什么时候失效?靠 fastcgi_cache_valid 30m 等 30 分钟太慢,靠 inactive 更慢。所以必须做主动清理。
Nginx 的 fastcgi_cache_purge 是第三方模块,官方包不带。检查方法:
nginx -V 2>&1 | tr ' ' '\n' | grep -i purge没有的话有三个选择:
- 用
cache_bypass加版本号:编辑文章时把该文 URL 加一个?v=时间戳的重定向规则,等价于绕过旧缓存。 - 直接删缓存文件:
fastcgi_cache_key决定文件名,可以用ngx_cache_purge的算法或grep -rl找出对应文件删掉,再 reload。粗暴但有效,只是文件多时慢。 - 改用应用层缓存(第一层方案):程序自己知道哪篇文章变了,删自己的缓存键最精确。
我自己的站用的是第三种:编辑保存后,PHP 里删掉 Redis 里对应的键,同时通过一个内部 URL 通知 Nginx 清掉那份 fastcgi 缓存。两层都清干净,一致性才有保证。
五、真静态化:生成、分发、清理三件事
如果确实要做到真静态(比如用来扛突发流量、或者要让 CDN 完整缓存),生成脚本要满足几个条件:
#!/bin/bash
set -euo pipefail
OUT=/www/wwwroot/static
TMP=$(mktemp -d)
trap 'rm -rf "$TMP"' EXIT
for id in $(mysql -N -B -e "SELECT cid FROM posts WHERE status='publish'"); do
php /www/wwwroot/bin/render.php "$id" > "$TMP/$id.html"
done
# 先写到临时目录,全部成功后再整体替换
rsync -a --delete "$TMP/" "$OUT/"
nginx -t && systemctl reload nginx
关键点是 mktemp -d + rsync --delete:绝不能在原目录里边生成边覆盖。否则生成到一半时,访客看到的是一半新一半旧、甚至空文件的页面。整体替换在秒级完成,中间只有一个极短的窗口。
- 生成失败不能发布:
set -e让render.php报错就中断,trap保证临时目录被清掉,正式目录一个字节都没动。 - 别用
--delete加时间戳目录:如果输出到static/20261029/这种带版本目录,就不用删旧目录,但那会让磁盘无限增长,需要另加清理策略。二选一,别混。 - 删除文章要同步:
rsync --delete天然处理了这个问题——数据库里没有了,临时目录里就没有,同步后正式目录也就删掉了。手写循环容易漏这一步。
六、静态化之后才出现的新问题
这一节是我踩坑最多的地方,静态化让动态时代的很多「默认行为」失效了:
第一,搜索页是动态的。静态化只适合确定性内容。搜索、分页参数、标签筛选这类页面一旦被缓存,会串结果。做法是把它们隔离到单独的 location,明确 fastcgi_cache off,或者干脆改成前端 JS 调用 API 渲染。
第二,登录态与评论。静态 HTML 里如果嵌了「你好,某某」,就彻底不能缓存。正确的做法是让静态页只包含公共部分,用户态由前端异步拉取,或者在 Nginx 层按 Cookie 分流。
第三,发布时间的可见性。定时发布的文章如果提前生成了静态页,会被搜索引擎先抓到。生成脚本必须只处理 created <= now 且 status='publish' 的记录,两个条件都不能少。
第四,sitemap 与静态页的同步。静态页重新生成时,lastmod 也要跟着变,否则搜索引擎不会重抓。两者必须由同一个脚本生成,不能一个手动更新一个自动更新。
七、CDN 前置:静态化收益再放大一次
真静态之后,把 /article/ 这类路径整体交给 CDN 缓存,回源率能压到个位数。但要注意两件事:
- 缓存键要包含编码:
Accept-Encoding不同的请求会拿到 gzip 与 brotli 两种副本。CDN 一般会自动处理,但自己写缓存键时若遗漏,会出现「浏览器拿到 brotli 但声明是 gzip」的解码错误。 - 发布后必须刷新:CDN 的 TTL 是分钟级的,文章改了要主动推送刷新,否则读者评论「页面还是旧的」。把清缓存做成发布脚本的最后一步,别指望人工去点。
还有一个经验:CDN 回源时带上 Host 头必须与站点 server_name 一致。我在一个站上碰到过 CDN 回源用 IP 直连,Nginx 落到默认 server 块,返回的是一个完全无关的站点首页,而 CDN 把它当成正常 200 缓存了一整天。表现是「全站内容变成另一个站」,非常吓人。检查方法是 curl -H "Host: www.example.com" http://源站IP/ 看返回对不对。
八、小结
页面静态化的核心不是「生成 HTML」,而是建立一套可验证的失效机制。缓存命中率看 X-Cache,一致性看「清缓存是不是发布流程的固定一步」,稳定性看「生成失败时旧文件是否完好」。这三点立住了,静态化就是纯收益;这三点没立住,静态化只是把数据库的问题换成了「页面内容莫名其妙不更新」的问题——后者更难排查,因为报障的人往往说不清是哪一层在返回旧内容。
如果只能做一件事,先上 Nginx fastcgi_cache 并打开 fastcgi_cache_lock,二十分钟的配置能换来 PHP 负载下降一大半。
