Zsens Admin 插件配置页:当 `saveConfig` 遇上 APCu 缓存穿透,我是如何用"双写标记"搞定配置热更新的

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

上周给运营后台加了个「动态阈值」配置页,保存完刷新页面,值变了;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 挂了时的降级策略怎么设计比较优雅。

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