一次把自己站点打挂的经历:过滤 get_post_metadata 导致的无限递归-第三维度MokIII

我写了一个插件,把自己站点的文章页打挂了

场景很普通:我想让新发布的文章默认带上版权声明——省得每篇都手动设一次。

思路是:如果文章没设这个字段,就按默认值处理。于是我在 mu-plugin 里过滤了 get_post_metadata:

// ❌ 这是错的
add_filter( 'get_post_metadata', function ( $value, $object_id, $meta_key, $single ) {
    if ( 'post_copyright_check' === $meta_key ) {
        if ( ! metadata_exists( 'post', $object_id, 'post_copyright_check' ) ) {
            return '1';    // 没设过就给个默认值
        }
    }
    return $value;
}, 10, 4 );

保存之后,首页一切正常,但打开任何一篇文章 —— HTTP 500。

一、定位:为什么只有单篇挂

首页正常、单篇 500,这个现象本身就很有信息量:说明问题出在"只有单篇文章页才会执行"的路径上,而不是全局。

直接看错误日志:

PHP Fatal error: Allowed memory size of ... exhausted

一个经验:"内存耗尽"的报错,十有八九是死循环,而不是真的内存不够。真正需要更多内存的场景,通常是"处理一张大图"这种有明显原因的。

顺着这个方向看代码——问题就在那个 metadata_exists() 上。

二、原因:metadata_exists() 自己也会触发这个过滤器

翻 WordPress 源码(wp-includes/meta.php,metadata_exists() 函数):

function metadata_exists( $meta_type, $object_id, $meta_key ) {
    // ...
    /** This filter is documented in wp-includes/meta.php */
    $check = apply_filters( "get_{$meta_type}_metadata", null, $object_id, $meta_key, true, $meta_type );
    if ( null !== $check ) {
        return (bool) $check;
    }
    // ...
}

它在第二行就直接 apply 了 get_{$meta_type}_metadata 过滤器——也就是我正在过滤的那个钩子。

于是形成闭环:

我的过滤器被调用
  → 里面调用 metadata_exists()
    → metadata_exists() 又 apply 同一个过滤器
      → 我的过滤器又被调用
        → ... 无限递归

每一层都往调用栈上叠,直到内存耗尽。

三、修法:别在"读"的时候伪造数据

正确做法是在写入时机把数据真的写进去,而不是在读取时假装有值:

// ✅ 在保存时写入真实 meta
add_action( 'save_post', function ( $post_id, $post ) {
    if ( 'post' !== $post->post_type ) { return; }
    if ( wp_is_post_revision( $post_id ) || wp_is_post_autosave( $post_id ) ) { return; }
    if ( in_array( $post->post_status, array( 'auto-draft', 'trash' ), true ) ) { return; }

    $existing = get_post_meta( $post_id, 'post_copyright_check', true );
    if ( '' === $existing ) {                 // 只在完全没设过时补默认
        update_post_meta( $post_id, 'post_copyright_check', '1' );
    }
}, 20, 2 );

从此读到的都是数据库里真实存在的值,不需要任何读过滤器。

四、四条教训

1. get_*_metadata 这类"读"钩子是重灾区

不要用它去"伪造数据"。WordPress 内部到处在调用它:metadata_exists()、has_meta()、get_post_meta() 自身……你以为只是拦截一次读取,实际上拦住了整个系统的基础设施。

要改数据,改在写入时机。

2. 报"内存耗尽"先怀疑死循环

没有明显内存大户(大图、大文件)的情况下,Allowed memory size exhausted 基本等于无限递归或无限循环。

3. 这类 bug 只在特定页面暴露,必须逐页测

这个 bug 首页完全正常,只有单篇才挂。所以发布前至少点一遍:

  • 首页 / 列表页 / 分类页;
  • 单篇文章页;
  • 后台文章列表;
  • 一个 404 页面。

如果我只看了首页就说"没问题",这个 500 可能会挂到线上。

4. mu-plugin 的价值在这一刻体现得最充分

我没有去回滚主题、没有去停用插件、没有去翻数据库——删掉那一个文件,站点立刻恢复。

这正是"不改主题文件、一律用 mu-plugin"这条规矩的回报:任何一次失误的修复成本,都等于删一个文件。

五、顺带一个同源的小坑

在写"默认值"逻辑时,判断"有没有设值"用的是严格比较:

if ( '' === $existing ) { ... }     // ✅
if ( empty( $existing ) ) { ... }   // ❌

因为"关闭"这个选项存的是字符串 "0",而 empty("0") 是 true——用 empty() 会把用户明确选择的"关闭"覆盖成默认值。

凡是有"0 是个有意义的值"的场景,判断一律用 ===。


一句话总结:不要用 get_post_metadata 这类读过滤器去伪造数据——metadata_exists() 内部就会触发它,直接无限递归把内存打爆。数据在写入时机写;而"内存耗尽"的报错,先怀疑死循环。