对象缓存 dropin 的原子性、性能与安全边界
系列目录
本文是「WordPress 对象缓存 dropin 实战」系列的第 3 篇,共 3 篇。三篇可独立阅读,但连起来是一条从故障、契约到性能与安全的完整路径:
- 第 1 篇:WordPress 对象缓存 dropin 出错时为什么「静默」:一次 502 零日志事故的复盘
- 第 2 篇:写 WordPress 对象缓存 dropin 前,先对齐这 11 处核心契约
- 第 3 篇:对象缓存 dropin 的原子性、性能与安全边界(本篇)
引子
契约对齐之后,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 前的检查清单
- 契约对齐:
site-transient走持久化;不改写调用方 TTL;空 group 归一default;delete/incr/decr/flush/reset/supports的返回值与核心一致;对象两侧clone;$force生效。 - 键前缀不依赖
HTTP_HOST,由安装标识派生;全局组前缀与站点前缀分开。 - 清空语义:
flush与flush_site分离;站点前缀在每次使用前重新同步blog_id。 - 原子性边界:
add/replace/incr/decr明确到后端能力;APCu 做不到原子 replace 的地方在注释里写清并建议用 Redis。 - 计数器走 Lua 服务端原子执行,解析 PHP 序列化格式,保留 TTL,
decr夹 0。 - 批量接口分批(如单批 500 键 / 1MB),删除用
UNLINK回退DEL。 - 请求内本地副本:
get先查副本、未命中回源;delete后清副本;副本有上限,非持久化组的唯一存储不受限。 - 清空只扫一轮 keyspace(根前缀统一);写入用
SETEX;设OPT_READ_TIMEOUT;警告日志限流。 - 安全:管理页键操作限定命名空间;写缓存类操作带 nonce;自愈摘除只认本插件文件;不回传原始值。
- 发布安全:自维护只在后台 / CLI 写文件;冷却 + 原子写 + 整文件 SHA256 校验。
参考文献
- WordPress.org. WP_Object_Cache class reference[EB/OL]. https://developer.wordpress.org/reference/classes/wp_object_cache/ (访问日期: 2026-10-12).
- Redis. EVAL / EVALSHA 与 Lua 脚本[EB/OL]. https://redis.io/docs/latest/develop/interact/programmability/eval-intro/ (访问日期: 2026-10-12).
- PHP.net. APCu 函数参考[EB/OL]. https://www.php.net/manual/en/ref.apcu.php (访问日期: 2026-10-12).