编程

对象缓存 dropin 的原子性、性能与安全边界

系列目录

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

引子

契约对齐之后,dropin 才算「正确」。但正确不代表好用。这一篇讲三件事:并发下的原子性(add / replace / incr 在两个后端上的不同解法)、请求内的性能优化(收益最大的一处其实是本地副本),以及一条常被忽视的边界——安全。最后给出一份写 dropin 前的检查清单。

原子性:同一个接口,两个后端,两套解法

对象缓存的原子性需求集中在几个操作上,而 APCu 与 Redis 的能力并不对称。

add:两边都原子

add 的语义是「键不存在才写入」。Redis 用 SET NX(或旧版回退 SETNX),APCu 用 apcu_add(),两者都是原子的,没有歧义。

replace:APCu 没有原生的原子 replace

replace 的语义是「键存在才写入」。Redis 用 SET XX,原子。但 APCu 没有原生的原子 replace——apcu_cas 只支持整数比较交换,没法覆盖任意值。只能退化为 exists + store 两步,存在一个极小的竞态窗口。

这里的处理方式是在注释里写清楚,并给出明确建议:需要强一致就选 Redis 后端。把「做不到」写进代码里,比假装做到了要诚实。

incr / decr:用 Lua 把「读改写」变成一次服务端往返

计数器是最容易丢更新的地方。直觉写法是「读旧值 → 计算 → 写回」,但这三步之间可能被其它请求插队(丢更新),而且至少三次网络往返。

解决办法是把整段逻辑放到 Redis 服务端,用 Lua 脚本原子执行:读旧值 → 计算 → 保留原 TTL 回写,一次往返、无并发丢更新。

-- incr/decr 在服务端原子执行:解析 PHP 序列化格式,保留原 TTL
local num = tonumber(raw)
if num == nil then
  num = tonumber(string.match(raw, '^i:(%-?%d+);$'))
end
if num == nil then
  num = tonumber(string.match(raw, '^s:%d+:"(%-?%d+)";$'))
end
if num == nil then return nil end

local new = num + offset
if not increase and new < 0 then new = 0 end   -- decr 夹到 0

local ttl = redis.call('TTL', key)
if ttl and ttl > 0 then
  redis.call('SET', key, value, 'EX', ttl)      -- 保留原 TTL
else
  redis.call('SET', key, value)
end

脚本里要做两件容易漏的事:

  • 解析 PHP 序列化格式。缓存里的值可能是整数 i:123;、浮点 d:1.5;、或字符串数字 s:5:"12345";。漏掉字符串数字这一种,就会回落到 PHP 侧的读改写兜底——又是三次往返,而且不原子。
  • decr 在脚本内夹到 0,避免负数。

调用时用 EVALSHA,脚本未缓存时自动回落 EVAL。

批量接口:省往返,但要分批

get_multiple / set_multiple / add_multiple / delete_multiple 都改由 Lua 在服务端循环执行,省掉 N 次往返。但必须分批——我们定的是单批 500 个键、或累计 1MB,避免 Lua 栈溢出和超大参数表。删除用 UNLINK(异步释放)回退 DEL。

性能(一):请求内本地副本——收益最大的一处

这是所有性能改动里收益最大的一处,也是最容易被忽略的。原实现里,持久化组的每次 get() 都直接打后端。但核心的 WP_Object_Cache 本身有一层 $this->cache:同一请求内读过的键直接从 PHP 数组返回。

问题在于,WordPress 渲染一个页面时 wp_cache_get() 常有几百到几千次调用,其中大量是同一个键——options、post meta、term 被反复读取。没有本地副本,就等于几百次白跑的往返。

做法是在 dropin 内再维护一层请求内副本:

  • get() 先查副本($force=true 时绕过),未命中才打后端,回源后写回副本。
  • get_multiple() 先吃副本,只把未命中的键发往后端。
  • set / add / replace / incr / decr 成功后更新副本。
  • delete 成功后必须清除副本——否则会读到「已经删掉」的值。
  • flush 清空副本。

副本必须有条目上限,我们取 5000。超上限只降命中率、不影响正确性;没有这个保护,一次读上万个大对象会把 PHP 内存顶满。核心的 WP_Object_Cache 其实没有这层保护,这是 dropin 该补上的。

有一个边界要分清:本地副本和「非持久化组」的唯一存储用的是同一个数组,但后者不能受上限约束——非持久化组本来就只活在请求内,被上限挤掉就等于数据丢了。所以上限只作用于「副本」,不作用于「唯一存储」:

// 请求内本地副本:非持久化组的唯一存储不能受上限约束,副本可以
protected function runtime_put(string $storage_key, mixed $data): void {
    if (!$this->local_cache_enabled()) {
        return;
    }
    if (count($this->runtime_cache) >= self::MAX_LOCAL_CACHE) {
        return;   // 副本满了就放弃写入,仅降命中率,不影响正确性
    }
    $this->runtime_cache[$storage_key] = $data;
}

性能(二):别把 keyspace 扫三遍

清空缓存原先分三类各扫一遍(站点键、全局组键、历史 salt 遗留键),等于把整个 keyspace 遍历三遍。Redis 是单线程的,在大库上「清空」会变成秒级阻塞,还会拖累同一个库上的其它业务。

做法很直接:三类键都在 {站点标识}_ 这个根前缀之下,一轮扫描即可全部覆盖。一个前缀设计上的决策,省掉两轮全库遍历。

性能(三):几条便宜但重要的规则

  • 写入用 SETEX(一次往返),不要 SET + EXPIRE 两条命令。
  • phpredis 必须设 OPT_READ_TIMEOUT(我们默认 1.0 秒)。不设的话,Redis 抖动或慢查询会让整个 PHP 请求一直挂着、没有上限。缓存是旁路,宁可快速放弃,也不要拖垮请求。
  • 管理页的「清空缓存」不要为了显示「清了多少个键」先做一次全库枚举——那比清空本身还贵,而条目数在统计面板里本来就有。
  • 查询单个键的元数据不要「导出整张表再遍历」(APCu 的 apcu_cache_info()),用单键精确查询(APCUIterator 传精确匹配的正则)。
  • 警告日志要限流(我们每请求最多 20 条):PHP 8.x 下一段高频 deprecation 就能产生上千次同步文件 IO,反而把站点拖慢。

安全:缓存里放的是真实数据,不是私有草稿

对象缓存常被当成「内部实现」,于是安全边界容易被忽略。实际上它存的是 options、用户数据、登录态,触碰它就是触碰真实数据。

  • 管理页的键操作必须限定在本插件命名空间内。只校验字符集是不够的:同一个 Redis 库 / APCu 里还有其它应用的键,会被误读误删。要校验存储键确实落在当前站点前缀或全局组前缀之下。
  • 「点了会真实读写缓存」的操作必须带 nonce(例如自洽性测试)。否则管理员点一个被构造好的链接,就会以他的权限触发一次缓存写入。
  • 「自愈摘除 dropin」的判定要收紧。原实现里,任何路径中包含本站目录标识或 object-cache 字样的致命错误都会触发摘除——等于同机其它插件的一次致命错误就能把本站对象缓存永久关掉,是一个可被远程触发的降级面。应改为只认本站 dropin 本体与本插件目录内的文件。
  • 查看缓存值的接口不要回传未截断的原始值:缓存里可能有令牌、会话数据。只回传已截断的展示文本。
  • AJAX 一律校验权限 + nonce,输入校验键名字符集与长度。

收尾:写对象缓存 dropin 前的检查清单

  1. 契约对齐:site-transient 走持久化;不改写调用方 TTL;空 group 归一 default;delete / incr / decr / flush / reset / supports 的返回值与核心一致;对象两侧 clone;$force 生效。
  2. 键前缀不依赖 HTTP_HOST,由安装标识派生;全局组前缀与站点前缀分开。
  3. 清空语义:flush 与 flush_site 分离;站点前缀在每次使用前重新同步 blog_id。
  4. 原子性边界:add / replace / incr / decr 明确到后端能力;APCu 做不到原子 replace 的地方在注释里写清并建议用 Redis。
  5. 计数器走 Lua 服务端原子执行,解析 PHP 序列化格式,保留 TTL,decr 夹 0。
  6. 批量接口分批(如单批 500 键 / 1MB),删除用 UNLINK 回退 DEL。
  7. 请求内本地副本:get 先查副本、未命中回源;delete 后清副本;副本有上限,非持久化组的唯一存储不受限。
  8. 清空只扫一轮 keyspace(根前缀统一);写入用 SETEX;设 OPT_READ_TIMEOUT;警告日志限流。
  9. 安全:管理页键操作限定命名空间;写缓存类操作带 nonce;自愈摘除只认本插件文件;不回传原始值。
  10. 发布安全:自维护只在后台 / CLI 写文件;冷却 + 原子写 + 整文件 SHA256 校验。

参考文献

  1. WordPress.org. WP_Object_Cache class reference[EB/OL]. https://developer.wordpress.org/reference/classes/wp_object_cache/ (访问日期: 2026-10-12).
  2. Redis. EVAL / EVALSHA 与 Lua 脚本[EB/OL]. https://redis.io/docs/latest/develop/interact/programmability/eval-intro/ (访问日期: 2026-10-12).
  3. PHP.net. APCu 函数参考[EB/OL]. https://www.php.net/manual/en/ref.apcu.php (访问日期: 2026-10-12).

发表评论

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