MCP 2026-07-28 规范正式发布。本文解析无状态协议、Tasks、MCP Apps、OAuth 安全和 JSON Schema 升级,并说明 PHP 与 Laravel 开发者需要关注的部署和兼容问题。
先说结论:绝大多数业务,选「先更新数据库,再删缓存」(Cache Aside),删除失败用重试兜底,再加一个 TTL 作为最后防线。「先删缓存再更新数据库」在并发下会把旧值重新写回缓存,是脏数据的经典来源;「先更新数据库再更新缓存」和「先更新缓存再更新数据库」则各有更麻烦的乱序问题。强一致场景才考虑延迟双删或订阅 binlog。
下面把这个判断拆开讲透。
一、四种基本顺序,为什么只有一种能打
更新一条数据,缓存和数据库都要动。排列组合就四种,先摆在一起看:
| 方案 | 顺序 | 致命问题 |
|---|---|---|
| 先更新缓存,再更新数据库 | 写缓存成功、写库失败 | 缓存里是新值,库里是旧值,且失败后没人回滚缓存 |
| 先更新数据库,再更新缓存 | 库成功、缓存失败 | 缓存旧值存活到 TTL 结束;并发写还会乱序 |
| 先删缓存,再更新数据库 | 删了缓存再去写库 | 并发读会把旧值重新加载进缓存(下文详解) |
| 先更新数据库,再删缓存 | 写库成功后删缓存 | 不一致窗口极小且触发条件苛刻(下文详解) |
前两种直接淘汰:更新缓存失败没有任何自愈手段,除非加事务回滚或消息补偿,复杂度立刻上去了。真正的争议在第三种和第四种之间。
二、为什么「先删缓存」是脏数据的经典来源
先删缓存的时序是这样的:
T1 写请求:删除缓存 key
T2 读请求:缓存 miss,从数据库读到旧值(还没写入)
T3 写请求:更新数据库为新值
T4 读请求:把旧值写回缓存
结果:数据库已经是新值,缓存里躺着旧值,而且会一直活到 TTL 过期。这不是理论风险——只要「读请求恰好插入在删除和更新之间」就触发,高并发读 + 慢 SQL(比如那条更新语句带了 300ms 的行锁等待)的场景下,命中率并不低。
有人会反驳:把「删缓存」挪到更新数据库之后不就完了?对,这正是第四种方案。但注意,它并没有 100% 消除问题,只是把问题发生的前提变得极其苛刻:
T1 读请求:缓存恰好过期,miss
T2 读请求:从数据库读到旧值
T3 写请求:更新数据库为新值
T4 写请求:删除缓存(此时缓存本来就是空的,删了个寂寞)
T5 读请求:把 T2 读到的旧值写入缓存
要同时满足三个条件:读的时候缓存恰好过期、读库发生在写事务提交之前、写回缓存发生在删除之后。三个时间窗口叠加,概率比「先删缓存」低了几个数量级。工程上没有绝对的一致,只有概率上的一致——这个窗口小到可以接受。
三、删除失败了怎么办:重试 + TTL 双保险
第四种方案唯一的软肋是「更新库成功、删缓存失败」。解法是两层兜底:
第一层:异步重试。 删除失败不阻塞主流程,丢进队列重试。以 Laravel 为例:
use Illuminate\Support\Facades\Cache;
public function updateArticle(int $id, array $data): void
{
// 1. 先更新数据库(走 Eloquent,事务内)
DB::transaction(function () use ($id, $data) {
Article::where('id', $id)->update($data);
});
// 2. 再删缓存,失败则交给队列重试
try {
Cache::forget("article:{$id}");
} catch (\Throwable $e) {
DeleteCacheJob::dispatch("article:{$id}")
->delay(now()->addSeconds(3))
->tries(3);
}
}
// DeleteCacheJob::handle() 里就是一句 Cache::forget,
// 失败自动指数退避重试,三次后进死信表人工处理
第二层:TTL 兜底。 所有缓存 key 必须设置过期时间。即使重试全部失败,脏数据最多存活到 TTL 结束。本站自己就是这么做的:文章列表缓存 TTL 是 2 小时(7200 秒),意味着哪怕缓存清理完全失效,新文章最迟 2 小时后也会出现在列表里。TTL 是一致性的最后防线,没有 TTL 的缓存等于把一致性押在运气上。
四、什么时候需要更进一步
两种情况值得上更重的方案:
主从延迟场景。 如果读走从库,写主库后立刻删缓存,读请求可能从还没同步完的从库把旧值又加载回来。这时用延迟双删:
更新数据库 → 删缓存 → 睡 500ms~1s(覆盖主从延迟)→ 再删一次
延迟双删是补偿手段,不是默认方案——它引入了 sleep,拖慢响应,而且延迟值没法精确预估。
订阅 binlog 的最终一致。 用 canal 之类的工具订阅 MySQL binlog,数据变更事件驱动缓存删除。好处是把删缓存从业务代码里彻底解耦出来,天然保证「库成功才删」,还能顺便做异构同步。代价是多维护一套中间件,个人项目和小团队通常犯不上。
五、一个容易忽略的点:读走不走缓存,别用「更新缓存」
很多教程会写「更新数据库后顺手把新值写回缓存」,看似省了一次 miss。但两个并发写请求会产生乱序:A 先提交库、B 后提交库,但 B 的「写缓存」先执行、A 的后执行——缓存里最终是 A 的旧值。删除就不一样,删多少次结果都一样,幂等性天然安全。这是「删缓存」优于「更新缓存」的根本原因,不是习惯,是数学。
FAQ
Q:那「先删缓存再更新数据库」加延迟双删行不行? 行,但不如直接「先更新库再删缓存」——后者天然只有一个删除点,前者要删两次还多扛一个并发窗口。
Q:缓存和数据库能不能强一致? 做不到,除非放弃缓存或用分布式锁串行化所有读写,那缓存就失去了意义。缓存一致性的本质是控制不一致窗口的大小。
Q:先更新库再删缓存,需要加事务吗? 删除操作不要塞进数据库事务里。事务拉长会扩大锁持有时间,而删缓存失败本来就有队列重试兜底,没必要为它牺牲数据库吞吐。