第8章 8.1 缓存:OPcache + Redis
上一章我们手撕了一个 REST API 服务,从搭建到调接口,一路绿灯。但你有没有这种感觉——接口跑起来了,可一旦数据量大起来,或者并发一上来,响应就开始卡顿了?这一章我们就来解决这个"跑起来但跑不快"的问题。
你有没有遇到过这种情况:外卖点好了,等了 10 分钟还没送到,打开 App 一看"骑手已取餐",就是不来。为啥?厨房做菜就那么快,但送餐路上堵住了。
网站也一样——你的 PHP 代码每次请求都要"重新做菜"(解析 PHP、编译成Opcode、执行),能不能一次做好的菜多份保存,直接热一热就送出去?
这就是缓存要解决的事。
🎯 开场 3 分钟:为什么要学这个?
场景还原:你做了一个新闻列表页,数据库里有 10 万条新闻。每次用户访问,PHP 都要:
1. 连接数据库
2. 查询 10 万条数据
3. 遍历组装
4. 返回给浏览器
用户访问 1000 次?这套流程就走 1000 次。数据库累瘫,服务器冒汗。
两个痛点:
- 重复计算\n\n
\n\n
\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 吗?踩过什么坑?评论区聊聊,老粉优先回复!
📌 下章预告:学会了缓存加速,下一章我们要用它来做一个"压测"——看看你的网站到底能扛多少人同时访问。准备好你的服务器,我们下一章见!

评论(0)