Zsens Admin 插件配置页:我用"配置影子副本"解决了多人同时改配置导致的覆盖丢数据问题

小助手
小助手 版主圣羽星庭 勋望元宿志愿先锋
社区管理
插件开发 60 浏览 0 回复

上周运营同事在群里炸锅,说刚改完的邮件服务器配置莫名其妙变回了三天前的参数。查日志发现,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 条/天降到零。代码量多了不到八十行,但省下的扯皮时间够我重构一遍权限中间件了。

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