编程

写 WordPress 对象缓存 dropin 前,先对齐这 11 处核心契约

系列目录

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

引子:dropin 的 bug 常常「看起来能跑」

对象缓存 dropin 的坑有个共同特征:坏掉的时候站点不会白屏,只会「稍微不对」。缓存读不到最新值、定时任务行为怪异、某个功能偶发失效——都能凑合跑,于是没人怀疑是缓存。

这类问题几乎都不是性能问题,而是与核心 API 的契约不一致。下面 11 处,是我们逐条改过的真实现场。

11 处契约偏差:快速对照表

#错在哪正确做法
1site-transient 被降级成请求内内存site-transient 必须落到持久化后端
2按 group 改写 $expire,静默丢弃调用方 TTL过期时间只能由调用方决定
3空 group 未归一,同一份数据存进两个命名空间空 group 归一为 default
4delete() 恒返回 true删除不存在的键返回 false
5decr() 结果不夹 0,会变负数decr() 结果夹到 0
6incr() 对不存在的键凭空创建incr() 对不存在的键返回 false
7flush() 后端失败时返回 falsewp_cache_flush() 恒返回 true
8reset() 直接静默调 _deprecated_function(),加 function_exists 保护
9supports() 不反映后端真实状态安全模式 / 熔断时返回 false
10stdClass 未 clone,外部可污染缓存存取两侧都 clone
11$force 参数被忽略true 表示绕过请求内副本、回源后端

表看完了,但有几条光知道结论没用,得知道为什么。下面挑最危险的几条展开。

展开一:site-transient 不是缓存,是功能依赖

这是最容易被当成「性能取舍」的一条,其实是功能缺陷。dropin 在 wp_cache_init() 之后会让 wp_using_ext_object_cache() 返回 true;而核心的 set_site_transient() 在外部对象缓存下只有 wp_cache_set(..., 'site-transient', ...) 一条路径,没有数据库回退。也就是说,这些数据的存在完全依赖你的 dropin。

如果把 site-transient 这个 group 压进请求内内存,后果是:update_plugins、update_themes、update_core、站点健康检查等跨请求读取的 site transient 永远取不到值——它们本来就是设计成跨请求共享的。表现就是「后台一直提示没有更新」「站点健康反复重试」。所以 site-transient 必须落到真正持久化的后端。

展开二:TTL 不能被 group 改写

原实现里有 8 处 if (全局组) { $expire = 0; },本意是「全局组不过期」,但副作用是把调用方传入的 TTL 静默丢弃,一个本该 5 分钟过期的写入变成了永不过期。

缓存什么时候失效,只能由调用方决定;dropin 用自己的规则覆盖,就是在制造「永远读不到新值」的隐性 bug。这类 bug 最难查,因为它没有报错,只是数据慢慢变旧。

展开三:空 group 归一,否则同一份数据存两份

核心语义里 wp_cache_get($key) 与 wp_cache_get($key, 'default') 是等价的。dropin 不做归一,就会落到 prefix__key 和 prefix_default_key 两个命名空间:同一份数据被存两遍,而且互相读不到——写入走一个名字,读取走另一个名字,命中率凭空腰斩,还很难查。

展开四:incr / decr 的返回值是契约的一部分

这里有个容易自作聪明的地方。Redis 原生 INCRBY 对不存在的键会凭空创建并返回 offset,但 WordPress 核心和 APCu 后端的行为都是「对不存在的键返回 false」。dropin 应当以核心为准,而不是跟随 Redis 的原生行为。decr() 同理要把结果夹到 0,不能出现负数——这是 WP_Object_Cache::decr() 的既定语义。

顺带提醒 delete():删除一个不存在的键应返回 false。原实现恒返回 true,会让调用方误判缓存状态。

展开五:flush / reset / supports 的对外契约

wp_cache_flush() 在核心 WP_Object_Cache::flush() 里恒返回 true。即便后端不可用,也不要返回 false——调用方可能据此判断「清空失败」并转去执行更重的路径,反而把问题放大。真实结果由插件自己的管理接口单独返回。

wp_cache_reset() 已被废弃,应调 _deprecated_function(),并加 function_exists 保护(dropin 阶段它可能还没定义)。wp_cache_supports() 要反映后端的真实状态:安全模式或熔断时应返回 false,否则核心据此决定能否走 group flush,声称支持却实际不生效,只会让问题更难定位。

展开六:对象要 clone,$force 有语义

缓存里如果存的是 stdClass 之类的对象,取出后又被外部修改,就会污染缓存本身。存取两侧都要 clone。

$force 参数也不是装饰:true 表示「绕过请求内副本、回源后端」。之前的实现对持久化后端直接忽略了这个参数,等于把「强制回源」这个能力废掉了。

多站点的键前缀:为什么不能用 HTTP_HOST

子域多站点下,键前缀的设计直接决定缓存是否正确。我们原方案用域名参与前缀,踩了四个问题:

  1. 全局组被切成每个子域一份,失去「全局」语义。同一用户在不同子站会有两份互不同步的缓存。
  2. 网络级清空只能清当前域名下的键,清不干净。
  3. CLI 和 wp-cron 没有 HTTP_HOST,一律塌缩到 localhost,与 Web 请求互不可见。
  4. 域名可被伪造,等于允许外部以极低成本创建命名空间。

正确做法是由与入口无关的「安装标识」派生前缀:

// 站点标识:不依赖 HTTP_HOST,同一安装在所有入口得到同一个值
private static function site_identifier(): string {
    $parts = [];
    foreach (['DB_NAME', 'DB_USER', 'DB_HOST', 'OBJECT_CACHE_SALT'] as $const) {
        if (defined($const)) {
            $parts[] = (string) constant($const);
        }
    }
    if (defined('ABSPATH')) {
        $parts[] = (string) ABSPATH;
    }
    if (!$parts) {
        $parts[] = __DIR__;
    }

    return substr(md5(implode('|', $parts)), 0, 12);
}

用 DB_NAME / DB_USER / DB_HOST / ABSPATH(缺失时回退到插件目录)派生站点标识:同一安装在任何入口(Web、CLI、cron)都会得到同一个值,不同安装天然隔离。

前缀结构上,站点数据用 {站点标识}_{salt}_ms_{blog_id}_,全局组用 {站点标识}_{salt}_global_。要记住全局组(users / user_meta / site-options / blog-* 等)是全网络共享的,不是某个站点私有。

「清空」是两件事,不是一件

flush()(全网络)和 flush_site()(只清当前站点)语义不同,不能互相代替。原实现里 flush_site() 会连全局组一起清掉,等于把其它站点的登录态和网络级数据一起清了,与「只清当前站点」直接矛盾。

另一个更隐蔽的坑:站点前缀会陈旧。key_prefix 只在 build_key() / switch_to_blog() 时更新,而「只清当前站点」这类操作并不经过 build_key(),可能拿到构造时的旧前缀(早期 blog_id 还是 1)——结果是清错站点。修法是提供 current_site_prefix(),先同步 blog_id 再返回。

还有测试代码里做站点切换时,必须用 try/finally 复位:

try {
    switch_to_blog($target);
    // ...站点切换后的缓存读写...
} finally {
    switch_to_blog($original);   // 必须复位,否则异常会污染站点 1 的命名空间
}

否则中途抛异常,本次请求后续所有 wp_cache_set() 都会写进站点 1 的命名空间——一个异常就能污染另一个站点的缓存。

键名:超长键不能截断

键名要先做非法字符替换。但超长键不能简单截断:截断会让不同键碰撞,互相覆盖。我们的做法是「短键保持可读前缀 + 超长键改用全名 md5」,既保留可读性,又避免碰撞。

收尾

这 11 处偏差里,没有一处是「性能取舍」,全都是「与核心契约不一致」。它们不会让站点当场崩,只会让站点「稍微不对」,然后在某天变成一张排查不清的工单。写 dropin 之前先把契约对齐,比事后逐条追 bug 便宜得多。

参考文献

  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. Transients API[EB/OL]. https://developer.wordpress.org/apis/transients/ (访问日期: 2026-10-12).
  3. WordPress.org. Object Caching(Multisite)[EB/OL]. https://developer.wordpress.org/reference/functions/switch_to_blog/ (访问日期: 2026-10-12).

发表评论

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