把你的手机收起来,把那个像咸菜一样伸长的手指头从鼠标上放下。
大家好,我是你们的“PHP 疼痛专科门诊”主任。今天不聊架构设计,也不谈什么微服务分布式,我们今天要聊的是一个非常接地气、甚至有点“羞耻”的话题:你的 PHP 网站怎么慢得像是在泥地里推磨?
很多开发者都有一个错觉,觉得 PHP 之所以慢,是因为它是解释型语言,或者是没有 Go 那种协程。大错特错!PHP 慢,有时候不是因为它慢,而是因为它“胖”,因为它“笨”,因为它在那儿傻乎乎地干着重复的体力活。就像一个健身教练,明明有一身蛮力,结果却让他在那儿扫落叶,扫到天黑还没扫完。
如果你的网站打开速度从 0.5秒 变成了 5秒,甚至 50秒,那说明你的系统已经不是在跑代码了,而是在跑一场马拉松。别慌,今天我们就来扒一扒 PHP 性能优化的“排雷指南”。
我们要遵循一个原则:别在那儿给系统穿秋裤了,先看看它是不是只穿了条底裤。
第一章:数据库 —— 罪魁祸首的鼻子
俗话说得好,“天下武功,唯快不破;天下慢速,唯库最甚。”
如果你的 PHP 网站慢,90% 的概率是数据库卡住了。数据库在数据库的世界里,那就是那种脾气极差的房东,你 Query 一下,它就得去翻一摞厚厚的账本,翻得满头大汗,脸涨得通红,然后告诉你:“别催了,找到了!”
1.1 索引:别翻书了,用目录
很多新手的代码里,数据库查询长得像一条死蛇:
// 坏榜样:全表扫描
$sql = "SELECT * FROM users WHERE username = '$username'";
$result = $pdo->query($sql);
假设你的 users 表有 100 万条数据。你虽然只传了一个用户名进去,但这个 SQL 命令就像是在图书馆大喊一声:“有没有叫张三的人?!” 结果整个图书馆(数据库)的所有书架(索引)都得停下来,一本一本地检查。这就是“全表扫描”,耗时指数级上升。
怎么破?
你需要给他一本“黄页”(索引)。
-- 优秀:使用索引
ALTER TABLE users ADD INDEX idx_username (username);
一旦有了索引,数据库就不需要一本一本地翻书了,它直接看“张三”在黄页的第几页,啪!找到了。
专家建议:
不要在 SELECT * 的字段后面加索引,那没用。你要把 WHERE、JOIN、ORDER BY、GROUP BY 后面的字段加上索引。记住,索引虽好,可不要贪杯。索引就像超市的 VIP 快车道,只有有索引的字段才能走,如果你在 SELECT 里面把所有字段都查了,数据库还得临时构建一个结果集,慢得很。
1.2 深分页的噩梦:永远停在 100 万页之后
当你写代码分页时,是不是觉得 LIMIT 100000, 10 很顺手?
// 这种写法简直是性能杀手
$sql = "SELECT * FROM articles ORDER BY id DESC LIMIT 100000, 10";
这时候数据库会说:“兄弟,我翻了十万页,终于翻到了 100000 页,然后给你取 10 条。费了我老鼻子劲了。”
怎么破?
别让他翻那么远。
-- 优化方案一:延迟关联
SELECT a.* FROM articles a
INNER JOIN (SELECT id FROM articles ORDER BY id DESC LIMIT 100000, 10) AS tmp ON a.id = tmp.id;
-- 优化方案二:上一次的最后 ID
SELECT * FROM articles WHERE id < 100000 ORDER BY id DESC LIMIT 10;
通过先查出 ID,再关联查询详情,或者利用上一页的最后一条 ID,数据库只需要在索引树上扫一小段路。这才是专业选手的写法。
1.3 N+1 查询问题:不要在循环里找对象
这是 ORM(比如 Laravel 的 Eloquent)最擅长的坑。
// 坏榜样:N+1 问题
$users = User::all(); // 1次查询,查出100个用户
foreach ($users as $user) {
echo $user->profile->description; // 这里循环了100次,又发起了100次查询!
}
// 总共:1 + 100 = 101 次查询!你的服务器都要哭了。
这就像你请了 100 个服务员(查询),只为服务这桌客人(用户)。这效率低得离谱。
怎么破?
预加载,预加载,还是 TMD 预加载。
// 优秀:Eager Loading
$users = User::with('profile')->get(); // 2次查询:1次查用户,1次查所有关联的 profile
foreach ($users as $user) {
echo $user->profile->description; // 0次额外查询!
}
这叫“批处理”。别在循环里做任何数据库操作,那是性能自杀。
第二章:缓存 —— 也就是别让大脑思考
如果说数据库是去图书馆翻书,那缓存就是脑子里的“备忘录”。你查一遍数据,把答案写在手心,下次再问,直接看手心,不用翻书了。
PHP 有一个超级好用的工具叫 APCu(注:这里指的是 APC User Cache,现在通常指 PHP 的 OPcache 的一部分,但为了通俗易懂,我们这里特指用户缓存)。如果你没用它,那你真的是在浪费 CPU 的算力。
2.1 APCu:内存里的“便签纸”
APCu 直接把数据存在内存里,比 Redis(磁盘或内存)快得多。
// 第一次访问:没有缓存,去查数据库,耗时 0.1秒
$cache = apcu_fetch('config_data');
if (!$cache) {
$data = DB::query("SELECT * FROM config WHERE key = 'site_title'");
apcu_store('config_data', $data, 3600); // 存进去,有效期1小时
} else {
$data = $cache; // 直接从内存拿,耗时 0.000001秒
}
对于那些不经常变的数据(比如网站标题、配置项、菜单列表),为什么要每次都查数据库?把它们扔进 APCu 吧,让你的服务器睡个好觉。
2.2 Redis:重装上阵
虽然 APCu 很快,但它有局限性(重启清空)。对于分布式系统或者大数据量,你得请出 Redis 这个重量级选手。
Redis 不仅能存简单的键值对,还能存列表、集合、哈希表。它是性能优化的万能钥匙。
// 用 Redis 缓存用户的 Session 或者热点数据
$redis = new Redis();
$redis->connect('127.0.0.1', 6379);
$user_id = 123;
$user_key = "user_info_{$user_id}";
// 1. 尝试从 Redis 拿
$user_data = $redis->get($user_key);
if (!$user_data) {
// 2. 没拿到?去数据库捞
$user = DB::find($user_id);
// 3. 序列化存入 Redis(注意:JSON 序列化有开销,大数据用 msgpack 或 igbinary)
$redis->set($user_key, json_encode($user), 600);
} else {
// 4. 反序列化
$user = json_decode($user_data);
}
echo $user->name;
专家建议:
不要什么都缓存。比如,一个秒杀活动,数据必须实时准确,如果你用 Redis 缓存了库存,但 Redis 没同步更新,结果就是超卖。所以,Cache Aside Pattern(旁路缓存模式) 要懂:查库时先查缓存,查不到再查库;写库时,先更新库,再删缓存(或者延时双删)。
第三章:PHP 本身 —— 别让你的脚本演独角戏
有时候,慢不是数据库的问题,也不是网络的问题,就是你的 PHP 代码写得像个刚学会走路的宝宝。
3.1 文件操作:不要反复进出同一个文件
很多代码喜欢写这种写法:
// 坏榜样:重复 IO
$content = file_get_contents('config.php');
eval($content); // 这里的 eval 哪怕跑得再快,file_get_contents 也是 IO 操作!
或者频繁地 fopen -> fwrite -> fclose。IO 操作(磁盘读写)比 CPU 计算慢成千上万倍。如果你在一个循环里打开文件写入 1000 次,那这脚本跑一天都跑不完。
怎么破?
“我们要做大侠,不能做只绣花针。”
// 优秀:使用资源句柄
$fp = fopen('data.log', 'a');
for ($i = 0; $i < 1000; $i++) {
fwrite($fp, "Line $in");
}
fclose($fp);
甚至,对于简单的日志,直接用 error_log() 或者专门的日志库(比如 Monolog),它们通常会把日志积攒一下再批量写入,比你手动写快得多。
3.2 正则表达式:如果你只是个字符串替换,别用 preg_replace
正则表达式(preg_* 系列函数)非常强大,但它很“贵”。它是一个图灵完备的解析器,CPU 要跑很复杂的算法。
如果你只是想替换字符串里的换行符:
// 坏榜样:用正则
$text = preg_replace('/s+/', ' ', $text); // 慢!
// 优秀:用原生函数
$text = str_replace(array("rn", "r", "n"), ' ', $text); // 快!
在极端性能要求的场景下(比如高并发下的数据清洗),能用 str_replace 就别用 preg_replace,能用 substr 就别用正则。当然,现代 PHP 引擎对正则优化得不错,但在循环里千万小心。
3.3 大数组与内存溢出
PHP 是单进程模型,虽然有了 FPM 池,但单个脚本在内存里是一步步走的。
// 坏榜样:一次性载入大文件
$big_array = file('huge_file.txt'); // 这会一次性把几百万行数据读进内存,导致 Out Of Memory
如果文件真的那么大,请使用流式处理。
// 优秀:分批处理
$handle = fopen('huge_file.txt', 'r');
while (($line = fgets($handle)) !== false) {
// 处理这一行
process_line($line);
}
fclose($handle);
让你的内存占用维持在几百 MB,别让它涨到 2GB,那样你的服务器连重启的时间都没给。
第四章:PHP-FPM 与 Nginx —— 搞好后勤保障
如果你的代码写得像手术刀一样精准,缓存像金钟罩一样厚实,但服务器还是慢,那可能就是你的“交通管制”出了问题。
4.1 PHP-FPM 进程数:别让出租车排队
你的 PHP 脚本在执行时,是在 PHP-FPM 的进程池里排队等待 CPU 调度的。
如果设置 pm.max_children = 1,那你这个网站就是单线程的。用户 A 访问,处理完了,用户 B 才能访问。这种网站,没人会等的,直接关掉。
如果你设置 pm.max_children = 1000,那你服务器可能会因为内存爆掉而死机。
怎么破?
根据你的内存和 CPU 核数来算。
# php-fpm.conf 示例
pm = dynamic
pm.max_children = 50 # 假设你的 PHP 脚本每个占用 30MB 内存,50 * 30MB = 1.5GB。如果你的服务器有 2GB 内存,这个参数是安全的。
pm.start_servers = 10
pm.min_spare_servers = 5
pm.max_spare_servers = 20
当访问量来了,PHP-FPM 会根据 pm.max_requests 自动调整进程数。一定要设置 pm.max_requests,默认是 500。如果设为 0,意味着无限循环,会导致内存泄漏。设置为 500 或 1000,迫使旧进程重启,释放内存碎片。
4.2 Nginx 配置:该压缩的压缩,该禁用的禁用
Nginx 就像个门卫大爷,他应该干好他的本职工作,别帮 PHP 做杂活。
# 开启 Gzip 压缩,让传输数据变小
gzip on;
gzip_types text/plain application/json application/javascript text/css;
gzip_min_length 1000;
# 静态资源直接交给 Nginx 处理,别扔给 PHP
location ~* .(jpg|jpeg|png|gif|ico|css|js|svg|woff|woff2)$ {
expires 30d;
access_log off;
}
图片、JS、CSS 这些文件,每次都要解析成 PHP 再输出吗?那是疯子才干的事。让 Nginx 直接扔给浏览器,把带宽留给动态内容。
第五章:代码结构与依赖 —— 做个极简主义者
你的项目可能用了 Laravel、Symfony,甚至 Symfony 都嫌你重。框架很强大,但它们也是“贵重物品”。
5.1 Composer Autoload:加载那些你根本不需要的文件
Composer 的自动加载是自动的,但它不是“智能”的。当你调用一个函数时,它会去扫描文件,包含它,解析命名空间。
如果你的项目依赖了 500 个包,而你只用其中 5 个,那你每次请求都在扫描那 495 个没用的包。
怎么破?
清理依赖。
composer prune-owners
把那些不再使用的包踢出去。这能显著减少 Autoload 的时间。
5.2 魔术方法的开销
PHP 的 __get, __set, __call 确实很方便,让你写代码时不用写那么多 getXXX() 方法。但它们很慢。
// 坏榜样:大量使用魔术方法
$obj = new User();
echo $obj->name; // 触发 __get
// 优秀:直接定义属性
class User {
public $name;
}
$obj = new User();
echo $obj->name; // 直接读内存,快!
除非你真的需要动态属性(比如处理 JSON 数据),否则老老实实写 getter/setter 吧。性能差异在百万级请求下非常明显。
第六章:HTTP 协议 —— 给信息穿件紧身衣
现在的网络条件好了,但文件传输还是太重了。
6.1 使用 HTTP/2 或 HTTP/3
如果你的服务器只支持 HTTP/1.1,那你基本上是在单线程发数据。HTTP/2 支持多路复用,一个 TCP 连接可以并发发送多个请求。
虽然这主要靠服务器配置(Nginx 支持 HTTP/2),但作为开发者,你需要知道这一点。
6.2 开启 Brotli 压缩
现在 Google 推出了 Brotli 算法,比 Gzip 压缩率更高,速度也更快。
# 在 Nginx 中开启
brotli on;
brotli_comp_level 6;
brotli_types text/plain text/css application/json application/javascript;
总结:别做“头痛医头”的庸医
好了,同志们,今天的讲座就到这里。让我们回顾一下刚才说的那些:
- 数据库是第一杀手,索引和避免 N+1 是救命的药。
- 缓存是保命符,能用内存缓存(APCu/Redis)的地方千万别查库。
- 代码里少用正则,少用 eval,IO 操作要小心。
- PHP-FPM 的进程数别乱设,防止内存爆炸。
- Nginx 别把静态资源扔给 PHP,要开启压缩。
- 依赖要精简,别让你的项目像个满身挂件的中年大叔。
最后,送给大家一句话:性能优化不是一次性的工程,而是一种生活方式。
当你写下一行代码时,脑子里要过一遍:“这行代码是去查数据库的,好慢啊,我要不要加个缓存?”、“这个正则是不是有点浪费 CPU?”。
如果你的网站慢,先别急着升级服务器(那只是掩盖症状),先看看你的代码是不是在“呼吸急促”,看看你的数据库是不是在“气喘吁吁”。
别让你的用户,变成那个在加载页面时数地砖的傻子。
下课!