PHP怎么保证缓存一致性

wen PHP项目 5

本文目录导读:

PHP怎么保证缓存一致性

  1. 终极方案:Cache Aside(旁路缓存) + 延迟双删(最常用)
  2. 进阶方案:基于 Binlog 的异步删除(最可靠,解耦)
  3. 读多写少场景:Cache Aside(不双删) + 短 TTL(兜底)
  4. 写多读少场景:先删缓存,再更新库(不推荐,但可了解)
  5. 终极解决:读写串行化(引入锁)
  6. 兜底方案:多级缓存 / 请求重试
  7. 总结:PHP 的最佳实践建议

在 PHP 中保证缓存一致性是分布式系统中的一个经典难题,既然引入了缓存(如 Redis、Memcached),就必然面临数据库和缓存数据不一致的风险。

要保证一致性,没有一种一劳永逸的绝对方案,只能根据业务场景选择最合适代价最小的折中方案。

以下是 PHP 开发中最常用、最有效的几种策略,按推荐程度从高到低排列:


终极方案:Cache Aside(旁路缓存) + 延迟双删(最常用)

这是目前 PHP 业务系统中最主流的方案,它的核心思想是:读请求走缓存,写请求更新数据库并删除缓存,而延迟双删解决了并发下的“脏读”问题。

执行流程:

  • 读操作:先读缓存,命中则返回;未命中则读数据库,写缓存,返回。
  • 写操作(核心)
    1. 先更新数据库(这是首要步骤,必须保证数据库是最新的)。
    2. 删除缓存(此时不直接更新缓存,而是删除,让下一次读请求去回源)。
    3. 休眠短暂时间(500ms,根据业务耗时设定)。
    4. 再次删除缓存(二次删除,用于清理在第一步和第二步之间可能被并发请求写入的旧缓存)。

PHP 代码示例(使用 Laravel 或原生 Redis):

<?php
function updateUserData($userId, $data) {
    // 1. 更新数据库(伪代码)
    DB::table('users')->where('id', $userId)->update($data);
    // 2. 第一次删除缓存
    Redis::del('user:' . $userId);
    // 3. 休眠(队列中异步操作也行)
    usleep(500 * 1000); // 500ms
    // 4. 第二次删除缓存(兜底)
    Redis::del('user:' . $userId);
}

为什么引入“双删”? 假设没有二次删除:A 请求更新数据库(旧值变成新值),B 请求此时读缓存未命中,去读数据库(读到旧值),写回缓存,A 请求才执行第一次删除——但此时 B 已经把旧值写进缓存了,导致缓存里永远是旧值。 双删保证了 A 在操作完成后,会再次把 B 写进去的旧值清掉。

注意事项:第二次删除的延迟时间要大于“读数据库 + 写缓存”的总耗时,否则二次删除也来不及清理。


进阶方案:基于 Binlog 的异步删除(最可靠,解耦)

如果数据库是 MySQL,且对一致性要求极高(如支付、库存),推荐使用 Canal(阿里开源) 或类似中间件监听 MySQL 的 Binlog,这是目前最可靠、对业务侵入度最低的方式。

原理: PHP 不负责去删缓存,而是只更新数据库,MySQL 的 Binlog 记录了本次更新,中间件订阅 Binlog,一旦发现数据变更,由中间件去主动删除 Redis 中的对应缓存

PHP 代码示例(只负责更新库):

<?php
// 业务代码只负责这一行,剩下的交给中间件
DB::table('inventory')->where('id', $productId)->decrement('stock');

优点:

  • 业务解耦:PHP 代码简单,不涉及 Redis 删除逻辑。
  • 绝对可靠:只要 Binlog 生成了,缓存必定会被清理(除非中间件宕机,但通常有重试机制)。

读多写少场景:Cache Aside(不双删) + 短 TTL(兜底)

如果业务逻辑非常简单(如读取用户个人信息),且并发更新频率极低,可以不用双删,直接使用旁路缓存

策略:

  • 写操作:更新数据库,删除缓存。
  • 设置一个较短的缓存过期时间(TTL),5 分钟。

在这种情况下,即便 B 请求把旧值写进去了(极小概率),由于 TTL 很短,5 分钟后缓存自动失效,会重新从数据库拉取新值,最终达到最终一致性,对于大多数非核心业务,这种方案在性能和复杂度上取得了很好的平衡。


写多读少场景:先删缓存,再更新库(不推荐,但可了解)

这通常被称为 Cache-As-SoR 的变种,但非常容易出错,一般不建议在 PHP 业务代码中主动使用。

流程: 删缓存 -> 更新数据库。 致命缺陷: 并发下,请求 A 删缓存,请求 B 读缓存未命中,去数据库读到了旧值,写回缓存,此时请求 A 更新数据库为新值,最终缓存是旧值,数据库是新值,数据库变成永久性不一致

除非你能保证更新和读取是串行的(例如用分布式锁锁住该 key),否则千万别用“先删缓存再更新库”。


终极解决:读写串行化(引入锁)

如果上面的所有方案都觉得不够安全,且业务能接受性能损失,可以考虑串行化

实现:

  • 更新数据时,获取一个针对该 Key 的分布式锁SETNX)。
  • 只有拿到锁的进程才能去读数据库和写缓存。
  • 释放锁。

这基本能保证 100% 一致,但在高并发下会导致请求阻塞,吞吐量下降。


兜底方案:多级缓存 / 请求重试

在 PHP 应用中加入本地进程缓存(如 APCu),给本机缓存设置极短的 TTL(1秒),即使 Redis 和 DB 有短暂不一致,也只会影响 1 秒内的读取,且不会跨机器传播。

在删除缓存的操作中,Redis 删除失败(例如网络抖动),需要有一个重试队列(如 RabbitMQ 延迟队列)来定时重试删除。


PHP 的最佳实践建议

业务场景 推荐方案
高并发、强一致(如秒杀库存) 数据库 + Binlog 订阅(Canal) 删除缓存,配合分布式锁。
常规业务(如商城订单) Cache Aside + 延迟双删 + 短 TTL(兜底)。
低并发、读多写少(如用户资料) Cache Aside(只删一次) + 短 TTL。

最后的核心原则:

  1. 数据库永远是最终真相(Source of Truth)
  2. 更新数据库后,缓存只做删除,不要去做更新(更新缓存需要额外的业务逻辑,容易出错)。
  3. 任何缓存都必须有 TTL,这是防止永久性脏数据最后的一道防线。
  4. 不要试图实现“强一致”缓存,在互联网应用中,最终一致性是常态,我们只需要把“不一致窗口期”压缩到最小即可。

抱歉,评论功能暂时关闭!