后台配置页 `wp_nonce_field` 与对象缓存的"幽灵写入":我如何用 `delete_transient` 的返回值当判据,错把缓存失效当保存成功
上周给内部工具插件加"全局开关"配置页,踩了个特别隐蔽的坑——表单提交后页面提示"保存成功",F5 刷新开关状态也对,但过几分钟再回来,配置居然回退了。更诡异的是,这个问题只在开了 Memcached 的生产环境出现,本地文件缓存完全正常。
先贴下最初的核心逻辑,看起来人畜无害:
public function save_settings() {
if ( ! isset( $_POST['my_plugin_nonce'] ) ||
! wp_verify_nonce( $_POST['my_plugin_nonce'], 'my_plugin_save' ) ) {
wp_die( 'Security check failed' );
}
$enabled = isset( $_POST['feature_enabled'] ) ? 1 : 0;
// 这里埋雷了
delete_transient( 'my_plugin_feature_status' );
update_option( 'my_plugin_feature_enabled', $enabled );
set_transient( 'my_plugin_feature_status', $enabled, HOUR_IN_SECONDS );
add_settings_error(
'my_plugin_messages',
'my_plugin_message',
'设置已保存',
'updated'
);
}
问题出在哪?我习惯性地用 delete_transient() 的返回值做"前置清理成功"的暗示——它返回 true 时我以为缓存肯定没了。但对象缓存里,delete_transient 对 Memcached 是异步失效,极端情况下存在"删除指令发出但未生效"的时间窗。紧接着的 update_option 触发了 update_option_{$option} 钩子,另一个监听该钩子的模块(老代码,同事写的)居然也操作了同一 transient,把我的删除结果覆盖了。
更坑的是,这个竞争窗口在本地几乎不可能复现,因为文件缓存的 delete_transient 是同步 unlink。到了生产环境,两个请求并行时就会出现:
请求A: delete_transient('my_plugin_feature_status') -> 发送 DEL 指令
请求B: update_option 钩子触发 -> set_transient('my_plugin_feature_status', old_value)
Memcached: DEL 实际执行 -> 但 B 已经写入了新值
请求A: set_transient('my_plugin_feature_status', new_value) -> 覆盖 B 的写入
// 最终结果是 old_value,取决于时序
排查时我走了弯路:先怀疑 wp_nonce_field 的 token 复用导致重复提交被拦截,加了 check_admin_referer 还是不行;又怀疑 update_option 的 autoload 参数,改成 false 问题依旧。
最后是用 Memcached::getStats() 抓到的证据:DEL 和 SET 的 cas token 对不上。根本解法不是加锁(后台配置页并发极低),而是把配置读写和缓存刷新拆成原子操作,用 option 本身当唯一数据源,transient 只作为纯读取加速层:
public function save_settings() {
check_admin_referer( 'my_plugin_save', 'my_plugin_nonce' );
$enabled = isset( $_POST['feature_enabled'] ) ? 1 : 0;
// 先写数据库,这是唯一真相来源
$updated = update_option( 'my_plugin_feature_enabled', $enabled );
// 缓存层只做一件事:无条件删除,不判断返回值,不立即重建
if ( $updated || get_option( 'my_plugin_feature_enabled' ) === $enabled ) {
wp_cache_delete( 'my_plugin_feature_status', 'transient' );
// 注意:不用 delete_transient,直接操作底层缓存,绕过 hook 链
}
// 读取时再懒加载 transient,不在保存时预填充
}
配套的读取端改成这样,放弃"保存时预热"的思路:
public function is_feature_enabled() {
$cached = get_transient( 'my_plugin_feature_status' );
if ( false !== $cached ) {
return (bool) $cached;
}
$value = (bool) get_option( 'my_plugin_feature_enabled', 0 );
set_transient( 'my_plugin_feature_status', (int) $value, HOUR_IN_SECONDS );
return $value;
}
几个血泪教训:
1. delete_transient() 返回 true 不代表"此刻已删除",只代表"指令已发出"。对象缓存别拿这个当流程判据。
2. wp_nonce_field + check_admin_referer 这对组合比 wp_verify_nonce 更严,能防 referer 伪造,但和本 bug 无关——排查时别被"安全相关"的直觉带偏。
3. 后台配置页的缓存策略,"写时删除、读时重建"比"写时更新"更安全,避免保存逻辑和读取逻辑耦合在同一个请求里。
4. 如果必须用 set_transient 做预填充,给 key 加版本号前缀(如 my_plugin_feature_status_v2),配置变更时换前缀,比删除旧 key 更可靠——虽然会多占点内存,但彻底规避竞态。
有没有人也遇到过对象缓存里"删了但没完全删"的情况?你们是怎么做配置页和缓存层的隔离设计的?