做后端开发这些年,几乎每天都有人在群里问:“接口又超时了,怎么办?”尤其是PHP项目,很多从传统页面开发转接口开发的同学,一遇到请求超时就懵:前端说请求发不出去,后端说日志里没有报错,运维说机器负载不高,最后互相甩锅半小时,才发现是某个配置项没设超时时间,或者依赖的第三方接口卡了十秒。
这篇文章我打算把PHP接口请求超时这件事彻底讲清楚。从请求链路拆解、常见诱因、定位手段,到实际配置和根治方案,全部基于我踩过的坑总结出来。不管你是刚接触PHP接口开发的新手,还是被线上超时折磨过的老手,这都会是一份可以直接拿去用的排查手册。排查超时,千万别一上来就改代码加超时时间,先搞明白“谁在等谁”,否则只会越调越乱。
1. 先搞清请求超时的链路与影响范围
很多人在排查超时问题时会陷入一个误区:只要接口响应慢,就盯着PHP代码看。但一次接口请求从客户端发出到响应返回,中间要经过DNS解析、TCP建连、Nginx转发、PHP-FPM执行、MySQL查询、Redis读写、第三方API调用等多个环节。任何一个环节卡住,最终表现都是“请求超时”,但根因可能完全不一样。
1.1 一次PHP接口请求到底走了多远
先画一条完整的链路,后面所有排查都是围绕这条链路展开的:
- 客户端发起HTTP请求(浏览器、App、小程序、服务端CURL等)
- DNS解析域名得到IP
- TCP三次握手建立连接
- 如果是HTTPS,还有TLS握手
- 请求到达Nginx或Apache
- Web服务器把请求转发给PHP-FPM(或mod_php)
- PHP-FPM分配worker进程执行脚本
- 脚本连接MySQL、Redis、外部HTTP接口等
- PHP处理完业务逻辑,返回响应
- Web服务器接收响应,返回给客户端
超时可能发生在以上任意一步。举个真实案例:有次线上接口偶发超时,PHP日志里明明有执行记录,但耗时只有200毫秒,后来抓包发现是Nginx到PHP-FPM的fastcgi连接超时了,因为PHP-FPM进程数被耗尽,请求在Nginx层排队等worker,Nginx的fastcgi_connect_timeout默认60秒,但客户端那边早就等不及了。这就是典型的“PHP没卡,但请求进不了PHP”的情况。
1.2 超时现象直接定级
拿到一个超时反馈,先不要急着看代码。我习惯先按现象定级,这能大幅缩小排查范围:
| 现象 | 可能环节 | 优先排查方向 |
|---|---|---|
| 请求发出去立刻超时(<1秒) | 网络不通、端口不通、DNS解析失败 | 连通性测试、抓包 |
| 固定时间后超时(如5秒、10秒) | 某层显式超时配置 | 找对应超时时间配置项 |
| 偶发性超时,时好时坏 | 依赖服务性能抖动、连接池耗尽、慢查询 | 看监控曲线、日志耗时分布 |
| 特定接口必超时 | 接口内逻辑问题 | 慢SQL、死循环、外部调用无超时 |
| 高峰时段超时,闲时正常 | 资源不足、队列堆积、锁竞争 | 压测、检查连接数 |
定级不是目的,目的是圈定排查方向。比如“固定时间超时”这个现象,基本就是有人设置了显式超时时间,找到那个时间对应的配置项就行。如果是“全接口都超时”,那大概率是基础设施问题,而不是PHP业务代码问题。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 按层拆解:常见超时诱因与定位手段
这一节我会按请求链路从外向里逐层拆,把每一层有哪些“超时点”、怎么定位、怎么处理都讲清楚。建议你在排查时也按这个顺序来,别从最里面的PHP代码开始。
2.1 客户端/调用方超时设置
所有超时问题,首先要确认“谁在报超时”。如果是客户端报的,那客户端本身的超时设置可能太短。比如前端用fetch,默认没有超时时间,但用axios默认超时是0(即不超时),如果业务里手写了5秒超时,而后端接口本身需要执行8秒,那必然超时。
排查手段很简单:让调用方把超时时间先放大到30秒,再看是否复现。如果放大后不再超时,那就是调用方超时设置过短,或者后端确实响应慢,需要继续往内查。
我见过一种尴尬情况:前端设置了10秒超时,后端接口在第九秒返回,但前端网络差,响应在途中被运营商缓存或代理截断,导致客户端在10秒没收到数据就报超时。这种问题只能通过抓包区分,不能光看两端配置。
2.2 DNS解析与TCP建连超时
如果客户端报“请求超时”,先做基础网络测试:
ping 目标域名看网络通不通nslookup 域名或dig 域名看DNS解析是否正常telnet 域名 80/443或nc -vz 域名 端口看端口是否可达
重点排查DNS。PHP服务端调用外部接口时,如果使用域名,而DNS解析不可用或很慢,就会在连接阶段卡住。Linux下有个经典坑:/etc/resolv.conf里配了不可达的DNS服务器,每次解析要等到超时才会切换下一个DNS,导致接口调用多出好几秒延迟。建议用time nslookup domain测一下解析耗时。
TCP建连超时一般由防火墙丢包或IP不通导致,表现为请求卡在连接阶段,直到系统TCP超时(Linux默认大概127秒)才断开。如果你的接口是服务端调用第三方API,一定要给HTTP客户端设置连接超时,避免进程长时间挂起。
2.3 PHP-FPM与Web服务器超时
这一层是PHP项目里最常出问题的位置。常见的超时点包括:
- Nginx的
fastcgi_connect_timeout、fastcgi_send_timeout、fastcgi_read_timeout,默认都是60秒 - Apache的
mod_proxy_fcgi或mod_fcgid相关超时 - PHP-FPM的
request_terminate_timeout(默认0,不限制) - PHP-FPM的
max_execution_time(CLI默认0,FPM默认30秒,但很多框架会覆盖) - PHP-FPM进程池的
pm.max_children、pm.start_servers等
如果PHP-FPM的worker全部被占用,新的请求会排队等待空闲worker。Nginx转发请求给FPM时,如果超过了fastcgi_connect_timeout,就会返回504或触发客户端超时。这时你在PHP-FPM日志里看不到任何错误,因为请求根本没进PHP。
排查方法:
- 查看
php-fpm状态接口(开启pm.status_path),观察active processes是否打满。 - 检查
slow log是否开启,看有没有脚本执行超过request_slowlog_timeout。 - 看Nginx的error.log,搜索
connect() to unix:/var/run/php-fpm.sock failed或upstream timed out。
关于max_execution_time,需要特别注意:这个参数对纯计算型代码有效,但如果有IO阻塞(如file_get_contents、CURL、sleep),在FPM模式下不一定生效,因为PHP的max_execution_time只统计CPU执行时间,不统计系统调用耗时(Windows除外)。所以很多外部HTTP调用卡住时,max_execution_time根本杀不掉它。真正能兜底的是request_terminate_timeout,它从请求开始算真实时间,超时直接终止worker进程,建议设置为30-60秒,并配合request_slowlog_timeout记录慢请求。
2.4 应用内部逻辑耗时
排除了外部因素后,就要看PHP代码本身。慢的原因通常这几类:
- 大循环或死循环:比如从数据库查了10万条数据,在内存里循环处理,每步还调一次Redis
- 业务逻辑串行化:一次请求里调用多个同步外部接口,每个接口2秒,5个就是10秒
- 大数据量处理:生成报表、导出Excel、批量计算
- 锁等待:MySQL行锁、Redis分布式锁没拿到,一直重试
- 序列化和反序列化开销过大
定位方法最有效的是给代码加耗时日志。在入口处记录microtime(true),在每个关键步骤(连DB、调外部接口、数据处理)前后都打日志。也可以使用Xdebug的profiler,或者引入APM工具(如SkyWalking、Pinpoint)做全局调用链追踪。
这里分享一个我自己的土办法:不用上APM,直接在框架的入口和出口各记一条日志,同时开启PHP-FPM的slow log,设置request_slowlog_timeout = 2s,这样执行超过2秒的请求会自动dumpPHP调用栈,直接能看到卡在哪一个函数。这招非常有效,尤其在排查偶发性超时时,慢日志能抓取到现场栈。
2.5 外部依赖(数据库、Redis、第三方API)
PHP接口大部分卡顿不是PHP本身,而是依赖的外部服务。
数据库方面:
max_execution_time管不到MySQL查询,一个慢查询可能拖很久。开启MySQL的slow_query_log,设置long_query_time=1,找出耗时超过1秒的SQL。- 检查数据库连接池是否耗尽,
Too many connections会直接导致新请求无法获取连接,从而超时。 - 用
SHOW PROCESSLIST;看当前正在执行的SQL,看看有没有长时间的Waiting for table lock或Sending data。
Redis方面:
- Redis本身很快,但如果有
KEYS *这种命令,或者Redis负载过高导致命令排队,也会拖慢接口。 - 网络问题:PHP和Redis之间的网络延迟如果在毫秒级,一次请求几十次Redis调用累积起来也很可观。
- Redis连接超时设置:PHP的Redis客户端默认连接超时可能较短,如果Redis刚好在GC或fork持久化,可能连接失败。建议在初始化连接时显式设置
timeout。
第三方API方面:
- 调用外部HTTP接口时,一定要设置连接超时和读取超时。不设超时是最大的坑,一旦外部接口挂住,你的PHP进程就会跟着挂住,直到操作系统TCP超时。
- 外部接口如果是公网接口,还可能受网络波动影响,建议把超时设置得保守一点,业务上能容忍就设3秒,不能容忍就设5秒,并做降级。
3. 核心实操:从零开始排查一次真实超时
理论说再多,不如亲手排查一次。这一节我以最常见的场景为例:前端调用PHP接口偶发504超时,服务端Nginx日志有upstream timed out。我会带着你一步步操作,并给出可以直接执行的命令。
3.1 抓包与耗时分段
当客户端和服务端都能复现问题时,第一时间在网络入口抓包。用tcpdump抓HTTP流量,配合Wireshark分析。但很多线上环境没有图形界面,可以用tcpdump直接抓,之后再导出分析。
假设客户端IP是192.168.1.10,服务端IP是192.168.1.20,在服务端执行:
bash复制tcpdump -i eth0 -nn -s 0 host 192.168.1.10 and port 80 -w /tmp/request.pcap
抓完包后可以用tshark统计HTTP事务时间:
bash复制tshark -r /tmp/request.pcap -T fields -e frame.time_relative -e ip.src -e tcp.stream -e http.request.uri -e http.response.code
重点看时间戳间隔:如果是客户端SYN发出后,服务端在几十毫秒内返回SYN+ACK,说明网络正常;如果中间间隔很久,可能是网络延迟或防火墙丢包。如果是HTTP请求发出后,服务端长时间没有返回响应,那关注点在服务端处理耗时上。如果是Nginx很快返回504,那要看upstream(PHP-FPM)的状态。
抓包定位是最基础的手段,但需要根因时,最好直接在应用层分段打点。我通常会在入口处加一个简单的计时器:
php复制$_start = microtime(true);
// 业务逻辑...
\Think\Log::record('before db: ' . round(microtime(true) - $_start, 4) . 's');
// 注意:ThinkPHP3.2.3的Log写法按项目版本调整
这样能快速判断时间消耗在哪个阶段。
3.2 用CURL复现并输出耗时细节
无论客户端是浏览器还是小程序,我们都可以用CURL在服务端或本地复现请求,并通过curl_getinfo拿到每个阶段的耗时。这是一个极其实用的方法。
示例脚本:
php复制<?php
$url = 'http://api.example.com/order/list';
$ch = curl_init($url);
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_TIMEOUT => 30,
CURLOPT_CONNECTTIMEOUT => 5,
CURLOPT_HTTPHEADER => [
'Content-Type: application/json',
'X-Token: your-[token](https://taotoken.net?utm_source=general)',
],
]);
$start = microtime(true);
$response = curl_exec($ch);
$total = microtime(true) - $start;
if (curl_errno($ch)) {
echo 'CURL error: ' . curl_error($ch) . [PHP](https://taotoken.net/?utm_source=general)_EOL;
$errorCode = curl_errno($ch);
}
$info = curl_getinfo($ch);
curl_close($ch);
printf("DNS解析耗时: %.4f s\n", $info['namelookup_time']);
printf("建连耗时: %.4f s\n", $info['connect_time']);
printf("TLS握手耗时: %.4f s\n", $info['appconnect_time']);
printf("开始传输耗时: %.4f s\n", $info['pretransfer_time']);
printf("首字节时间(TTFB): %.4f s\n", $info['starttransfer_time']);
printf("总耗时: %.4f s\n", $total);
if ($errorCode === CURLE_OPERATION_TIMEOUTED) {
echo "请求超时。\n";
}
curl_getinfo返回的字段能精确告诉我们时间花在哪个环节:
namelookup_time:DNS解析耗时。如果这个值很大,检查和DNS服务器的连通性。connect_time:TCP建连耗时。如果这个值大,说明网络不可达或防火墙丢包。appconnect_time:TLS握手耗时。证书大、协商慢都可能。starttransfer_time:从请求发出到收到第一个响应字节的时间,通常用来表示服务端处理耗时。如果这个值接近总耗时,说明服务端处理慢。
使用命令行的方式也可以:
bash复制curl -o /dev/null -s -w "DNS:%{time_namelookup}s,连接:%{time_connect}s,开始传输:%{time_starttransfer}s,总计:%{time_total}s\n" http://api.example.com/order/list
通过这个数据,你可以直接判断超时发生在服务端之前还是之后。如果time_connect很小而time_starttransfer很大,说明服务端处理慢;如果time_connect本身就很大,那就是网络问题。
3.3 配合日志定位慢SQL和慢接口
当确认是服务端处理慢后,就要结合日志找“罪魁祸首”。这里我以LNMP环境为例,建议按顺序做三件事:
第一,打开PHP-FPM慢日志。
编辑php-fpm.conf或pool配置:
ini复制slowlog = /var/log/php-fpm/slow.log
request_slowlog_timeout = 2s
然后重载PHP-FPM:
bash复制systemctl reload php-fpm
之后如果某个请求执行超过2秒,慢日志里会记录调用栈,比如:
text复制[pool www] pid 1234
script_filename = /data/www/app/public/index.php
[0x00007f...] curl_exec() /data/www/app/application/api/controller/Order.php:120
看到curl_exec,说明卡在外部请求上;看到mysqli_query,说明卡在数据库查询上。这个日志是定位请求内部阻塞的最快途径。
第二,开启MySQL慢查询日志。
在MySQL配置文件中设置:
ini复制slow_query_log = 1
slow_query_log_file = /var/log/mysql/mysql-slow.log
long_query_time = 1
然后重启MySQL或者用SET GLOBAL动态开启。慢查询日志会记录所有执行超过1秒的SQL语句,包括执行时间和扫描行数。
线上还可用以下SQL直接查看当前正在执行的语句:
sql复制SHOW FULL PROCESSLIST;
如果有状态为Sending data或Waiting for table metadata lock的长时间查询,基本就是元凶。尤其要注意那种SQL上千万数据没走索引,一条语句跑几秒甚至几十秒的情况。
第三,在PHP代码里埋点耗时日志。
如果慢日志没有抓到栈(有时request_slowlog_timeout可能不生效,或者进程被其他原因终止),就需要在业务关键分支手动埋点。以TP3.2.3项目为例,可以在入口文件加上全局记录:
php复制// index.php 入口处
$GLOBALS['_api_start_time'] = microtime(true);
在控制器基类的_initialize和输出返回前分别记录:
php复制// 基类构造函数末尾
$GLOBALS['_api_initialized_time'] = microtime(true);
// 输出响应前
$total = microtime(true) - $GLOBALS['_api_start_time'];
\Think\Log::write('API总耗时: ' . $total . 's,初始化耗时: ' . ($GLOBALS['_api_initialized_time'] - $GLOBALS['_api_start_time']) . 's');
再结合每个model里对Redis/MySQL的调用统计,基本能画出时间分布。
3.4 配置修改落地示例(Nginx / PHP-FPM / ThinkPHP)
在实际项目中,我们常需要调整超时参数。下面给出推荐配置和示例。
Nginx针对PHP站点的upstream超时设置:
在server或location ~ \.php$块中:
nginx复制location ~ \.php$ {
include fastcgi_params;
fastcgi_pass 127.0.0.1:9000;
# 指定脚本路径
fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;
# 连接超时:Nginx与PHP-FPM建立连接
fastcgi_connect_timeout 5s;
# 发送超时:Nginx向PHP-FPM发送请求体
fastcgi_send_timeout 30s;
# 读取超时:Nginx等待PHP-FPM响应
fastcgi_read_timeout 30s;
}
如果业务中确实有长时间执行的接口(如导出、批量处理),不建议把全局fastcgi_read_timeout调得太大,只在该接口对应的location里单独放宽即可。
PHP-FPM超时配置:
ini复制request_terminate_timeout = 30s
request_slowlog_timeout = 2s
slowlog = /var/log/php-fpm/slow.log
注意request_terminate_timeout设置后,超时的请求会被直接杀掉,进程会记录一条日志。如果业务本身需要很久,就要配合异步处理,而不是无限放宽。
PHP代码层设置请求超时
对于在代码中使用CURL调用外部接口,必须显式设置超时,且连接超时和总超时分开:
php复制curl_setopt($ch, CURLOPT_CONNECTTIMEOUT, 3); // 连接超时3秒
curl_setopt($ch, CURLOPT_TIMEOUT, 10); // 总超时10秒
对于使用file_get_contents的地方,可以设置stream context超时:
php复制$ctx = stream_context_create([
'http' => [
'timeout' => 3, // 单位秒
'ignore_errors' => true,
]
]);
$data = file_get_contents($url, false, $ctx);
ThinkPHP3.2.3中常见超时点
老项目用TP3.2.3的不少,这个框架本身没有全局请求超时设置,但有几个地方要注意:
C('DB_HOST')里的连接参数使用的是PDO连接,需要额外设置PDO的ATTR_TIMEOUT。可以在数据库配置中加上'params' => [PDO::ATTR_TIMEOUT => 3]。- TP3.2.3的
S()缓存如果使用Redis,连接超时默认是0(不超时),需要检查Redis连接是否正常,避免连接挂死。 - 如果用TP的
http请求类,超时默认可能没有设置,建议二次封装时统一加上。
4. 超时问题的设计层根治与防御
超时问题“头痛医头脚痛医脚”是不行的,配置调完也许一时不报错了,但系统稳定性并没有提升。真正靠谱的做法是在设计层面就考虑超时与降级,避免让用户无限等待。
4.1 调用外部接口必须设超时
这是我反复强调的第一原则。不管是调用第三方支付、短信、地图,还是内部其他微服务接口,都必须设置超时。而且最好把连接超时和读取超时分离开来。
为什么分开设置?因为连接超时通常很短(1-3秒),如果对方IP不通,不必等太久;读取超时取决于业务场景,一般设3-10秒。如果只设一个总超时,很可能连接阶段就把总时间耗完了,真正的数据读取还没开始就断了。
另外,还可以给每次外部调用增加“熔断”机制。比如用一个计数器统计最近1分钟的外部接口失败率,超过阈值后直接走降级逻辑,不再调用外部接口。PHP里可以用Redis加计数器实现,也可以用成熟开源组件(如hyperf的熔断器,但老项目TP里需自己封装)。虽然实现简单,但能避免雪崩。
4.2 内部任务异步化与队列
很多接口超时的根源,是在一个同步请求里做了太多事。比如用户下单接口,既要写订单表,还要同步调库存系统、发送短信、生成对账单,这些操作串行执行下来没个几十秒不正常。
正确做法是拆分任务:
- 用户直接需要的核心操作同步执行(写订单、返回结果)
- 非核心操作异步化(发短信、生成对账单、推送消息)
- 重任务用消息队列(Redis列表、RabbitMQ、Kafka)削峰填谷
PHP本身没有常驻内存的优势,所以异步化通常依赖队列消费进程。开一个queue_worker脚本,用CLI模式常驻,处理任务后写回状态。这样即使异步任务执行很慢,也不会阻塞用户的HTTP请求。
另外,一些导出类接口天然耗时,建议设计成异步任务:
- 客户端请求创建导出任务,接口立即返回
task_id。 - 后台worker执行导出,完成写入文件或数据库。
- 客户端轮询任务状态,或通过WebSocket/SSE接收完成通知。
不要让前端为了一个导出任务同步等待几十秒。
4.3 缓存与降级方案
接口慢很大一部分原因是重复查询。比如商品详情接口每次请求都查DB,如果数据不常变化,完全可以缓存到Redis。如果Redis挂了,要有兜底直接查DB。如果DB也慢,那就返回简单的降级数据。
常见的降级策略:
- 静态配置:当依赖数据源不可用时,返回可用的静态配置。
- 默认值:返回空的列表或默认文案。
- 本地文件缓存:短时间内的请求直接读本地缓存文件,避免DB压力。
降级的目的不是提供完美数据,而是保证“请求不超时”。用户能快速收到响应,哪怕数据是旧的,也远比一直转圈好。
5. 常见问题排查速查表与避坑心得
这个部分把项目中最常遇到的超时问题和排查要点整理成表格,可以收藏起来。接着讲几个容易忽略的坑。
5.1 高频排查问题表
| 现象 | 直接原因 | 排查方法 | 处理方式 |
|---|---|---|---|
| 偶发504 | PHP-FPM worker耗尽 | 看php-fpm状态页、Nginx error log |
调整pm.max_children,优化脚本执行时间,加机器 |
| 固定60秒超时 | Nginx fastcgi_read_timeout | curl -w看TTFB,确认服务端处理时长 |
调大或优化接口 |
| 固定30秒超时 | PHP max_execution_time或request_terminate_timeout | 查看错误日志Maximum execution time exceeded |
优化代码,拆分任务 |
| 大量TIME_WAIT | 短连接过多 | netstat -anp | grep TIME_WAIT |
开连接复用,长连接池 |
| 接口偶发卡死 | Redis连接阻塞 | Redis slowlog、info clients |
检查慢命令,调连接超时 |
| 数据库连接获取超时 | 连接池不够或DB慢 | SHOW PROCESSLIST,show status like 'Threads_connected' |
连接池扩容,慢SQL优化 |
| 调用第三方接口无响应 | 未设超时 | CURL错误日志、curl_getinfo |
补超时,加熔断降级 |
| DNS解析慢/失败 | resolv.conf配置不当 | dig测耗时 |
修改DNS配置,加缓存 |
| 前端报错但后端无日志 | 请求未到PHP | 抓包,查Nginx访问日志 | 检查网络、Nginx配置 |
这些问题是排查时的“高频选手”,按表格对应关系去查,能节省不少时间。
5.2 几个容易忽略的坑
第一个坑:PHP-FPM的max_execution_time对阻塞IO不生效。我在前面提过,这里再强调一次。很多同学以为设置了max_execution_time = 30就能防止脚本跑太久,结果用CURL调用外部接口卡了60秒进程还在。真正保险的是request_terminate_timeout,它是基于绝对时间的,不区分CPU时间还是IO时间。但要注意,它会直接杀掉worker,如果不想误杀正常长任务,最好和业务校验配合使用。
第二个坑:Nginx和PHP-FPM的访问日志时间不一致,导致误判。Nginx日志记录的是请求到达和响应返回的时间,PHP-FPM日志记录的是脚本执行时间。如果Nginx显示耗时10秒,PHP日志显示执行耗时只有0.2秒,说明时间消耗在Nginx到PHP-FPM之间的排队或网络传输上,而不是PHP执行本身。反之,如果PHP日志显示执行耗时10秒,那就要盯着代码和数据库看。
第三个坑:使用了HTTP代理但客户端和服务端都忽略了代理超时。内网环境经常会走Nginx代理或Squid代理,如果你的PHP代码调用外部接口时用了代理,但代理本身很慢,也会让整个请求超时。排查时一定问清楚有没有设置代理环境变量,或者curl_setopt($ch, CURLOPT_PROXY, ...)。之前有个项目排查了一下午,最后发现是自己写代码时把代理地址拼错了,导致所有通过代理的外呼都卡着不动。
第四个坑:ThinkPHP3.2.3的缓存Session锁导致超时。TP3.2.3如果使用文件Session,在高并发下同一用户的多个请求会互相等待文件锁。特别是Ajax并发请求多个接口时,第一个请求拿着session文件锁,第二个请求就会阻塞直到超时。这个坑排查起来非常隐蔽,因为代码和数据库都看不出问题。解决方案是给两个接口的Session改为无锁,或者使用Redis存Session关掉锁。
第五个坑:超时时间不要只改一处。一个完整接口链路上可能有十多个超时时间,建议理顺优先级。比如前端设置30秒,Nginx设置10秒,PHP-FPM设置5秒,那外部调用3秒超时,整个链路才会不至于出现“外层宽松、内层严格”的矛盾。一般来说,内层超时要小于外层超时,否则内层卡死,外层也必然超时,而且由于外层还没到超时,内层报错信息也很难传出来。
第六个坑:没有关注系统级文件描述符和进程数限制。当PHP-FPM的进程数超过系统ulimit -n限制,或连接数达到上限,新请求会被拒绝或排队,也会表现为超时。检查ulimit -n,通常是1024或65535,如果接口量上来了,建议提高到65535以上。同时注意net.core.somaxconn和socket backlog,这些内核参数在高并发下同样会导致连接等待。
6. 从一次线上事故看完整排查流程
理论知识够了,还是用一个完整案例把流程串一遍吧,这样你会更有画面感。
情况是这样的:公司一个面向App的PHP接口,在用户量高峰期频繁超时,前端等待约15秒后报错。监控面板显示Nginx的504比例从0.2%飙到8%。项目环境是Nginx + PHP-FPM + MySQL + Redis。
拿到问题后,我按顺序做了这些事:
第一步,先看监控。Nginx的upstream_response_time平均值从0.8秒上升到4秒,说明PHP-FPM处理确实变慢。再看PHP-FPM状态接口,active processes已经打到max_children上限,同时listen queue里有积压请求。这个信号基本锁定是PHP-FPM并发处理能力不足。
第二步,看PHP慢日志。通过request_slowlog_timeout = 2s抓到了几个慢请求的调用栈,发现大量栈停在一个公共的UserModel::getUserInfo方法里,再细看是该方法内部用Redis存储用户信息时,通过一个外网Redis域名连接,而Redis域名解析到的是公网IP,网络路由不稳定,导致每次连接都要等几秒。
第三步,看数据库。MySQL慢查询日志里没有明显慢SQL,但Threads_connected很高,说明连接数压力大。进一步确认是每个请求都建立新的连接,而不是用连接池。
第四步,定位到根因:高峰期大量请求同时尝试连接一个外网Redis实例,连接超时设置不合理(默认15秒),加上外网延迟,导致worker长时间被Redis连接占住。同时PHP-FPM的pm.max_children设置过低,worker一旦占满,新请求全部排队。
修复方案:
- 将外网Redis迁移到内网,或者用内网域名区分,避免公网节点不稳定。
- Redis客户端连接超时从默认改为2秒,读取超时2秒。
- PHP-FPM的
pm.max_children从50调整到100,同时增加pm.max_requests值为500,避免进程长期运行产生内存膨胀。 - 给App接口的Nginx超时时间从60秒降到20秒,确保快速失败,不要把异常传导给客户端。
- 增加针对Redis的降级逻辑:如果Redis不可用,直接读MySQL,并记录降级日志。
上线后,504比例归零,接口p99从3秒降到1.2秒。这次事故从现象到根因,用了大概40分钟。关键点就在于每一步都用数据说话,而不是拍脑袋猜测。
我自己在排查过程中还有一个习惯:每次解决完问题,都会把“现象-定位-修复”三要素写成内部文档,哪怕只是几行字。因为超时问题真的会反复,今天这个原因,过几天可能换个姿势又出现,有文档对照会快很多。
最后再送大家一个小技巧:在PHP项目根目录放一个诊断用的脚本,比如ping.php,先不要加业务逻辑,只输出当前时间、PHP版本、连接DB和Redis的耗时。当再次出现“所有接口都超时”时,先访问ping.php,如果它响应也超时,说明是基础环境问题;如果它正常,那肯定是业务代码或数据库/缓存问题。这样一个浅显的分类,能省掉大量排查时间。
超时排查没有银弹,但有了这套方法和配置,至少不会在手忙脚乱中浪费一整天。下次线上又报超时,记得先深呼吸,按链路逐层拆,再难的问题也能找到出口。
