页面缓存一次都没命中:六个静默失效,与一条 nonce 结构性保证

页面缓存一次都没命中:六个静默失效,与一条 nonce 结构性保证-知集
页面缓存一次都没命中:六个静默失效,与一条 nonce 结构性保证
此内容为付费阅读,请付费后查看
¥1
立即购买
您当前未登录!建议登陆后购买,可保存购买订单
付费阅读
阅读约 92 分钟27,472 字

摘要:我给站点加了整页缓存。上线以后看响应头,发现一个尴尬的事实:它看起来在跑,其实一次都没命中过。整页缓存是”一份 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,二次访问 HITcurl 看响应头
登录不共享带登录 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 篇) · 系列总目录与阅读路径 →

① 前端性能与加载

② 排版与样式系统

③ 功能实现与边界

④ 系统与运维

⑤ 成就与活动系统

© 版权声明
THE END
喜欢就支持一下吧
点赞14 分享
评论 抢沙发

请登录后发表评论

    暂无评论内容