本机缓存架构设计

  1. 「Redis 存指针 + 高速本地磁盘存内容」方案的缺点、风险举例与改进建议(§ 一~四)。
  2. 分布式 PHP 推荐方案:APCu(L1)+ Redis(L2)+ 版本号失效(§ 五~六)。
    背景:database 皮肤高流量下 Redis 分片带宽压力;本机缓存分担 Redis 出网。

一、原始方案回顾

1.1 目录结构

store_cache/{店铺id后1位}/{店铺id后2位}/{店铺id后3位}/{店铺Id}/{业务}/{写入时间戳}/{原始cache_key}.tmp

示例(首页 HTML):

store_cache/3/73/073/24073/home_index_cache/1748836800/home_index_cache:24073:1:99:0:USD:html_string.tmp

1.2 读写与清理

环节设计
写入内容写磁盘 → Redis key 存文件完整路径
读取GET Redis 得路径 → file_get_contents 读磁盘
清理Shell 脚本:每个业务目录下只保留最新两个时间戳目录(最新一份 + 冗余一份)

1.3 前提

  • 使用高速本地磁盘(非共享盘)
  • 各机器挂载路径一致
  • Redis 为多机共享

二、问题与危险点(含举例)

2.1 多机不共享 — 缓存命中率低

问题

Redis 指针全局共享,文件只在写入那台机器的本地磁盘上。请求若被负载均衡打到其他机器,Redis 有 key 但文件不存在。

举例

机器 A 处理请求:
  渲染首页 → 写 /store_cache/.../1748836800/home_index.tmp
  Redis SET key → "/store_cache/.../1748836800/home_index.tmp"

机器 B 处理下一个相同请求:
  Redis GET key → 拿到路径
  file_get_contents(...) → 文件不存在 → miss → 重新渲染并再写一份

10 台机器、无会话粘滞时,理论命中率约 1/10,缓存对 Redis/PHP 减压效果大打折扣。

危险

  • 误以为「Redis 有 key = 全集群命中」
  • 高 QPS 下每台机器各自冷启动,Redis 指针大量悬空

2.2 按目录代际清理 — 误删仍有效的缓存

问题

清理规则是「业务目录下只保留最新两个时间戳目录」,与单个 key 的 TTL 无关,也不知道哪些 Redis key 仍指向哪个目录。

举例

t=0:   写 key_A(首页 USD,TTL=600s)→ 目录 1748836800/
       Redis key_A → .../1748836800/key_a.tmp,TTL=600s

t=100: 写 key_B(首页 EUR,TTL=600s)→ 目录 1748836900/

t=200: 写 key_B 更新 → 目录 1748837000/
       Redis key_B 更新指向 1748837000/

清理脚本执行:保留 1748836900/、1748837000/,删除 1748836800/

结果:
  Redis key_A 还有约 400s TTL,仍指向 1748836800/key_a.tmp
  → 文件已被删 → 读取 miss → 突发回源
  → 高 QPS 下可能形成缓存雪崩

危险

  • 不同 key、不同 TTL 混在同一业务目录的时间戳层级下
  • 「保留最新两个目录」无法保证「未被引用的文件才可删」

2.3 文件名使用原始 cache_key — 路径与安全风险

问题

将完整 Redis key 作为文件名,例如:

home_index_cache:24073:1:99:0:USD:html_string.tmp

风险说明
长度常见文件系统单文件名上限 255 字节,复杂 key 可能超限
特殊字符: 等在部分环境有问题(如 Windows)
信息暴露路径中明文包含 storeId、themeId、currency 等

危险

  • 写入失败或路径异常
  • 日志、备份、权限审计时泄露业务信息

2.4 写入非原子 — 可能读到半写文件

问题

若直接 file_put_contents($finalPath, $content),写到一半进程被 kill,文件不完整;并发读可能拿到截断内容。

举例

进程 A:写入大段 HTML,写到一半 PHP-FPM 超时退出
进程 B:按 Redis 路径读取同一文件 → 截断 HTML → 页面错乱或白屏

危险

  • 缓存层应「要么完整、要么 miss」,不应返回损坏数据

2.5 时间戳目录无限增长 — 元数据与扫描成本

问题

每次写入新建 {当前时间戳}/ 目录。即使定期删旧目录,长期仍可能积累大量子目录,find / readdir 变慢。

危险

  • 清理脚本本身耗 CPU/IO
  • 单业务目录下目录数随时间增长,运维不可预期

2.6 Redis 指针悬空 — 缺少读路径容错

问题

文件被误删、清理脚本删早、或本机从未写过该文件时,Redis 仍可能保留旧路径。

危险

  • 若业务未处理「文件不存在」,可能报错而非回源
  • 应在读失败时 DEL Redis key 并降级重建

2.7 各 key TTL 不一致 — 无法用「统一文件保留时长」替代精准清理

问题

若采用「文件比 Redis 多存活 N 倍时间」的简单策略,在每个 cache key TTL 不同的场景下,无法用一个固定倍数覆盖所有 key 的安全窗口。

危险

  • 要么删早(误删),要么删晚(磁盘占用高)
  • 需要 per-file 过期语义,而不是 per-directory

三、改进建议

3.1 多机:路由 + 读失败降级

措施说明
一致性哈希 / 按 storeId 粘滞同店铺尽量固定到同一台机器,提高本机文件命中率
读路径容错file_exists 失败 → DEL Redis key → 回源重建,不抛致命错误

3.2 清理:过期时间写入文件名(不依赖 Redis)

去掉「版本号时间戳目录」作为清理依据,改为:

{sha1(cache_key)}_e{expiry_unix}.tmp

示例:

store_cache/3/73/073/24073/home_index_cache/a3/a3f9bc2d_e1748837100.tmp

清理脚本(无需连 Redis):

now=$(date +%s)
find /store_cache/ -name "*_e*.tmp" | while read file; do
    exp=$(basename "$file" | grep -oP '_e\K[0-9]+(?=\.tmp)')
    [[ -n "$exp" && "$exp" -lt "$now" ]] && rm "$file"
done

要点:写入时 expiry = time() + ttl,与 Redis key 的 TTL 同一过期时刻;Redis 先无人引用后,文件才到 _e 时间可被删。

3.3 目录:用 hash 分片替代时间戳目录

时间戳目录可去掉(清理改按文件名 _e{ts} 后,代际目录无必要)。

推荐结构:

store_cache/{id[-1]}/{id[-2:]}/{id[-3:]}/{storeId}/{业务}/{hash前2位}/{hash}_e{expiry}.tmp
  • 避免单目录文件过多
  • 分片目录数量固定(如 256 个),不随时间膨胀

3.4 写入原子性

$hash      = sha1($cacheKey);
$expiry    = time() + $ttl;
$dir       = "{$root}/{$storeId}/{$business}/" . substr($hash, 0, 2);
$tmpPath   = "{$dir}/{$hash}_writing.tmp";
$finalPath = "{$dir}/{$hash}_e{$expiry}.tmp";
 
@mkdir($dir, 0755, true);
file_put_contents($tmpPath, $content);
rename($tmpPath, $finalPath);              // 同文件系统内原子
$redis->set($cacheKey, $finalPath, $ttl); // 最后写 Redis

3.5 读取

$path = $redis->get($cacheKey);
if ($path && file_exists($path)) {
    return file_get_contents($path);
}
$redis->del($cacheKey);
return null; // miss,回源

四、原方案 vs 改进方案对照

维度原方案改进后
多机指针共享、文件不共享,易 miss粘滞路由 + 读失败回源
清理保留最新两个时间戳目录文件名 _e{expiry},与 Redis 解耦
误删可能删仍被 Redis 引用的目录文件 expiry 与 key TTL 对齐
文件名原始 cache_keysha1(key)_e{ts}.tmp
写入可能半写tmp + rename 原子落盘
目录{业务}/{时间戳}/ 持续增长{业务}/{hash前2位}/ 固定分片
悬空指针未明确处理读失败 DEL key + 重建

五、分布式 PHP 下的业界常见替代(参考)

若本机文件方案运维与边界 case 过多,高流量场景更常见组合为:

层级方案适用
整页 HTMLNginx FastCGI Cache命中后 PHP 不执行,Redis 零读
片段 / assetAPCu + Redis 按需读 + 版本号失效挡 Redis 带宽,多机靠 INCR rev
共享大块Redis / 对象存储跨机一致

APCu 无法跨机共享;多机一致性与失效信号由 Redis 承担,本机 APCu 只做加速。具体 Key 设计与读写流程见 § 六


六、方案:APCu + Redis 版本号失效(L1 / L2)

6.1 为什么用版本号

问题版本号如何解决
APCu 各机独立不跨机删 APCu;变更时 Redis INCR rev,新 key 带新版本,旧 key 自然 miss
磁盘方案「误删目录」无目录清理;APCu 靠 TTL,Redis L2 靠 TTL + rev
多机同时失效一次 INCR 全集群可见

原则:业务数据变更 → 递增版本号;缓存 key 必须包含版本号;不遍历各机 APCu 删 key。


6.2 分层与职责

请求
  → L1 APCu(本机 SHM,同机所有 FPM worker 共享,TTL 短)
  → L2 Redis(全集群共享,TTL 长)
  → L3 MySQL / OSS(回源)
存储典型 TTL
L1 APCu反序列化后的数组 / 字符串60~300s
L2 RedisJSON 字符串(slim,只存必要字段)300~86400s
revRedis 整数,可不设 TTL 或极长 TTL随业务 bump

6.3 Redis Key 约定

6.3.1 版本号(全局失效信号)

{storeId}:cache:rev:{namespace}:{resourceId}
字段示例说明
storeId24073租户
namespacetheme_asset业务命名空间
resourceId99themeId 等

示例:

24073:cache:rev:theme_asset:99  →  42

变更时(ThemeTool 改模板、删 asset 等):

$revKey = "{$storeId}:cache:rev:theme_asset:{$themeId}";
$redis->incr($revKey);
// 可选:同时 DEL 已知 L2 热 key,或依赖 rev 让 L2 key 名变化后自然 miss

6.3.2 L2 数据 Key

{storeId}:cache:l2:{namespace}:{resourceId}:v{rev}:{itemKey}
字段示例
itemKeyfile:/sections/header.liquidsha1 后的短 hash

示例(rev=42):

24073:cache:l2:theme_asset:99:v42:file:/sections/header.liquid

Value:slim JSON,例如:

{"content":"...","public_url":"","checksum":"abc","folder":"/sections","file_name":"header.liquid"}

写入:

$redis->set($l2Key, json_encode($slimRow), $l2Ttl);

6.4 APCu Key 约定

APCu key 必须与 L2 使用相同的逻辑 key(含 rev),保证版本 bump 后本机自动 miss:

l1:{storeId}:{namespace}:{resourceId}:v{rev}:{itemKey}

示例:

l1:24073:theme_asset:99:v42:file:/sections/header.liquid

itemKey 较长时可用 sha1($itemKey) 缩短,但 rev 必须参与拼接


6.5 读取流程(remember)

public function remember(
    int $storeId,
    string $namespace,
    int $resourceId,
    string $itemKey,
    int $l1Ttl,
    int $l2Ttl,
    callable $loader
): array {
    $redis = app('redis');
    $rev   = intval($redis->get($this->revKey($storeId, $namespace, $resourceId)) ?: 1);
    $l1Key = $this->l1Key($storeId, $namespace, $resourceId, $rev, $itemKey);
    $l2Key = $this->l2Key($storeId, $namespace, $resourceId, $rev, $itemKey);
 
    // 1. L1 APCu
    if (function_exists('apcu_enabled') && apcu_enabled()) {
        $cached = apcu_fetch($l1Key, $success);
        if ($success) {
            return json_decode($cached, true);
        }
    }
 
    // 2. L2 Redis
    $l2Raw = $redis->get($l2Key);
    if ($l2Raw !== false && $l2Raw !== null) {
        $data = json_decode($l2Raw, true);
        if (function_exists('apcu_enabled') && apcu_enabled()) {
            apcu_store($l1Key, $l2Raw, $l1Ttl);
        }
        return $data;
    }
 
    // 3. L3 回源(DB / OSS / 渲染)
    $data = $loader();
    $json = json_encode($data);
 
    $redis->set($l2Key, $json, $l2Ttl);
    if (function_exists('apcu_enabled') && apcu_enabled()) {
        apcu_store($l1Key, $json, $l1Ttl);
    }
 
    return $data;
}

要点

  • 先读 当前 rev,再拼 L1/L2 key;rev 变了,旧 APCu 条目不会被读到。
  • L2 hit 时回填 L1,减少本机后续 Redis 读。
  • 禁止在业务层 HGETALL 全主题;loader 内只查单文件或 HMGET 必要 field。

6.6 失效流程(写 / 改 / 删)

public function invalidate(int $storeId, string $namespace, int $resourceId): int
{
    $revKey = $this->revKey($storeId, $namespace, $resourceId);
    return intval(app('redis')->incr($revKey));
}
场景操作各层效果
ThemeTool 改单个 liquidINCR rev新请求 rev+1,L1/L2 旧 key 全部 miss
删主题INCR rev + 可选 DEL L2 前缀同左
仅想清 Redis、保留 APCu不推荐会导致短暂不一致

不需要

  • 各机 SSH 清 APCu
  • 扫目录删文件
  • Pub/Sub(多数场景 INCR 足够;极端低延迟可再加)

可选加速(L2 key 很多时):

// bump rev 后,异步或同步删除上一版 rev 的 L2 key(SCAN + DEL)
// 非必须:旧 L2 key 靠 TTL 自然过期即可

6.7 完整示例:database 皮肤单文件

读取 /sections/header.liquid

$storeId    = app('Context')->storeId;
$themeId    = app('Context')->theme_id;
$itemKey    = 'file:/sections/header.liquid';
 
$row = (new LayeredCache())->remember(
    $storeId,
    'theme_asset',
    $themeId,
    $itemKey,
    120,   // L1 APCu 2 分钟
    86400, // L2 Redis 1 天
    function () use ($storeId, $themeId, $itemKey) {
        // L3:MySQL 或 Redis Hash 单 field HGET(非 HGETALL)
        return (new ThemeAssetService())->loadOneFromDb($themeId, '/sections', 'header.liquid');
    }
);
 
$content = $row['content'] ?? '';

ThemeTool 更新该文件后

(new LayeredCache())->invalidate($storeId, 'theme_asset', $themeId);

下一请求任意机器:

  1. GET rev → 43(原 42)
  2. APCu l1:...:v42:... → miss
  3. Redis l2:...:v43:... → miss(或新 key 尚未写入)
  4. loader() 读 DB → 写 L2 + L1

6.8 与「首页 HTML」等不同内容类型

逻辑 key(itemKey)因业务而异,读写/失效框架相同

内容namespaceresourceIditemKey 示例
theme 单文件theme_assetthemeIdfile:/layout/theme.liquid
首页 HTMLhome_indexthemeIdhtml:{domainId}:{viewId}:{currency}
语言包localethemeIdfile:/locales/zh-CN.json

首页大 HTML 更推荐 Nginx FastCGI Cache(L0);若仍走 Redis,可用同一套 L1/L2/rev,但 value 体积大时注意 APCu shm_size


6.9 多机行为说明

现象说明
机器 A 预热 APCu机器 B 无该条目,B 的 L1 miss 后读 Redis L2 或回源
INCR rev全集群下一读用新 rev,一致失效
旧 rev 的 APCu 条目无人再读,等 TTL 过期;占少量 SHM,可接受
提高 L1 命中率LB 按 storeId 粘滞 到固定机器(可选)

6.10 APCu 运维参数(参考)

; php.ini
apcu.enabled=1
apcu.shm_size=256M
apcu.ttl=0          ; 条目 TTL 由 apcu_store 第三个参数控制
注意说明
reload php-fpmAPCu 清空,短暂 miss 高峰
SHM 满LRU 淘汰,仅提前 miss,不返回脏数据
CLI默认不共享 FPM 的 APCu

6.11 APCu vs 磁盘文件方案(对照)

维度磁盘 + Redis 指针APCu + Redis rev
同机 FPM 共享文件可共享,多机指针不一致SHM 天然同机共享
多机一致INCR rev
误删 / 清理脚本需精心设计TTL + rev,无扫盘
半写损坏有风险基本无
各 key 不同 TTL_e{ts} 文件名apcu_store per-key TTL

6.12 L1CacheHelper 工具类(per-key 版本号)

实现文件:extend/helper/L1CacheHelper.php。与 §6 的 全局 INCR rev 方案互补:适用于独立 Redis key / hash,每条数据自带 {key}_v 版本号,无需资源级 rev bump。

读写路径

行为
GET {key}_v(轻量)→ APCu 版本一致则读 APCu → 否则读 Redis 并回填 APCu
Lua 原子写 Redis 数据 + {key}_v(同 TTL);不写 APCu
Lua 删 Redis 数据 + {key}_v;本机 apcu_delete

所有 Redis 操作经 ThinkPHP Cache::store('redis')->getCacheKey() 加 prefix,与 Cache::get/set 一致;禁止对同一逻辑 key 混用裸 $redis->set

开关:config('app.apcu') + php-apcu 扩展 + apcu_enabled()。关闭时行为等同纯 Redis。

调用约定(无版本 key = 未缓存)

方法{key}_v 时返回值调用方动作
get($key)null回源并 set()
hGet($hash, $field)false回源并 hSet() / hMSet()
hGetAll($hash)[]回源并写入
hMGet($hash, $fields)$fields 等长、全 false回源并写入

历史仅有 data、无 _v 的 key 会被视为 miss,须用本 helper 重写后才能走 L1。

Hash 的 APCu 设计

  • APCu 只存一份 {hashKey}:hash_all + 版本 {hashKey}_v(逻辑名,不含 Redis prefix)。
  • hGet / hMGet miss 时 HGETALL 全量拉取再取 field;适合 field 数量适中(< 数百)的 hash。

与 rev 方案选型

场景推荐
theme 整包失效、大量 key 同 rev§6 INCR rev + key 带 v{rev}
单 key(如 sys:brands:142)、独立 hashL1CacheHelper per-key _v

七、相关文档