一、先说结论:500 错误根本不是"报错",而是一个兜底动作
绝大多数个人站长第一次遇到 500,第一反应是去 Nginx 错误日志里找"哪一行代码报错了"。这个方向从根上就是错的。Nginx 的 error.log 里那条记录通常长得像这样:
2026/09/23 10:14:22 [error] 21837#0: *99231 FastCGI sent in stderr: "PHP message: PHP Fatal error: Uncaught Error: Call to undefined function curl_init()" while reading response header from upstream, client: 203.0.113.77, server: www.example.com, request: "GET /tools/check.html HTTP/1.1", upstream: "fastcgi://unix:/run/php/php7.4-fpm.sock:", host: "www.example.com"注意这条日志其实是 Nginx "转述"的:真正的内容来自 PHP-FPM 的 stderr。也就是说,Nginx 只是个传话的,报错的源头在 PHP 侧。而"500"这个状态码本身,是 Nginx 在收到一个无法构成有效 HTTP 响应的上游输出后,替 PHP 给用户的回复——它是一个兜底动作,不是错误的本身。
理解这一点之后,整个排查路径就变成一条单向链路:用户 → Nginx → PHP-FPM → PHP 脚本 → 被调用的扩展/依赖。500 只是这条链路某处断了的信号,而断点的具体位置,只可能出现在上面这几个环节之一。
二、第一步永远是找 PHP 的错误原文,而不是猜
很多教程会让人先去检查权限、检查磁盘、重启 php-fpm,这些都属于"跳过证据直接行动"。正确的第一步只有一个:把 PHP 的真实错误原文拿到手。
如果你的站点用 php-fpm(绝大多数现代 Typecho、WordPress 部署都是),错误原文有三个出口,按可靠性排序:
- PHP-FPM 自身的错误日志,通常位于
/var/log/php7.4-fpm.log或/var/log/php-fpm/www-error.log。这是最容易被忽略也最有用的一处,因为即使display_errors = Off,致命错误仍然会写到这里。 - Nginx error.log 里的 "PHP message" 行,即上面那条。它只包含 FPM 抓到的 stderr 片段,可能被截断,但通常足够定位。
- 站点自己的 PHP 错误日志,由
error_log指令指定,看 phpinfo 或/etc/php/7.4/fpm/php.ini。
先确认这些日志有没有在写:
ls -l /var/log/php7.4-fpm.log /var/log/php-fpm/www-error.log 2>/dev/null
grep -E "error_log|log_level|display_errors" /etc/php/7.4/fpm/php.ini
grep -E "error_log|log_level" /etc/php/7.4/fpm/pool.d/www.conf如果日志文件存在但一直是空的,大概率是 log_level 被设成了 alert、或者 pool 里的 php_admin_value[error_log] 把错误重定向到了一个不存在的目录。一个非常隐蔽的坑是:php-fpm 以 www-data 身份运行,而目标日志目录属主是 root 且权限 700,结果就是错误被静默丢弃,你看到的 500 永远没有上下文。
临时提高日志级别的做法(改完必须 reload,不要 restart,restart 会中断正在处理的请求):
# /etc/php/7.4/fpm/pool.d/www.conf
php_admin_value[error_log] = /var/log/php-fpm/www-error.log
php_admin_flag[log_errors] = on
php_admin_value[error_reporting] = E_ALL
systemctl reload php7.4-fpm三、500 的六大真实成因,按出现频率排
3.1 PHP 致命错误:调用了不存在的函数或类
这是个人站最常见的一种,典型触发场景是"为了省内存卸载了某个扩展"。比如 Call to undefined function curl_init()、Call to undefined function mb_strlen()、Class 'Redis' not found。它们的共同特征是:代码在本地或旧服务器上跑得好好的,迁到新机器就 500。
定位方式很直接:
php -m | grep -iE "curl|mbstring|redis|gd|xml|json"
php -i | grep -E "extension_dir|Loaded Configuration"要紧的是 CLI 与 FPM 可能加载不同的 php.ini。php -m 用的是 CLI 的那份,FPM 用的是 /etc/php/7.4/fpm/php.ini。所以经常出现"命令行明明有 curl,网站还是报 undefined function"的诡异局面。这时要专门查 FPM 的扩展目录:
ls /etc/php/7.4/fpm/conf.d/ | grep -i curl
php-fpm7.4 -m | grep -i curl # 有些发行版支持3.2 内存耗尽:Allowed memory size exhausted
报错原文是 Fatal error: Allowed memory size of 134217728 bytes exhausted。这里有一个特别容易误判的点:报错发生在哪个脚本,不代表问题就在那个脚本。更常见的是某个查询把整张大表捞进了内存,或者某个循环里不断往数组里塞数据。
不要急着把 memory_limit 从 128M 调到 512M 了事。正确的顺序是:
- 看错误日志里报错的行号,确认是
SELECT之后还是循环内部。 - 如果是查询,加
LIMIT或改用分页,而不是加内存。 - 只有确认确实需要大内存(比如用 PhpSpreadsheet 导出几万行 Excel),才针对性提升,并且用 pool 级别的
php_admin_value[memory_limit]只给受影响的站点提,不要全局放宽。
顺带说,memory_limit 调到 512M 之后,如果 pm.max_children 是 20,理论峰值内存就是 10GB——很多人调完内存限制之后服务器开始 OOM Killer 杀进程,根源就在这里。这两个参数必须一起算:
可用内存 ≈ pm.max_children × memory_limit × 1.2
# 一台 2G 的机器,memory_limit=256M 时 max_children 不该超过 63.3 PHP-FPM 池被打满:pm.max_children 饱和
这种情况的 500 在 Nginx 日志里往往不是 PHP message,而是别的形态,例如 [error] connect() to unix:/run/php/php7.4-fpm.sock failed (11: Resource temporarily unavailable) 或者 upstream timed out (110: Connection timed out)。本质是 listen backlog 满了,新连接进不来。
判断是否饱和,看 FPM 的 status 页(前提是配了 pm.status_path = /status):
curl -s http://127.0.0.1/status?full | head -30
# 关键字段
# active processes: 当前活跃数
# listen queue: backlog 排队数,长期 > 0 就是饱和
# max children reached: 达到上限的次数,> 0 就是真的堵过没配 status 页的,也可以直接从进程数近似判断:
ps -eo pid,comm | grep -c "php-fpm"
ss -lntp | grep php这里有个反直觉的结论:max_children 调大不一定更好。如果瓶颈在数据库(比如 MySQL 只有 100 个连接、磁盘 IO 已经打满),把 FPM 并发从 10 提到 50,只会让每个请求都变慢、最终一起超时,500 反而更多。真正的解法是找出慢请求并削掉它,或者给静态化/缓存加一层。
3.4 文件权限与属主错误
这类 500 的日志里通常是 Failed opening required ... Permission denied 或者干脆什么都没有(因为连错误日志都写不进去)。高频触发点是三种操作:
- 用 root 通过 rsync/scp 上传了新文件,属主变成 root,php-fpm 的 www-data 读不了。
- 手动
chmod -R 777之后某些脚本写了非法内容,或.user.ini被改坏。 - 部署脚本用了
tar解开时没有保留属主,或者 umask 设置错误导致新建目录没有执行位(x位缺失时目录不可进入)。
一次性修正的标准做法:
chown -R www-data:www-data /var/www/example.com
find /var/www/example.com -type d -exec chmod 755 {} \;
find /var/www/example.com -type f -exec chmod 644 {} \;
# 仅可写目录单独放开
chmod -R 775 /var/www/example.com/usr/uploads
chmod -R 775 /var/www/example.com/var顺带一个诊断技巧:用 php-fpm 的运行身份去实际读一下那个文件,能一眼看出是文件问题还是代码问题:
sudo -u www-data cat /var/www/example.com/index.php > /dev/null && echo "readable"
sudo -u www-data php -r 'require "/var/www/example.com/config.php";'3.5 语法错误与不兼容的 PHP 版本
升级 PHP 大版本之后突然全站 500,基本可以锁定这条。Parse error: syntax error, unexpected ')' 这类错误会直接导致白屏和 500,而且往往没有任何堆栈。典型的破坏点包括:
- PHP 7.x 移除了
mysql_*系列函数,老代码直接致命错误。 - PHP 8.0 起,把
null传给非可空参数(如strlen(null))从警告升级为 TypeError。 - PHP 8.1 起
dynamic properties废弃警告,若是框架层可能被E_ALL+ 自定义 error handler 转成异常,进而演化成 500。 - 短标签
<?在short_open_tag = Off时不再被识别,整段 PHP 会被当作 HTML 原文输出。
批量语法体检可以跑:
find /var/www/example.com -name "*.php" -print0 | xargs -0 -n1 -P4 php -l 2>&1 | grep -v "No syntax errors"注意这条命令只检查语法,不检查运行时兼容性。真正的兼容性问题要靠实际打日志。
3.6 超时与上游被 kill
当某个请求执行时间超过 request_terminate_timeout,php-fpm 会直接 kill 掉 worker,Nginx 收到的是一个提前关闭的连接,于是回 502 或 500。max_execution_time 管不到这种情况,因为那个是 PHP 内部的计时器,而对 file_get_contents、数据库等待这类阻塞调用,计时器不计入。
两者的职责要分清:
; PHP 内部脚本执行时间上限(不计入系统阻塞调用)
max_execution_time = 30
; php-fpm 层面的硬性 terminate,超了就 kill worker
request_terminate_timeout = 60
# Nginx 侧的上游等待上限
fastcgi_read_timeout 60s;推荐的关系是 max_execution_time < request_terminate_timeout < fastcgi_read_timeout。顺序反了,就会出现"PHP 还在跑、FPM 已经把它杀掉、Nginx 也早就放弃了"的三输局面,而且日志里三个超时时间互相矛盾,非常难读。
四、一条可复用的排查流程
- 拿到错误原文:先看 Nginx error.log 的 PHP message,再看 FPM 日志。没有原文就先把
log_level和error_log修好,别猜。 - 判断是单页还是全站:只有某个 URL 500,是代码/数据问题;全站 500,优先查权限、扩展、PHP 版本、磁盘和 inode。
- 判断是否是流量相关:低峰正常、高峰 500,指向
pm.max_children、数据库连接数、内存上限这类资源问题。 - 二分法排除:把自建插件/主题目录改名,用官方默认主题测一次。如果恢复,问题在自建代码里,再用逐行
error_log()或debug_print_backtrace()缩范围。 - 回滚比修复快:如果 500 是某次部署之后出现的,先
git revert或恢复上一版文件让站点先活过来,再离线分析。线上不是调试器。
最后提醒一个易忽略的观测点:dmesg -T | tail。如果日志里有 OOM Killer 记录,那么 500 的根源跟你改的 PHP 配置可能一点关系都没有,是整台机器内存不够了。这种时候调 PHP 参数只是把问题从应用层推到系统层,早晚会以更难看的方式爆出来。
五、白屏与 500 的区别:一个更容易被忽略的分支
有时你看到的是彻底的白屏而不是 500,这两者在排查顺序上略有不同。白屏通常意味着 PHP 已经执行到了输出阶段,但输出被中断了,或者被缓冲区吞掉了。可能的原因包括:display_errors 关闭且没有日志出口,于是致命错误被静默处理;页面启用了 ob_start() 但中途抛异常,缓冲区里的内容被丢弃;输出编码问题导致内容存在但不可见。
判断方法是直接把 PHP 的执行结果从 Web 层剥离出来,用命令行跑一次:
# 以 fpm 的身份、用 fpm 的 ini 在命令行跑一遍,errors 会直接打在屏幕上
sudo -u www-data php -c /etc/php/7.4/fpm/php.ini -f /var/www/example.com/index.php
# 若站点依赖 HTTP 上下文($_SERVER、$_GET),退一步只做语法与加载检查
sudo -u www-data php -c /etc/php/7.4/fpm/php.ini -r 'chdir("/var/www/example.com"); require "index.php";' 2>&1 | head -40
这一步能过的和过不了的差别,就把问题一分为二:命令行也不报错 → 问题在 Web 上下文(权限、扩展加载、环境变量、Nginx 传参);命令行直接报错 → 是代码或依赖本身的问题,日志里没看到只是错误出口没配好。这个二分能省掉大量来回试探。
另外一个容易被忽略的细节是 opcache。opcache.validate_timestamps = 0 时,改完代码不会生效,你会对着一个"应该已经修好"的文件继续报 500。排查期间要么临时把它设为 1,要么显式重启:
grep -E "validate_timestamps|revalidate_freq" /etc/php/7.4/fpm/php.ini
systemctl reload php7.4-fpm
curl -s https://www.example.com/opcache-status.php 2>/dev/null | head -3
六、日志轮转与告警:让下一次 500 在第一时间被发现
排查完一次事故之后,真正体现站长水平的是"下一次能不能更早发现"。个人站长没有值班团队,所以要靠低成本的自动化:
# 每分钟统计 500 数量,超过阈值就发通知
*/5 * * * * /usr/bin/awk '$9 ~ /^5[0-9][0-9]$/ {c++} END {if (c>20) print c}' /var/log/nginx/access.log | /usr/bin/mail -s "5xx spike on example.com" you@example.com
# 同时别忘了日志本身的轮转,否则 access.log 会先撑爆 inode
cat > /etc/logrotate.d/nginx-custom <<'EOF'
/var/log/nginx/*.log {
daily
rotate 14
missingok
notifempty
compress
delaycompress
sharedscripts
postrotate
[ -f /run/nginx.pid ] && kill -USR1 $(cat /run/nginx.pid)
endscript
}
EOF
kill -USR1 而不是 systemctl restart nginx,是因为 USR1 信号让 Nginx 重新打开日志文件句柄,可以做到不中断任何连接完成轮转。用 restart 的话,每次轮转都会短暂丢掉一批正在处理的请求,得不偿失。
还有一点:错误日志要保留足够长的时间窗口。很多疑难问题是间歇性的(一天几次),rotate 3 会让证据在三天后消失。建议 error.log 保留 30 天,access.log 保留 14 天,磁盘紧的话至少把 error.log 单独放开。