摘要:我给站点加了整页缓存。上线以后看响应头,发现一个尴尬的事实:它看起来在跑,其实一次都没命中过。整页缓存是”一份 HTML 发给所有人”,它的失效几乎全是静默的 —— 不报错、页面能开,只是没生效,或者更糟:生效了但内容是错的。本文记录踩到的六个静默失效,一条让”缓存页里的 nonce 永远有效”的结构性保证,以及一次“我的侦察脚本给了假阴性、把我一路带进坑里”的教训。附一份零依赖交付代码(42 项单测 + 24 项真实 HTTP 验证全绿)。
适合阅读:给站点加过(或打算加)整页缓存/静态化的开发者,尤其是遇到过”功能明明开了却像没开”的人。
一、先划清边界:它是”一份 HTML 发给所有人”
整页缓存是性价比最高的一类优化:访客请求命中时,PHP 几乎什么都不干,一个 readfile() 就把页面发出去,耗时从几百毫秒掉到几毫秒。
但它也是最危险的一类,因为它的输出不是给某一个人的。同一个缓存文件会发给成千上万个不同的访客。这意味着:任何”因人而异”的东西一旦被写进缓存,就等于把第一个访客的版本当成标准答案。
更麻烦的是它的失效方式。缓存写错,通常不会报错、不会有异常日志、页面也照常打开 —— 你只会发现”某个东西怪怪的”。我这次踩到的六个,按后果正好分成两类:
| 失效 | 现象 | 后果类别 |
|---|---|---|
| ① transient 自杀 | 写自己的缓存时顺手清掉了页面缓存 | **不命中**(等于没开) |
| ② 空/半截文件算命中 | 写盘被中断留下的 0 字节文件被当正常缓存发出去 | **命中错内容**(空白页) |
| ③ 404 被缓存成 200 | 访问一个不存在的 URL,把 404 页面写进缓存 | **命中错内容**(且会被收录) |
| ④ 显示偏好被冻结 | 父主题按 Cookie 服务端渲染深浅色,第一个访客的配色发给所有人 | **命中错内容** |
| ⑤ nonce 被固化 | 页面里的服务端 nonce 写进缓存,过期后表单提交必然失败 | **命中错内容**(且极难排查) |
| ⑥ 跨环境共用目录 | 两套代码/两个主题共用缓存目录,读到了对方写的 HTML | **命中错内容**(全站旧主题) |
下面逐条说。先说结论:六个里有四个,根因都不是”缓存逻辑写错了”,而是”漏了某个前提” —— 而这个前提往往在别的文件、别的模块,甚至父主题里。
二、失效①:写自己的缓存,清掉了自己的缓存
这条是我排查最久的,因为它完全静止:缓存目录一直是空的,但日志里没有任何错误,代码看上去也没问题。
起因是一个”为了更保守”的钩子。缓存的失效链里有一条是”设置变化就清空缓存”:
// 看起来非常合理的失效钩子
add_action('updated_option', 'my_cache_clear'); // 任何选项变化 → 清空全站缓存
问题在于:transient 也是 option。set_transient() 内部走的就是 update_option('_transient_xxx'),所以它同样会触发 updated_option。
于是链条变成了这样:
写页面缓存 → 命中
某处写了一个 transient(很常见,比如给自己的功能做块缓存)
→ 触发 updated_option('_transient_xxx')
→ 我的钩子判定「设置变了」
→ 清空全部页面缓存
→ 下次请求又是 MISS …… 循环
当一个站点里用 transient 做缓存的地方足够多(我这边有文章系列、时光机、安全扫描结果等好几处),这个循环的周期就会短到”页面缓存永远来不及被命中”。而它的表现不是报错,是命中率为 0。
修法只有一行判据,但必须先想清楚”什么才算设置变化”:
function my_cache_on_option_change($option) {
$option = (string) $option;
// transient 是 option,但它不是「设置」——写它不该清页面缓存
if (0 === strpos($option, '_transient_')
|| 0 === strpos($option, '_site_transient_')) {
return;
}
// 本模块自己的运行态选项也不触发
if (0 === strpos($option, 'my_')) { return; }
my_cache_clear();
}
为了确认排除之后”设置变化”仍然是干净信号,我装了一个临时 mu-plugin,把一次访客前台访问里所有 updated_option / added_option 都记下来:结果是一次都没有。也就是说访客请求本身不写任何选项,排除 transient 之后,这个钩子确实只在”管理员真的改了设置”时才触发。
一个”为了更保守”而加的钩子,会把整个功能静默变成一个摆设。这类钩子的危险之处在于:它不会出错,只会让别的东西不出效果 —— 所以它永远不会在日志里现身。
三、失效②③:0 字节文件,与”404 正文配 200 状态”
② 写盘被中断 → 全站空白页,而且”好不了”
落盘用的是一次 file_put_contents()。如果这次写入因为磁盘满、进程被 kill、或超时被中断,磁盘上会留下一个 0 字节文件。
而”命中判定”如果只写 file_exists()(这是最自然的写法),这个 0 字节文件就会被当成正常缓存发给所有人:整站空白页,直到 TTL 过期为止都恢复不了。
修法是让判定同时看完整性和时效,并且把不合格文件就地删掉(自愈)—— 这样它最多影响一个请求,而不是一整个 TTL 周期:
function my_cache_is_fresh($file) {
if (!is_file($file)) { return false; }
$size = (int) @filesize($file);
if ($size < 500) { // 明显不是完整页面
@unlink($file); // ← 自愈:删掉它,别让它继续被命中
return false;
}
$mtime = (int) @filemtime($file);
if ($mtime <= 0) { return false; }
return (time() - $mtime) < TTL;
}
③ 不看状态码 → 把 404 固化成了 200
落盘发生在输出缓冲回调里(拿到完整 HTML 再写)。但”拿到 HTML”只说明有输出,不代表这是一次成功的响应:404 页、503 页、302 跳转页,全都有 HTML。
于是在 v2 的实现里发生过这样的事:访客访问一个不存在的 URL → 那个 404 页面被写进缓存 → 接下来 TTL 内所有访问这个 URL 的人,拿到的都是”404 的正文 + 200 的状态码”(因为命中分支直接 readfile 后 exit,根本没走 WordPress 的状态码逻辑)。它还会被搜索引擎收录。
修法是在落盘闸门上加一条状态码判定:
function my_cache_output_cacheable($html) {
if (strlen($html) < 500) { return false; }
// 只落 200 —— 404/503/301 一律不缓存
$code = function_exists('http_response_code')
? (int) http_response_code() : 200;
if (200 !== $code) { return false; }
// ……其它判定
return true;
}
验证方式很直接:访问一个肯定不存在的 URL,然后数缓存目录里的文件数 —— 它必须没有增加。这条断言我后来做成了固定检查项。
附:我自己踩的一个”防御过头”的坑
同一批防御里我写了一条”输出里如果出现后台资源就不缓存”,本来是防异常场景。清单里我顺手写了 wp-admin/load-scripts、wp-admin/load-styles,还加了 admin-ajax.php —— 因为”后台接口”听起来就像后台资源。
结果是每一页都被拒绝缓存:父主题的页面本来就引用 admin-ajax.php(远程加载区块、AJAX 地址、各模块注入的配置里都有它)。这个坑的表现和失效①一模一样 —— 目录里一个文件都没有,而且看起来完全像”模块没生效”。
教训:防御清单要用”已知必然命中/必然不命中”的样本各验一次,别靠名词直觉。load-scripts 是后台专用,admin-ajax.php 是前台也会用的公共入口 —— 它们不是一类东西。
四、失效④:父主题按 Cookie 服务端渲染,第一个访客的配色被冻结给所有人
这条是查父主题源码查出来的,属于”不查就一定漏”的那一类。
缓存键最自然的写法是 md5(host + REQUEST_URI):同一个 URL 就同一个键。但如果同一 URL 在不同条件下会渲染出不同 HTML,这个键就是错的。
在父主题里搜 $_COOKIE[,很快就找到了这一行:
// 父主题 inc/functions/zib-theme.php(深浅色模式)
if (_pz('theme_mode_button', true) && isset($_COOKIE['theme_mode'])) {
$theme_mode = $_COOKIE['theme_mode']; // ← 参与服务端渲染
}
也就是说深浅色是服务端渲染的,而它取决于访客自己的 Cookie。那么在”只按 URL 缓存”的实现下会发生什么?
第一个访客(没有 Cookie,渲染成默认浅色)访问首页 → 浅色 HTML 被缓存 → 之后所有访客都拿到浅色 HTML。一个习惯深色模式的访客点”切换深色”→ 前端把 Cookie 写上了、页面也刷新了 → 但服务端又把那份浅色 HTML 发回来 —— 表现就是”按钮点了没反应”。
修法是把这些”会改变服务端渲染结果”的 Cookie 的值并入缓存键:
// 变体 Cookie 清单(可按站点扩充)
$variant = array('theme_mode', 'showed_system_notice');
$parts = array();
foreach ($variant as $name) {
$parts[] = $name . '=' . (isset($_COOKIE[$name]) ? $_COOKIE[$name] : '');
}
$key = md5($host . '|' . $uri . '|' . implode(';', $parts));
这样默认/浅色/深色各有一份缓存,互不干扰,命中率也只降一点点。
怎么找全这份清单:在主题(含父主题)里搜 $_COOKIE[,凡是”读了它、并且影响输出”的都要登记。漏一个的后果就是”某个人的显示偏好被发给所有人”,仍然是不报错的。
同一类问题的另一半:查询串把缓存目录撑爆
缓存键里包含 REQUEST_URI(含查询串)是最自然的做法,但如果只屏蔽”搜索/翻页”这类参数,就会漏掉分享来源参数。?utm_source=weixin、?fbclid=…、?from=… 会为每个来源生成一份内容完全相同的缓存文件 —— 目录无限膨胀,而真正被命中的只有那么几份。
修法是先把 URI 归一化(剥掉跟踪参数、其余参数排序)再进键:
$drop = array('utm_source','utm_medium','utm_campaign','utm_term',
'utm_content','fbclid','gclid','msclkid','_ga','ref','from');
parse_str($query, $q); // ⚠️ PHP 8 起必须传第二个参数
foreach ($drop as $k) { unset($q[$k]); }
ksort($q); // 排序:?b=2&a=1 与 ?a=1&b=2 同键
$uri = $path . ($q ? '?' . http_build_query($q) : '');
验证方法同样直接:带上 ?utm_source=xxx 再访问一次,应该仍然是 HIT,并且目录里没有新增文件。
五、失效⑤:nonce 被固化 —— 判据是”端点能否匿名调用”,不是”页面上有没有 nonce”
WordPress 的 nonce 是防 CSRF 的:它由 f(tick, action, uid, session_token) 算出来,会随时间轮换。把它写进整页缓存,等于把所有访客的 nonce 冻结成同一个,而且这个值几小时后一定会过期 —— 之后所有表单提交都会失败。
这个故障最讨厌的地方是它有时差:刚缓存那会儿是好的,过几小时才开始坏;而且”表单提交失败”的原因看起来像别的问题(权限?限频?),排查方向很容易跑偏。
关键判据:这个 nonce 属于”允许匿名调用”的端点吗?
我一开始的判断标准是”页面上有 nonce 就不缓存”。这个标准看起来最安全,实际会把缓存彻底废掉。原因是:
访客的 uid = 0,所以对同一个 action,所有未登录访客拿到的 nonce 本来就是同一个值 —— 它并不是”因人而异”的秘密。真正的风险只在于”这个值会不会过期”。而对于“仅登录用户可调用”的端点,访客拿着这个 nonce 毫无用处:服务端在 nonce 校验之前就会因为”未登录”而拒绝;反过来,登录用户又永远不会读到访客缓存。
所以在真实站点上数一遍是必要的。我在测试站首页数到 6 个内联的服务端 nonce,逐个核对它们所属端点的鉴权级别:
| 端点 | 鉴权级别 | 访客拿这个 nonce 有用吗 |
|---|---|---|
| 消息角标计数 | 仅登录用户 | **没用**(服务端先按未登录拒绝) |
| 站点彩蛋上报 | 仅登录用户 | **没用** |
| 答题开始/提交 | 仅登录用户 | **没用** |
| 活动礼包领取 | 仅登录用户 | **没用** |
| **友链申请提交** | **允许匿名** | **有用 —— 这一页必须排除** |
6 个里有 5 个是”仅登录用户”。如果按”有 nonce 就不缓存”,这 5 个会把几乎所有页面都判成不可缓存 —— 缓存等于没开。真正需要排除的只有那 1 个允许匿名调用的表单页。
于是三道防线:
① 产出方自声明(最可靠)。谁在页面上放”允许匿名提交”的表单,谁就登记自己的容器标记 —— 标记的唯一权威是产出它的那个模块,缓存侧不做任何硬编码:
// 表单模块里(产出方)
add_filter('my_cache_nonce_markers', function ($m) {
$m[] = 'my-anon-form-wrap'; // 它就是那段表单的容器 class
return $m;
});
② 低误报的通用模式。服务端渲染出来的 nonce 有两处特征非常干净:表单里的 name="_wpnonce" 隐藏字段,以及 wp_nonce_url() 生成的 ?wpnonce= 链接。这两个模式在真实站点上误报率是 0。
③ 结构性保证。见下一节 —— 它才是真正兜底的那一层。
六、把 nonce tick 放进缓存键:一条”缓存页里的 nonce 必然有效”的结构性保证
前两道防线都是”白名单”思路:声明的就排除,没声明的就放行。白名单的弱点很明确 —— 将来有人忘了声明,故障就是”表单过几小时开始必然失败”这种极难排查的静默问题。
所以我加了一条不依赖任何声明的保证。它建立在 WordPress nonce 的两个事实上:
// ① nonce 由 tick 参与计算
wp_create_nonce($action)
= hash( wp_nonce_tick() , $action , $uid , $token )
// ② wp_nonce_tick() 的变化周期 = nonce_life / 2 = 86400 / 2 = 12 小时
// 而校验时 **同时接受「当前 tick」与「上一个 tick」**
// → 一个 nonce 的实际有效期 = 2 个 tick = 24 小时
既然校验接受两个 tick,那么只要保证”发出去的缓存文件不会比一个 tick 更旧”,里面的 nonce 就必然还在有效期内。做法是把 tick 放进缓存键,并且写只写当前 tick,读先试当前 tick 再试上一个 tick:
function my_cache_key($tick_offset = 0) {
$tick = (int) wp_nonce_tick() - max(0, (int) $tick_offset);
return md5($host . '|' . $uri . '|' . $tick . '|' . $variants);
}
function my_cache_serve() {
// 读:当前 tick → 上一个 tick(后者的 nonce 仍然有效)
foreach (array(0, 1) as $offset) {
$file = my_cache_file(my_cache_key($offset));
if (my_cache_is_fresh($file)) { readfile($file); exit; }
}
// 未命中:开缓冲,落盘时用 my_cache_key(0)(当前 tick)
ob_start('my_cache_buffer_callback');
}
这带来三个性质:
① nonce 永远有效。能发出去的缓存文件最旧是 1 个 tick(12 小时),而有效期是 2 个 tick(24 小时)—— 有整整一个 tick 的余量。
② 命中率不受影响。配置的 TTL 仍是主约束:默认 10 小时 < 12 小时,所以正常情况下缓存是”TTL 到期才重生成”,tick 机制根本不会触发。只有把 TTL 调到 24 小时以上时,才会被 tick 窗口兜住(此时”缩短缓存寿命”正是我们想要的)。
③ 失效方式从”静默故障”变成”自动重生成”。将来有人在页面上新加了允许匿名调用的 nonce 却忘了声明,结果是”这个页面每隔 12 小时自动重新生成一次”——性能损失可忽略,而不是”表单过几小时开始必然失败”。
白名单保证”我知道的东西是对的”;结构性保证保证”我不知道的东西也不会坏”。一个面向未来的实现,应该尽量把关键正确性从前者搬到后者。
★ 顺带记一次代价不小的教训:我的侦察脚本给了假阴性
上面那条”5 个仅登录用户端点、1 个允许匿名”的结论,是在我踩了一次坑之后才拿到的。前因值得记下来。
为了决定要不要加”通用 nonce 扫描”,我先做了一次侦察:抓下游客首页,看里面到底有没有服务端 nonce。我用的命令长这样:
grep -o -E '"nonce":"[a-f0-9]{6,14}"' page.html | sort -u
它返回了空。于是我的结论是:”这个站点的游客页面不含 nonce,可以放心加通用兜底”。我就按这个结论写了实现。
结果是一个缓存文件都没写出来。真实原因是:那次 grep 因为多层引号转义和字符类过窄(只允许小写十六进制)而漏掉了全部命中 —— 把它改成一条明确的模式再数,首页实际命中 6 次,全部来自我们自己模块注入的内联配置。一条”有 nonce 就不缓存”的规则,直接把缓存变成了空转。
教训不是”要小心 grep”,而是:
凡是用”扫某个模式是否存在”来做判断,必须先用一条”已知必然命中”的样本验证这个扫描本身。侦察脚本的假阴性是最危险的一种错误 —— 它不报错、不留痕迹,还会让你带着一个错误的结论去做设计决策,而错误的设计决策要等到很久以后、以完全不同的症状暴露出来。
这次如果先做那一步一分钟的自检,就能省掉后面一整轮排查。后来我把它固定成了一个习惯:任何”数一数有没有”的脚本,都先喂一条自己知道一定命中的输入,确认它能命中。
附:安装与验证速查
安装(三步)
① 把交付文件放到主题里(例如 inc/page-cache.php)或 wp-content/mu-plugins/;
② 在 functions.php 顶层 require_once 引入 —— 它需要在 init 之前挂钩子;
③ 访客访问任意页面,然后到后台「工具 → 页面缓存」看统计。
上线前必验四条(每条都有明确的通过标准)
| 检查 | 通过标准 | 怎么验 |
|---|---|---|
| 命中生效 | 访客首访 X-ZDWCACHE: MISS,二次访问 HIT | curl 看响应头 |
| 登录不共享 | 带登录 Cookie 访问**不带**缓存头 | Cookie 注入后 curl |
| 404 不落盘 | 访问不存在的 URL 后,目录里**没有新增文件** | 比对前后文件数 |
| 跟踪参数不膨胀 | 带 ?utm_source=x 仍 HIT 且**不新增文件** | 比对前后文件数 |
怎么在不装 WordPress 的情况下跑单测
交付件里的关键判定(URI 归一化、缓存键、新鲜度、输出闸门、清空统计)都是纯函数或只依赖文件系统,所以可以脱离 WordPress 直接测:
// 桩掉这几个 WP 函数即可 require 交付文件本身
define('ABSPATH', '/tmp/');
define('WP_CONTENT_DIR', '/tmp/content');
define('HOUR_IN_SECONDS', 3600);
define('DAY_IN_SECONDS', 86400);
function add_action() {} function add_filter($t, $cb) { /* 记下来 */ }
function apply_filters($t, $v) { /* 依次调用记下的回调 */ }
function wp_parse_url($u) { return parse_url($u); }
function wp_nonce_tick() { return (int) ceil(time() / (DAY_IN_SECONDS / 2)); }
function trailingslashit($s) { return rtrim($s, '/') . '/'; }
function wp_mkdir_p($d) { return is_dir($d) || @mkdir($d, 0777, true); }
function is_user_logged_in() { return false; }
function is_admin() { return false; }
function wp_doing_ajax() { return false; }
function wp_is_json_request() { return false; }
require __DIR__ . '/inc/page-cache.php';
这份交付件的单测共 42 项(含”跟踪参数不产生新键””404 拒绝落盘”、”写 transient 不清缓存”、”含内联 nonce JSON 仍允许缓存”等反向断言),加上在真实站点上的 24 项 HTTP 验证,全部通过后才发布。
三条边界提醒
① 整页缓存必须先在预发环境验证。它命中即 exit,把一份 HTML 发给所有人 —— 在线试错的代价是”真实访客看到错内容”,不是”慢一点”。我这边固定是”测试站先跑通,才动线上”。
② 不要把”命中率”当成唯一目标。任何时候”正确性”和”命中率”冲突,都是选正确性:这个页面不确定能不能缓存,就不缓存。一条漏掉的排除规则,代价是全站级的。
③ 页面缓存与”页面上有 nonce 的功能”是一对天生冲突的需求。加缓存之前,先回答一个问题:我的站点上有哪些页面允许匿名提交表单?把这些页面找出来排除掉,剩下的才是缓存的地盘。
📚 zibll 子主题开发实录(共 22 篇) · 系列总目录与阅读路径 →
① 前端性能与加载
② 排版与样式系统
- 用 alt 后缀驱动的 WordPress 密集墙排版:从 float 到 flex-wrap 与纵向叠列
- 当主题模板没有过滤器:把「字数+阅读时间」并进它的 meta 行
- 设计令牌的最小实现:一个颜色变量驱动全站(附阅读进度条)
③ 功能实现与边界
- 一个 81 行的朗读按钮改了三轮:三个静默失效的坑
- 删数据类功能怎么写才不闯祸:一个「删文清附件」的六条边界
- 令牌模型与 lost update:一个只在并发时出现的计数 bug
- 「我填了 1 天,它却按 7 天算」:文章过期提示的四个边界
- 「历史上的今天」为什么永远是空的:三个不报错的死写法,与两个时区坑
- 文章系列卡片:一个 PHP 8 致命错误,和三处「不报错但白干」的写法
- 友链申请表单:一次越权写入,和一处「写了但不发」的死角
④ 系统与运维
- 404 监控的四个坑:计数翻倍、键名推导、点路径取值、与「自己看不到数据」
- 评论反垃圾的四个坑:先被自己拦住、节流伪装失败、空内容归属、与「WP 原生背锅」
- 页面缓存一次都没命中:六个静默失效,与一条 nonce 结构性保证 (本文)
- 维护模式:五个「看起来在工作、其实开了后门」的写法
- WebP 自动转换:三个「上传成功、图片没变」的静默失效
- 安全扫描器:五个「扫了但没扫到」的坑,和一个实测确认的路径逃逸
⑤ 成就与活动系统










暂无评论内容