写 WordPress 对象缓存 dropin 前,先对齐这 11 处核心契约
系列目录
本文是「WordPress 对象缓存 dropin 实战」系列的第 2 篇,共 3 篇。三篇可独立阅读,但连起来是一条从故障、契约到性能与安全的完整路径:
- 第 1 篇:WordPress 对象缓存 dropin 出错时为什么「静默」:一次 502 零日志事故的复盘
- 第 2 篇:写 WordPress 对象缓存 dropin 前,先对齐这 11 处核心契约(本篇)
- 第 3 篇:对象缓存 dropin 的原子性、性能与安全边界
引子:dropin 的 bug 常常「看起来能跑」
对象缓存 dropin 的坑有个共同特征:坏掉的时候站点不会白屏,只会「稍微不对」。缓存读不到最新值、定时任务行为怪异、某个功能偶发失效——都能凑合跑,于是没人怀疑是缓存。
这类问题几乎都不是性能问题,而是与核心 API 的契约不一致。下面 11 处,是我们逐条改过的真实现场。
11 处契约偏差:快速对照表
| # | 错在哪 | 正确做法 |
|---|---|---|
| 1 | site-transient 被降级成请求内内存 | site-transient 必须落到持久化后端 |
| 2 | 按 group 改写 $expire,静默丢弃调用方 TTL | 过期时间只能由调用方决定 |
| 3 | 空 group 未归一,同一份数据存进两个命名空间 | 空 group 归一为 default |
| 4 | delete() 恒返回 true | 删除不存在的键返回 false |
| 5 | decr() 结果不夹 0,会变负数 | decr() 结果夹到 0 |
| 6 | incr() 对不存在的键凭空创建 | incr() 对不存在的键返回 false |
| 7 | flush() 后端失败时返回 false | wp_cache_flush() 恒返回 true |
| 8 | reset() 直接静默 | 调 _deprecated_function(),加 function_exists 保护 |
| 9 | supports() 不反映后端真实状态 | 安全模式 / 熔断时返回 false |
| 10 | stdClass 未 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
子域多站点下,键前缀的设计直接决定缓存是否正确。我们原方案用域名参与前缀,踩了四个问题:
- 全局组被切成每个子域一份,失去「全局」语义。同一用户在不同子站会有两份互不同步的缓存。
- 网络级清空只能清当前域名下的键,清不干净。
- CLI 和 wp-cron 没有
HTTP_HOST,一律塌缩到localhost,与 Web 请求互不可见。 - 域名可被伪造,等于允许外部以极低成本创建命名空间。
正确做法是由与入口无关的「安装标识」派生前缀:
// 站点标识:不依赖 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 便宜得多。
参考文献
- WordPress.org. WP_Object_Cache class reference[EB/OL]. https://developer.wordpress.org/reference/classes/wp_object_cache/ (访问日期: 2026-10-12).
- WordPress.org. Transients API[EB/OL]. https://developer.wordpress.org/apis/transients/ (访问日期: 2026-10-12).
- WordPress.org. Object Caching(Multisite)[EB/OL]. https://developer.wordpress.org/reference/functions/switch_to_blog/ (访问日期: 2026-10-12).