后台配置页表单提交后设置生效、刷新却回退:我卡在 `update_option` 与对象缓存的"异步裂缝"里的两小时

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

上周给内部工具链插件加"批量重命名规则"配置页,表单提交提示"保存成功",页面刷新后值却变回旧的。不是 nonce 问题,不是权限问题,甚至数据库里 `wp_options` 表都写进去了——但 get_option() 读出来就是老数据。

最后发现是 Redis 对象缓存的锅,但根子在我对 WordPress 配置读写流程的想当然。把踩的坑拆成三段,省得你们也在这裂缝里浪费时间。

一、我以为的"原子操作"其实有三层

后台配置保存,粗看就两步:表单 POST → update_option。实际 WordPress 会走这套:

// 你以为的
$_POST 数据 → update_option() → 数据库 → 完事

// 实际的(启用了对象缓存时)
$_POST 数据 
  → update_option() 
    → 写数据库 wp_options
    → 触发 "update_option_{$option}" 动作
    → 若存在对象缓存:wp_cache_set() 更新缓存键
    → 若存在全页缓存(如 Batcache):?不管你的

问题就出在第 5 步的"若"字上。我的测试环境没开对象缓存,线上开了 Redis Object Cache,但插件代码里混用了两种写法:

// A 处:保存时(正确,会刷缓存)
update_option( 'my_plugin_rules', $rules );

// B 处:某处临时绕过(手贱埋雷)
$wpdb->query( "UPDATE {$wpdb->options} SET option_value = ..." );

B 处是三个月前赶工写的"快速修复",直接裸写表。数据库变了,Redis 里的缓存键没过期,get_option 优先读缓存,新旧数据就对不上了。

二、对象缓存的"沉默契约":你必须叫它的名字

WordPress 的对象缓存不是透明层,它要求你遵守调用约定。几个容易踩的边界:

1. 直接 SQL 写表 = 自己负责刷缓存

// 错误示范:你以为更新了,缓存不知道
$wpdb->update( $wpdb->options, [ 'option_value' => $v ], [ 'option_name' => 'x' ] );

// 补救:手动清,但别硬编码键名
wp_cache_delete( 'x', 'options' );
// 或者更稳妥:delete_option() 再 add_option(),让 WP 自己管

2. 复杂值序列化后的缓存长度陷阱

我的 $rules 是嵌套数组,序列化后 12KB。某次 Redis 配置了 maxmemory-policy allkeys-lru,大键被优先驱逐,但 wp_cache_set 返回 true(写成功了),只是下一秒就被踢。表现就是"偶尔丢配置",极难复现。

现在保存前加一层压缩/截断检查:

add_action( 'update_option_my_plugin_rules', function( $old, $new ) {
    $serialized = maybe_serialize( $new );
    if ( strlen( $serialized ) > 1024 * 10 ) {
        // 写日志、拆配置、或换存储方式
        do_action( 'my_plugin_config_oversized', strlen( $serialized ) );
    }
}, 10, 2 );

3. 多站点下 wp_cache_flush() 的范围

别在配置保存时无脑 wp_cache_flush()。我干过这事,结果整站缓存清光,瞬时 DB 查询飙到 2000+ QPS。多站点应该用 wp_cache_delete( $key, $group ),且注意 $group 带不带 blog prefix。

三、配置页刷新后"旧值闪现":浏览器缓存 vs 服务端缓存

还有一种更隐蔽的情况:表单 POST 成功后我加了 wp_redirect( admin_url( '...' ) ),但 302 前的响应头里带了 Cache-Control: max-age=3600(某全局插件加的)。Chrome 直接拿 302 前的缓存页,连 GET 请求都没发。

现在保存后的重定向强制带时间戳:

wp_safe_redirect( add_query_arg( [
    'page'   => 'my-plugin',
    'saved'  => '1',
    '_t'     => wp_rand(),  // 打破浏览器缓存
], admin_url( 'admin.php' ) ) );
exit;

四、我现在用的"配置读写"最小闭环

class My_Plugin_Config {

    private const OPTION_KEY = 'my_plugin_rules_v2'; // 带版本号,方便迁移

    public static function get(): array {
        $raw = get_option( self::OPTION_KEY, [] );
        // 防御:缓存里可能是旧版结构的残留
        if ( ! is_array( $raw ) || ! isset( $raw['schema_ver'] ) ) {
            return self::get_defaults();
        }
        return wp_parse_args( $raw, self::get_defaults() );
    }

    public static function save( array $data ): bool {
        $data['schema_ver'] = 2;
        $data['saved_at']   = time();

        $result = update_option( self::OPTION_KEY, $data, false ); // false = 不自动加载(大配置省内存)

        if ( $result ) {
            // 显式清相关派生缓存
            wp_cache_delete( 'my_plugin_compiled_rules', 'my_plugin' );
            do_action( 'my_plugin_config_updated', $data );
        }

        return $result;
    }
}

几个刻意的设计:

  • update_option 第三个参数 false:配置大于 1KB 时不自动加载到 $GLOBALS['wp_object_cache'] 的 "alloptions" 里,避免每次请求都拖这个大数组
  • 版本号内嵌:方便以后做结构迁移,不用猜缓存里是什么时代的尸体
  • 派生缓存显式清:规则保存后会编译成正则缓存,这个编译结果必须跟着失效

五、调试时的"裂缝照明弹"

遇到配置读写不一致,按这个顺序排查:

  1. 确认 update_option 返回值是 true 还是 false(值没变化时返回 false,不是失败)
  2. 直连数据库看 wp_options 实际值,区分"没写进去"还是"读不出来"
  3. 打印 wp_cache_get( 'your_key', 'options' )get_option() 的结果对比
  4. 检查是否有 Must-Use 插件或 pre_option_* 过滤器拦截了读取
  5. 对象缓存插件的日志模式(Redis Object Cache 有 $wp_redis->debug())看实际命中情况

最坑的一次,是某安全插件的 pre_option_my_plugin_rules 过滤器从远程配置中心拉数据覆盖本地,文档里没写,代码里没注释,我 diff 了二十个文件才抓到。

你们配置页遇到过什么"保存成功但不生效"的诡异情况?特别是那种数据库里有、缓存里没、或者反过来缓存里有、数据库里没的双向裂缝。

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