同一个站点,昨天还好好的,今天打开要五六秒;后台点个列表页转圈半天。重启 Nginx、重启 PHP-FPM 能好一阵,过一会儿又慢了。这是 PHP 站点最常见的性能投诉,也是最容易“瞎优化”的场景——很多人第一反应是升级服务器配置,结果加了内存只是把崩溃时间从下午三点推到了晚上八点。
PHP 慢的原因可以归纳成四个层面:PHP 自身(有没有 OPcache、进程池是否够用)、数据库(慢查询、连接数、锁等待)、外部依赖(第三方 API、文件系统、磁盘 IO)、以及系统资源(CPU、内存 swap、磁盘 IO、网络)。本文给出一套自上而下的分析方法和具体的优化参数,重点讲透 OPcache 的配置与验证,以及怎么把“慢”定位到具体的某一行 SQL。
一、先量化:慢在哪里,慢了多少
没有数据的优化都是玄学。第一步是把耗时测出来,并且把 PHP 执行时间和数据库时间分开。
1.1 用 Nginx 日志分离出后端耗时
# 定义带耗时字段的日志格式
log_format timed '$remote_addr [$time_local] "$request" $status '
'rt=$request_time urt=$upstream_response_time';
access_log /var/log/nginx/access.log timed;
# 找出最慢的 20 条请求
awk -F'urt=' '{split($2,a," "); if(a[1]+0>1) print a[1], $0}' /var/log/nginx/access.log \
| sort -rn | head -20
# 统计超过 1 秒的请求数量与占比
awk '{total++; match($0,/urt=([0-9.]+)/,b); if(b[1]+0>1) slow++} END {print "总数:",total,"慢请求:",slow}' /var/log/nginx/access.log
判读方法:
urt(等待 PHP-FPM 的时间)≈rt(总耗时)→ 时间全花在 PHP/数据库,继续往下查。rt远大于urt→ PHP 很快,但把响应写给客户端很慢,多为大响应体、慢客户端或带宽不足。- 两者都很小、页面却感觉慢 → 瓶颈在前端渲染或静态资源(图片没压缩、JS 阻塞)。
1.2 应用层打点
// 在入口文件记录耗时,快速判断是 PHP 慢还是 SQL 慢
$t0 = microtime(true);
register_shutdown_function(function () use ($t0) {
$cost = round((microtime(true) - $t0) * 1000);
error_log(sprintf('[PERF] %s %sms mem=%dMB',
$_SERVER['REQUEST_URI'] ?? '',
$cost,
memory_get_peak_usage(true) / 1048576
));
});
二、第一层:确认 OPcache 是否真的在工作
PHP 是解释型语言,每次请求都要把 .php 文件重新编译成 opcode。OPcache 的作用是把编译结果缓存到共享内存里,省掉重复编译。没有 OPcache 的 PHP 站点,性能差距通常在 2 到 5 倍,这是投入产出比最高的一项优化。
2.1 检查当前状态
# CLI 与 FPM 用的是不同配置,必须用 php-fpm 的实际配置来查 php -i | grep -i opcache php -m | grep -i opcache # 最准确的方式:通过 Web 访问 phpinfo 或查询 OPcache 状态 curl -s 'http://127.0.0.1/opcache_status.php'
更实用的方式是用 opcache_get_status() 建一个受限访问的状态页:
关键判断标准:
- 命中率低于 99%:缓存不够用,多半是
opcache.memory_consumption或max_accelerated_files太小。 - oom_restarts 大于 0:内存耗尽导致 OPcache 整体重置,会周期性变慢,必须调大内存。
- num_cached_scripts 接近 max_cached_keys:脚本数超了,要调大
max_accelerated_files。
2.2 生产环境推荐配置
# /etc/php/8.1/fpm/conf.d/10-opcache.ini
zend_extension=opcache.so
opcache.enable=1
opcache.enable_cli=0 # CLI 脚本通常不需要,避免干扰 cron
opcache.memory_consumption=256 # 共享内存 MB,默认 128;框架类站点建议 256-512
opcache.interned_strings_buffer=32 # 字符串驻留缓冲 MB,默认 8;Composer 项目建议 16-64
opcache.max_accelerated_files=20000 # 最大缓存脚本数,默认 10000
# 必须取大于实际文件数的最小质数近似值
# (2237, 4639, 9631, 20011, 39043, 79699...)
opcache.validate_timestamps=1 # 生产可设为 0 换取极致性能(需发布时手动重置)
opcache.revalidate_freq=60 # 检查文件变更的间隔秒数,默认 2
opcache.revalidate_path=0
opcache.save_comments=1 # 依赖注解(Doctrine/Symfony)时必须为 1
opcache.fast_shutdown=1
opcache.jit=0 # PHP 8 默认关闭 JIT;Web 场景收益有限,谨慎开启
opcache.jit_buffer_size=0
opcache.log_verbosity_level=0
opcache.error_log=/var/log/php-opcache.log
2.3 关键参数速查与取值依据
| 参数 | 默认值 | 作用 | 调优依据 |
|---|---|---|---|
| opcache.enable | 1 | 总开关 | 生产必须为 1 |
| opcache.memory_consumption | 128 MB | 存储编译后 opcode 的共享内存 | 看 used_memory 占用,留 30% 余量;出现 oom_restarts 立刻调大 |
| opcache.max_accelerated_files | 10000 | 哈希桶容量 | 必须大于项目 PHP 文件总数;Laravel 含 vendor 常超 10000 |
| opcache.interned_strings_buffer | 8 MB | 字符串常量池 | 框架类项目建议 32,不足时字符串无法复用 |
| opcache.revalidate_freq | 2 秒 | 文件变更检查频率 | 生产设 60;设为 0 表示每次请求都检查 |
| opcache.validate_timestamps | 1 | 是否检查文件时间戳 | 设为 0 性能最好,但改代码必须手动 reload/reset |
| opcache.max_wasted_percentage | 5 | 浪费内存比例达到此值触发重启 | 不建议调低,否则频繁重启 |
| opcache.jit | 0 | PHP 8 JIT 编译 | Web 请求场景收益有限,CPU 密集型才有意义 |
2.4 修改后必须做的事
# 统计项目 PHP 文件数,用来定 max_accelerated_files find /data/www/site -type f -name '*.php' | wc -l # 修改配置后重载 FPM php-fpm8.1 -t systemctl reload php8.1-fpm # validate_timestamps=0 时,发布后必须重置 OPcache # 方式一:reload FPM(最稳) systemctl reload php8.1-fpm # 方式二:调用 opcache_reset()(需要一个受限访问的接口)
三、第二层:PHP-FPM 进程池是否成为瓶颈
OPcache 之外,第二常见的瓶颈是进程池打满——请求排队等着空闲 worker,前端就表现为“偶发性很慢”。
# 1. 看当前进程数与配置 ps aux | grep 'php-fpm: pool www' | grep -v grep | wc -l grep -nE '^\s*pm|max_children|start_servers|spare|max_requests|request_terminate|listen.backlog' \ /etc/php/8.1/fpm/pool.d/www.conf # 2. 状态页(需先在 www.conf 里配置 pm.status_path = /fpm-status) curl http://127.0.0.1/fpm-status?full状态页关键字段判读:
| 字段 | 含义 | 异常判断 |
|---|---|---|
| listen queue | 等待被 accept 的连接数 | 持续 > 0 说明请求已在排队 |
| listen queue len | 队列最大长度(受 listen.backlog 限制) | 接近上限需调大 backlog 与 somaxconn |
| active processes | 活跃进程数 | 长期等于 max_children 说明进程不够 |
| max children reached | 达到上限的次数 | 计数持续增长 = 明确的扩池信号 |
| slow requests | 慢请求计数 | 增长说明有脚本超过 request_slowlog_timeout |
# 估算合适的 pm.max_children
ps --no-headers -o rss -C php-fpm | awk '{s+=$1;n++} END{printf "进程数:%d 平均内存:%.1fMB\n", n, s/n/1024}'
# 示例:16G 机器,单进程平均 45MB
# pm.max_children = 16384 × 0.7 ÷ 45 ≈ 254,保守取 180-200
# 推荐的生产配置(dynamic 模式) pm = dynamic pm.max_children = 200 pm.start_servers = 40 pm.min_spare_servers = 20 pm.max_spare_servers = 60 pm.max_requests = 1000 # 防内存泄漏,500-2000 均可 request_terminate_timeout = 30s request_slowlog_timeout = 3s slowlog = /var/log/php8.1-fpm.slow.log listen.backlog = 2048 # 需同步调 net.core.somaxconn
注意:pm.max_children 不是越大越好。超过物理内存承载能力后,系统会开始用 swap,PHP 响应时间会成倍恶化,最终被 OOM Killer 杀进程,表现为批量 502。
四、第三层:数据库才是大多数 PHP 慢站的真凶
如果 OPcache 正常、进程池够用,那 80% 以上的慢都来自数据库。
4.1 定位慢 SQL
# 是否开启慢日志 mysql -e "SHOW VARIABLES LIKE 'slow_query_log'; SHOW VARIABLES LIKE 'long_query_time';" # 临时开启(持久化需写入 my.cnf 的 [mysqld] 段) mysql -e "SET GLOBAL slow_query_log=ON; SET GLOBAL long_query_time=1; SET GLOBAL log_queries_not_using_indexes=ON;" # my.cnf 持久化配置 [mysqld] slow_query_log = 1 slow_query_log_file = /var/log/mysql/slow.log long_query_time = 1 log_queries_not_using_indexes = 1 # 分析慢日志(mysqldumpslow 自带) mysqldumpslow -s t -t 20 /var/log/mysql/slow.log # 或用 pt-query-digest(Percona Toolkit,分析更全面) pt-query-digest /var/log/mysql/slow.log | head -60
4.2 实时查看阻塞
mysql -e "SELECT id,user,host,db,command,time,state,LEFT(info,100) AS sql_text \ FROM information_schema.processlist WHERE command!='Sleep' AND time>3 ORDER BY time DESC LIMIT 20;" # 连接数与上限 mysql -e "SHOW STATUS LIKE 'Threads_connected'; SHOW VARIABLES LIKE 'max_connections';" # 持续高于 max_connections × 0.8 → 应用侧需要连接池或减少连接持有时间 # 缓存命中率(InnoDB) mysql -e "SHOW ENGINE INNODB STATUS\G" | grep -A3 'BUFFER POOL AND MEMORY' # Buffer pool hit rate 应 > 990/1000
4.3 最常见的四类慢 SQL 与解法
| 问题 | 典型症状 | 解法 |
|---|---|---|
| 缺索引 / 索引失效 | EXPLAIN 的 type=ALL,rows 极大 | 加复合索引;避免索引列上使用函数、隐式类型转换、前导 % 的 LIKE |
| 深分页 | LIMIT 100000,20 耗时数秒 | 改用基于主键的游标:WHERE id > 上一页最大id LIMIT 20 |
| SELECT * 与大字段 | 网络传输与内存开销大 | 只查需要的列;TEXT/BLOB 单独拆表 |
| N+1 查询 | 循环里查数据库,请求数百次 | 批量查询 + 内存组装;或加缓存 |
| 锁等待 | state 显示 Waiting for ... lock | 缩短事务、避免大事务、按固定顺序加锁 |
# 用 EXPLAIN 验证索引是否生效 mysql -e "EXPLAIN SELECT * FROM orders WHERE user_id=123 AND status=1 ORDER BY id DESC LIMIT 20\G" # 添加复合索引(注意区分度高的列放前面,遵循最左前缀原则) mysql -e "ALTER TABLE orders ADD INDEX idx_user_status (user_id, status);" # 查看冗余索引 mysql -e "SELECT * FROM sys.schema_redundant_indexes;"
五、第四层:外部依赖与系统资源
5.1 外部 API 与文件系统
# 所有对外 HTTP 调用都必须设超时,否则对方抖动会拖垮你
$ch = curl_init($url);
curl_setopt_array($ch, [
CURLOPT_CONNECTTIMEOUT => 2, // 连接超时 2 秒
CURLOPT_TIMEOUT => 5, // 总超时 5 秒
CURLOPT_RETURNTRANSFER => true,
]);
$res = curl_exec($ch);
$errno = curl_errno($ch);
if ($errno) { /* 降级处理:返回缓存或默认值,绝不让整站卡住 */ }
- 支付、短信、物流、地图等第三方接口必须有超时 + 重试上限 + 熔断降级。
- 避免在循环里写文件、避免用文件做锁或计数(改 Redis)。
- NFS / 共享盘上的 session 或日志目录,网络抖动时会让 PHP 全部卡住。
- DNS 解析失败也会导致请求卡到超时,可在
/etc/hosts固化内部域名。
5.2 系统资源判断标准
# CPU:load 长期高于核数 × 2 说明排队严重 top -b -n 1 | head -15 # 内存与 swap:si/so 持续不为 0 且 free 低于总量 10% → 已在换页,性能断崖 free -m vmstat 1 5 # 磁盘 IO:await > 20ms 或 %util 持续接近 100% → 磁盘瓶颈 iostat -x 1 3 # 连接与句柄 ss -s ulimit -n cat /proc/$(pgrep -o php-fpm)/limits | grep 'open files' # OOM 痕迹 dmesg -T | grep -i -E 'out of memory|killed process'
| 资源 | 观察命令 | 瓶颈阈值 | 典型处理 |
|---|---|---|---|
| CPU | top / vmstat | load > 核数 × 2,us+sy > 80% | 优化代码/SQL,或加核/扩节点 |
| 内存 | free / vmstat | si/so > 0,free < 10% | 降低 max_children,或加内存 |
| 磁盘 | iostat -x | await > 20ms,%util ≈ 100% | 换 SSD、减少日志写入、加索引 |
| 网络 | iftop / sar -n DEV | 带宽打满 | 接 CDN、压缩、合并资源 |
| 句柄 | ulimit -n / ls /proc | 接近上限 | 调 nofile 与 worker_connections |
六、PHP 慢站优化实施清单
- 确认 OPcache 已启用:查
opcache_get_status(),命中率应 > 99%,oom_restarts 应为 0。 - 根据项目文件数调好
opcache.max_accelerated_files和memory_consumption,改完 reload 并复测。 - 开启
request_slowlog_timeout与 slowlog,拿到慢的具体文件与行号。 - 用带
urt字段的 Nginx 日志找出最慢的 URL,按耗时排序逐个治理。 - 检查 FPM 状态页:listen queue 是否 > 0、max children reached 是否增长。
- 按“可用内存 × 0.7 ÷ 单进程内存”重算
pm.max_children,并确认没有用 swap。 - 开启 MySQL 慢日志(long_query_time=1),用
pt-query-digest找出 TOP 慢 SQL。 - 对 TOP SQL 逐条 EXPLAIN,加索引、改深分页、消除 N+1。
- 为所有外部 HTTP 调用加超时与降级,禁止无限等待。
- 静态资源接 CDN,开 gzip,给 CSS/JS 加版本号;大结果查询加 Redis 缓存。
- 优化前后各压测一次,用数据验证效果(
wrk -t4 -c200 -d60s)。
小结
PHP 网站慢的优化顺序建议固定为:OPcache → 慢日志定位 → 进程池容量 → 数据库慢 SQL → 外部依赖超时 → 系统资源 → 缓存/CDN。其中 OPcache 是零成本换数倍性能的第一优先级,而慢 SQL 通常是真正的大头。切忌一上来就加机器——如果 SQL 没索引,加多少台机器都只是把崩溃时间推后。
避坑清单:
- CLI 和 FPM 是两套配置,
php -i看到的结果不代表 Web 生效的配置。 max_accelerated_files必须大于项目 PHP 文件总数(含 vendor),Laravel/ThinkPHP 项目常超过 10000。validate_timestamps=0时发版后忘记重置 OPcache,会出现“代码改了但页面没变”的诡异现象。pm.max_children盲目调大会导致 swap 和 OOM,一定要按内存实算。- 生产环境
display_errors必须 Off,错误信息写日志,否则既慢又不安全。 - 迁移或升级后务必重新核对
opcache.save_comments,注解失效会让框架报莫名的错误。 - 慢日志要定期轮转,否则几个月后单个 slowlog 可能有几十 GB。
关键词回顾:PHP 网站优化、OPcache 配置、opcache.memory_consumption、max_accelerated_files、revalidate_freq、PHP-FPM max_children、慢 SQL 分析、服务器资源瓶颈。
企业QQ咨询




