Zsens Admin 插件配置页:当 `saveConfig` 遇上 APCu 缓存穿透,我是如何用"双写标记"搞定配置热更新的
上周给运营后台加了个「动态阈值」配置页,保存完刷新页面,值变了;F5 再刷,又变回去了。排查两小时,APCu 缓存的 TTL 和数据库事务提交时序打架了。记录一下这个挺隐蔽的坑,以及最后落地的双写标记方案。
一、先复现场景:你以为的保存成功,可能只是"幻觉"
核心代码大概长这样,看着没毛病:
public function save(Request $request)
{
$data = $request->only(['alert_threshold', 'sync_interval', 'retry_times']);
// 校验...
$this->validate($data);
// 入库
ConfigModel::updateOrCreate(
['group' => 'alert'],
['values' => json_encode($data)]
);
// 清缓存
Cache::tag('config')->clear();
return json(['code' => 0]);
}
问题出在两个地方:
1. updateOrCreate 默认走读写分离的写库,但 ConfigModel 如果配了 sticky 没生效,下一行读配置可能打到从库
2. Cache::tag('config')->clear() 在 APCu 里是惰性删除,其他 worker 进程的缓存实例没感知到
结果就是:页面第一次刷新,Controller 读到了新值(刚好命中本进程);第二次刷新,请求打到另一个 worker,旧缓存还在,数据"回滚"。
二、第一版修复:强制刷新 + 延迟双删,反而更糟
网上搜到的方案,加了延迟队列:
// 先删缓存
Cache::delete('config:alert');
// 写库...
// 延迟 500ms 再删一次(用的队列)
Queue::later(0.5, new ClearConfigCache('config:alert'));
测试环境没问题,生产环境队列堆积时,延迟从 500ms 变成 5s,期间所有读请求都穿透到数据库。配置页访问量不大,但关联的告警判断接口被刷爆了。
而且 APCu 的 apcu_delete 在 CLI 队列进程里执行,和 FPM worker 不是同一个缓存池,删了个寂寞。
三、最终方案:版本号标记 + 本地缓存短路
换个思路,不做"删缓存",改成"让缓存自己失效"。
数据库表加了个 version 字段,每次更新自增。缓存结构变成两层:
// 缓存里存的不是配置值,而是"值 + 版本号"
[
'data' => ['alert_threshold' => 80, ...],
'version' => 42, // 当前缓存的版本
'checked' => 1693094400, // 上次校验时间戳
]
读取时先拿本地缓存,但带个"轻量校验":
public function get(string $group): array
{
$cached = apcu_fetch("config:{$group}");
// 1. 缓存不存在,直接走库
if (!$cached) {
return $this->loadFromDb($group);
}
// 2. 10秒内刚校验过,直接返回(减少DB压力)
if (time() - $cached['checked'] < 10) {
return $cached['data'];
}
// 3. 拿数据库当前版本号,比对
$dbVersion = ConfigModel::where('group', $group)->value('version');
if ($dbVersion == $cached['version']) {
// 版本一致,刷新校验时间,续命
$cached['checked'] = time();
apcu_store("config:{$group}", $cached, 3600);
return $cached['data'];
}
// 4. 版本不一致,重新加载
return $this->loadFromDb($group);
}
保存逻辑也改了,用数据库行锁保证版本号原子递增:
public function save(string $group, array $data): bool
{
return DB::transaction(function () use ($group, $data) {
$config = ConfigModel::where('group', $group)
->lockForUpdate()
->first();
$config->values = json_encode($data);
$config->version += 1; // 关键:版本号+1
$config->updated_at = now();
$config->save();
// 写完后直接刷新缓存,避免立即读穿透
$this->warmCache($group, $config);
return true;
});
}
四、几个踩过的边角料
1. 事务提交和缓存写入的时序
一开始 warmCache 写在 transaction 闭包外面,事务没提交就写缓存,并发下另一个请求读到未提交的数据(隔离级别是 RC,但缓存不管这个)。
改到事务内部,但注意:如果事务回滚,缓存已经写了脏数据。所以加了个 try-catch-finally,回滚时强制把版本号标为 -1,下次读取触发重新加载。
2. APCu 的内存碎片导致 apcu_store 偶发失败
配置值比较大(带了个 JSON 规则数组),apcu_store 返回 false 没处理。现在加了降级:写缓存失败直接记 warning,读的时候走数据库,不影响业务。
3. 批量保存时的版本号抖动
运营后台有个「一键同步」功能,连改 5 个配置组。如果每个组独立事务,页面刷新时可能看到"部分新部分旧"。
改成外层包一个大事务,但注意 MySQL 行锁范围。最后方案是:批量保存时先统一加锁、改版本号、再分别写值,保证原子性。
五、监控埋点:怎么知道缓存有没有捣乱
加了两个指标,接入 Prometheus:
// 缓存命中但版本不一致的次数(说明延迟删除失效了)
Metrics::counter('config_cache_version_mismatch_total', ['group']);
// 强制回源数据库的次数(穿透或首次加载)
Metrics::counter('config_db_load_total', ['group', 'reason']);
上线一周,version_mismatch 日均 3 次(正常波动),db_load 的 reason=expired 占 95%,说明 10 秒校验窗口生效了。
六、还没解决的
多节点部署时,APCu 是每个机器独立的。如果请求轮询到节点 A(新配置)再轮询到节点 B(旧配置),用户体感还是闪烁。
下一步打算用 Redis 存版本号,本地 APCu 存数据,形成"集中版本 + 分布缓存"的混合结构。有做过类似方案的兄弟可以聊聊,Redis 挂了时的降级策略怎么设计比较优雅。

