ARTICLE DETAIL

资讯详情

深耕网站视觉设计与运营推广的一线实战洞察。

PHP 8.2接口怎么对接第三方API不超时

PHP 8.2接口怎么对接第三方API不超时 前言“接口怎么会超时”——大多数时候不是你的代码慢而是你在等别人。对接第三方 API 时典型症状有这么几类监控上偶发cURL error 28: Operation timed out上游限流之后请求量翻倍、整站雪崩压测时 P99 正常一到线上就出现大量 504以及最诡异的一种——进程明明卡在curl_exec()上max_execution_time却怎么都不触发。这些症状指向同一件事超时不是“一个数字”而是一整条预算链。连接多久、TLS 握手多久、总耗时多少、失败重试几次、退避多久、Nginx 与 PHP-FPM 各允许多久——任何一个环节算错都会以“接口超时”的形式暴露出来。本文以 PHP 8.2 为运行环境讲清楚四件事cURL 里到底有几个超时、超时预算怎么分配、重试该怎么做才不会引发雪崩、并发请求怎么避免串行等待。示例用到了 PHP 8.0 的构造函数属性提升constructor property promotion、8.1 的枚举enum和 8.2 的只读类readonly class都可以直接保存成.php运行。一、cURL 的超时不是一个而是四个这是排查超时问题的第一课。用curl_setopt()能控制的时长至少有这么几层选项控制什么默认值说明CURLOPT_CONNECTTIMEOUT/_MS建立 TCP 连接 TLS 握手的耗时300 秒不设置时由 libcurl 的默认值兜底CURLOPT_TIMEOUT/_MS从发起到整个请求结束的总耗时0即无限等待上限受CURLOPT_MAXFILESIZE之外无约束CURLOPT_LOW_SPEED_LIMITCURLOPT_LOW_SPEED_TIME低于多少速率持续多久就中断0不启用对“连上了但不传数据”的场景有效CURLOPT_DNS_CACHE_TIMEOUTDNS 缓存时长60 秒影响解析在请求中的位置关键点是这两者的关系CONNECTTIMEOUT只有在小于总超时时才有意义。总超时 3 秒、连接超时 5 秒时实际生效的永远是前者报错信息却仍可能写着Operation timed out让人误以为是连接慢。默认不设CURLOPT_TIMEOUT就等于无限等待这是绝大多数“接口挂死”事故的直接原因。libcurl 在总超时为 0 时会一直等到socket 出错为止而 TCP 在没有 RST 的情况下可以僵持非常久。还有两个 PHP 侧的细节值得记住max_execution_time在 Linux 上不统计阻塞的系统调用耗时DNS 解析、socket 读写都属于此类所以一个卡在curl_exec()上的请求不会被这个指令杀死。超时必须由 cURL 自己来管。PHP 8.0 起curl_init()返回的是CurlHandle对象而不是resourcecurl_close()因此基本成了空操作——句柄由垃圾回收负责释放。用is_resource($ch)判断句柄类型的老代码在 8.x 上会失效要改用instanceof CurlHandle。二、超时预算让每一层都比上一层短一个请求从浏览器到第三方 API会穿过好几层超时设置。原则只有一条内层超时必须小于外层超时否则外层会先掐断而内层还在傻等你既拿不到明确的错误也浪费了一次本可以重试的机会。层级典型配置项建议取值理由浏览器 / 客户端前端 fetch 超时10s用户体验上限网关Nginxproxy_read_timeout8s比前端短先返回 504PHP-FPMrequest_terminate_timeout8s兜底的硬杀比 Nginx 略短即可业务代码总预算单次请求内所有下游之和6s留出 2s 给框架和数据库单个第三方调用总超时CURLOPT_TIMEOUT_MS3s必须小于业务总预算单次连接超时CURLOPT_CONNECTTIMEOUT_MS800ms必须小于单次调用总超时把这条链写成配置就不容易出错; php-fpm.d/www.conf ; 硬超时比 Nginx 的 proxy_read_timeout 略短谁先到谁说话 request_terminate_timeout 8s ; php.ini max_execution_time 8# 网关层 location ~ \.php$ { proxy_connect_timeout 2s; proxy_send_timeout 8s; proxy_read_timeout 8s; }三、重试只重试该重试的并且要有抖动第三方接口失败时立刻重试是最容易把小故障放大成故障的做法。三条规则第一只重试幂等请求。GET、HEAD、PUT、DELETE 天然幂等前提是 PUT 的语义没被写成“追加”POST 要想重试必须带幂等键idempotency key并且服务端真的按这个键去重。第二分清可重试与不可重试。连接被拒、连接超时、5xx、429 属于“过一会儿可能就好了”值得重试400、401、403、404、422 属于“再试一百次也一样”重试只是浪费配额和延迟。失败类型是否重试说明CURLE_COULDNT_CONNECT7是对端可能正好在重启CURLE_OPERATION_TIMEOUTED28谨慎请求可能已经到达并被执行仅幂等请求可重试HTTP 429是必须优先遵守Retry-After响应头HTTP 500 / 502 / 503 / 504是服务端瞬时故障HTTP 400 / 401 / 403 / 404 / 422否参数或鉴权问题重试无意义第三退避要带抖动jitter。所有失败请求按同样间隔重试会在同一时刻再次撞到上游形成“重试风暴”在指数退避基础上加随机量把时间打散即可。四、并发别让串行等待吃掉预算如果一次请求里要调三个第三方接口串行执行的话总耗时是三者之和超时风险直接翻三倍。用curl_multi_*把这几个请求并发发出总耗时取决于最慢的那个$mh curl_multi_init(); foreach ($urls as $ch) { // $urls 是已经用 curl_init 超时选项初始化好的句柄数组 curl_multi_add_handle($mh, $ch); } $running null; do { curl_multi_exec($mh, $running); if ($running 0) { curl_multi_select($mh, 0.2); // 等待事件避免忙轮询烧 CPU } } while ($running 0); foreach ($urls as $ch) { $body curl_multi_getcontent($ch); // 各句柄的超时仍由 CURLOPT_TIMEOUT_MS 兜底 curl_multi_remove_handle($mh, $ch); } curl_multi_close($mh);要点是每个句柄都要单独设置CURLOPT_TIMEOUT_MScurl_multi_select()的等待时间要远小于最短超时循环里不要做任何耗时操作——多路复用的本质是让事件循环转起来任何阻塞都会让并发退化回串行。五、完整可运行示例带超时、分类、退避的客户端把前面的规则组合成一个可以直接用的客户端。它用到 PHP 8.2 的只读类、8.1 的枚举、8.0 的属性提升与match?php declare(strict_types1); // 运行环境PHP 8.2 enum Failure: string { case Connect connect; // 连不上值得重试 case Timeout timeout; // 超时仅幂等请求可重试 case RateLimited rate_limited; // 429按 Retry-After 重试 case Server server; // 5xx值得重试 case Client client; // 4xx重试无意义 case Unknown unknown; } readonly class HttpResult { public function __construct( public int $status, public string $body, public ?Failure $failure null, public int $attempts 1, public float $elapsedMs 0.0, public array $headers [], ) {} public function ok(): bool { return $this-failure null $this-status 200 $this-status 300; } public function header(string $name): ?string { return $this-headers[strtolower($name)] ?? null; } } final class ApiClient { private const RETRYABLE_CODES [429, 500, 502, 503, 504]; public function __construct( private string $baseUrl, private int $connectTimeoutMs 800, private int $totalTimeoutMs 3000, private int $maxAttempts 3, private array $defaultHeaders [Accept: application/json], ) {} public function get(string $path, array $headers []): HttpResult { return $this-send(GET, $path, null, $headers); } /** 只有带幂等键的写请求才允许重试 */ public function post(string $path, array $payload, string $idempotencyKey): HttpResult { return $this-send(POST, $path, $payload, [ Content-Type: application/json, Idempotency-Key: . $idempotencyKey, ]); } private function send(string $method, string $path, ?array $payload, array $headers): HttpResult { $attempt 0; $started microtime(true); $result null; while ($attempt $this-maxAttempts) { $attempt; $result $this-attempt($method, $path, $payload, $headers, $attempt, $started); if ($result-ok() || !$this-shouldRetry($result)) { return $result; } if ($attempt $this-maxAttempts) { $this-sleepBackoff($attempt, $result); } } return $result; } private function attempt( string $method, string $path, ?array $payload, array $headers, int $attempt, float $started, ): HttpResult { $ch curl_init(); $responseHeaders []; $options [ CURLOPT_URL rtrim($this-baseUrl, /) . / . ltrim($path, /), CURLOPT_RETURNTRANSFER true, CURLOPT_CUSTOMREQUEST $method, // 连接超时必须小于总超时否则这个设置形同虚设 CURLOPT_CONNECTTIMEOUT_MS $this-connectTimeoutMs, CURLOPT_TIMEOUT_MS $this-totalTimeoutMs, CURLOPT_TCP_KEEPALIVE 1, CURLOPT_ENCODING , CURLOPT_HTTPHEADER array_merge($this-defaultHeaders, $headers), CURLOPT_HEADERFUNCTION static function ($ch, string $line) use ($responseHeaders): int { $pos strpos($line, :); if ($pos ! false) { $responseHeaders[strtolower(trim(substr($line, 0, $pos)))] trim(substr($line, $pos 1)); } return strlen($line); }, ]; if ($payload ! null) { $options[CURLOPT_POSTFIELDS] json_encode($payload, JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR); } curl_setopt_array($ch, $options); $body curl_exec($ch); $errno curl_errno($ch); $status (int) curl_getinfo($ch, CURLINFO_RESPONSE_CODE); curl_close($ch); $elapsed (microtime(true) - $started) * 1000; $failure match (true) { $errno CURLE_OPERATION_TIMEOUTED Failure::Timeout, $errno CURLE_COULDNT_CONNECT Failure::Connect, $errno ! 0 Failure::Unknown, $status 429 Failure::RateLimited, $status 500 Failure::Server, $status 400 Failure::Client, default null, }; return new HttpResult( status: $status, body: is_string($body) ? $body : , failure: $failure, attempts: $attempt, elapsedMs: round($elapsed, 1), headers: $responseHeaders, ); } private function shouldRetry(HttpResult $r): bool { return match ($r-failure) { Failure::Connect, Failure::Server, Failure::RateLimited true, Failure::Timeout false, // 仅当确认上游幂等时才改为 true default false, } || in_array($r-status, self::RETRYABLE_CODES, true); } private function sleepBackoff(int $attempt, HttpResult $r): void { // 429 优先尊重服务端给的 Retry-After秒没有再用指数退避 $retryAfter (int) ($r-header(retry-after) ?? 0); $baseMs $retryAfter 0 ? $retryAfter * 1000 : 100 * (2 ** ($attempt - 1)); // 100ms、200ms、400ms // 全抖动full jitter在 [0, base] 内随机把重试打散 $delayMs random_int((int) ($baseMs / 2), $baseMs); if ($retryAfter 0 $retryAfter 5) { throw new RuntimeException(上游要求等待 . $retryAfter . 秒超过本次请求预算); } usleep($delayMs * 1000); } }调用与验证?php require __DIR__ . /ApiClient.php; // 地址放环境变量便于区分环境 $client new ApiClient(baseUrl: getenv(API_BASE_URL), totalTimeoutMs: 1500); $res $client-get(/v1/ping); printf(状态%d 成功%s 尝试%d 耗时%.1fms\n, $res-status, $res-ok() ? yes : no, $res-attempts, $res-elapsedMs);验证超时是否真的生效最快的办法是把totalTimeoutMs设成 200 去请求一个需要 1 秒才响应的地址观察failure是否变成timeout再调大到 5000 看是否成功。HttpResult是只读类readonly classPHP 8.2 引入构造后属性不可再改响应头统一按小写键名存放Retry-After这类取值不受大小写影响。常见坑点1. 不设CURLOPT_TIMEOUT❌curl_setopt($ch, CURLOPT_URL, $url); $body curl_exec($ch);✅curl_setopt($ch, CURLOPT_TIMEOUT_MS, 3000)并让CONNECTTIMEOUT_MS更小2. 把max_execution_time当成超时保护❌ 只设max_execution_time 8就以为卡住的curl_exec()会被杀掉 ✅ 超时由 cURL 自己管max_execution_time在 Linux 上不计阻塞 IO 的耗时3. 连接超时比总超时还大❌CURLOPT_CONNECTTIMEOUT_MS 5000而CURLOPT_TIMEOUT_MS 3000✅ 连接超时必须小于总超时否则前者永远不会先触发4. 无差别重试所有失败❌ 收到 404 也重试三次或者对未带幂等键的 POST 重试 ✅ 只重试连接错误、429、5xx写请求必须带幂等键5. 固定间隔重试❌sleep(1)然后重试所有请求同时回来再撞一次上游 ✅ 指数退避 随机抖动并优先遵守Retry-After6. 循环里逐条调用第三方❌ 100 条数据循环调 100 次 API每次都等 1 秒 ✅ 能批量就用批量接口不能批量则用curl_multi_*并发并给整体设一个 deadline7. 判断句柄类型用了老写法❌if (is_resource($ch))——PHP 8.0 起句柄是CurlHandle对象这个判断恒为 false ✅if ($ch instanceof CurlHandle)总结关注点正确做法关键数字来源总超时显式设置CURLOPT_TIMEOUT_MS必须小于上游业务预算连接超时显式设置且小于总超时一般取总超时的 1/3 左右超时预算内层 外层逐层收紧Nginx / FPM / cURL 三层对齐重试条件幂等 可重试错误码4xx除 429一律不重试退避策略指数退避 随机抖动优先使用Retry-After并发curl_multi_* 整体 deadline总耗时取决于最慢的那个校验用慢接口实测超时是否真的触发不靠猜靠跑对接第三方 API 的核心不是“调通”而是给失败留好退路超时要有上限上限要互相匹配重试要克制且有随机性并发要拉开。把这四条固定成客户端里的默认行为线上那种“偶发 504 越滚越大”的问题就很难再出现。
返回列表