编程

WordPress 对象缓存 dropin 出错时为什么「静默」:一次 502 零日志事故的复盘

系列目录

本文是「WordPress 对象缓存 dropin 实战」系列的第 1 篇,共 3 篇。三篇可独立阅读,但连起来是一条从故障、契约到性能与安全的完整路径:

引子:一次「站点 502、没有任何日志」的线上事故

那天收到的反馈只有一句话:站点打不开。没有慢查询告警,没有 PHP 错误日志,没有插件报错。浏览器给的是 502,服务器上的 debug.log 是空的——连一条 warning 都没有。一个正常的 PHP 致命错误至少会留下点痕迹,但这次什么都没有。

事后复盘,问题出在我们自己写的 object-cache.php dropin 上。而「什么都没有」这件事本身,就是这个文件所在位置的固有属性。本文记录事故的成因、排查路径,以及我们最后改掉的发布机制。

object-cache.php 是什么:加载时机决定了它的脆弱

WordPress 的对象缓存 dropin,就是放在 wp-content/object-cache.php 的一个 PHP 文件。核心在启动早期会检查它是否存在,存在就 require 它,用它替换内置的 WP_Object_Cache。它的价值在于让 wp_cache_get() / wp_cache_set() 落到 APCu 或 Redis,而不是每次请求重算。

关键在于加载时机:这个文件被 require 的时刻,远早于 WordPress 的错误处理就绪、早于插件加载。这意味着两件事:

  • 它出错时,谁来兜底还没有就绪。没有 error handler、没有 shutdown hook,异常不会被捕获成一条日志,只会变成一次请求级别的失败。
  • 它的任何副作用(写文件、连数据库、发网络请求)都发生在最前面,影响面是「全站所有请求」,而不是某一个后台页面。

为什么是「静默」的

我们当时的判断错在第一步:以为「没有日志」等于「没有错误」。实际上有几个因素叠加,让错误彻底消失:

  • dropin 阶段的致命错误发生在错误处理注册之前,既不显示、也不写 debug.log。
  • 更巧的是,这台机器上 wp-config.php 里的 WP_DEBUG、WP_DEBUG_LOG、WP_DEBUG_DISPLAY 三个常量其实都是 false——调试根本没打开。就算错误处理已就绪,也不会留下任何东西。

结论:dropin 的故障表现就是「502 + 零日志」,这是机制决定的,不是运气差。

怎么排查一次「零日志」的 502

既然日志指望不上,就用分层定位法把范围一步步缩小。我们最终沉淀成四步:

  1. 放一个只输出 ok 的探针文件(probe.php)到站点根目录,直接访问。能出 ok,说明 PHP-FPM 与 Nginx 这一层是活的,问题在 WordPress 层;出不来,问题在更外层。
  2. 对比 CLI 与 FPM 两边的 PHP 版本和 SAPI。它们是两套配置,可能是不同版本、不同扩展集——一边能跑不代表另一边能跑。两边都执行 php -v 确认。
  3. 逐个文件 php -l 做语法检查。dropin 出错不一定是语法问题,但语法问题一定先排除掉,成本最低。
  4. 真正要打开调试,必须三个常量都为 true,并且在 wp-config.php 顶部尽早 @ini_set('display_errors', 1)——因为 dropin 加载得比常量后面的逻辑还早。

还有一条容易忽略:删除 dropin 文件不会立刻生效。PHP-FPM 的 realpath cache(默认 TTL 120 秒)和 OPcache 仍然认为文件存在,file_exists() 返回 true,然后 require_once 一个已经不存在的文件——又是一个致命错误。要么重启 FPM,要么等缓存过期,别急着下「删了也没用」的结论。

故障是怎么被放大到全站的

事故的导火索不是 dropin 本身写错了,而是我们给它加的一个「贴心功能」——dropin 自维护:每次加载时自检,发现线上文件和内置模板不一致,就同步回来,初衷是插件升级后 dropin 自动跟上。

事故链是这样的:

  1. 新模板未经生产验证就合进了代码。
  2. 一个前台请求触发了自维护。
  3. 自维护在极早期(错误处理未就绪)把新文件写到全站最前面的执行路径上。
  4. 新模板在这个位置出错,全站 502,零日志。

换句话说,我们把「一次未验证的更新」和「一个错误发生时机最恶劣的位置」连在了一起。前台自维护是一个放大器。

我们改成了什么

修法只有一句话:自维护只在后台 / CLI 上下文真正写文件。前台、REST、admin-ajax 请求完全不做文件操作。

// 自维护:只有后台 / CLI 才真正写文件
private static function in_maintenance_context(): bool {
    return PHP_SAPI === 'cli'
        || PHP_SAPI === 'phpdbg'
        || (defined('WP_ADMIN') && WP_ADMIN)
        || (defined('WP_CLI') && WP_CLI);
}

判断依据是 WP_ADMIN(由 wp-admin/admin.php 在 wp-load.php 之前定义,dropin 极早期就可用)和 PHP_SAPI / WP_CLI。

为什么这么切:后台写入受 WordPress 错误处理保护,最坏也只是后台报错;前台写入会把故障瞬间推到全站。代价是「插件升级后需要管理员进一次后台才会同步」——这是刻意接受的取舍,用一个可控的不便换掉一个不可控的全局故障。

配套还加了几件事:

  • 冷却标记文件:同类操作 5 分钟最多一次,不依赖数据库(极早期选项 API 不可用)。
  • 同请求内静态标记:一次请求只跑一次自检。
  • 原子写:先写临时文件再 rename,避免读到半个文件。
  • 完整性校验用整文件 SHA256,而不是版本号——版本落后、被第三方覆盖、被手工或恶意改动都能发现。

一个必须写清楚的边界

如果 dropin 被整体换成了第三方文件,我们的代码根本不会被加载,也就无法自愈。这种情况只能走管理界面里「接管并升级(自动备份)」的显式通道。把能力的边界写进文档,比事后解释「为什么它没救回来」划算。

收尾

这次事故真正的收获不是修好了某个 bug,而是三件事:第一,dropin 的「静默」是加载时机决定的,别再用「没有日志」去判断「没有错误」;第二,任何发生在极早期的自动写文件行为,都要先问一句「如果这个文件是错的,影响面有多大」;第三,把不可逆的、全局的操作限制在受保护的上下文(后台 / CLI)里执行,是最廉价的保险。

参考文献

  1. WordPress.org. WP_Object_Cache class reference[EB/OL]. https://developer.wordpress.org/reference/classes/wp_object_cache/ (访问日期: 2026-10-12).
  2. WordPress.org. wp_cache_init() reference[EB/OL]. https://developer.wordpress.org/reference/functions/wp_cache_init/ (访问日期: 2026-10-12).
  3. PHP.net. OPcache[EB/OL]. https://www.php.net/manual/en/book.opcache.php (访问日期: 2026-10-12).

发表评论

您的邮箱地址不会被公开。 必填项已用 * 标注