第8章 8.1 缓存:OPcache + Redis

上一章我们手撕了一个 REST API 服务,从搭建到调接口,一路绿灯。但你有没有这种感觉——接口跑起来了,可一旦数据量大起来,或者并发一上来,响应就开始卡顿了?这一章我们就来解决这个"跑起来但跑不快"的问题。

你有没有遇到过这种情况:外卖点好了,等了 10 分钟还没送到,打开 App 一看"骑手已取餐",就是不来。为啥?厨房做菜就那么快,但送餐路上堵住了。

网站也一样——你的 PHP 代码每次请求都要"重新做菜"(解析 PHP、编译成Opcode、执行),能不能一次做好的菜多份保存,直接热一热就送出去?

这就是缓存要解决的事。


🎯 开场 3 分钟:为什么要学这个?

场景还原:你做了一个新闻列表页,数据库里有 10 万条新闻。每次用户访问,PHP 都要:
1. 连接数据库
2. 查询 10 万条数据
3. 遍历组装
4. 返回给浏览器

用户访问 1000 次?这套流程就走 1000 次。数据库累瘫,服务器冒汗。

两个痛点
- 重复计算\n\nSimple tech illustration expla\n\nAI comic creation scene, creat\n\n:同样的数据,每次请求都要重新算一遍
-
响应慢**:数据库查询 + PHP 执行 = 几百毫秒没了

学完本文你能
- 用 OPcache 把 PHP 编译结果缓存起来,省掉"重新做菜"的时间
- 用 Redis 把数据库查询结果缓存起来,省掉"重复查库"的时间
- 搞清楚什么时候用哪种缓存,怎么组合使用


🧱 基础 25 分钟:核心概念(小白视角)

8.1.1 OPcache 是什么?

生活类比:你有没有用过那种"快速备忘录"?比如你在公司前台登记身份证号,前台小姐姐不是每次都翻你的证,而是扫一眼你的脸——"哦,老王啊,信息我这儿有",直接给你刷门禁。

OPcache 就是 PHP 的"快速备忘录"。它把 PHP 第一次运行的"编译结果"存起来,下次再跑同一段代码,直接读备忘录,不用重新编译。

为什么要用:PHP 每次请求都要经过「读取文件 → 解析语法 → 编译成 Opcode → 执行」这四步。其中「解析 + 编译」叫"编译时",占到总时间的 30%-50%。OPcache 把"编译时"的结果缓存下来,下次直接跳到"执行"这一步。

怎么用?

首先确保 OPcache 扩展开启了。创建一个检测脚本:

<?php
// 检查 OPcache 是否开启
if (function_exists('opcache_get_status')) {
$status = opcache_get_status();
echo "OPcache 状态: " . ($status['opcache_enabled'] ? '✅ 已开启' : '❌ 未开启') . "\n";
echo "内存使用: " . round($status['memory_usage']['used_memory'] / 1024 / 1024, 2) . " MB\n";
echo "缓存文件数: " . $status['opcache_statistics']['num_cached_scripts'] . "\n";
} else {
echo "❌ OPcache 扩展未安装";
}

运行输出:

OPcache 状态: ✅ 已开启
内存使用: 2.35 MB
缓存文件数: 12

恭喜!如果显示已开启,你什么都不用做,OPcache 已经自动生效了。

8.1.2 OPcache 配置详解

虽然 OPcache 默认就能用,但想用得顺手,还得知道几个关键配置。

php.ini 里常用的几个参数

; 分配给 OPcache 的内存大小(单位 MB)
opcache.memory_consumption=128

; 最多缓存多少个 PHP 文件
opcache.max_accelerated_files=10000

; 缓存过期时间(秒),0 表示永不过期
opcache.revalidate_freq=2

; 是否对依赖文件的检查(开发环境建议开启)
opcache.enable_in Develop=1

一个实际案例:假设你的项目有 50 个 PHP 文件,每个文件平均 20KB。设置 memory_consumption=64 够用;如果你的项目有 200 个文件,建议 memory_consumption=256

8.1.3 Redis 是什么?

生活类比:如果说 OPcache 是前台小姐姐记住你信息的"快速备忘录",那 Redis 就是你家楼下的快递柜

  • 前台小姐姐的脑子(OPcache)只能记几个人,人多了就记不住了
  • 快递柜(Redis)可以存很多很多,而且24小时随时可取

Redis 是一个"内存数据库"——数据存在内存里,读写速度比 MySQL 快 10-100 倍。它适合存那些"变化不频繁,但被访问很频繁"的数据。

为什么要用
- MySQL 查询要 50ms,Redis 只要 0.5ms
- 减轻数据库压力
- 支持复杂数据结构(字符串、列表、哈希、集合)

怎么用?

首先用 PHP 连接 Redis:

<?php
// 方式1:使用 phpredis 扩展(性能更好)
$redis = new Redis();
$redis->connect('127.0.0.1', 6379);
echo "Redis 连接状态: " . ($redis->ping() ? '✅ 成功' : '❌ 失败') . "\n";

// 方式2:使用 Predis 库(纯 PHP 实现,不需要扩展)
// $redis = new Predis\Client();

然后试试存数据、取数据:

<?php
$redis = new Redis();
$redis->connect('127.0.0.1', 6379);

// 存一个字符串,过期时间 300 秒
$redis->setex('user:1001:name', 300, '张三');

// 取出来
$name = $redis->get('user:1001:name');
echo "用户名: {$name}\n";  // 输出: 用户名: 张三

// 检查还有多久过期
$ttl = $redis->ttl('user:1001:name');
echo "剩余有效期: {$ttl} 秒\n";

运行输出:

用户名: 张三
剩余有效期: 298 秒

8.1.4 什么时候用 OPcache?什么时候用 Redis?

场景 用 OPcache 用 Redis
缓存什么 PHP 文件的编译结果 任意数据(查询结果、API 响应、Session)
存在哪 内存(PHP-FPM 进程内) 独立内存服务器
生命周期 跟随 PHP-FPM 重启 独立管理,可跨进程共享
适用场景 减少重复编译 减少重复查询、跨请求共享数据

最常见的组合打法:OPcache + Redis 双剑合璧——OPcache 加速 PHP 执行,Redis 加速数据读取。


🔥 实战 35 分钟:3 个递进的小项目

项目 1(5 分钟):给博客加个 Redis 缓存

场景:你的博客首页每次访问都要查数据库,实际上这个页面 5 分钟内变化不大。

<?php
// blog_home.php - 博客首页(带 Redis 缓存)

$redis = new Redis();
$redis->connect('127.0.0.1', 6379);

$cache_key = 'blog:home:posts';
$cache_ttl = 300; // 5 分钟

// 第1步:尝试从缓存读取
$posts = $redis->get($cache_key);

if ($posts === false) {
// 第2步:缓存没有,从数据库查
echo "🆕 缓存未命中,从数据库查询...\n";

// 模拟数据库查询(实际项目替换成 PDO/MySQLi)
$posts = [
    ['id' => 1, 'title' => 'PHP 入门指南', 'views' => 1024],
    ['id' => 2, 'title' => 'Redis 快速上手', 'views' => 856],
    ['id' => 3, 'title' => 'MySQL 优化技巧', 'views' => 2103],
];

// 第3步:存入缓存
$redis->setex($cache_key, $cache_ttl, json_encode($posts));
} else {
echo "⚡ 缓存命中,直接返回...\n";
$posts = json_decode($posts, true);
}

// 第4步:渲染页面
echo "=== 博客首页 ===\n";
foreach ($posts as $post) {
echo "- {$post['title']} ({$post['views']} 阅读)\n";
}

预期输出(第一次运行):

🆕 缓存未命中,从数据库查询...
=== 博客首页 ===
- PHP 入门指南 (1024 阅读)
- Redis 快速上手 (856 阅读)
- MySQL 优化技巧 (2103 阅读)

预期输出(5 分钟内再运行):

⚡ 缓存命中,直接返回...
=== 博客首页 ===
- PHP 入门指南 (1024 阅读)
- Redis 快速上手 (856 阅读)
- MySQL 优化技巧 (2103 阅读)

一句话解释:缓存命中时直接返回,不走数据库,响应速度提升 10 倍以上。


项目 2(15 分钟):监控数据缓存器

场景:做一个服务器监控面板,要从多个 API 获取数据(CPU、内存、磁盘),每个 API 调用要 200ms,全部查完要 800ms。用 Redis 缓存这些数据,10 秒内只查一次。

<?php
// monitor.php - 服务器监控面板(带缓存)

class MonitorService {
private $redis;
private $cache_ttl = 10; // 缓存 10 秒

public function __construct() {
    $this->redis = new Redis();
    $this->redis->connect('127.0.0.1', 6379);
}

// 获取 CPU 使用率(模拟,实际从 API 读取)
public function getCpuUsage() {
    return $this->getData('monitor:cpu', function() {
        echo "  → 查询 CPU API...\n";
        return rand(10, 90); // 模拟返回 10-90%
    });
}

// 获取内存使用率
public function getMemoryUsage() {
    return $this->getData('monitor:memory', function() {
        echo "  → 查询 内存 API...\n";
        return rand(20, 80);
    });
}

// 获取磁盘使用率
public function getDiskUsage() {
    return $this->getData('monitor:disk', function() {
        echo "  → 查询 磁盘 API...\n";
        return rand(30, 70);
    });
}

// 通用的缓存读取逻辑
private function getData($key, $fetch_func) {
    $data = $this->redis->get($key);

    if ($data !== false) {
        echo "  ✓ {$key} 从缓存读取\n";
        return json_decode($data, true);
    }

    // 缓存没有,调用回调函数获取
    $data = $fetch_func();
    $this->redis->setex($key, $this->cache_ttl, json_encode($data));
    return $data;
}

// 渲染监控面板
public function renderDashboard() {
    $cpu = $this->getCpuUsage();
    $mem = $this->getMemoryUsage();
    $disk = $this->getDiskUsage();

    echo "\n========== 服务器监控 ==========\n";
    echo "CPU 使用率:    {$cpu}%\n";
    echo "内存使用率:    {$mem}%\n";
    echo "磁盘使用率:    {$disk}%\n";
    echo "================================\n";

    // 计算综合健康度
    $health = 100 - ($cpu + $mem + $disk) / 3;
    echo "服务器健康度: " . round($health, 1) . "%\n";
}
}

// 运行监控
$monitor = new MonitorService();
$monitor->renderDashboard();

预期输出(第一次运行):

→ 查询 CPU API...
→ 查询 内存 API...
→ 查询 磁盘 API...

========== 服务器监控 ==========
CPU 使用率:    45%
内存使用率:    62%
磁盘使用率:    38%
================================
服务器健康度: 85.0%

预期输出(10 秒内再运行):

✓ monitor:cpu 从缓存读取
✓ monitor:memory 从缓存读取
✓ monitor:disk 从缓存读取

========== 服务器监控 ==========
CPU 使用率:    45%
内存使用率:    62%
磁盘使用率:    38%
================================
服务器健康度: 85.0%

一句话解释:把 API 调用结果缓存 10 秒,多人同时访问只查一次源,数据库/API 再也不累了。


项目 3(15 分钟):带缓存的 RSS 阅读器

场景:写一个 RSS 订阅器,定时抓取几个科技网站的更新,用 Redis 做缓存,避免频繁请求被封 IP。

<?php
// rss_reader.php - 带缓存的 RSS 阅读器

class RssReader {
private $redis;
private $cache_ttl = 900; // 缓存 15 分钟

public function __construct() {
    $this->redis = new Redis();
    $this->redis->connect('127.0.0.1', 6379);
}

// 获取某个 RSS 源的最新文章
public function getFeed($source, $url) {
    $cache_key = "rss:{$source}";

    // 尝试从缓存读取
    $cached = $this->redis->get($cache_key);
    if ($cached) {
        return json_decode($cached, true);
    }

    // 模拟抓取 RSS(实际项目用 file_get_contents 或 cURL)
    echo "📡 抓取 {$source}...\n";
    $articles = $this->fetchRss($url);

    // 存入缓存
    $this->redis->setex($cache_key, $this->cache_ttl, json_encode($articles));

    return $articles;
}

// 模拟 RSS 抓取(返回假数据)
private function fetchRss($url) {
    usleep(200000); // 模拟网络延迟 200ms
    return [
        ['title' => "{$url} 文章1", 'time' => date('H:i:s')],
        ['title' => "{$url} 文章2", 'time' => date('H:i:s')],
    ];
}

// 展示所有订阅源
public function showAll() {
    $sources = [
        '36kr' => 'https://36kr.com/feed',
        '少数派' => 'https://sspai.com/feed',
        '虎嗅' => 'https://huxiu.com/feed',
    ];

    echo "========== RSS 阅读器 ==========\n\n";

    foreach ($sources as $name => $url) {
        echo "【{$name}】\n";
        $articles = $this->getFeed($name, $url);

        foreach ($articles as $article) {
            echo "  • {$article['title']}\n";
        }
        echo "\n";
    }

    echo "================================\n";
    echo "缓存过期时间: {$this->cache_ttl} 秒\n";
}
}

// 运行阅读器
$reader = new RssReader();

$reader->showAll();

预期输出(第一次运行):

========== RSS 阅读器 ==========

【36kr】
📡 抓取 https://36kr.com/feed...
• https://36kr.com/feed 文章1
• https://36kr.com/feed 文章2

【少数派】
📡 抓取 https://sspai.com/feed...
• https://sspai.com/feed 文章1
• https://sspai.com/feed 文章2

【虎嗅】
📡 抓取 https://huxiu.com/feed...
• https://huxiu.com/feed 文章1
• https://huxiu.com/feed 文章2

================================
缓存过期时间: 900 秒

预期输出(15 分钟内再运行):

========== RSS 阅读器 ==========

【36kr】
✓ 从缓存读取
• https://36kr.com/feed 文章1
• https://36kr.com/feed 文章2

【少数派】
✓ 从缓存读取
• https://sspai.com/feed 文章1
• https://sspai.com/feed 文章2

【虎嗅】
✓ 从缓存读取
• https://huxiu.com/feed 文章1
• https://huxiu.com/feed 文章2

================================
缓存过期时间: 900 秒

一句话解释:15 分钟内多次访问只抓取一次 RSS,保护你的 IP 不被封,阅读体验也更快。


💪 进阶 20 分钟:常见坑 + 性能小贴士

坑 1:缓存未序列化直接存数组

// ❌ 错误示例:直接存数组,Redis 存的是 "Array" 字符串
$redis->set('user', ['name' => '张三', 'age' => 25]);

// ✅ 正确示例:先 json_encode
$redis->set('user', json_encode(['name' => '张三', 'age' => 25]));
$user = json_decode($redis->get('user'), true);

原因:Redis 的 set 只接受字符串,整型、数组传进去会自动转成字符串。


坑 2:缓存永不过期

// ❌ 错误示例:set 没有设置过期时间
$redis->set('config', json_encode($config));

// ✅ 正确示例:用 setex 设置过期时间
$redis->setex('config', 3600, json_encode($config)); // 1 小时后自动删除

原因:不设过期时间的缓存会一直占内存,积累多了把 Redis 撑爆。


坑 3:缓存穿透(查询不存在的值)

// ❌ 错误示例:数据不存在也缓存,浪费内存
if ($data = $db->query("SELECT * FROM users WHERE id = $id")) {
$redis->setex("user:{$id}", 3600, json_encode($data));
}

// ✅ 正确示例:不存在的值也要标记,用空值防穿透
if ($data) {
$redis->setex("user:{$id}", 3600, json_encode($data));
} else {
$redis->setex("user:{$id}:empty", 60, '1'); // 标记为空,60 秒后再查
}

原因:黑客可能用大量不存在的 ID 请求你,每次都查到数据库,数据库压力暴增。


坑 4:缓存雪崩(同一时间大量缓存过期)

// ❌ 错误示例:所有数据同时设置 1 小时过期
foreach ($products as $p) {
$redis->setex("product:{$p['id']}", 3600, json_encode($p));
}
// 到了 1 小时,所有缓存同时过期,数据库被击穿

// ✅ 正确示例:随机偏移量,打散过期时间
$ttl = 3600 + rand(0, 600); // 1 小时 ± 10 分钟
$redis->setex("product:{$p['id']}", $ttl, json_encode($p));

原因:大量缓存同时过期 = 同时去查数据库 = 数据库扛不住。


坑 5:混淆 OPcache 和 Redis 的作用

操作 用 OPcache 用 Redis
缓存 PHP 代码编译结果
缓存数据库查询结果
跨 PHP-FPM 进程共享数据
加速页面渲染 ✅(间接)

一句话:OPcache 管"代码执行加速",Redis 管"数据读取加速",别混了。


性能小贴士:管道批量操作

如果你要存很多条数据,一条一条 set 很慢。用管道(pipeline)可以一次发一堆命令:

<?php
// ❌ 慢:10 条数据要 10 次网络往返
for ($i = 1; $i <= 10; $i++) {
$redis->setex("key:{$i}", 3600, "value:{$i}");
}

// ✅ 快:10 条数据 1 次网络往返
$pipe = $redis->multi(Redis::PIPELINE);
for ($i = 1; $i <= 10; $i++) {
$pipe->setex("key:{$i}", 3600, "value:{$i}");
}
$pipe->exec();
echo "批量写入完成!\n";

调试技巧:用 monitor 命令看 Redis 在干嘛

打开终端,运行:

redis-cli monitor

然后访问你的 PHP 页面,终端会实时打印 Redis 执行的所有命令:

OK
1488-12-01 12:00:01.123 [0 127.0.0.1:6379] "GET" "blog:home:posts"
1488-12-01 12:00:01.456 [0 127.0.0.1:6379] "SETEX" "blog:home:posts" "300" "[...]"

这个命令超适合排查"我的缓存怎么没生效"的问题。


✏️ 练习题 + 作业题

练习题(5 道,10 分钟内完成)

练习 1(1 分钟):只改 TTL

项目 1 的博客缓存过期时间是 300 秒,改成 1 小时。

  • 输入:无
  • 预期输出:修改后代码中 TTL 值为 3600
  • 提示:$cache_ttl 变量在哪里?

练习 2(2 分钟):加一个缓存判断

给项目 1 加一个需求:只有当文章阅读量 > 500 时才缓存。

  • 输入:无(修改代码逻辑)
  • 预期输出:代码中有 if ($post['views'] > 500) 判断
  • 提示:在把数据存入 Redis 之前加判断

练习 3(2 分钟):换一个数据源

把项目 2 的监控数据改成从 JSON 文件读取(自己创建一个 data.json),而不是模拟数据。

  • 输入:创建 data.json 文件
  • 预期输出:代码读取本地 JSON 文件并渲染
  • 提示:用 file_get_contents('data.json') + json_decode()

练习 4(3 分钟):串联项目 2 和项目 3

把项目 2 的缓存策略(getData 方法)用到项目 3 的 RSS 读取上,让 RSS 也能享受统一的缓存管理。

  • 输入:无(修改代码结构)
  • 预期输出:RSS 读取类也有 getData 方法
  • 提示:把项目 2 的 getData 抽取成独立方法或 trait

练习 5(2 分钟):分析缓存未命中

假设你运行项目 1,第一次显示"缓存未命中",第二次显示"缓存命中"。如果第二次还是显示"未命中",列出 3 个可能原因。

  • 输入:无(分析题)
  • 预期输出:3 个可能原因
  • 提示:检查 Redis 连接、TTL 值、key 名拼写

作业题(30 分钟-2 小时)

作业:做一个「新闻聚合缓存工具」

需求描述:做一个命令行工具,定时抓取多个新闻源的最新标题,按分类展示。用 Redis 缓存,第二次运行要快。

功能点
1. 支持添加多个新闻源(至少 3 个),每个源有名称、URL、分类
2. 抓取后按分类展示新闻标题
3. 用 Redis 缓存抓取结果,缓存有效期可配置
4. 显示每次抓取/读取缓存的来源统计

加分项
1. 支持手动清除指定分类的缓存
2. 支持按更新时间排序(最新的在前)

验收标准
- 能跑起来(php news_aggregator.php
- 第一次运行显示抓取来源,第二次显示从缓存读取
- 代码有注释,关键地方说明在干嘛

提交方式:评论区贴代码或 GitHub 链接


📚 总结 + 资源

本文学了 3 个核心点
1. OPcache 把 PHP 编译结果存内存,下次执行跳过错译步骤
2. Redis 做数据缓存,读写比数据库快 10-100 倍
3. 双剑合璧:OPcache 加速执行,Redis 加速读取,各司其职

延伸学习资源
- Redis 官方文档 - 权威、全面、例子多
- 《高性能 PHP》- 专门讲 PHP 性能优化,OPcache/Redis 讲得很透
- PHP 官方 OPcache 文档 - 配置参数最全

互动钩子:你在项目里用过 Redis 或 OPcache 吗?踩过什么坑?评论区聊聊,老粉优先回复!


📌 下章预告:学会了缓存加速,下一章我们要用它来做一个"压测"——看看你的网站到底能扛多少人同时访问。准备好你的服务器,我们下一章见!

声明:本站所有文章,如无特殊说明或标注,均为本站原创发布。任何个人或组织,在未征得本站同意时,禁止复制、盗用、采集、发布本站内容到任何网站、书籍等各类媒体平台。如若本站内容侵犯了原著者的合法权益,可联系我们进行处理。