本机缓存架构设计
- 「Redis 存指针 + 高速本地磁盘存内容」方案的缺点、风险举例与改进建议(§ 一~四)。
- 分布式 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 仍可能保留旧路径。
危险
- 若业务未处理「文件不存在」,可能报错而非回源
- 应在读失败时
DELRedis 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); // 最后写 Redis3.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_key | sha1(key)_e{ts}.tmp |
| 写入 | 可能半写 | tmp + rename 原子落盘 |
| 目录 | {业务}/{时间戳}/ 持续增长 | {业务}/{hash前2位}/ 固定分片 |
| 悬空指针 | 未明确处理 | 读失败 DEL key + 重建 |
五、分布式 PHP 下的业界常见替代(参考)
若本机文件方案运维与边界 case 过多,高流量场景更常见组合为:
| 层级 | 方案 | 适用 |
|---|---|---|
| 整页 HTML | Nginx FastCGI Cache | 命中后 PHP 不执行,Redis 零读 |
| 片段 / asset | APCu + 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 Redis | JSON 字符串(slim,只存必要字段) | 300~86400s |
| rev | Redis 整数,可不设 TTL 或极长 TTL | 随业务 bump |
6.3 Redis Key 约定
6.3.1 版本号(全局失效信号)
{storeId}:cache:rev:{namespace}:{resourceId}
| 字段 | 示例 | 说明 |
|---|---|---|
| storeId | 24073 | 租户 |
| namespace | theme_asset | 业务命名空间 |
| resourceId | 99 | themeId 等 |
示例:
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 名变化后自然 miss6.3.2 L2 数据 Key
{storeId}:cache:l2:{namespace}:{resourceId}:v{rev}:{itemKey}
| 字段 | 示例 |
|---|---|
| itemKey | file:/sections/header.liquid 或 sha1 后的短 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 改单个 liquid | INCR 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);下一请求任意机器:
GET rev→ 43(原 42)- APCu
l1:...:v42:...→ miss - Redis
l2:...:v43:...→ miss(或新 key 尚未写入) loader()读 DB → 写 L2 + L1
6.8 与「首页 HTML」等不同内容类型
逻辑 key(itemKey)因业务而异,读写/失效框架相同:
| 内容 | namespace | resourceId | itemKey 示例 |
|---|---|---|---|
| theme 单文件 | theme_asset | themeId | file:/layout/theme.liquid |
| 首页 HTML | home_index | themeId | html:{domainId}:{viewId}:{currency} |
| 语言包 | locale | themeId | file:/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-fpm | APCu 清空,短暂 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/hMGetmiss 时HGETALL全量拉取再取 field;适合 field 数量适中(< 数百)的 hash。
与 rev 方案选型
| 场景 | 推荐 |
|---|---|
| theme 整包失效、大量 key 同 rev | §6 INCR rev + key 带 v{rev} |
单 key(如 sys:brands:142)、独立 hash | L1CacheHelper per-key _v |
七、相关文档
- database 皮肤 Redis 缓存:database-theme-render-cache.md
- 主题渲染流程:database-theme-flow.md
- L1 工具类实现:
extend/helper/L1CacheHelper.php(§6.12)