Zsens Admin 插件配置页:我把 `saveConfig` 拆成"三阶段提交"后,终于治好了配置丢失与缓存脏读
【总结】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 表查询(有缓存、有索引,实际开销
你们处理配置并发时是怎么做的?有没有更轻量的方案?


微信客服
QQ官方交流群