Zsens Admin 插件配置页:我把 `saveConfig` 拆成"三阶段提交"后,终于治好了配置丢失与缓存脏读

小助手
小助手 版主圣羽星庭 勋望元宿志愿先锋
社区管理
插件开发 284 浏览 0 回复
详情摘要ZsAdmin - GPT

【总结】Zsens Admin插件配置页通过拆分saveConfig为三阶段提交解决并发缓存问题,建议避免缓存forever、统一JSON编码、细化配置分组、提供强制回源后门,最终消除配置丢失与脏读,虽代码量增加但效果显著。

之前写后台配置页,总觉得 `saveConfig()` 是个黑盒——表单 POST 过去,成功就刷新页面,失败就弹个红字。直到有次运营同事反馈:刚保存的阈值配置,刷新页面又变回老数值,清浏览器缓存没用,过两分钟自己好了。

排查下来是并发下的缓存穿透 + 异步刷新 race condition。两个管理员同时改配置,A 先写库、B 后写库,但 B 的缓存刷新请求先到达,A 的缓存刷新后到达,最终数据库是 A 的值,缓存却是 B 的值。更坑的是,某些框架的缓存组件默认用 `file` 驱动时,`mtime` 精度只有秒级,1 秒内多次写入直接判定"无需更新"。

后来我干脆把配置保存拆成三阶段提交,把"写库"和"刷缓存"解耦成显式步骤,而不是依赖框架的魔法方法:

// 阶段一:预校验 + 版本锁生成
public function prepareSave(array $input): ConfigDraft
{
    $draft = new ConfigDraft();
    $draft->payload = $this->validator->stripTags($input); // 先清洗
    $draft->version = $this->meta->bumpVersion();          // 原子自增
    $draft->lockKey = 'config:lock:' . $draft->version;
    
    // 5 秒排他锁,防止同一管理员连点
    if (!$this->cache->add($draft->lockKey, 1, 5)) {
        throw new ConcurrentEditException('操作太频繁,请稍后再试');
    }
    
    return $draft;
}

// 阶段二:事务写库,带乐观锁校验
public function commitSave(ConfigDraft $draft): bool
{
    return $this->db->transaction(function () use ($draft) {
        $affected = $this->configModel
            ->where('group', $draft->payload['group'])
            ->where('version', 'version) // 关键:版本必须小于当前
            ->update([
                'data'    => json_encode($draft->payload, JSON_UNESCAPED_UNICODE),
                'version' => $draft->version,
                'updated_at' => now()
            ]);
            
        if ($affected === 0) {
            // 说明期间有更高版本写入,当前草稿已过期
            $this->cache->delete($draft->lockKey);
            throw new StaleConfigException('配置已被他人更新,请刷新页面重试');
        }
        
        return true;
    });
}

// 阶段三:缓存双写 + 延迟校验
public function publishToCache(ConfigDraft $draft): void
{
    $cacheKey = 'config:' . $draft->payload['group'];
    $rawValue = json_encode($draft->payload, JSON_UNESCAPED_UNICODE);
    
    // 第一写:带版本号的缓存项,永不过期
    $this->cache->forever($cacheKey . ':data', $rawValue);
    $this->cache->forever($cacheKey . ':ver', $draft->version);
    
    // 第二写:设置一个"软过期"标记,用于读时校验
    $this->cache->put($cacheKey . ':ttl', 1, 60); // 60 秒内允许异步兜底
    
    // 异步触发缓存预热(可选,队列实现)
    // Queue::push(new WarmConfigCache($draft->payload['group']));
    
    // 释放排他锁
    $this->cache->delete($draft->lockKey);
}

读配置的时候反过来做版本对齐校验,而不是无脑取缓存:

public function getConfig(string $group): array
{
    $cacheKey = 'config:' . $group;
    $cachedVer = $this->cache->get($cacheKey . ':ver', 0);
    
    // 缓存未命中或软过期标记丢失,直接回源
    if (!$this->cache->has($cacheKey . ':ttl')) {
        return $this->fallbackToDb($group);
    }
    
    // 缓存命中,但做一层轻量版本校验(查 meta 表,有索引,极快)
    $dbVer = $this->meta->getVersion($group);
    if ($dbVer !== $cachedVer) {
        // 版本不一致,说明阶段三未完成或存在脏读
        $this->cache->deleteMultiple([
            $cacheKey . ':data',
            $cacheKey . ':ver',
            $cacheKey . ':ttl'
        ]);
        return $this->fallbackToDb($group);
    }
    
    return json_decode($this->cache->get($cacheKey . ':data'), true);
}

几个踩坑点单独拎出来:

1. 别用缓存的 `forever` 当银弹

之前图省事全用 `forever`,结果数据库回滚了缓存还在,变成"幽灵配置"。现在 `forever` 只存数据本体,用 `:ttl` 做软过期控制,回滚时清掉 `:ttl` 即可让读请求自动回源。

2. JSON 编码陷阱比想象中多

空数组 `[]` 和空对象 `{}` 在 PHP 里都是 `array()`,但前端表单序列化后可能变成字符串 `"[]"` 或 `"{}"`。保存前统一走 `json_decode($input, true)` 再 `json_encode`,确保类型一致,否则版本比较时 `[] !== {}` 会触发无意义更新。

3. 配置分组粒度要够细

早期我把全站配置塞一个 `site` 组,任何字段修改都触发整组缓存刷新。现在按功能拆成 `mail`、`storage`、`audit` 等独立组,减少锁竞争面,也便于按模块灰度。

4. 给运营留一条"强制回源"的后门

在配置页 URL 加 `?nocache=1` 参数,开发排查时直接绕过缓存读库。生产环境可以绑定特定 IP 白名单或临时 token,避免被滥用。

这套三阶段跑下来,配置丢失和脏读再也没出现过。代价是多了一次 meta 表查询(有缓存、有索引,实际开销

你们处理配置并发时是怎么做的?有没有更轻量的方案?

评论0
回复 · 0
还没有回复
微信客服 微信客服 QQ官方交流群 QQ官方交流群