配置页"假保存"陷阱:当 `update_option` 返回 true 却读到旧值,我怀疑了半小时人生
昨晚改一个后台配置插件,用户反馈"保存成功但刷新后配置回退"。我本地复现:点击保存,顶部飘绿字"设置已更新",再切回来——数据还是老的。
第一反应是缓存。清 Redis、清 OPcache、甚至重启了 php-fpm,幽灵依旧。开始怀疑人生:难道 `update_option` 在骗我?
打断点跟进去,发现真相藏在两个同名 key 的"影子战争"里:
// 插件 A 的代码
update_option( 'my_plugin_settings', $data );
// 插件 B 的代码(某个上古版本遗留下来的)
update_option( 'my_plugin_settings', $legacy_data );
两个插件用了同一个 option_name,保存时互相覆盖。更坑的是,插件 B 的保存逻辑挂在 `admin_init`,每次后台页面加载都执行一次,直接把插件 A 刚写进去的值盖掉。
但这只是开胃菜。真正让我想写这篇的是后来遇到的"缓存层叠"问题——当 option 被对象缓存接管后,`update_option` 的返回值和实际落盘之间出现了"时空裂缝"。
WordPress 的 option 缓存机制大概长这样:
// wp-includes/option.php 简化逻辑
function update_option( $option, $value ) {
$old_value = get_option( $option ); // 这里先读缓存
if ( $value === $old_value ) {
return true; // ← 坑:直接返回 true,实际没写库
}
// ... 继续更新
}
注意那个严格比较 `===`。如果你的旧值是字符串 `"0"`,新值是整数 `0`,`update_option` 会老老实实写库;但如果两边都是数组,且某个嵌套值从 `null` 变成了 `false`,PHP 的 `==` 比较可能认为相等,`===` 却认为不等——反过来,如果数组结构一样只是顺序不同,`===` 也可能给你意外。
更隐蔽的场景:对象缓存插件(比如 W3 Total Cache 或自建的 Redis 后端)在 `update_option` 后,缓存刷新和数据库写入不是原子操作。我遇到的情况是:
// 保存配置
update_option( 'my_complex_config', $new_config );
// 立刻读取用于响应 AJAX
wp_send_json_success( [
'saved' => true,
'config' => get_option( 'my_complex_config' ) // ← 可能读到旧值!
] );
AJAX 响应里返回的 `config` 和刚保存的 `$new_config` 不一致,前端以为保存失败疯狂报错。实际上数据已经进库,只是缓存还没失效。
我的补丁分两层:
第一层:给 option 加命名空间前缀
// 别再裸奔了
define( 'MY_PLUGIN_OPTION_KEY', 'mp2024_cfg_v2' ); // 带版本号,方便迁移
// 保存时加校验和,防止静默损坏
function mp_save_config( $data ) {
$payload = [
'ver' => '2.1',
'ts' => time(),
'data' => $data,
'hash' => wp_hash( serialize( $data ) )
];
return update_option( MY_PLUGIN_OPTION_KEY, $payload );
}
第二层:强制绕过缓存的"急救读取"
function mp_get_config_fresh() {
// 先清缓存再读,代价是一次 extra query,但配置页不在乎
wp_cache_delete( MY_PLUGIN_OPTION_KEY, 'options' );
$raw = get_option( MY_PLUGIN_OPTION_KEY );
// 校验完整性
if ( ! isset( $raw['hash'] ) || ! hash_equals( $raw['hash'], wp_hash( serialize( $raw['data'] ) ) ) ) {
// 损坏告警,回退到默认
do_action( 'my_plugin_config_corrupted', $raw );
return mp_default_config();
}
return $raw['data'];
}
第三层:AJAX 保存后的响应强制刷新
add_action( 'wp_ajax_mp_save_settings', function() {
check_ajax_referer( 'mp_settings_nonce' );
// ... 校验、过滤 ...
$saved = mp_save_config( $sanitized );
// 关键:这里必须读"新鲜"的,不能信任缓存
wp_send_json_success( [
'saved' => $saved,
'config' => mp_get_config_fresh(), // ← 不是 get_option
'debug' => [
'db_value' => $GLOBALS['wpdb']->get_var( // 直接查库兜底
$GLOBALS['wpdb']->prepare(
"SELECT option_value FROM {$GLOBALS['wpdb']->options} WHERE option_name = %s",
MY_PLUGIN_OPTION_KEY
)
)
]
] );
} );
那个 `debug.db_value` 是给自己留的后门。线上再出现"保存成功但读不到"时,让前端把 debug 信息吐出来,一眼就能定位是缓存层还是数据库层的问题。
还有个冷知识:`update_option` 的 `$autoload` 参数默认是 `'yes'`。如果你的配置数组很大(比如存了上千条规则的 JSON),每次 WordPress 启动都会把它从 options 表全量加载到内存。改成 `'no'`,配合按需读取,能显著降低后台内存占用。
// 大配置别 autoload
update_option( 'my_huge_config', $data, 'no' );
但代价是 `get_option` 会走一次数据库查询。我的折中方案:把"热配置"(开关、基础参数)和"冷数据"(规则列表、日志缓存)拆成两个 option,前者 autoload,后者按需。
最后贴个踩坑 checklist,保存配置页代码前过一遍:
- option_name 全局搜一遍,确认没撞车
- 数组保存前 `serialize` 对比哈希,防静默损坏
- AJAX 响应不用裸 `get_option`,封装带缓存清理的读取
- 大数组显式设置 `$autoload = 'no'`
- 多站点环境用 `update_blog_option` / `get_blog_option`,别直接操作 `$wpdb->options`
有人遇到过 `update_option` 返回 true 但实际没写库的情况吗?我怀疑还有别的触发条件没覆盖到。

