本文目录导读:

- 终极方案:Cache Aside(旁路缓存) + 延迟双删(最常用)
- 进阶方案:基于 Binlog 的异步删除(最可靠,解耦)
- 读多写少场景:Cache Aside(不双删) + 短 TTL(兜底)
- 写多读少场景:先删缓存,再更新库(不推荐,但可了解)
- 终极解决:读写串行化(引入锁)
- 兜底方案:多级缓存 / 请求重试
- 总结:PHP 的最佳实践建议
在 PHP 中保证缓存一致性是分布式系统中的一个经典难题,既然引入了缓存(如 Redis、Memcached),就必然面临数据库和缓存数据不一致的风险。
要保证一致性,没有一种一劳永逸的绝对方案,只能根据业务场景选择最合适且代价最小的折中方案。
以下是 PHP 开发中最常用、最有效的几种策略,按推荐程度从高到低排列:
终极方案:Cache Aside(旁路缓存) + 延迟双删(最常用)
这是目前 PHP 业务系统中最主流的方案,它的核心思想是:读请求走缓存,写请求更新数据库并删除缓存,而延迟双删解决了并发下的“脏读”问题。
执行流程:
- 读操作:先读缓存,命中则返回;未命中则读数据库,写缓存,返回。
- 写操作(核心):
- 先更新数据库(这是首要步骤,必须保证数据库是最新的)。
- 删除缓存(此时不直接更新缓存,而是删除,让下一次读请求去回源)。
- 休眠短暂时间(500ms,根据业务耗时设定)。
- 再次删除缓存(二次删除,用于清理在第一步和第二步之间可能被并发请求写入的旧缓存)。
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。 |
最后的核心原则:
- 数据库永远是最终真相(Source of Truth)。
- 更新数据库后,缓存只做删除,不要去做更新(更新缓存需要额外的业务逻辑,容易出错)。
- 任何缓存都必须有 TTL,这是防止永久性脏数据最后的一道防线。
- 不要试图实现“强一致”缓存,在互联网应用中,最终一致性是常态,我们只需要把“不一致窗口期”压缩到最小即可。