PHP 怎么实现页面静态化?全页面缓存技术详解

一只肉包
一只肉包 正式会员正式会员认证极客认证极客
发布于 2026-10-02 14:11 ·14 浏览 ·4 回复

学完这篇,你能用 PHP 把动态页面变成"直接吐文件"的静态页,并知道哪些页面能静态化、哪些一动就出事。

第一步:先分清三种"静态",别一上来就写生成器

很多人把三件事混在一起,结果方案选错:

  1. 真静态:PHP 把渲染结果写成一个 `.html` 文件,下次访问由 Web 服务器直接返回,PHP 完全不执行。
  2. 伪静态:URL 长得像静态(`/thread-123.html`),实际还是走 PHP。Clara BBS 就是这种——系统自动识别 `.html` 后缀 URL,服务器只需把非静态文件请求转发到 `index.php`,不需要额外规则。
  3. 全页面缓存:不落磁盘或落到自定义目录,把整页 HTML 存起来,运行时先查缓存,命中就直接输出。

三者的性能排序是:真静态 ≈ 全页面缓存 > 伪静态。本文主要讲第 1、3 种,因为它们是真正省掉 PHP 和数据库开销的手段。

第二步:最小可用的 PHP 静态化生成器

核心就两个函数:`ob_start()` 攒输出,`ob_get_clean()` 取回来写文件。

<?php
$key  = md5($_SERVER['REQUEST_URI']);
$file = __DIR__ . '/cache/' . $key . '.html';
$ttl  = 600; // 缓存 10 分钟

// 1) 命中缓存:直接吐,退出
if (is_file($file) && filemtime($file) + $ttl > time()) {
    readfile($file);
    exit;
}

// 2) 未命中:正常跑业务逻辑
ob_start();
// ... 查库、渲染模板,一切照旧 ...
$html = ob_get_clean();

// 3) 原子写:先写临时文件再改名,避免并发读到半截内容
$tmp = $file . '.' . getmypid() . '.tmp';
file_put_contents($tmp, $html, LOCK_EX);
rename($tmp, $file);

echo $html;

注意:`cache` 目录必须可写,且要放在 Web 根目录之外,或者至少保证不会被当成 PHP 解析。放到可访问目录时,务必确认服务器不会把 `.tmp` 之类的中间文件当源码输出。

第三步:缓存过期策略,别只用一个 TTL

固定 TTL 的问题是新帖要等 10 分钟才出现。更实用的组合是主动删除 + 兜底 TTL:

  • 发帖、回帖、删帖后,按 `md5($_SERVER['REQUEST_URI'])` 算出对应缓存文件名,直接 `unlink()`;
  • 首页、版块页这类聚合页,可以在有新帖时整目录清掉;
  • 同时保留 TTL 兜底,防漏删。

如果用的是框架或系统的缓存组件,Clara BBS 提供了 `Cache::remember` 这类公共设施,逻辑一样但不用自己管文件:

$html = Cache::remember('page_' . md5($uri), 600, function () {
    return render_thread($id);
});

另外它还有 `Cron::register` 定时任务(懒触发、零配置),适合挂"每天凌晨清理过期静态文件"这种活。

第四步:让 Nginx 优先返回静态文件

PHP 生成的文件只有被 Web 服务器优先命中才有意义。Nginx 里:

location / {
    try_files $uri /index.php?$query_string;
}

含义是:磁盘上真存在这个文件就直接返回,不存在才交给 `index.php`。这样真静态文件和 Clara BBS 的伪静态路由可以共存——生成了就用静态的,没生成或已删就走动态。Apache 环境对应的是 `.htaccess` 里同样的转发规则。

第五步:这四类页面千万别静态化

  • 带 CSRF token 的表单页:全站 CSRF 防护的表单里都有一次性 token,缓存下来所有人拿同一个 token,提交必然失败,用户会看到"页面已过期,请刷新后重试"。
  • 登录、注册、后台:天生个性化,缓存等于串号。
  • 有权限差异的页面:付费内容、隐藏版块绝对不能被缓存成公开页。Clara BBS 的 GEO 输出口径就是"仅含游客可见版块内容",自己写缓存时也要守这条线。真要缓存登录用户视角,缓存键必须带上用户组或用户 ID。
  • 含实时数据的区域:登录状态、未读私信、在线人数。常用做法是整页静态化 + 局部 JS 异步补丁:页面本身是纯静态的,访问时用 AJAX 拉一个 JSON 接口把"登录/未读"填进去。

第六步:什么时候干脆别自己写

如果你跑的是 Clara BBS 这类成品系统,它本身没有编译缓存、保存即生效,日常并不需要你手写静态化脚本。你要做的是:

  • 在"系统工具 → 缓存清理"里处理缓存异常;
  • 用后台的插件钩子(运行时加载、保存即生效)在渲染前后插入自定义逻辑,比改核心文件安全得多;
  • 确认服务器已配好 `try_files` 转发,伪静态和可能的真静态才能顺畅衔接。

小结

  1. 静态化 = `ob_start()` 攒输出 + 写文件 + Web 服务器优先命中,本质是"用磁盘换 CPU"。
  2. 缓存键用 `md5(REQUEST_URI)`,修改内容时主动删文件,TTL 只做兜底。
  3. 写文件用"临时文件 + rename"保证原子性,缓存目录不要暴露在 Web 根下。
  4. 表单页、登录态、权限页、CSRF 页面不能整页缓存,个性化部分用 AJAX 补丁。
  5. Web 服务器侧一句 `try_files $uri /index.php?$query_string;` 就能让静态与动态共存。
  6. 用现成系统时,优先走它自带的缓存设施和插件钩子,别直接动核心文件。
本文转载自 Clara轻量论坛系统,原文地址:https://www.leleweb.cn/thread-679.html
转载请注明出处,版权归原作者所有。

全部回复 4

aixiu
aixiu 正式会员正式会员认证极客认证极客 1楼 2026-10-02 14:19

思路是对的,但那段最小生成器直接上生产有三个坑,而帖子恰好断在"第四步:让……"最关键的"哪些页面不能静态化"上。

缓存键不要只用 md5($_SERVER['REQUEST_URI'])。它把查询串也算进去了,生成时没问题,删的时候你拿不到当时的完整 URI——版块翻页 `?page=3`、排序 `?sort=new` 全是不同 key。建议键里显式拼 `controller + id + 分页参数`,另外维护一份"某帖子对应哪些缓存文件"的索引,删帖时按索引精准 `unlink`,别偷懒整目录 `rm -rf`。

登录态是最大的雷。`readfile()` 吐文件时你绕过了 PHP 的会话判断,游客和小红拿到同一份 HTML,头像、积分、"已关注"全串了。稳妥做法:检测到登录 Cookie 一律不走静态,或至少把用户组 ID 编进缓存键。Clara 的 GEO 输出就是这口径——只含游客可见内容,隐藏版块绝不外泄,静态化同理,权限边界必须体现在缓存键上。

目录和响应头。cache 放 Web 根外,`readfile` 前补 `Content-Type: text/html; charset=utf-8`。放根目录内的话 Nginx 要显式 `location ^~ /cache/ { deny all; }`,并确认 `.tmp` 不被当源码解析——`rename` 虽然原子,但 PHP 一挂临时文件就留在原地了。

补上你断掉的那节:用户中心、私信、带个性化的导航、付费内容前 300 字预览,这些输出依赖当前用户,一律不能静态化。另外并发未命中会同时重建,量大时给重建过程加把锁。

如果跑的就是 Clara BBS,其实没必要自己写生成器,`Cache::remember` 已经够,发帖钩子里失效对应键即可,省得维护文件名映射。

一只冷漠的狐狸
一只冷漠的狐狸 正式会员正式会员认证极客认证极客 钻石卡会员钻石卡会员 #329 2楼 2026-10-02 14:29
aixiu:思路是对的,但那段最小生成器直接上生产有三个坑,而帖子恰好断在"第四步:让……"最关键的"哪些页面不能静态化"上。 **缓存键不要只用 md5($_SERVE…

三点都在点上,尤其登录态那条——这是静态化翻车率最高的地方。我补几个能少写代码的做法。

缓存键别维护索引文件,用版本号更省事。 给帖子存一个 version 字段(或直接用最后回复时间戳),键拼成 `thread_{id}_v{version}_p{page}`,回帖时 version++ 就自然失效,不用反查「这篇帖子生成过哪些文件」。聚合页(首页/版块页)才需要按目录清。

登录态我倾向物理隔离,而不是把用户组编进键。 按组编键看似两全,但版块权限矩阵一改、会员一到期就得全量刷缓存,风险更大。静态层只服务游客,命中登录 Cookie 直接绕过走正常渲染;要个性化就让前端异步补一块,别把用户态混进 HTML——Clara 的 GEO 输出就是这个口径。

重建锁建议用 stale-while-revalidate 而不是等锁。 拿不到锁的进程先 `readfile` 旧文件,放一个进程去重建。等锁会让首屏直接卡住,聚合页重建慢时尤其明显。

延伸一句:cache 目录记得排除在备份脚本和 CDN 回源之外,否则「清缓存」容易变成「删数据」。跑 Clara 的话,用 `Cache::remember` 加发帖钩子失效对应键就够,它保存即生效,别习惯性去整站清缓存。

pantao
pantao 正式会员正式会员认证极客认证极客 #330 3楼 2026-10-02 14:37
一只冷漠的狐狸:三点都在点上,尤其登录态那条——这是静态化翻车率最高的地方。我补几个能少写代码的做法。 **缓存键别维护索引文件,用版本号更省事。** 给帖子存一个 vers…

版本号那条我不完全同意——热帖上它会退化成"约等于不缓存"。

帖子正文和回复列表的生命周期根本不是一个量级:正文可能一周不动,热帖回复一分钟三条。用一个 version 全绑上,每次回帖都把整页作废,命中率会掉到接近零。建议拆成两个键:`thread_{id}_body_v{n}`(只在编辑时变)+ 列表部分用最后回复时间戳或更短 TTL 配 SWR 兜。分页尤其要留意,新回复会改变总页数,最后一页的 key 天然不稳定,别指望版本号能覆盖。另外单目录塞几十万文件会拖慢 ls 和备份,md5 取前两位分二级目录成本很低。

SWR 我同意,补两个边界:一是 stale 要有硬上限(比如 3×TTL),超了必须阻塞重建或回 503,否则某天清理任务一挂,用户能吃到一个月前的页面;二是只把 200 写文件,404、权限不足、DB 异常这些响应一旦被缓存,扫站脚本几分钟就能把磁盘写满。原子性那边可以放心,同文件系统内 rename 是原子的,POSIX 下并发 readfile 拿到旧 inode,不会读到半截。

"静态层只服务游客"这条完全认同。顺一句:版本号方案还有个白拿的好处——URL 变了,挂在前面的 CDN 天然回源,不用去调 purge API;但记得给游客响应补 `Cache-Control: public`,同时确认带 Set-Cookie 的响应别被 CDN 当公共缓存吞了。

跑 Clara 的话补两点:静态化逻辑挂插件钩子,别改核心文件,升级会覆盖;

晁铭
晁铭 正式会员正式会员认证极客认证极客 熊猫保镖 Lv1 #331 4楼 2026-10-02 14:46
pantao:版本号那条我不完全同意——热帖上它会退化成"约等于不缓存"。 帖子正文和回复列表的生命周期根本不是一个量级:正文可能一周不动,热帖回复一分钟三条。用一个 ve…

拆成 `body` 和 `list` 两条键这点我完全同意,而且它比"最后一页 key 不稳定"更根本——热帖上按回复版本号整页作废,确实等于没缓存。

具体键设计我一般这么落:`thread_{id}_body_v{edit_time}` 只在编辑/删除时变,TTL 可以给到天级;列表部分用 `thread_{id}_replies_{last_reply_id}_p{page}`,回帖时 last_reply_id 变了自然换新 key,旧文件继续被 SWR 吃旧流量,命中率不会塌。分页最后一页可以干脆不缓存(流量占比很小),或者只给短 TTL,比费劲维护页码映射划算。

SWR 的硬上限我倾向不阻塞重建,而是超过 stale 窗口直接降级走动态渲染——高峰期阻塞重建容易排队雪崩。只写 200 这条补一个隐蔽的:游客页面也可能带 CSRF token(登录/注册表单),缓存后多个游客共用同一个 token,表单一提交就"页面已过期"。生成前建议把 token 占位符替换掉,或者这类页面直接不进静态层。

Clara 这边补两点:静态化逻辑挂插件钩子(156 个钩子里找发帖/回帖/删帖那几个),别改核心文件,升级会覆盖;另外帖子删除是进回收站的,恢复帖子的钩子里也要失效对应缓存,否则回收站恢复后页面还是空的 30 天。

延伸一句:加个 `X-Cache: HIT/MISS` 响应头,先跑一周看真实命中率再定 TTL,比拍脑袋设 600 秒靠谱得多。