PHP如何设计高性能统一缓存层避免业务直接依赖Redis

告别“到处都是 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 的双雄会

有了接口,我们得有人去干活。这里我们引入适配器模式

我们需要两个实现:

  1. RedisCache:负责处理网络 IO,处理复杂的 TTL。
  2. 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,它们怎么配合?这就是我们架构的灵魂

想象一下,我们要查一个用户数据。

  1. 首先去 APCu 查(内存里)。如果有,直接返回。这一步耗时可能只有 0.01ms。
  2. 如果 APCu 没有,去 Redis 查(网络里)。这一步耗时可能是 1ms。
  3. 如果 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() 的烂摊子,一步步构建出了:

  1. CacheInterface 接口:确立了契约。
  2. RedisCache & LocalCache 适配器:实现了底层逻辑。
  3. MultiLevelCache 多级缓存:用内存换时间,实现高性能。
  4. CacheFactory 工厂类:统一入口,解耦配置。
  5. Pipeline & Swoole Serialize:极致的性能优化手段。

现在,你的业务代码里,再也看不到那一堆乱七八糟的 Redis 调用。
Controller 只需要 Cache::get('key'),Service 只需要 Cache::set('key', $val)
当你想换缓存了,或者想加缓存了,你只需要动工厂类的几行代码。

这,就是架构之美。它不显山不露水,但它在你代码的深处,默默支撑着系统的稳定与高速。

不要让你的代码像一团乱麻,试着给你的系统穿上一件“统一缓存层”的西装吧!祝大家编码愉快,缓存飞起!

发表回复

您的邮箱地址不会被公开。 必填项已用 * 标注