Zsens Admin 插件配置页:我用"配置影子副本"解决了多人同时改配置导致的覆盖丢数据问题
上周运营同事在群里炸锅,说刚改完的邮件服务器配置莫名其妙变回了三天前的参数。查日志发现,A 同事 14:23 打开配置页,B 同事 14:25 也打开了同一页,A 保存后 B 没刷新直接点提交——经典的"乐观覆盖"事故。WordPress 的 update_option 可不会帮你做版本控制。
我最初想的方案是直接上数据库行锁,但 Zsens Admin 的配置页走的是 REST 路由,wp_options 的更新也不支持 SELECT FOR UPDATE。后来换了个思路:给每条配置记录加"影子副本",保存时比对客户端携带的"编辑令牌",不匹配就拒绝并提示冲突。
核心改动在配置读写层。先在 ConfigRepository 里加两个字段:
// 配置主表新增 meta 列:edit_token 与 shadow_copy
// edit_token: UUID v4,每次成功保存后刷新
// shadow_copy: JSON,存储上一次成功保存的完整快照
public function acquireEditLock(string $configKey): array
{
$raw = $this->db->get_row(
$this->db->prepare(
"SELECT option_value, edit_token
FROM {$this->table}
WHERE option_name = %s",
$this->prefix . $configKey
),
ARRAY_A
);
if (!$raw) {
return ['token' => null, 'data' => [], 'conflict' => false];
}
$data = json_decode($raw['option_value'], true);
$token = $raw['edit_token'] ?? wp_generate_uuid4();
// 首次迁移旧数据,补 token
if (empty($raw['edit_token'])) {
$this->db->update(
$this->table,
['edit_token' => $token],
['option_name' => $this->prefix . $configKey],
['%s'],
['%s']
);
}
return [
'token' => $token,
'data' => $data,
'conflict' => false
];
}
保存阶段的三次校验是重点。第一次在校验层,比对客户端 X-Edit-Token 与数据库当前 token;第二次在业务层,用影子副本做字段级 diff,只提交真正变更的键(减少无意义写入);第三次在持久化后,把旧数据压入影子副本,生成新 token 返回。
public function commit(string $configKey, array $payload, string $clientToken): array
{
$this->db->query('START TRANSACTION');
try {
// 1. 当前行加短暂锁,防止并发写
$current = $this->db->get_row(
$this->db->prepare(
"SELECT option_value, edit_token, shadow_copy
FROM {$this->table}
WHERE option_name = %s
FOR UPDATE",
$this->prefix . $configKey
),
ARRAY_A
);
if (!$current || $current['edit_token'] !== $clientToken) {
$this->db->query('ROLLBACK');
// 返回冲突详情,前端弹 diff 视图
return [
'success' => false,
'code' => 'EDIT_CONFLICT',
'serverToken' => $current['edit_token'] ?? null,
'shadow' => json_decode($current['shadow_copy'] ?? '{}', true),
'current' => json_decode($current['option_value'] ?? '{}', true),
];
}
// 2. 字段级 diff,过滤未变更的键
$oldData = json_decode($current['option_value'], true);
$delta = array_diff_assoc($payload, $oldData);
if (empty($delta)) {
$this->db->query('ROLLBACK');
return ['success' => true, 'noop' => true];
}
$merged = array_merge($oldData, $delta);
// 3. 双写:主数据更新 + 影子副本归档 + token 刷新
$newToken = wp_generate_uuid4();
$this->db->update(
$this->table,
[
'option_value' => wp_json_encode($merged),
'edit_token' => $newToken,
'shadow_copy' => $current['option_value'], // 旧主数据变影子
'updated_at' => current_time('mysql', true),
],
['option_name' => $this->prefix . $configKey],
['%s', '%s', '%s', '%s'],
['%s']
);
$this->db->query('COMMIT');
// 4. 缓存刷新:先删 object cache,再发本地事件
wp_cache_delete($this->prefix . $configKey, 'options');
do_action('zsens_config_updated', $configKey, $delta, $oldData);
return [
'success' => true,
'newToken' => $newToken,
'applied' => array_keys($delta),
];
} catch (\Exception $e) {
$this->db->query('ROLLBACK');
throw $e;
}
}
缓存刷新这块我踩过一个坑:直接调 wp_cache_flush() 会把全站缓存清掉,配置页保存频繁时简直灾难。现在改成精准删除 + 事件广播,监听方(比如定时任务调度器)按需重载自己的本地副本。
前端配合也简单。每个配置页组件挂载时拿 token,保存时带在 header 里,遇到 EDIT_CONFLICT 就弹三栏对比:我的修改 | 服务器当前 | 影子副本(上次成功版本),让用户选覆盖、合并或放弃。运营同事说这比 Git 冲突界面友好多了——毕竟他们不用学 rebase。
有个边缘 case 提一下:如果用户开了两个标签页,第二个标签页的 token 会失效。我加了 window.addEventListener('storage') 监听 localStorage 里的 token 变更,同浏览器内自动同步,跨设备就只能走冲突流程了。
这套"影子副本"机制上线两周,配置相关的工单从平均 4 条/天降到零。代码量多了不到八十行,但省下的扯皮时间够我重构一遍权限中间件了。

