给商业主题加 mu-plugin:让我加班到凌晨的 4 个坑

商业主题不让动源码,我用 mu-plugin 给自己博客加了一堆功能。过程中踩了 4 个不大不小的坑,从 MySQL 凌晨被 OOM 杀,到正则一不小心匹到 里的字符串。写下来当备忘,也给打算干同样事的人省点头发。

给商业主题加 mu-plugin:让我加班到凌晨的 4 个坑起因

我用的是 B2 这套商业 WordPress 主题,买了好多年了。问题是商业主题作者更新挺勤,要是直接改 wp-content/themes/b2/ 下面的源码,下次升级要么被覆盖、要么打补丁打不上去。

但我又想加东西 —— 深色模式切换、代码高亮换皮、文章顶部的"字数·阅读时长·已读进度"小条、写作时间追踪 dashboard、控制台彩蛋……一直加。

正解是 WordPress 自带的 mu-plugin(must-use plugin):放进 wp-content/mu-plugins/ 下的任何 PHP 文件会自动激活,后台关不掉,也不归任何主题管,刚好适合用来挂"项目级"补丁。

wp-content/
├── plugins/                       ← 普通插件,后台可激活/停用
├── mu-plugins/                    ← 自动加载,后台关不掉
│   ├── b2-blog-enhance.php       ← 我所有的改造都在这一个文件
│   └── dark-mode.php
└── themes/b2/                     ← 不动,主题随便升

mu-plugin 的入口结构非常简单,就是普通 WP 插件去掉 "Plugin Name" 头也能用,挂钩点和正常插件没区别:

<?php
/**
 * Plugin Name: B2 Blog Enhance
 */
if (!defined('ABSPATH')) exit;

class B2_Blog_Enhance {
    const OPT = 'b2_blog_enhance_opts';

    public static function init() {
        $self = new self();
        add_action('wp_head',            [$self, 'output_frontend'], 3);
        add_action('template_redirect',  [$self, 'maybe_buffer_home']);
        add_filter('the_content',        [$self, 'append_signature'], 99);
        add_shortcode('b2be_dashboard',  [$self, 'shortcode_dashboard']);
        add_action('wp_ajax_b2be_seo',   [$self, 'ajax_seo_suggest']);
        // …
    }
}
B2_Blog_Enhance::init();

听起来很美。但我连着踩了 4 个坑,中间至少两次怀疑人生。

坑 1:MySQL 凌晨被 OOM 杀掉

接手没多久,某天早上发现博客 502。SSH 进去先看内核日志:

dmesg -T | grep -iE "out of memory|killed process" | tail -20
journalctl -u mariadb --since "24 hours ago" | grep -i "killed|out of"

翻到:

[Fri May  9 01:34:17 2026] Out of memory: Killed process 12834 (mysqld)
                            total-vm:1873992kB anon-rss:842116kB

OOM Killer 把 MySQL 干掉了。过去 24 小时被杀了 4 次,都集中在凌晨 1:30 - 2:30 之间。

这台机器只有 1.6GB 内存,凌晨那个时段同时跑了:

  • 宝塔面板的"网站备份"(打包 wwwroot 目录,要消耗 200-400MB)
  • mysqldump 全库导出(瞬间占内存峰值)
  • 主题作者自带的"统计数据归档"(也是 cron,跑 SQL 聚合)

三个进程一起申请内存,加起来超过物理内存,Linux 就看谁吃得多杀谁。MySQL 不幸中枪。

修复方案有三步:

第一步,改 swappiness。默认 60 偏激进,内存吃紧时优先把进程内存换出去而不是先用 swap:

# 立刻生效
sysctl -w vm.swappiness=10

# 持久化(重启后还在)
echo "vm.swappiness=10" >> /etc/sysctl.conf

第二步,给 mysqld 写一个 systemd drop-in,把它在 OOM 评分上拉到最低,真到 OOM 时也轮不到它先死:

mkdir -p /etc/systemd/system/mariadb.service.d
cat > /etc/systemd/system/mariadb.service.d/oom.conf <<'EOF'
[Service]
OOMScoreAdjust=-500
EOF

systemctl daemon-reload
systemctl restart mariadb

验证生效:

cat /proc/$(pgrep mysqld)/oom_score_adj
# 应该输出 -500

第三步,错开 cron。把宝塔里两个重叠的备份任务改成不同时段:

# /var/spool/cron/root
30 1 * * * /www/server/panel/script/backup.sh   # 站点备份
30 4 * * * /www/server/panel/script/mysql_backup.sh   # 数据库备份
# 中间隔 3 小时,谁也碰不上谁

三步组合拳下来,过去这段时间再没被 kill 过。

教训:2GB 以下的小机器,定时任务一定要错开,且每个 cron 跑之前可以 echo 3 > /proc/sys/vm/drop_caches 主动释放页缓存,给"申请内存的大兵"腾出空间。

坑 2:Vue 主题 wipe 掉我注入的 DOM

B2 主题前端用了 Vue。我写了段 JS,想在首页 header 后面插一个浮动 banner:

var header = document.querySelector('.header-banner');
var banner = document.createElement('div');
banner.id = 'b2be-banner';
banner.innerHTML = '<pre><code>...ASCII...</code></pre>';
header.parentNode.insertBefore(banner, header.nextSibling);

querySelector 拿到了 header,insertBefore 也没报错。F12 一看 — banner 不在 DOM 里。

我又试了下:把 insertBefore 放进 setTimeout(..., 500) 延后执行 → banner 出现了!再刷新 → banner 又消失了。这种"时灵时不灵"的现象基本就是时序竞态。

为了搞清楚是谁删的,我写了个 MutationObserver 蹲点:

// 监听 banner 被谁干掉的
new MutationObserver(function (muts) {
  muts.forEach(function (m) {
    m.removedNodes.forEach(function (n) {
      if (n && n.id === 'b2be-banner') {
        console.warn('banner 被删了。父节点:', m.target);
        console.warn('调用栈:');
        console.trace();
      }
    });
  });
}).observe(document.body, { childList: true, subtree: true });

console 立刻打出来 —— 调用栈第一行是 Vue 的 patch 函数。Vue 在 mounted 后会重新渲染整个根容器,把我刚塞进去的 banner 当作"外来 DOM"扔掉了。Vue 视图模型里没有这个节点,虚拟 DOM diff 一对比,就是个该删的东西。

JS 这条路堵死,我改成 PHP 服务端渲染 —— 用 template_redirectob_start,在输出 buffer 里用正则把 banner 塞到 <body> 后面。这样 banner 是 HTML 文档原本就有的元素,在 Vue mount 之前就在那里,Vue 把它包进自己的根容器里 —— 框架不会动它。

add_action('template_redirect', function() {
    if (!is_home()) return;
    ob_start(function ($html) {
        $blk = '<div id="b2be-banner">...</div>';
        // 锚定 </head><body> 防止匹到 head 里的字面字符串(详见坑 4)
        return preg_replace(
            '~(</head>\s*<body\b[^>]*>)~i',
            '$1' . "\n" . $blk,
            $html,
            1
        );
    });
});

类似地,文章末尾签名章用 the_content filter,完全跳过 JS:

add_filter('the_content', function ($content) {
    if (!is_singular('post')) return $content;
    global $post;
    // 只挂在主文章上,避免 B2 在 related posts / 侧栏也调 the_content
    if (!$post || (int)$post->ID !== (int)get_queried_object_id()) {
        return $content;
    }
    static $done = false;
    if ($done) return $content;
    $done = true;
    return $content . '<div class="b2be-sig">…签名…</div>';
}, 99);

教训:跟 Vue / React 主题斗,客户端永远输给框架,但服务端输出框架管不到。能 PHP 渲染的就别用 JS 注入。这条规则我在后续给 dashboard、签名章、欢迎语 toast 加功能时反复用到,稳得很。

坑 3:PHP-FPM Opcache 让我改的代码"看起来没改"

某次改完 PHP 代码 scp 上去,刷新 — 行为一点没变。我以为是浏览器缓存:

curl -fsSL -H "Cache-Control: no-cache" 
     -H "Pragma: no-cache" 
     "https://blog.biekanle.com/?_=$(date +%s)" -o /tmp/cur.html
diff /tmp/cur.html /tmp/prev.html
# 输出空 → HTML 完全一致 → 不是浏览器/CDN 缓存

开始怀疑代码本身没改对,跑去服务器上对比文件:

md5sum local-copy.php /www/wwwroot/.../b2-blog-enhance.php
# 两个 md5 一致 → 文件确实是新的

加了一行调试 error_log("HIT v2"),什么都没打。

排查了半小时才反应过来:这台机器 PHP-FPM 开了 opcache,内存里跑的还是上次解析过的字节码。生产环境推荐配置长这样:

; /www/server/php/82/etc/php.ini
opcache.enable=1
opcache.memory_consumption=128
opcache.max_accelerated_files=10000
opcache.revalidate_freq=60          ; 60 秒才检查一次时间戳
opcache.validate_timestamps=0       ; 生产关掉,完全不检查
opcache.fast_shutdown=1

validate_timestamps=0 意味着 PHP 完全不重新检查源文件,启动后第一次解析就当永久版本。这设置在性能上很划算,但代价就是你的 scp 没人理。

解决:每次部署后 graceful reload:

systemctl reload php-fpm-82
# graceful — 不影响正在处理的请求

把它写进部署脚本,从此不会再忘:

#!/usr/bin/env bash
# deploy.sh
set -euo pipefail

LOCAL=deploy/mu-b2-blog-enhance.php
REMOTE=root@8.163.80.243:/www/wwwroot/blog.biekanle.com/wp-content/mu-plugins/b2-blog-enhance.php

scp -i ~/.ssh/blog_key "$LOCAL" "$REMOTE"
ssh -i ~/.ssh/blog_key root@8.163.80.243 
    'php -l /www/wwwroot/blog.biekanle.com/wp-content/mu-plugins/b2-blog-enhance.php 
     && systemctl reload php-fpm-82'

echo "✓ deployed at $(date +%H:%M:%S)"

更优雅的方式是 opcache_invalidate($file, true),只让 opcache 重新解析改过的文件,不打断其他请求:

// opcache-bust.php — 通过浏览器访问触发
<?php
if ($_GET['key'] !== getenv('OPCACHE_KEY')) {
    http_response_code(403);
    exit('forbidden');
}
$files = [
    '/www/wwwroot/blog.biekanle.com/wp-content/mu-plugins/b2-blog-enhance.php',
    '/www/wwwroot/blog.biekanle.com/wp-content/mu-plugins/dark-mode.php',
];
foreach ($files as $f) {
    opcache_invalidate($f, true);
    echo "invalidated $fn";
}

但 reload 简单粗暴够用,不需要在 PHP 里写"自己反射自己"的代码,而且 PHP-FPM 是 graceful reload,正在处理的请求不会被打断。

教训:生产服务器一定开 opcache(性能 2-3 倍),但要知道它怎么失效。否则你的"修改"永远只在硬盘上。

坑 4:正则一不小心匹到 <head> 里的字符串

上面坑 2 的 PHP ob_start 方案,我以为搞定了。结果 banner 还是不见。F12 找了一圈,发现 banner 居然出现在 <head> 里 —— 位置 53930,而真正的 <body> 开标签在位置 93824 之后。

我写了几行 PHP 把两版正则的命中位置打出来对比:

$html = file_get_contents('home.html');

// 贪婪版
preg_match('~<body\b[^>]*>~', $html, $m1, PREG_OFFSET_CAPTURE);
echo "❌ 贪婪版命中:" . $m1[0][1] . "  →  " . substr($html, $m1[0][1], 60) . "n";

// 锚定版
preg_match('~</head>\s*<body\b[^>]*>~', $html, $m2, PREG_OFFSET_CAPTURE);
echo "✓ 锚定版命中:" . $m2[0][1] . "  →  " . substr($html, $m2[0][1], 60) . "n";

输出立刻清晰了:

❌ 贪婪版命中:53930  →  <body class="fake">'); console.log('header...
✓ 锚定版命中:93824   →  </head><body class="home blog wp-theme-b2 ...

原因:某个加载在 <head> 里的 JS 文件,里面有一段字符串字面量包含 <body class="...">(后来确认是另一个插件做控制台 console 调试输出时的 banner)。我那条正则 <body\b[^>]*> 撞上了它,把 banner 插到那里去了。

情况 正则 命中位置 结果
❌ 贪婪版 <body\b[^>]*> head 里 JS 字符串字面值 banner 插到 head 里,看不见
✓ 锚定版 </head>\s*<body\b[^>]*> 真正的 </head><body> 边界 banner 紧跟 body 开标签

改成锚定 </head>\s*<body…>,因为 </head> 后面只会是真正的 body 开标签,不会出现在字符串字面量里(没人会在 JS 字符串里写 </head><body> 这种组合):

// 最终版
$new = preg_replace(
    '~(</head>\s*<body\b[^>]*>)~i',
    '$1' . "\n" . $blk,
    $html,
    1
);
// 顺便兜底:正则失败时返回原 $html 不报错
return $new !== null && $new !== $html ? $new : $html;

教训:HTML 字符串里看起来像标签的东西,可能根本不是标签。正则匹 HTML 时多锚定一个具体的 token,比单纯靠 \b 词边界靠谱得多。(顺便,这也是为啥很多老资料说"别用正则解析 HTML",理论上正确,但加点边界条件实际生产里也能用。)

复盘

回头看,这四个坑分别属于四个层面 —— 看起来都不大,但一个晚上就能把人耗光:

层面 具体表现 最终症状
系统层 资源调度没规划,多个 cron 撞车 凌晨 MySQL 被 OOM 杀
架构层 跟前端框架抢 DOM 所有权 注入的 DOM 被 Vue wipe 掉
运行时层 字节码缓存,opcache 持有旧版本 改代码不生效,以为代码有 bug
字符串处理层 正则贪婪,撞上字面值 banner 插到 head 里,看不见

都不是什么稀奇问题,但当你只是想"给博客加个小功能"的时候,会一个个把你拖进去。每个坑独立看都不难,但叠在一起就是一个晚上没了。

这一类问题的特征:每一步看起来都对了,但合起来不工作。这种时候比起对着代码硬调,先停下来问自己"我对每一层的假设是不是都站得住",通常更省时间。具体到这次:

  • "内存够用"的假设 → 没考虑 cron 叠加峰值
  • "DOM 我说了算"的假设 → Vue 主题里不成立
  • "改了源文件就是改了"的假设 → opcache 戳穿
  • "<body> 字符串就是 body 标签"的假设 → JS 字面值不答应

如果你也打算用 mu-plugin 改商业主题,我能给的三条建议:

  • 能服务端渲染就别 JS 注入。前端框架是不可控变量,服务端 HTML 是确定的。能在 PHP 里 echo 出来的就别让 JS 在客户端塞。
  • 每次 scp 后 reload PHP-FPM。这条经验值好几个深夜调试。最好把 reload 命令写进部署脚本,根本不要让自己有"忘了"的机会。
  • 正则匹 HTML 时锚定具体 token(</head><!--more-->、闭合标签之类),别只靠 \b 词边界 —— HTML 是出了名"看起来像标签但其实不是"的字符串富集地。

mu-plugin 不会让你的代码更优雅,但能让主题升级和你的改造井水不犯河水。这就够了。我那个文件现在 3000 多行,一行没动主题源码,主题作者下次发新版本我点一下"升级",该工作的还工作。

—— 别看了 · 2026
声明:本站所有文章,如无特殊说明或标注,均为本站原创发布。任何个人或组织,在未征得本站同意时,禁止复制、盗用、采集、发布本站内容到任何网站、书籍等各类媒体平台。如若本站内容侵犯了原著者的合法权益,可联系我们进行处理 邮箱1846861578@qq.com。
技术教程

Vue3最新版本所有 API 完整列表及代码示例

2025-2-21 18:25:29

软件分享

最新平替Cursor的开源编辑器

2025-3-6 16:20:33

0 条回复 A文章作者 M管理员
    暂无讨论,说说你的看法吧
个人中心
购物车
优惠劵
今日签到
有新私信 私信列表
搜索