尧图精选

PHP 8.4接口怎么实现数据缓存更新策略

🕒 发布时间:2026/10/1 22:24:33 📁 来源:尧图网络
前言缓存和数据库不一致是后端最常见的幽灵 Bug。典型症状是后台改了一条商品价格详情页刷新还是旧价要等几分钟甚至等到缓存过期才对用户下单看到的是旧库存超卖之后才被发现。更麻烦的是它不可复现——本地开发永远正常只有并发量上来之后才偶发。根源不在于缓存本身而在于更新策略没有被明确地设计出来。很多项目里缓存读写散落在各个 Service 方法中写法还不统一有人先删缓存再写库有人先写库再删缓存有人干脆更新缓存。同一个系统里三种策略并存不一致就成了必然。本文用 PHP 8.4 的接口interface把缓存读写策略抽象出来让用哪种策略变成可替换、可测试的一件事并顺手用上 PHP 8.4 的三个新语言特性属性钩子Property Hooks、非对称可见性Asymmetric Visibility和array_find()系列函数核对事实array_find()/array_find_key()/array_any()/array_all()是PHP 8.4引入的本文按 8.4 讲。示例不依赖 Redis用纯 PHP 数组模拟存储可以直接运行。一、四种基本更新模型模型读路径写路径一致性复杂度Cache-Aside旁路缓存先查缓存未命中查库并回填先更新数据库再删除缓存最终一致低最常用Read-Through应用只读缓存缓存自身负责回源——最终一致中需缓存层支持Write-Through写穿透先查缓存未命中查库并回填同时写库与写缓存较强中Write-Behind写回同 Cache-Aside只写缓存异步批量刷库弱可能丢数据高PHP 应用绝大多数场景应该选Cache-AsidePHP 是请求结束进程就回收的模型FPM 与 CLI 都一样没有常驻进程去维护缓存与数据库之间的一致性状态机。这里有一个反直觉结论写路径推荐删除缓存而不是更新缓存。并发写场景下更新缓存很容易出现旧值覆盖新值——两个请求分别拿到 v1、v2 并先后写入缓存若执行顺序颠倒缓存里就留下旧值 v1而数据库里是新值。删除操作是幂等的不依赖执行顺序因此更安全。二、接口怎么切读策略与写策略分开按职责分三层而不是搞一个巨型CacheManagerCacheStore存储层 —— 只管 get/set/delete不关心业务 CachePolicy策略层 —— 只管读写时该做什么不关心存哪里 CacheRepository业务层 —— 组合两者暴露业务语义方法这样切的好处存储层换成 Redis 时策略层不用改策略层从 Cache-Aside 换成 Write-Through 时业务层不用改。第二层再拆成读、写两个接口因为两者的关注点完全不同?php declare(strict_types1); // 最低要求PHP 8.4 /** 存储层无论数组、文件还是 Redis都只暴露这几个动作。ttl 为 null 表示不过期 */ interface CacheStore { public function get(string $key): mixed; public function set(string $key, mixed $value, ?int $ttlSeconds null): void; public function delete(string $key): void; } /** 写策略param callable():mixed $persist 真正写数据库的回调 */ interface WritePolicy { public function supports(string $scene): bool; public function write(string $key, callable $persist, mixed $value): mixed; } /** 读策略 */ interface ReadPolicy { public function read(string $key, callable $loader, ?int $ttlSeconds null): mixed; }关键设计点write()接收的是回调而不是最终值这样策略才能自己决定先写库还是先删缓存写库失败时要不要动缓存。直接传值就失去了对执行顺序的控制权。三、用 PHP 8.4 的语言特性把接口落地3.1 属性钩子把校验塞进赋值动作缓存配置最怕ttl 0或负数它会导致存进去立刻过期这种看起来像缓存没生效的怪现象。用属性钩子可以把校验固定下来final class CacheConfig { // set 钩子每次赋值都执行钩子里的 $this-ttl 指向后备存储不会递归 public int $ttl { get $this-ttl; set (int $value) { if ($value 1) { throw new ValueError(缓存 TTL 必须大于 0 秒); } $this-ttl $value; } } public function __construct(int $ttl 300) { $this-ttl $ttl; // 触发 set 钩子非法值立刻炸 } }注意get $this-ttl;这种短钩子必须带分号set (int $value) { ... }是块形式两种可以混用。构造器里赋值同样会触发set钩子所以校验绕不过去。3.2 非对称可见性外部只能读不能改统计字段应该由类自己维护外部只能看final class CacheStats { public private(set) int $hits 0; // 外部可读、仅类内可写 public function recordHit(): void { $this-hits; } } $stats new CacheStats(); $stats-recordHit(); echo $stats-hits; // 1外部读取允许 // $stats-hits 99; // Error: Cannot modify private(set) property以前要写只读暴露得加一个getHits()方法再加一个私有字段现在一个修饰符解决。3.3 array_find策略选择变干净/** var WritePolicy[] $policies */ $policies [new CacheAsideWriter($store), new WriteThroughWriter($store, $config)]; $policy array_find($policies, fn (WritePolicy $p) $p-supports(high_consistency)); if ($policy null) { throw new RuntimeException(没有匹配的写策略); }array_find()找不到时返回null不是falsearray_any()与array_all()分别判断是否存在与是否全部满足。这三个是PHP 8.4新增的别和 PHP 8.5 才加入的array_first()/array_last()搞混。四、实战一份可运行的 Cache-Aside 实现?php declare(strict_types1); // 最低要求PHP 8.4 // 运行php cache_demo.php final class ArrayStore implements CacheStore { /** var array 每项形如 [value 缓存值, expireAt 到期时间戳或 null] */ private array $items []; public function get(string $key): mixed { $item $this-items[$key] ?? null; if ($item null) { return null; } if ($item[expireAt] ! null $item[expireAt] microtime(true)) { unset($this-items[$key]); // 惰性删除 return null; } return $item[value]; } public function set(string $key, mixed $value, ?int $ttlSeconds null): void { $this-items[$key] [ value $value, expireAt $ttlSeconds null ? null : microtime(true) $ttlSeconds, ]; } public function delete(string $key): void { unset($this-items[$key]); } } final class CacheStats { public private(set) int $hits 0; public private(set) int $misses 0; public function recordHit(): void { $this-hits; } public function recordMiss(): void { $this-misses; } } final class CacheConfig { public int $ttl { get $this-ttl; set (int $value) { if ($value 1) { throw new ValueError(TTL 必须大于 0); } $this-ttl $value; } } public function __construct(int $ttl 300) { $this-ttl $ttl; } public function ttlWithJitter(): int { $delta (int) ceil($this-ttl * 0.1); return max(1, $this-ttl random_int(-$delta, $delta)); } } final class CacheAsideReader implements ReadPolicy { public function __construct( private CacheStore $store, private CacheConfig $config, private CacheStats $stats, ) {} public function read(string $key, callable $loader, ?int $ttlSeconds null): mixed { $cached $this-store-get($key); if ($cached ! null) { $this-stats-recordHit(); return $cached; } $this-stats-recordMiss(); $value $loader(); // 回源 $this-store-set($key, $value, $ttlSeconds ?? $this-config-ttlWithJitter()); return $value; } } /** 先写库再删缓存删除是幂等的不怕执行顺序颠倒 */ final class CacheAsideWriter implements WritePolicy { public function __construct(private CacheStore $store) {} public function supports(string $scene): bool { return $scene default; } public function write(string $key, callable $persist, mixed $value): mixed { $result $persist($value); // 1. 先落库 $this-store-delete($key); // 2. 再让缓存失效 return $result; } } /** 写库 同步更新缓存 */ final class WriteThroughWriter implements WritePolicy { public function __construct( private CacheStore $store, private CacheConfig $config, ) {} public function supports(string $scene): bool { return $scene high_consistency; } public function write(string $key, callable $persist, mixed $value): mixed { $result $persist($value); $this-store-set($key, $value, $this-config-ttl); return $result; } } final class ArticleRepository { /** 假装这是数据库 */ private array $table [1 [id 1, title 原标题]]; public function __construct( private ReadPolicy $reader, private WritePolicy $writer, ) {} public function find(int $id): array { return $this-reader-read(article:{$id}, function () use ($id): array { echo [回源] 查库 article:{$id}\n; return $this-table[$id] ?? throw new RuntimeException(文章不存在); }); } public function rename(int $id, string $title): array { return $this-writer-write( article:{$id}, function (array $patch) use ($id): array { echo [落库] 更新 article:{$id}\n; $this-table[$id][title] $patch[title]; return $this-table[$id]; }, [id $id, title $title], ); } } // ---------- 组装与验证 ---------- $store new ArrayStore(); $config new CacheConfig(300); $stats new CacheStats(); $policies [ new CacheAsideWriter($store), new WriteThroughWriter($store, $config), ]; $scene default; $writer array_find($policies, fn (WritePolicy $p) $p-supports($scene)); if ($writer null) { throw new RuntimeException(没有匹配的写策略); } $repo new ArticleRepository(new CacheAsideReader($store, $config, $stats), $writer); echo 第一次读应回源 \n; var_dump($repo-find(1)[title]); echo 第二次读应命中缓存不再打印[回源] \n; var_dump($repo-find(1)[title]); echo 改标题先写库再删缓存 \n; $repo-rename(1, 新标题); echo 再读应回源拿到新值 \n; var_dump($repo-find(1)[title]); printf(命中 %d 次未命中 %d 次命中率 %.0f%%\n, $stats-hits, $stats-misses, $stats-hitRate() * 100);把$scene改成high_consistency同样的业务流程就会切到WriteThroughWriter业务层一个字都不用动——这就是把策略抽象成接口的价值。常见坑点1. 先删缓存再写库// ❌ 删了缓存、库还没写完此时并发读请求回源读到旧值并回填 // 缓存里留下的旧值会一直活到 TTL 到期 $store-delete($key); $db-update(...);// ✅ 先写库、再删缓存把读到旧值的窗口压到最小 $db-update(...); $store-delete($key);2. 用empty()或if (!$v)判断缓存未命中// ❌ 缓存里合法存了 0、 或 false 时会被当成未命中永远回源 $cached $store-get($key); if (!$cached) { $cached $loader(); }// ✅ 用 null 作为唯一的未命中标记查不到也要包装成合法值 $cached $store-get($key); if ($cached null) { $cached $loader(); $store-set($key, $cached, $ttl); }3. 所有 key 使用同一个 TTL整点集体失效// ❌ 发布时批量预热 10 万条全部 300 秒后同时过期数据库瞬间被打爆 $store-set($key, $value, 300);// ✅ 加随机抖动把过期时刻打散 $store-set($key, $value, 300 random_int(-30, 30));4. 热点 key 失效瞬间被打穿// ❌ 首页热点 key 一过期几百个并发同时回源查同一条 SQL $value $store-get($key) ?? $loader();// ✅ 加一把短锁只让一个请求回源其余稍等后重试读缓存 $lockKey lock:{$key}; if ($store-get($lockKey) null) { $store-set($lockKey, 1, 5); // 5 秒互斥窗口 try { $value $loader(); $store-set($key, $value, $ttl); } finally { $store-delete($lockKey); // 无论成功失败都要释放 } } else { usleep(50_000); $value $store-get($key) ?? $loader(); }5. 在数据库事务里删缓存// ❌ 事务未提交就删了缓存此时别的请求回源读到的是提交前的旧值 // 若事务随后回滚这次删除纯属白删 $db-beginTransaction(); $db-update(...); $store-delete($key); $db-commit();// ✅ 先提交再删或在框架的事务提交后回调里删 $db-beginTransaction(); $db-update(...); $db-commit(); $store-delete($key);6. 直接serialize()整个对象进缓存结构一变就崩// ❌ 类改名或属性增删后旧缓存反序列化出 __PHP_Incomplete_Class // 调用方法时报错 $store-set($key, serialize($article), $ttl);// ✅ 缓存只存标量与数组或以 JSON 编码 $store-set($key, json_encode($article-toArray(), JSON_THROW_ON_ERROR), $ttl);7. 在属性钩子里做网络 IO// ❌ PHP 8.4 的 set 钩子每次赋值都执行循环里赋值 1000 次就发 1000 次写缓存请求 public string $title { set (string $value) { $this-title $value; $this-store-set(title, $value, 60); // 副作用藏在赋值里 } }// ✅ 钩子只做纯校验或纯计算IO 交给显式方法 public string $title { set (string $value) { if (trim($value) ) { throw new ValueError(标题不能为空); } $this-title $value; } } public function save(): void { $this-store-set(title, $this-title, 60); }总结关注点做法PHP 8.4 特性默认模型Cache-Aside读先查缓存回源写先落库再删缓存——写路径删缓存幂等不要直接更新缓存——接口分层CacheStore/ReadPolicy/WritePolicy各管一件事——参数校验把 TTL 的合法性固定在set钩子里属性钩子状态暴露外部只读、类内可写非对称可见性private(set)策略选择按场景匹配策略实现array_find()/array_any()抗雪崩TTL 加随机抖动热点 key 加互斥锁——一句话结论缓存不一致从来不是缓存坏了而是写路径的语义没定清楚。用接口把存储、读策略、写策略拆成三层把先写库再删缓存这类约定固化成可替换的实现再配合 TTL 抖动、互斥回源、只缓存可序列化的数组这几条纪律绝大多数不一致问题在架构层面就被消掉了。
上一篇/下一篇内容由系统自动关联 返回资讯列表 →