告别“到处都是 new Redis()”的屎山:PHP 高性能统一缓存层设计实战
各位听众朋友们,大家好!
今天我们要聊一个听起来很枯燥,但实际上非常“救命”的话题——架构设计。
在座的各位,不管是写 PHP 的新手,还是混迹江湖多年的老司机,肯定都经历过这样的痛苦时刻:你的业务代码里充满了 new Redis()。Controller 里一堆,Service 里一堆,甚至连一个简单的工具类里都有。
$redis->get('user:123');
$redis->set('user:123', 'data', 3600);
好,你改需求了,要把 Redis 换成 Memcached?或者要加一层 APCu 做本地缓存?你看着满屏幕的 $redis,手里的鼠标仿佛有千斤重。这就是我们要解决的问题:如何设计一个高性能、统一、优雅的缓存层,让业务代码彻底“失忆”,不知道底层用的是 Redis 还是 APCu,甚至都不知道有没有缓存。
这就好比,我们不想让外卖小哥直接冲进后厨拿菜,我们要建一个仓库,外卖小哥只管在仓库拿东西。这就是“统一缓存层”存在的意义。
第一部分:当业务代码直接接触 Redis 会发生什么?
假设我们现在的系统里,每个人都是“发明家”。
业务 A 模块写了一个 getUser 方法,直接调 $redis->get('user:1001')。
业务 B 模块也写了一个 getUser 方法,为了追求“极致性能”,直接调 $redis->get('user:1001')。
甚至业务 C 模块想缓存个配置,直接 $redis->set('config:app', 'v2')。
你看,这就乱套了。代码里到处都是 Redis 的键名,到处都是序列化/反序列化的逻辑,到处都是 try-catch 捕获 Redis 连接失败的代码。
这就像你请了一群装修工盖房子。这工头说要用红砖,那个说要用水泥,那个说我要用混凝土。你作为甲方,还得去帮他们调色、配比。最后这房子盖得是挺乱,你也累得半死。
更可怕的是性能问题。直接调用 new Redis() 是有开销的(虽然很小,但在高并发下就是雪崩)。更重要的是,Redis 有网络延迟,如果业务代码直接写在 HTTP 请求的每一个步骤里,那这延迟就是实打实的延迟。
所以,我们要做的,是造一个“保安队长”和一个“仓库管理员”。
第二部分:定义契约——接口隔离原则
我们首先得告诉业务代码:“别慌,我有缓存。”但别告诉我你用的是啥,我只跟你说话。
在 PHP 里,我们要用面向对象的思想。第一步,定义接口。
<?php
namespace AppCache;
/**
* 缓存接口:这是业务代码唯一的“朋友”
*/
interface CacheInterface
{
/**
* 获取数据
* @param string $key
* @param mixed $default 如果没有值,返回什么
* @return mixed
*/
public function get(string $key, $default = null);
/**
* 设置数据
* @param string $key
* @param mixed $value
* @param int $ttl 过期时间(秒),0表示永不过期
* @return bool
*/
public function set(string $key, $value, int $ttl = 0): bool;
/**
* 删除数据
* @param string $key
* @return bool
*/
public function delete(string $key): bool;
/**
* 批量设置(性能优化)
* @param array $data ['key' => 'value']
* @return bool
*/
public function setMultiple(array $data): bool;
}
看,这就叫优雅。业务代码里写 new Redis() 吗?不写了。业务代码里写 new SwooleTable() 吗?也不写了。业务代码只知道有一个 CacheInterface。
这就实现了业务逻辑与底层存储的解耦。 哪怕明天我们要把 Redis 换成 Memcached,或者是换成自研的“内存哈希表”,你只需要新建一个 MemcachedCache implements CacheInterface 类,然后改一行配置,全站业务代码不用动一根头发。
第三部分:实现层——Redis 与 APCu 的双雄会
有了接口,我们得有人去干活。这里我们引入适配器模式。
我们需要两个实现:
- RedisCache:负责处理网络 IO,处理复杂的 TTL。
- LocalCache:负责处理本地内存,速度快到飞起。
先看 RedisCache,这是主角。
<?php
namespace AppCacheDrivers;
use AppCacheCacheInterface;
use Exception;
use Redis;
class RedisCache implements CacheInterface
{
private ?Redis $redis = null;
private string $prefix = 'app:'; // 命名空间前缀,防止键冲突
public function __construct(array $config)
{
$this->redis = new Redis();
$this->redis->connect($config['host'], $config['port'], $config['timeout']);
$this->redis->auth($config['auth']);
$this->redis->select($config['db']);
}
/**
* 关键点:序列化/反序列化
* PHP 默认的 serialize/ unserialize 很慢,且不安全。
* 这里我们使用 JSON,虽然稍微慢一点点,但是配合 swoole_serialize 可以起飞。
*/
public function get(string $key, $default = null)
{
try {
$data = $this->redis->get($this->prefix . $key);
if ($data === false) {
return $default;
}
return json_decode($data, true);
} catch (Exception $e) {
// 异常处理:降级策略,这里可以记录日志,或者抛出异常让上层处理
return $default;
}
}
public function set(string $key, $value, int $ttl = 0): bool
{
// 转换 PHP 数组为 JSON 字符串
$data = json_encode($value);
// Redis 的 TTL 传入的是秒
$redisTtl = $ttl > 0 ? $ttl : 0;
return $this->redis->setex($this->prefix . $key, $redisTtl, $data);
}
// ... delete 和 setMultiple 类似实现,为了省篇幅略过,逻辑同上
}
再看看 LocalCache,也就是 APCu。APCu 是 PHP 的本地缓存,速度是 Redis 的几倍,因为它不需要走网络协议栈。
<?php
namespace AppCacheDrivers;
use AppCacheCacheInterface;
class LocalCache implements CacheInterface
{
/**
* @var APCu
*/
private $cache;
public function __construct()
{
if (!function_exists('apcu_fetch')) {
throw new RuntimeException("APCu extension not installed");
}
}
public function get(string $key, $default = null)
{
$ret = apcu_fetch($key, $success);
return $success ? $ret : $default;
}
public function set(string $key, $value, int $ttl = 0): bool
{
// APCu TTL 单位是秒
return apcu_store($key, $value, $ttl);
}
public function delete(string $key): bool
{
return apcu_delete($key);
}
// 批量操作通常 APcu 原生支持不好,或者需要循环调用,这里简单起见
public function setMultiple(array $data): bool
{
foreach ($data as $k => $v) {
apcu_store($k, $v, 3600); // 默认缓存1小时
}
return true;
}
}
第四部分:多级缓存——性能的核武器
有了 Redis 和 APCu,它们怎么配合?这就是我们架构的灵魂。
想象一下,我们要查一个用户数据。
- 首先去 APCu 查(内存里)。如果有,直接返回。这一步耗时可能只有 0.01ms。
- 如果 APCu 没有,去 Redis 查(网络里)。这一步耗时可能是 1ms。
- 如果 Redis 也没有,去数据库查(磁盘 IO,很慢)。
为了极致性能,我们必须实现 L1 (Local) -> L2 (Remote) 的查找策略。
<?php
namespace AppCache;
use AppCacheDriversLocalCache;
use AppCacheDriversRedisCache;
class MultiLevelCache implements CacheInterface
{
private LocalCache $localCache;
private RedisCache $redisCache;
private float $localCacheTTL = 60; // 本地缓存哪怕 Redis 没过期,本地也缓存一段时间,避免穿透
public function __construct(RedisCache $redisCache)
{
// 这里的 LocalCache 我们可以先搞个假的或者简单的实现,为了演示
// 实际生产中 LocalCache 应该是 APCu 或 SwooleTable
$this->localCache = new LocalCache();
$this->redisCache = $redisCache;
}
public function get(string $key, $default = null)
{
// 1. 先查本地 APCu (L1)
$data = $this->localCache->get($key);
if ($data !== null) {
return $data;
}
// 2. 本地没有,查 Redis (L2)
$data = $this->redisCache->get($key);
if ($data !== null) {
// 3. Redis 有,回填到本地 APCu (预热)
$this->localCache->set($key, $data, $this->localCacheTTL);
return $data;
}
// 4. 都没有,返回默认值
return $default;
}
public function set(string $key, $value, int $ttl = 0): bool
{
// 设置 Redis
$redisResult = $this->redisCache->set($key, $value, $ttl);
// 同时设置本地 APCu,设置一个稍短的 TTL,比如 Redis 的 1/10
// 为什么?因为本地内存便宜,丢了也没事,保证一致性稍微没那么苛刻
$localTtl = $ttl > 0 ? (int)($ttl / 10) : 60;
$this->localCache->set($key, $value, $localTtl);
return $redisResult;
}
// 删除逻辑稍微复杂点,因为要删两层
public function delete(string $key): bool
{
$this->localCache->delete($key);
return $this->redisCache->delete($key);
}
}
看,这就是性能! 99% 的请求,走的是 MultiLevelCache 的第一行代码,瞬间返回。只有缓存失效的那 1% 请求,才会流落到网络 IO,此时我们再用 Redis 挡一下。这叫“以空间换时间”。
第五部分:工厂模式与配置——让系统像瑞士军刀
现在我们有 RedisCache,有 LocalCache,有 MultiLevelCache。怎么让它们按需启动?我们需要一个工厂类。
<?php
namespace AppCache;
class CacheFactory
{
private static $instance = null;
/**
* @param string $driver redis, local, multi
* @return CacheInterface
*/
public static function getInstance(string $driver = 'multi'): CacheInterface
{
if (self::$instance === null) {
// 这里为了演示方便,硬编码了配置。
// 实际项目中应该从 .env 或者 配置中心读取
$config = [
'host' => '127.0.0.1',
'port' => 6379,
'auth' => '',
'db' => 0
];
switch ($driver) {
case 'local':
self::$instance = new LocalCache();
break;
case 'redis':
self::$instance = new RedisCache($config);
break;
case 'multi':
default:
self::$instance = new MultiLevelCache(new RedisCache($config));
break;
}
}
return self::$instance;
}
}
这就是所谓的“统一入口”。 以后你的代码里,无论你想用哪个缓存,只要调用 CacheFactory::getInstance('multi')。
第六部分:业务代码的“失忆”术
好了,架构搭好了,接口有了,工厂有了。现在,请看业务代码怎么写。
以前的代码(垃圾):
// UserService.php
class UserService {
public function getUser($id) {
$redis = new Redis();
$redis->connect('127.0.0.1', 6379);
$key = "user:{$id}";
$data = $redis->get($key);
if (!$data) {
$data = $this->db->find($id);
$redis->setex($key, 3600, json_encode($data));
}
return json_decode($data, true);
}
}
现在的代码(高级):
// UserService.php
class UserService {
// 依赖注入!这是现代 PHP 的标配
private $cache;
public function __construct(CacheInterface $cache)
{
// 这里的 CacheInterface 实例,就是工厂类塞进来的
$this->cache = $cache;
}
public function getUser($id) {
// 1. 定义 Key 的命名空间
// 业务代码根本不需要关心 "user:1001" 这个 Key 的具体实现,也不需要关心序列化
// 这就是“关注点分离”
$key = "user:{$id}";
// 2. 获取数据
$data = $this->cache->get($key);
// 3. 如果缓存没中,查数据库
if ($data === null) {
$data = $this->db->find($id);
// 4. 写入缓存 (缓存穿透保护)
if ($data) {
// 设置缓存,TTL 1小时,系统会自动序列化
$this->cache->set($key, $data, 3600);
} else {
// 缓存空值,防止有人刷库
$this->cache->set($key, null, 60);
}
}
// 5. 返回数据
return $data;
}
}
哇,是不是干净多了? 业务代码根本不知道有没有 Redis,也不知道有没有 APCu。它只知道有一个东西能存能取。这叫什么?这叫低耦合。
第七部分:性能优化的黑科技——Pipeline 与 批量操作
有时候,业务代码需要一次查 10 个用户。
如果是直接用 Redis 客户端,你得循环 10 次,发 10 个网络包。这在网络延迟高的时候(比如跨机房),性能会非常差。
我们的 RedisCache 需要支持 Pipeline(管道) 模式。
// 改进 RedisCache 的 setMultiple 方法
public function setMultiple(array $data): bool
{
if (empty($data)) {
return true;
}
// 构建 Redis Pipeline
// Pipeline 可以把多条命令打包成一个网络包发送给 Redis,极大降低 RTT
$pipeline = $this->redis->multi();
foreach ($data as $key => $value) {
$pipeline->setex($this->prefix . $key, 3600, json_encode($value));
}
// 执行
$pipeline->exec();
return true;
}
在 MultiLevelCache 里也要同步优化。
public function setMultiple(array $data): bool
{
// 1. 先全量写入 Redis (Pipeline)
$this->redisCache->setMultiple($data);
// 2. 再全量写入 APCu (循环写入,虽然 APCu 没有原生 Pipeline,但在本地内存写 100 条数据也就是几微秒的事)
foreach ($data as $key => $value) {
$this->localCache->set($key, $value, 60);
}
return true;
}
场景模拟:
假设你要做“秒杀”活动,需要把 10000 个商品详情预热到缓存。
如果用普通模式,发 10000 个请求,耗时约 10秒。
如果用 Pipeline 模式,发 1 个请求包,耗时约 0.1秒。
这就是技术的红利。
第八部分:数据一致性与“脏读”的博弈
聊完了性能,我们得聊聊一致性问题。
业务代码更新了数据库,这时候缓存怎么办?
直接删除。这是最简单的策略。
public function updateUser($id, $newData) {
// 1. 更新数据库
$this->db->update($id, $newData);
// 2. 删除缓存
$this->cache->delete("user:{$id}");
}
但是,如果 Redis 删成功了,但网络断了,APCu 删失败了怎么办?
这时候,APCu 里还存着旧数据。用户下次请求,走的是 APCu,读到的是旧数据。这就叫“脏读”。
解决方案:延迟双删。
public function updateUser($id, $newData) {
$this->db->update($id, $newData);
// 1. 删 Redis 和 APCu
$this->cache->delete("user:{$id}");
// 2. 稍微睡一下(比如 100ms),确保 Redis 请求发出去了
// 此时其他进程可能已经把旧数据写入 Redis 了
usleep(100000);
// 3. 再删一次
$this->cache->delete("user:{$id}");
}
或者更高级一点,使用消息队列来保证删除操作一定执行。但为了我们的文章保持通俗易懂,延迟双删是个在中小型高并发场景下非常实用的招式。
第九部分:序列化的艺术——Swoole vs JSON
回到最开始的代码。我说了,PHP 原生的 serialize 很慢。
如果你的系统是基于 PHP-FPM 的,性能要求没那么变态,用 json_encode 完全没问题。
但是!如果你的系统是基于 Swoole 或者 RoadRunner 的常驻内存进程模式,PHP 原生的序列化简直是噩梦。
Swoole 提供了 swoole_serialize,它的速度是 JSON 的 20 倍,而且支持对象反序列化。
改造一下 RedisCache:
// 如果环境是 Swoole
use SwooleSerialize;
public function get(string $key, $default = null) {
$data = $this->redis->get($this->prefix . $key);
if ($data === false) return $default;
// 使用 Swoole 的极速序列化
return Serialize::unserialize($data);
}
public function set(string $key, $value, int $ttl = 0): bool {
$data = Serialize::serialize($value);
return $this->redis->setex($this->prefix . $key, $ttl, $data);
}
这不仅仅是快,这是质的飞跃。 在高并发下,序列化占用的 CPU 时间如果能减少 80%,你的服务器承载能力就能翻倍。
第十部分:命名空间的艺术——解决键冲突
最后,再送大家一个小技巧:键的命名空间。
你的业务 A 叫 UserModule,业务 B 叫 OrderModule。它们都想缓存 list。
业务 A 写了:Cache::set('list', $data)
业务 B 写了:Cache::set('list', $data)
结果就是互相覆盖,或者莫名其妙的数据错乱。
我们的 CacheInterface 必须强制要求传入 Namespace。
// MultiLevelCache 的构造函数
public function __construct(string $namespace, RedisCache $redisCache)
{
$this->namespace = $namespace; // 例如 "order:" 或 "user:"
$this->localCache = new LocalCache();
$this->redisCache = $redisCache;
}
// get 方法内部
public function get(string $key, $default = null) {
// 强制加上前缀
$realKey = $this->namespace . $key;
// ... 后续逻辑
}
这样,业务 A 查的是 order:list,业务 B 查的是 user:list,互不干扰。
总结
好了,朋友们,今天我们讲了这么多。
我们从一个满屏 new Redis() 的烂摊子,一步步构建出了:
- CacheInterface 接口:确立了契约。
- RedisCache & LocalCache 适配器:实现了底层逻辑。
- MultiLevelCache 多级缓存:用内存换时间,实现高性能。
- CacheFactory 工厂类:统一入口,解耦配置。
- Pipeline & Swoole Serialize:极致的性能优化手段。
现在,你的业务代码里,再也看不到那一堆乱七八糟的 Redis 调用。
Controller 只需要 Cache::get('key'),Service 只需要 Cache::set('key', $val)。
当你想换缓存了,或者想加缓存了,你只需要动工厂类的几行代码。
这,就是架构之美。它不显山不露水,但它在你代码的深处,默默支撑着系统的稳定与高速。
不要让你的代码像一团乱麻,试着给你的系统穿上一件“统一缓存层”的西装吧!祝大家编码愉快,缓存飞起!