上周三晚上,我一个PV不高的PHP服务突然错误率飙到35%。CPU、内存、数据库全部正常,nginx日志里却出现大量upstream timed out,应用日志里密密麻麻全是cURL error 28: Operation timed out。问题出在一个第三方接口偶尔响应很慢,而我的代码里只给每个curl设置了一个笼统的timeout=5。结果5秒超时是到了,但php-fpm进程已经被拖住,慢请求开始堆积,最后所有接口都跟着遭殃。那晚复盘时我才真正意识到,网络超时不是加一个timeout参数就能解决的,它必须被当成一套级联体系来设计——从用户能容忍的等待时间出发,把预算逐层分配到每一个网络调用、每一个中间件、每一行配置。这篇文章把我后来重构这套超时级的完整思路和踩坑过程写出来,希望给正在处理PHP接口稳定性问题的朋友一些参考。
1. 一次线上事故:所有接口同时变慢,问题出在“超时”上
1.1 事故现场还原
那天的监控曲线很有代表性。错误率不是缓慢爬升,而是突然从0.2%跳到35%。进程数直接撞到php-fpm的pm.max_children上限,新请求进不来,老请求又卡在等待外部响应上,整个服务进入了典型的死锁状态。
我在登录服务器之后,先看了nginx错误日志,发现504和502夹杂出现。再看php-fpm的slow log,所有慢请求都卡在同一个函数上——一个调用第三方供应商API的封装方法。这个方法里有一个curl请求,设置了CURLOPT_TIMEOUT => 5。正常情况下这个接口50毫秒内就能返回,所以这个5秒看起来毫无问题。
但那天供应商的接口因为对方机房网络问题,单次请求需要6到8秒才超时。由于我的curl超时设置是5秒,请求确实会在5秒后断开,但响应不可能再回来,于是php-fpm的worker进程在等待期间被全部占满。数据库连接池也被打满,连Redis这种本来毫秒级的操作都开始排队。整个服务从“个别接口慢”演变成了“所有接口瘫痪”。
1.2 根因:只有独立超时,没有级联约束
复盘时我在代码里数了一下,项目中对第三方服务的调用有十多个,每个调用都设置了独立的超时时间。有的写3秒,有的写5秒,有的用框架默认值,没有一个统一的口径。更关键的是,这些接口在业务上存在串联关系——用户下单时会先调用库存服务,再调用支付服务,再调用通知服务。假设用户请求进入PHP后,调用库存服务花3秒超时,再调用支付服务花3秒超时,再调用通知服务花3秒超时,那么用户感知到的总耗时就是9秒。而nginx给PHP的fastcgi_read_timeout默认是60秒,所以PHP进程能撑到9秒,但用户那边的网关早在5秒就断开了。
这就是“独立超时”和“级联超时”的本质区别。独立超时只保证“每个调用点自己不会无限等”,但完全不关心多个调用点串联之后的总时间,也不关心上层网关、中间件、PHP执行时间之间的约束关系。级联超时需要做的事情是:从用户容忍度出发,把总预算拆到每一层,并且保证每一层的超时上限严格小于上一层给的剩余时间。
那次事故之后,我把所有网络调用重新梳理了一遍,包括cURL、file_get_contents、Guzzle、MySQL连接、Redis连接,以及nginx和php-fpm的配置。今天这篇文章,就是那套梳理结果的浓缩版。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 一次PHP网络调用到底会卡在哪几段
2.1 网络请求的五个耗时阶段
要设计超时,首先得知道一个网络调用从发起到结束,中间要经历哪些阶段。大多数人对超时的理解停留在“整个请求设置一个最大时间”,但实际上一个完整的HTTP请求包含多个耗时组件,每一段都可能卡住。
以PHP调用一个HTTPS接口为例:
- DNS解析:把域名解析成IP。这步可能快也可能慢,极端情况下几秒都不返回。
- TCP连接:发起TCP三次握手。如果目标IP不通,或者被防火墙丢包,连接阶段就会一直等。
- TLS握手:HTTPS请求需要协商加密参数,证书链验证也在这里。加密套件不同,耗时差异很大。
- 发送请求:把HTTP请求头、请求体写入socket。绝大多数情况下这个阶段很快,但如果发送缓冲区满或者网络拥塞,也可能阻塞。
- 等待响应首字节(TTFB):客户端发出请求后,等待服务器返回第一个字节。这是最容易被拖长的阶段,也是“服务端处理慢”的主要体现。
- 读取响应体:响应头返回后,Content-Length比较大的话,传输也可能持续一段时间。
我见过很多PHP项目只设置一个CURLOPT_TIMEOUT,这个参数确实是整个请求的总时间上限。但如果一个请求卡在了DNS解析阶段,cURL的总超时能不能准确打断,取决于libcurl版本和系统解析器的行为,存在不确定性。更常见的误判是把CURLOPT_CONNECTTIMEOUT当成“连接建立之后就没有限制”,实际上它只管连接阶段。
2.2 单点超时参数覆盖不了所有阶段
用一个生活化的类比来说明。去医院就诊,总目标是“2小时内看完病”。你不可能只在进医院时定一个闹钟,2小时后不管在哪一个环节都直接走人。你需要拆开看:挂号排队最长20分钟,候诊最长40分钟,医生开单检查最长30分钟,取药最长20分钟。如果哪一环超出自己的预算,你就要及时调整,比如告诉医生“我时间不够了,先开主要检查”。
网络调用也一样。只设一个总超时,意味着“无论卡在哪一步,到了总时间就走”。这个思路没有错,但问题在于:总超时的粒度太粗,一旦哪一步出现了异常慢,你很难知道到底是谁拖了后腿,而且无法在“还能救”的时候快速止损。比如连接已经成功,只是服务端处理慢,那还值得再等等;但如果根本连不上,等再久也没有意义。
所以真正的级联超时设计,至少在每一个调用点要做两层区分:
- 连接超时:TCP连接(包括DNS解析、TLS握手)的预算,一般看内部服务还是公网服务,200ms到2s不等。
- 请求总超时:连接建立成功后的“等待服务端处理并返回完整响应”的最长时间。
2.3 隐藏的罪魁:default_socket_timeout
有一个PHP配置项非常容易忽视,叫default_socket_timeout。它的默认值是60秒,影响的是所有基于流操作的socket读写超时。如果你用file_get_contents通过HTTP抓取接口,没有显式指定超时,那这个60秒就是你的读超时上限。很多老项目的“偶发卡死60秒”就是它造成的。
用stream_context_create设置HTTP请求的超时时,timeout参数其实会覆盖default_socket_timeout,但很多人不知道的是,这个timeout同时作用于连接和读取,无法分开控制连接阶段和响应阶段。
php复制$ctx = stream_context_create([
'http' => [
'method' => 'GET',
'timeout' => 3, // 连接和读取一共3秒
],
]);
$result = file_get_contents('http://example.com/api', false, $ctx);
这段代码看起来“设置了3秒超时”,但实际上这个3秒是连接加读取的总额。如果连接花了2.9秒才建立,那读取阶段只剩0.1秒,正常响应根本读不完。所以在级联设计里,我建议所有正式的HTTP调用都走cURL或Guzzle,而不是stream流,因为cURL能分别控制连接、总超时、DNS缓存等多个维度,stream适合快速验证脚本,不适合生产级链路。
3. 级联超时的核心:预算分配、deadline传递与兜底关系
3.1 从用户容忍度倒推每一层预算
级联超时的出发点是用户容忍度,而不是某一个接口的“历史平均耗时”。如果你的产品要求接口在2秒内返回,那2秒就是总预算。这个总预算要在调用链上的所有环节之间做切分,切分完还要留出余量。
举个例子。有一个接口叫“创建订单”,内部逻辑是:
- PHP应用收到请求。
- 调用用户服务验证token。
- 调用库存服务锁定库存。
- 写入MySQL订单表。
- 返回成功。
假设总预算为2秒,按照倒推法可以这样切:
| 环节 | 预算 | 说明 |
|---|---|---|
| 用户服务调用(HTTP) | 500ms | 内部服务,连接超时200ms,总超时500ms |
| 库存服务调用(HTTP) | 600ms | 内部服务,但业务较重,总超时600ms |
| MySQL写入 | 300ms | 含连接池等待,超时300ms |
| 其他逻辑余量 | 600ms | PHP框架初始化、日志、序列化等 |
这里有个原则:各环节预算之和不能等于总预算,而必须小于总预算。上例中500+600+300+600=2000,刚好等于2秒,这是不合理的。因为框架初始化、日志写入、网络抖动这些不可控因素也要占时间。我通常的做法是会再留20%左右的buffer,也就是各核心环节预算之和控制在总预算的80%以内。
调整后的方案是:用户服务400ms,库存服务500ms,MySQL 250ms,其他逻辑余量控制在450ms以内。这样即使某个环节抖动50ms,整体也不会超过2秒。
3.2 连接超时和总超时必须分开设
实际设置时,每个HTTP调用点至少要有两个超时参数,这意味着cURL里必须同时设置CURLOPT_CONNECTTIMEOUT_MS和CURLOPT_TIMEOUT_MS。Guzzle里对应的是connect_timeout和timeout。
连接超时的作用只有一个:快速识别“这个服务当前不可达”。内部服务一般设200到500ms,公网API可以放宽到1秒到2秒。连接超时到了,不要重试,直接降级或者返回失败,因为连不上的服务大概率在短时间内也连不上。
总超时是真正留给服务端处理请求的时间。在级联预算中,总超时 = 连接超时 + 服务端处理时间预算。比如一个内部服务的总预算是400ms,连接超时100ms,那服务端处理预算就是300ms。如果服务端处理时间超过300ms,即使连接很快,也会触发总超时。
很多人只设一个CURLOPT_TIMEOUT_MS,不设连接超时。这样做的后果是:如果目标服务IP不通,cURL会等在TCP握手阶段,直到总超时耗尽。这个行为比“只设连接超时”要糟糕得多,因为它消耗的是整个请求的全部预算,而不是连接阶段的小额预算。比如总超时1秒,连接卡了900ms才失败,那剩下的100ms不可能完成后续的请求,整个调用已经失败了。
3.3 用deadline上下文在代码里传递剩余预算
级联超时的高级做法是:不在每个调用点写死超时值,而是在请求开始时生成一个deadline时间戳,然后把这个deadline通过调用上下文传递给所有下游调用。每经过一个环节,就检查当前剩余时间够不够完成下一步。如果不够,立即返回错误,而不是继续尝试。
这个思路在微服务架构里很常见,对应的是“剩余预算传递”。PHP单体应用同样可以用,尤其是业务内部有多个串联API调用时。
一个简单的实现思路:
php复制class DeadlineContext
{
private float $deadline;
public function __construct(float $budgetMs)
{
$this->deadline = microtime(true) + $budgetMs / 1000;
}
public function leftMs(): float
{
$left = ($this->deadline - microtime(true)) * 1000;
return max(0, (int)$left);
}
public function hasEnough(float $needMs): bool
{
return $this->leftMs() >= $needMs;
}
public function consume(float $costMs): void
{
// 无需特殊处理,deadline时间戳自动扣除消耗
}
}
在入口生成deadline:
php复制public function createOrder(Request $request): Response
{
// 总预算1.5秒
$deadline = new DeadlineContext(1500);
return $this->handleCreateOrder($request, $deadline);
}
在调用用户服务之前检查剩余预算:
php复制private function callUserService(array $params, DeadlineContext $deadline): array
{
if (!$deadline->hasEnough(400)) {
throw new BudgetExceededException('user_service budget not enough');
}
$start = microtime(true);
$result = $this->httpClient->get('/user/info', [
'connect_timeout' => 0.2,
'timeout' => min(0.4, $deadline->leftMs() / 1000),
]);
$deadline->consume(microtime(true) - $start);
return $result;
}
注意这里有个细节:调用点的超时值不是固定写死的,而是取“预设预算”和“当前剩余预算”的较小值。这样做的好处是,一旦上游环节耗时超出预期,下游调用会自然缩短超时,而不是无脑用预设值继续等。整个链路的超时行为就像一串灯笼,前面亮多久,后面就自动缩短多少,不会出现预算透支。
3.4 每一层都要给下层留出余量
级联设计中,“余量”这个词值得反复强调。上层超时永远要大于下层所有可能耗时的总和,否则就会出现“下层还没超时,上层先断了”的倒挂现象。
以nginx和php-fpm为例,最理想的关系是:nginx读超时大于php-fpm的request_terminate_timeout,request_terminate_timeout又大于PHP的max_execution_time,max_execution_time再大于业务内部所有网络调用的总预算。这样每一层都是下一层的安全网,而不是“先动手砍人的那个人”。
如果配置反过来了,比如nginx的fastcgi_read_timeout只有5秒,而PHP业务内部某个调用设置了8秒超时,那么nginx会在第5秒断开连接,返回504,但PHP脚本还会继续执行到8秒才超时。用户看到的错误是504,php-fpm的worker进程却仍然被占用,慢请求问题依然存在,只是错误码变了而已。所以配置超时的时候,脑子里始终要有一张“层级金字塔图”:越靠外的层,超时越长;越靠内的层,超时越短。
4. 不同PHP技术栈下的超时落地写法
4.1 cURL:最常见的实现与两个容易踩的坑
cURL是PHP里最常用的HTTP客户端,也是超时配置最容易出错的地方。一个相对完整的写法是这样:
php复制$ch = curl_init($url);
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_CONNECTTIMEOUT_MS => 200,
CURLOPT_TIMEOUT_MS => 500,
CURLOPT_NOSIGNAL => true,
CURLOPT_FOLLOWLOCATION => false,
CURLOPT_HEADER => false,
]);
$start = microtime(true);
$response = curl_exec($ch);
$costMs = (microtime(true) - $start) * 1000;
if (curl_errno($ch) === CURLE_OPERATION_TIMEDOUT) {
// 超时了,需要分清楚是哪一阶段超时
$info = curl_getinfo($ch);
// $info['namelookup_time'] DNS耗时
// $info['connect_time'] TCP连接耗时
// $info['starttransfer_time'] 首字节耗时
// $info['total_time'] 总耗时
$this->logger->warning('curl timeout', [
'url' => $url,
'connect_timeout' => 200,
'timeout' => 500,
'namelookup_time' => $info['namelookup_time'],
'connect_time' => $info['connect_time'],
'starttransfer_time' => $info['starttransfer_time'],
'total_time' => $info['total_time'],
]);
}
curl_close($ch);
几个容易踩的坑:
第一,CURLOPT_CONNECTTIMEOUT_MS和CURLOPT_TIMEOUT_MS的毫秒版本在PHP 5.5以上才稳定可用。老项目如果还在用PHP 5.4以下,只能传秒数,也就是CURLOPT_CONNECTTIMEOUT。这个兼容性坑会让很多从老代码复制过来的开发者莫名其妙。
第二,当DNS解析结果来自系统解析器时,CURLOPT_CONNECTTIMEOUT_MS可能无法精确打断正在进行的系统调用。我遇到过一次在部分服务器上DNS解析卡了800ms,但我设置连接超时100ms,结果实际连接耗时800ms才失败。这也是为什么我建议对关键域名在/etc/hosts做静态映射,或者用本地DNS缓存服务。
第三,CURLOPT_NOSIGNAL一定要设为true。在PHP中,如果libcurl处理DNS解析时发生超时,默认会使用SIGALRM信号。在多线程环境下,这个信号可能干扰PHP进程的其他操作,导致奇怪的崩溃或行为异常。设成true之后,libcurl会用超时事件机制替代信号机制,更安全也更可控。
4.2 stream和file_get_contents:简单但精度有限
如果你的项目还在用file_get_contents做HTTP调用,建议尽快迁移。stream方式的超时精度太粗,它的timeout只接受秒数,无法设置毫秒。在高性能接口调用场景里,500ms和1000ms的差别是巨大的。
如果确实只是临时脚本或简单场景,可以用:
php复制$ctx = stream_context_create([
'http' => [
'method' => 'GET',
'timeout' => 3,
'ignore_errors' => true,
],
]);
$result = file_get_contents('http://example.com/api', false, $ctx);
这里有个细节:timeout => 3单位是秒,既覆盖连接也覆盖读取。如果你需要更精细的控制,可以结合stream_set_timeout对socket进行二次设置,但操作起来远不如cURL直观。我的建议很直接:项目里任何需要计入级联预算的网络调用,一律用cURL或者基于cURL的Guzzle,stream只适合本地文件的读写操作。
4.3 Guzzle等HTTP客户端:用超时选项组合
Guzzle是目前PHP社区使用最广泛的HTTP客户端,它的超时配置比原生cURL更友好一点,但命名容易混淆。
php复制use GuzzleHttp\Client;
$client = new Client([
'base_uri' => 'http://service-b.internal/',
'timeout' => 1.2,
'connect_timeout' => 0.5,
'read_timeout' => 1.0,
]);
这里的timeout是请求总超时,connect_timeout是连接超时,read_timeout是读取响应体阶段的最大时间。初次接触的人容易漏掉read_timeout。如果你用Guzzle的stream模式读取大的响应体,而服务端持续推送数据很慢,timeout可能从发起请求开始计时,会包含处理响应体的时间。这时read_timeout单独限流会更精准。
需要注意,Guzzle底层仍然是cURL,所以前面提到的CURLOPT_NOSIGNAL等行为依然有效,只是Guzzle已经帮你封装好了。对于大多数PHP项目,我从稳定性角度建议直接用Guzzle,而不是在自己封装的curl函数里做复杂的参数管理。
4.4 Swoole/Workerman常驻进程:用协程和定时器控制
如果你的项目已经迁移到Swoole或Workerman这种常驻内存模型,超时控制的方式会发生根本变化。传统PHP FPM模式下,一个请求对应一个进程,超时到了之后要么靠PHP的max_execution_time,要么靠cURL自己的超时来打断。但Swoole协程模式下,一个进程里可以存在上万个并发协程,你不能再依赖进程级的中断机制,而是要在每个协程的I/O操作上挂上超时。
Swoole的协程HTTP客户端直接支持超时参数:
php复制use Swoole\Coroutine\Http\Client;
$client = new Client('service-b.internal', 80);
$client->set([
'timeout' => 0.5,
'connect_timeout' => 0.2,
]);
$client->get('/api');
更复杂的场景是用Swoole\Coroutine\Channel配合Swoole\Timer,实现“等待结果或超时二选一”的效果。比如一个请求需要同时调用多个下游接口,整体预算1秒,可以用多个协程并发发出请求,主协程用Channel->pop(1)等待,谁先回来谁先处理,超过1秒仍然没返回的协程自动丢弃或降级。
这种并发汇聚模式在传统FPM里很难优雅实现,但在Swoole环境里是标配。做级联超时设计时,如果用了Swoole,建议把业务里所有的HTTP调用都换成协程客户端,而不是仍然用Guzzle加同步阻塞,否则会浪费协程并发带来的吞吐优势。
4.5 DNS解析阶段的超时控制思路
DNS解析是很多PHP超时问题的灰色地带。cURL的CURLOPT_CONNECTTIMEOUT_MS理论上会覆盖包含DNS在内的连接建立阶段,但在某些系统配置下,getaddrinfo调用本身可能阻塞很久,甚至不受连接超时控制。
我的做法有三层:
- 对核心依赖服务的域名,在代码层做进程级或应用级DNS缓存。PHP原生没有DnsClient,但可以通过静态数组在进程内做短时缓存,或者用Redis缓存解析结果。
- 在服务器层面,对核心域名配置
/etc/hosts静态映射。内部服务用内部域名解析,外部公众服务如果IP很少变动,也可以考虑静态host。 - 如果libcurl的DNS解析行为实在不可控,可以考虑在cURL调用前先自己用
dns_get_record做一次域名解析,如果解析时间超过阈值,直接判定为超时。
DNS解析阶段在整个调用链路中占比通常不大,但它一旦卡住,后续所有超时都形同虚设。我建议把DNS视为一个独立的子系统来治理,尤其在做级联设计时,必须意识到这一环也有自己的超时预算。
5. Nginx、PHP-FPM和应用层一起构成最后防线
5.1 三个容易混淆的超时参数
很多PHP项目即使代码层把cURL超时做得再好,nginx和php-fpm配置不对,依然会出问题。这里有三个参数经常被搞混:
| 参数 | 所在层 | 作用 | 默认值 | 触发表现 |
|---|---|---|---|---|
fastcgi_read_timeout |
nginx | nginx等待PHP-FPM返回响应的最长时间 | 60s | nginx返回504 |
request_terminate_timeout |
php-fpm | PHP-FPM最多允许一个请求处理多久 | 0(不限制) | php-fpm终止请求,nginx可能返回502 |
max_execution_time |
PHP引擎 | PHP脚本最多允许执行多久 | 30s | PHP抛Fatal error |
需要特别注意的是,有三个超时参数作用范围不同,但经常被混为一谈。max_execution_time是PHP引擎自己掐表,只能限制PHP脚本执行的时间;request_terminate_timeout是PHP-FPM的强制清理机制,到点后不管脚本在干什么都会终止;fastcgi_read_timeout是nginx从网络层超时的兜底。
5.2 推荐配置:上游永远大于下游
根据级联设计的原则,这三个参数应该从外到内依次递减。一套我实测下来的合理配置如下:
nginx的PHP location配置:
nginx复制location ~ \.php$ {
include fastcgi_params;
fastcgi_pass 127.0.0.1:9000;
fastcgi_connect_timeout 3s;
fastcgi_send_timeout 10s;
fastcgi_read_timeout 10s;
}
php-fpm池配置(php-fpm.d/www.conf):
ini复制request_terminate_timeout = 9s
php.ini:
ini复制max_execution_time = 8
default_socket_timeout = 6
这个配置里,nginx最外层容忍10秒,PHP-FPM强制9秒内结束,PHP脚本最长8秒,业务内部所有网络调用的总预算要控制在7秒以内。每一层都是上一层的“小一号版本”,就像套娃一样。业务层的总预算7秒,已经给框架初始化、日志输出、异常处理等非网络开销留了1秒余量,这些余量在涉及重试时尤其重要。
如果你前面还有API网关或者负载均衡器,那网关的超时也要大于nginx的10秒,比如12秒到15秒。整个链条就是一个从外到内逐层收缩的金字塔。任何一层的超时设置违反了“外大内小”的顺序,都可能出现上层先断、下层还在跑的怪物行为。
5.3 一层超时引发的时间线
为了更直观地理解各层超时的先后关系,可以模拟这样一个场景:一个下单接口内部调用库存服务,库存服务很慢,代码里给这个调用的总超时设置为7秒。
时间线如下:
- 第0秒:用户请求进入nginx,nginx开始等待PHP-FPM返回。
- 第0.5秒:PHP脚本开始执行,调用库存服务,cURL超时设置7秒。
- 第3秒:nginx的
fastcgi_read_timeout配置为10秒,此时未触发,继续等待。 - 第6.5秒:PHP脚本调用库存服务的cURL超时,抛出异常,脚本进入异常处理,准备返回错误响应。
- 第7秒:PHP脚本返回错误响应,nginx收到,整个过程未触发nginx的超时,用户看到的是业务层定义的错误信息。
但如果在步骤4中,PHP脚本因为某个原因卡住了,比如日志写入阻塞,到第8秒时max_execution_time触发,脚本被强制终止,返回500。如果脚本连终止都不能及时,到第9秒时php-fpm的request_terminate_timeout触发,强制杀掉请求,nginx可能返回502。如果php-fpm自身僵住,nginx到第10秒时fastcgi_read_timeout触发,返回504。
这条时间线演示的是:级联超时的意义在于让问题尽量在“内层”解决,而不是拖到外层触发。如果业务层第7秒就能返回错误,用户收到的是明确的业务错误,这是最理想的结果。如果业务层没有控制好,问题就会逐级向外传播,最终演变成502、504,用户完全不知道发生了什么,排查也变得更加困难。
6. 超时后的连锁动作:重试、降级、熔断
6.1 重试会偷走你的超时预算
很多人在接口超时后的第一反应是“重试一次”。这个想法很危险,因为重试不是免费的,它会从总预算中扣除额外的时间。
假设一个调用点预算是1200ms,第一次调用设置总超时800ms。结果800ms超时了,如果立刻重试,第二次调用就算设置新的超时400ms,总耗时已经达到1200ms。如果第二次也超时,整个请求就没有任何时间返回兜底结果了。用户会直接等到总预算耗尽,然后收到一个空白超时错误。
正确做法是把首试和重试当成一个整体来规划预算。比如总调用预算1200ms,我可以这样拆分:
- 首试:连接超时200ms,总超时500ms。
- 重试等待:指数退避200ms到300ms,加随机抖动。
- 重试:连接超时150ms,总超时300ms。
- 如果重试也超时,剩余时间直接走降级逻辑,不再发起任何网络请求。
换算下来,首试500ms + 等待300ms + 重试300ms = 1100ms,还留有100ms给降级逻辑。这样重试才能做到“失败之后还能体面退出”。
6.2 重试必须结合幂等和剩余预算
重试还有一个前提条件:这个请求必须幂等。如果第一次调用已经把订单创建出来了,只是响应超时,你重试一次,可能会创建两笔订单。所以对于写操作,要么接口本身支持幂等键,要么不重试,或者仅在明确的连接失败时才重试。
我的经验是:连接超时(CURLE_COULDNT_CONNECT)或DNS解析失败这类“请求根本没发出去”的错误,可以安全重试;已经发出请求并且等待响应超时的错误,比如CURLE_OPERATION_TIMEDOUT,要谨慎重试,最好配合幂等键。在代码层面,可以用Guzzle的中间件或者自己封装的重试函数,把这两个错误类型区分开。
在级联设计中,重试还必须检查剩余预算。可以用前面提到的DeadlineContext,每次重试前问一句“剩余时间还够不够跑一次完整调用”,不够就立即放弃,走降级。
6.3 熔断和降级:把失败挡在应用外面
如果某个下游服务持续故障,重试只会让整个链路雪上加霜。这时候需要熔断机制。熔断的核心是“快速失败,不要一直尝试”。
PHP里可以用Redis做一个轻量级的计数器熔断:
- 对每个下游接口维护一个失败计数和总请求计数。
- 在一个时间窗口内(比如10秒),如果失败率超过50%,或者失败次数达到某个阈值,直接开启短路模式,后续请求不再实际调用该接口,而是立即返回预设的兜底值。
- 短路模式持续30秒后进入半开状态,放少量探活请求进来,如果成功,则恢复;如果失败,则继续短路。
这个逻辑不需要引入复杂的服务治理框架,用Redis的INCR和EXPIRE就能实现。放在ServiceProvider或者请求入口处,对所有下游调用做统一拦截。
降级则是在调用失败后返回兜底数据。对读操作,可以返回上次成功的缓存结果;对写操作,可以返回“系统繁忙,请稍后重试”或者把请求写入待处理队列。降级本身也有时间预算,必须保证在总预算耗尽之前完成。
6.4 可观测性:每次超时都要能复盘
最后一点可能不算是超时设计本身,但没有它,前面的设计都很难持续优化。我用了一个很朴素的做法:在每次网络调用发生时,记录调用点名称、预设超时、实际耗时、失败阶段、剩余预算和最终结果,写入结构化日志或者按接口分桶的独立日志文件。
php复制$this->logger->info('http_call_finished', [
'operation' => 'user_service.info',
'timeout' => 500,
'connect_timeout' => 200,
'dns_ms' => $info['namelookup_time'],
'connect_ms' => $info['connect_time'],
'ttfb_ms' => $info['starttransfer_time'],
'total_ms' => $costMs,
'remaining_budget_ms' => $deadline->leftMs(),
'success' => $success,
]);
等日志积累一周后,你就能看到哪些调用点的平均耗时接近超时阈值,哪些调用点在高峰期波动极大,哪些调用点经常出现“连接快但响应慢”的规律。这时候再回头调整预算分配,调整的每一步都有数据支撑,而不是靠感觉猜。
这几年我碰到的线上事故,十次里有八次不是流量问题,而是超时链路没有设计好。预算分配、分层兜底、重试策略、熔断降级,这些东西看着琐碎,但它们组合在一起就是一套完整的稳定性方案。与其每次出事之后临时调大某个timeout,不如花一个下午把调用链路上的所有超时梳理一遍,算清楚每一层的预算。这套活儿做完之后,你再去面对线上抖动,心态会完全不一样。
