PHP网站运行缓慢怎么优化?OPcache、数据库与服务器资源分析

同一个站点,昨天还好好的,今天打开要五六秒;后台点个列表页转圈半天。重启 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.enable1总开关生产必须为 1
opcache.memory_consumption128 MB存储编译后 opcode 的共享内存看 used_memory 占用,留 30% 余量;出现 oom_restarts 立刻调大
opcache.max_accelerated_files10000哈希桶容量必须大于项目 PHP 文件总数;Laravel 含 vendor 常超 10000
opcache.interned_strings_buffer8 MB字符串常量池框架类项目建议 32,不足时字符串无法复用
opcache.revalidate_freq2 秒文件变更检查频率生产设 60;设为 0 表示每次请求都检查
opcache.validate_timestamps1是否检查文件时间戳设为 0 性能最好,但改代码必须手动 reload/reset
opcache.max_wasted_percentage5浪费内存比例达到此值触发重启不建议调低,否则频繁重启
opcache.jit0PHP 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'
资源观察命令瓶颈阈值典型处理
CPUtop / vmstatload > 核数 × 2,us+sy > 80%优化代码/SQL,或加核/扩节点
内存free / vmstatsi/so > 0,free < 10%降低 max_children,或加内存
磁盘iostat -xawait > 20ms,%util ≈ 100%换 SSD、减少日志写入、加索引
网络iftop / sar -n DEV带宽打满接 CDN、压缩、合并资源
句柄ulimit -n / ls /proc接近上限调 nofile 与 worker_connections

六、PHP 慢站优化实施清单

  1. 确认 OPcache 已启用:查 opcache_get_status(),命中率应 > 99%,oom_restarts 应为 0。
  2. 根据项目文件数调好 opcache.max_accelerated_files 和 memory_consumption,改完 reload 并复测。
  3. 开启 request_slowlog_timeout 与 slowlog,拿到慢的具体文件与行号。
  4. 用带 urt 字段的 Nginx 日志找出最慢的 URL,按耗时排序逐个治理。
  5. 检查 FPM 状态页:listen queue 是否 > 0、max children reached 是否增长。
  6. 按“可用内存 × 0.7 ÷ 单进程内存”重算 pm.max_children,并确认没有用 swap。
  7. 开启 MySQL 慢日志(long_query_time=1),用 pt-query-digest 找出 TOP 慢 SQL。
  8. 对 TOP SQL 逐条 EXPLAIN,加索引、改深分页、消除 N+1。
  9. 为所有外部 HTTP 调用加超时与降级,禁止无限等待。
  10. 静态资源接 CDN,开 gzip,给 CSS/JS 加版本号;大结果查询加 Redis 缓存。
  11. 优化前后各压测一次,用数据验证效果(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 分析、服务器资源瓶颈。