Zsens Admin 插件后台配置页:表单保存时的并发竞争与缓存一致性处理

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

在 Zsens Admin 框架中开发插件后台配置页时,很多开发者只关注表单渲染和单次保存,却忽略了生产环境下高频出现的两个隐患:并发请求导致的配置覆盖,以及缓存未及时刷新引发的读取脏数据。本文基于 ThinkPHP 8 的锁机制与缓存标签功能,给出一份可落地的配置读写方案。

一、配置表设计:预留版本戳字段

不要只用简单的 key-value 结构。建议在插件配置表中增加 version 整型字段和 update_time 字段,每次写入时版本戳自增。这为后续的乐观锁校验提供基础:

CREATE TABLE `plugin_demo_config` (
  `id` int unsigned NOT NULL AUTO_INCREMENT,
  `plugin_name` varchar(50) NOT NULL DEFAULT '',
  `config_key` varchar(100) NOT NULL DEFAULT '',
  `config_value` text,
  `version` int unsigned NOT NULL DEFAULT '1',
  `update_time` int unsigned NOT NULL DEFAULT '0',
  PRIMARY KEY (`id`),
  UNIQUE KEY `plugin_key` (`plugin_name`,`config_key`)
) ENGINE=InnoDB;

二、表单保存:三层校验防覆盖

后台管理员 A 打开配置页的同时,管理员 B 已修改并保存了同一配置。A 提交时若不做处理,会直接覆盖 B 的更改。采用"隐藏域版本号 + 数据库乐观锁 + 业务重试"三层策略:

public function saveConfig(Request $request)
{
    $data = $request->post();
    $pluginName = 'demo_plugin';
    
    // 第一层:表单隐藏域携带版本号
    $formVersion = (int) $data['_version'];
    unset($data['_version']);
    
    Db::startTrans();
    try {
        foreach ($data as $key => $value) {
            // 第二层:UPDATE 时校验版本号
            $result = Db::name('plugin_demo_config')
                ->where('plugin_name', $pluginName)
                ->where('config_key', $key)
                ->where('version', $formVersion)
                ->update([
                    'config_value' => json_encode($value, JSON_UNESCAPED_UNICODE),
                    'version' => Db::raw('version + 1'),
                    'update_time' => time()
                ]);
            
            // 版本不匹配说明已被他人修改
            if ($result === 0) {
                throw new \Exception("配置项 {$key} 已被其他管理员更新,请刷新页面重试");
            }
        }
        
        // 第三层:缓存刷新与事务绑定
        $this->refreshConfigCache($pluginName);
        
        Db::commit();
        return json(['code' => 1, 'msg' => '保存成功']);
    } catch (\Exception $e) {
        Db::rollback();
        return json(['code' => 0, 'msg' => $e->getMessage()]);
    }
}

三、缓存读写:标签化而非键值裸存

ThinkPHP 8 支持缓存标签(需 Redis 驱动),这是插件配置缓存的最佳实践。避免使用 Cache::set('plugin_demo_config', ...) 这种全局键,否则多插件场景下难以精准清理:

protected function refreshConfigCache(string $pluginName): void
{
    // 按插件名打标签,支持批量失效
    $configs = Db::name('plugin_demo_config')
        ->where('plugin_name', $pluginName)
        ->column('config_value', 'config_key');
    
    // 序列化前做类型还原
    $parsed = [];
    foreach ($configs as $k => $v) {
        $decoded = json_decode($v, true);
        $parsed[$k] = (json_last_error() === JSON_ERROR_NONE) ? $decoded : $v;
    }
    
    Cache::tag("plugin_config:{$pluginName}")->set(
        "plugin:{$pluginName}:settings",
        $parsed,
        86400
    );
}

读取时优先走缓存,但需处理缓存击穿:

public function getConfig(string $pluginName, string $key = null, $default = null)
{
    $cacheKey = "plugin:{$pluginName}:settings";
    
    $configs = Cache::tag("plugin_config:{$pluginName}")->get($cacheKey);
    
    // 缓存未命中时重建,加锁防止并发重建
    if ($configs === null) {
        $lockKey = "lock:rebuild:{$pluginName}";
        $lock = Cache::get($lockKey);
        
        if (!$lock) {
            Cache::set($lockKey, 1, 10); // 10秒重建锁
            $this->refreshConfigCache($pluginName);
            Cache::delete($lockKey);
            $configs = Cache::tag("plugin_config:{$pluginName}")->get($cacheKey);
        } else {
            // 其他进程正在重建,直接读库兜底
            $configs = $this->getConfigFromDb($pluginName);
        }
    }
    
    return $key === null ? ($configs ?? []) : ($configs[$key] ?? $default);
}

四、后台页渲染:版本号注入与变更提示

配置表单模板中,将当前版本号写入隐藏域,同时利用 Think View 的模板继承机制,在页面顶部增加"配置已被修改"的实时检测:

<form method="post" action="{:url('saveConfig')}" id="config-form">
    <input type="hidden" name="_version" value="{$currentVersion}">
    
    {foreach $configItems as $item}
    <div class="layui-form-item">
        <label class="layui-form-label">{$item.title}</label>
        <div class="layui-input-block">
            <input type="text" name="{$item.key}" value="{$item.value}" 
                   class="layui-input" data-original="{$item.value}">
        </div>
    </div>
    {/foreach}
    
    <div class="layui-form-item">
        <button type="submit" class="layui-btn" id="submit-btn">保存配置</button>
        <span id="conflict-tip" style="color:#ff5722;display:none;">
            检测到配置已被其他管理员更新,请刷新页面
        </span>
    </div>
</form>

<script>
// 每 30 秒轮询版本号,提前发现冲突
setInterval(function() {
    fetch('{:url("checkVersion")}')
        .then(r => r.json())
        .then(res => {
            if (res.version > document.querySelector('[name="_version"]').value) {
                document.getElementById('conflict-tip').style.display = 'inline';
                document.getElementById('submit-btn').classList.add('layui-btn-disabled');
            }
        });
}, 30000);
</script>

五、插件卸载时的缓存清理陷阱

插件 uninstall 方法中,务必按标签清理而非猜测键名。ThinkPHP 的 Cache::tag() 在文件缓存驱动下不支持标签清除,这是常见踩坑点。建议在插件安装检测环节就强制要求 Redis 缓存驱动,或在 uninstall 中显式遍历清理:

public function uninstall()
{
    $pluginName = 'demo_plugin';
    
    // 安全做法:先清缓存再删表
    Cache::tag("plugin_config:{$pluginName}")->clear();
    
    // 备选:若驱动不支持标签,手动清理已知键
    $keys = ["plugin:{$pluginName}:settings", "lock:rebuild:{$pluginName}"];
    foreach ($keys as $k) {
        Cache::delete($k);
    }
    
    return true;
}

六、调试建议:开启缓存操作日志

开发阶段在 config/cache.php 中增加自定义事件,记录每次配置缓存的读写与失效,能快速定位"为什么我的配置没生效":

// 在 refreshConfigCache 方法中埋点
Event::trigger('PluginConfigCache', [
    'action' => 'refresh',
    'plugin' => $pluginName,
    'timestamp' => microtime(true),
    'trace' => debug_backtrace(DEBUG_BACKTRACE_IGNORE_ARGS, 5)
]);

以上方案已在日活过万的后台场景中验证,核心思路是"数据库乐观锁保正确性,缓存标签保一致性,轮询检测保体验"。如有更极端的并发场景,可进一步将版本号校验下沉到 Redis Lua 脚本层。

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