后台配置页"保存即失效":我追查三天才发现 `update_option` 触发了对象缓存但 `wp_cache_delete` 漏了 group 前缀

插件开发 33 浏览 0 回复 返回上级

上周被个诡异 bug 折磨惨了——用户在后台改完配置点保存,页面刷新后显示保存成功,但前端读取的还是老数据。更邪门的是,多刷新几次后台,偶尔又能看到新值。这种"薛定谔的保存"最要命,因为无法稳定复现。

先贴我当时最开始的"朴素"实现,估计不少人写过类似代码:

public function save_settings() {
    if (!current_user_can('manage_options')) {
        wp_die('Unauthorized');
    }
    
    check_admin_referer('zsens_save_settings');
    
    $settings = [
        'api_endpoint' => esc_url_raw($_POST['api_endpoint']),
        'cache_ttl'    => absint($_POST['cache_ttl']),
        'enable_debug' => !empty($_POST['enable_debug']),
    ];
    
    update_option('zsens_settings', $settings);
    
    // 当时觉得这就够了,天真
    wp_cache_delete('zsens_settings');
    
    wp_redirect(admin_url('admin.php?page=zsens-settings&saved=1'));
    exit;
}

问题就藏在第 17 行。`wp_cache_delete` 没指定 group,默认走 `options` group,但我的配置读取层为了性能,在 `get_option` 之前套了一层自定义缓存:

public function get_settings() {
    $cached = wp_cache_get('zsens_settings', 'zsens_config');
    if (false !== $cached) {
        return $cached;
    }
    
    $settings = get_option('zsens_settings', $this->defaults);
    
    // 这里!group 是 'zsens_config',不是默认的 'options'
    wp_cache_set('zsens_settings', $settings, 'zsens_config', HOUR_IN_SECONDS);
    
    return $settings;
}

看出来了吗?保存时清的是 `options` group 里的 key,但读取时走的是 `zsens_config` group。两个缓存各玩各的,`update_option` 内部确实会清自身的对象缓存,但它清的是 `options` group,我的自定义 group 成了"法外之地"。

更坑的是,为什么偶尔能读到新值?因为 `wp_cache_get` 返回 `false` 有两种可能:key 不存在,或者缓存失效被驱逐了。当 Redis/Lua 内存紧张时,我的自定义缓存被 LRU 踢掉,就会穿透到 `get_option`,这时候读到的是 `update_option` 已经写好的新数据。这解释了"薛定谔"现象——不是代码对了,是缓存刚好没了。

修复方案我迭代了三版。第一版是"暴力同步":保存时把可能用到的 group 全清一遍,但这样每次保存要发四五个 `DELETE`,而且以后加新 group 还得记得改这里,容易遗漏。

// 第一版:笨办法,能跑但不优雅
wp_cache_delete('zsens_settings', 'options');
wp_cache_delete('zsens_settings', 'zsens_config');
wp_cache_delete('zsens_settings', 'site-options'); // 多站点时可能用到
// ... 以后每加一种缓存策略就要来改

第二版我改成了"统一入口":所有配置读写必须过同一个类,缓存 group 和 key 规则收拢到常量,保存时调用专门的刷新方法。但这样还是有个隐患——如果其他地方直接调了 `get_option` 呢?虽然我的代码里不会,但保不齐第三方扩展或者以后的自己手滑。

第三版也是最终版,我换了个思路:不跟 `update_option` 打架,而是利用它自带的钩子做"被动失效"。

public function __construct() {
    // 监听选项变更,无论从哪里触发的 update_option,都能收到通知
    add_action('update_option_zsens_settings', [$this, 'invalidate_config_cache'], 10, 2);
}

public function invalidate_config_cache($old_value, $new_value) {
    // 清自定义 group
    wp_cache_delete('zsens_settings', 'zsens_config');
    
    // 如果用了 transients 做降级,一并处理
    delete_transient('zsens_settings_fallback');
    
    // 触发内部事件,让依赖配置的模块有机会刷新自己的派生缓存
    do_action('zsens_config_updated', $old_value, $new_value);
}

// 读取层保持简洁,不再自行处理缓存写入
public function get_settings() {
    // 优先走对象缓存
    $cached = wp_cache_get('zsens_settings', 'zsens_config');
    if (false !== $cached) {
        return $cached;
    }
    
    // 穿透到数据库
    $settings = get_option('zsens_settings', $this->defaults);
    
    // 回填缓存,下次命中
    wp_cache_set('zsens_settings', $settings, 'zsens_config', HOUR_IN_SECONDS);
    
    return $settings;
}

这个方案的好处是解耦:保存逻辑不用关心缓存细节,缓存层不用关心保存逻辑,两者通过 WordPress 原生的 `update_option_*` 钩子桥接。即使以后再加一层 opcache 或者本地文件缓存,也只需要在 `invalidate_config_cache` 里扩展。

还有个衍生的坑:`update_option` 在值"看起来没变"时会提前返回 `false`,不会触发钩子。我遇到过用户把 `api_endpoint` 从 `https://a.com` 改成 `https://a.com/`(多了个斜杠),业务语义变了,但序列化后的字符串长度和内容都变了,所以正常触发。真正要注意的是对象/数组里顺序调整但键值对相同的情况,不过这种对配置来说一般不影响。

最后列个自查清单,给同样写后台配置页的同学:

1. 你的缓存 group 是不是散落在多个文件里?建议收拢到常量定义文件
2. `wp_cache_delete` 和 `wp_cache_set/get` 的 group 参数是否一致?用 IDE 全局搜一下 key 名,看看所有出现位置的 group
3. 有没有直接调 `get_option` 绕过你的缓存层的地方?`grep -r "get_option.*zsens" --include="*.php"` 扫一遍
4. 多站点环境下,`get_blog_option`/`update_blog_option` 的缓存行为跟单站点不同,group 可能是 `site-options` 或者带 blog id 的

这次折腾让我养成个习惯:凡是自定义缓存,必须配套写"失效策略",而且失效触发点要尽可能靠近数据变更的"唯一真相源"。`update_option` 就是那个源,钩子比手动调用靠谱。

评论0
回复 · 0
还没有回复
微信客服 微信客服