知识

如何在 PHP 中用 cURL 和 Guzzle 使用住宅代理

PHP 里的代理:cURL 的 CURLOPT_PROXY 和 CURLOPT_PROXYUSERPWD、Guzzle 的 proxy 选项与连接复用、CURLOPT_HTTPPROXYTUNNEL,以及按请求的地理轮换。

Chris Collins

Chris Collins

2026年7月26日 · 4 分钟阅读

PHP 至今仍承载着 web 上海量的服务器到服务器流量,而其中大部分都走两条路径之一:裸 cURL,或架在 cURL 之上的 Guzzle。把一个住宅代理接进这两者中的任何一个,只是几个选项,而不是一次重写。摩擦在于每个库处理方式不同的那些细节:代理认证是怎么传的、一个决定 HTTPS 经代理到底能不能通的设置,以及那个把快抓取器和”每次请求都要付一次完整握手”的抓取器区分开来的连接复用行为。

这是与用 Python 使用住宅代理用 Playwright用 Go同一系列的 PHP 篇:cURL 和 Guzzle 都能跑的代码,加上 PHP 特有的那些陷阱。

下面的一切都使用 Shifter 的住宅 gateway:一个端点 p.shifter.io:443,所有定位都编码在用户名里。换成别家供应商,就换掉主机和凭据;形态是一样的。

一段话讲清 gateway 模型

代理用户名同时承载你的认证你的定位。你不是靠切换端点来换国家或换会话,而是靠改用户名字符串:

customer-USERNAME-country-us-sid-abc123-ttl-600

country-us 定位美国,sid 固定一个粘性会话,ttl 把那个 IP 保持 N 秒。去掉 sid/ttl,每一条新连接都会轮换。密码是恒定的。这一整条字符串就是你交给 cURL 或 Guzzle 的用户名。

裸 cURL

cURL 用 CURLOPT_PROXY 接收代理主机,用 CURLOPT_PROXYUSERPWD 接收凭据。你也可以把 user:pass@host 内联进代理字符串,但把凭据放在它自己的选项里更干净,也省去了长用户名带来的 URL 编码麻烦。

<?php
$user = getenv('SHIFTER_USER') . '-country-us';
$pass = getenv('SHIFTER_PASS');
$ch = curl_init('https://api.ipify.org');
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_PROXY => 'p.shifter.io:443',
CURLOPT_PROXYUSERPWD => "$user:$pass",
CURLOPT_HTTPPROXYTUNNEL => true, // 经代理走 HTTPS 的 CONNECT 隧道
CURLOPT_CONNECTTIMEOUT => 10, // 仅连接阶段
CURLOPT_TIMEOUT => 30, // 整个传输
]);
$body = curl_exec($ch);
if ($body === false) {
fwrite(STDERR, 'curl error: ' . curl_error($ch) . "\n");
} else {
echo $body, "\n"; // 一个美国住宅 IP
}
curl_close($ch);

有两件事要内化。$user 字符串里包含了定位标志(-country-us),因为地理就住在那里。而 CURLOPT_HTTPPROXYTUNNEL 正是让 HTTPS-经-代理能通的东西:它告诉 cURL 打开一条 CONNECT 隧道,好让 TLS 与目标端到端地协商,而不是与代理。把它关掉,经 HTTP 代理的 HTTPS 请求就会失败、或表现得很怪。这是最常见的 PHP 代理错误,没有之一。

Guzzle

Guzzle 通过请求(或客户端)的 proxy 选项接收代理,凭据内联在 URL 里。因为 Guzzle 是 cURL 的封装,底层适用同样的隧道行为,但对 https:// 目标,Guzzle 会自动处理 CONNECT

<?php
require 'vendor/autoload.php';
use GuzzleHttp\Client;
$user = getenv('SHIFTER_USER') . '-country-us';
$pass = getenv('SHIFTER_PASS');
$proxy = "http://$user:$pass@p.shifter.io:443";
// 只构建一次客户端并复用它(见下面关于连接复用的说明)。
$client = new Client([
'proxy' => $proxy,
'connect_timeout' => 10, // 连接阶段
'timeout' => 30, // 整个请求
]);
$res = $client->get('https://api.ipify.org');
echo $res->getBody(), "\n"; // 一个美国住宅 IP

注意分开的 connect_timeouttimeout。这个”连接对总时长”的划分,正是当有东西卡住时让超时可被诊断的关键:连接慢指向你这侧、或没有匹配的 IP,总时长慢指向目标。

Guzzle 也接受按请求传的 proxy 选项,以及一个按协议方案作键的数组,你就是这样只把 https 路由经代理、或设一个 no 绕过列表:

$res = $client->get('https://example.com', [
'proxy' => [
'http' => $proxy,
'https' => $proxy,
'no' => ['localhost', '127.0.0.1'],
],
]);

坑 1:复用 Guzzle 客户端(以及 cURL 句柄)

每个请求都造一个新的 GuzzleHttp\Client、或每个请求都 curl_init() 一次,就要每次都经代理付一次完整的 TCP + TLS 握手,正是延迟指南存在的意义所要消除的那部分开销。Guzzle 在底层维护着一个 cURL 句柄池,当你复用客户端时它会复用连接。在启动时构建一个客户端、注入它、并把它留住。

对裸 cURL,跨请求复用句柄,两次调用之间只改 URL,好让连接保持温热:

$ch = curl_init();
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_PROXY => 'p.shifter.io:443',
CURLOPT_PROXYUSERPWD => "$user:$pass",
CURLOPT_HTTPPROXYTUNNEL => true,
]);
foreach ($urls as $url) {
curl_setopt($ch, CURLOPT_URL, $url); // 复用句柄,保持连接
$body = curl_exec($ch);
// ... 处理 $body
}
curl_close($ch);

坑 2:PHP 默认一次抓一个 URL

PHP 的请求处理默认是同步的。一个在 curl_exec 上循环的抓取器,严格地一次抓一个 URL,这对小活儿没问题,但在规模上慢得让人痛苦。两条上行的路:

Guzzle 异步配一个有界的池,让 N 个请求在途、但绝不更多:

use GuzzleHttp\Pool;
use GuzzleHttp\Psr7\Request;
$requests = function ($urls) {
foreach ($urls as $u) { yield new Request('GET', $u); }
};
$pool = new Pool($client, $requests($urls), [
'concurrency' => 8, // 限制在途请求数
'fulfilled' => function ($response, $i) { /* 处理 */ },
'rejected' => function ($reason, $i) { /* 记日志 + 重试 */ },
]);
$pool->promise()->wait();

如果你在裸 cURL 上,就用 curl_multi_*。无论哪种,都按目标主机限制并发,而不是全局限制,好让脆弱的站点不被猛击、宽松的站点也不被饿死。越过一个目标的容忍度之后,更多并行买来的是封锁,而不是吞吐量(如何避免被封)。

坑 3:消费响应,并检查传输错误,而不只是状态

cURL 在传输失败时(代理拒绝、隧道失败、超时)返回 false,在有 HTTP 响应时返回一个 body 字符串——哪怕是 407 或 502。在信任状态码之前,先把 curl_exec 的返回值对着 false 检查,并读取 curl_error/curl_errno。在 Guzzle 里,连接失败会抛 ConnectException,而 4xx/5xx 只有在 http_errors 开着时才抛 RequestException(默认它是开着的)。两者都要处理,并且总是读取 body,好让句柄空出来可复用:

use GuzzleHttp\Exception\ConnectException;
use GuzzleHttp\Exception\RequestException;
try {
$res = $client->get($url);
$body = (string) $res->getBody(); // 排空 body
} catch (ConnectException $e) {
// 传输:代理/隧道/超时 —— 用新身份重试
} catch (RequestException $e) {
// HTTP 状态 —— 检查 $e->getResponse()->getStatusCode()
}

轮换地理与会话

因为定位住在用户名里,不同的身份就是不同的凭据字符串。

裸 cURL: 在调用前把 CURLOPT_PROXYUSERPWD 设为新的用户名。同一个句柄可以在多次迭代之间携带不同的身份。

Guzzle: 按请求传 proxy 选项来覆盖客户端默认值,这样一个客户端就能服务多个身份、无需重建:

function userFor(string $country, ?string $sid = null): string {
$u = getenv('SHIFTER_USER') . '-country-' . $country;
if ($sid !== null) { $u .= '-sid-' . $sid . '-ttl-600'; }
return $u;
}
$pass = getenv('SHIFTER_PASS');
$proxy = 'http://' . userFor('de', 'job-42') . ":$pass@p.shifter.io:443";
$res = $client->get('https://example.com', ['proxy' => $proxy]);

给每个逻辑工作单元它自己的 sid,并在单元之间轮换、而不是在流程中途轮换(粘性 vs 轮换讲了这个区分),并按负载均衡那篇所述把工作映射到身份。

验证你确实在走代理

在给别的任何东西做基准或调试之前,先确认出口 IP:

$res = $client->get('http://ip-api.com/json');
echo $res->getBody(), "\n"; // 期望是目标国家的一个住宅 IP

返回你自己的 IP,说明代理选项没被应用。挂住不动,说明本地出站被挡了。这两种情况都在超时诊断指南里有讲。

常见问题

为什么我的 HTTPS 请求在裸 cURL 里经代理会失败? 你几乎肯定漏了 CURLOPT_HTTPPROXYTUNNEL。经 HTTP 代理走 HTTPS 需要一条 CONNECT 隧道,好让 TLS 与目标协商、而不是与代理。设 CURLOPT_HTTPPROXYTUNNEL => true。对 https:// 目标,Guzzle 会自动做这件事,所以这个坑只咬裸 cURL。

代理凭据我该放进 URL 还是放进单独的选项? 两者都行。在裸 cURL 里,CURLOPT_PROXYUSERPWD 把长长的 gateway 用户名挡在 URL 之外,并避免 URL 编码问题。在 Guzzle 里,把 user:pass@host 内联进 proxy 字符串是常规做法。选一种,并保持一致。

我的 PHP 抓取器很慢,尽管代理很快。为什么? 最可能的是你在每个请求都创建一个新的 Guzzle 客户端(或 curl_init),每次都付一次全新的握手,或者你严格地一次抓一个 URL。复用一个客户端/句柄,并用一个有界的 Guzzle Poolcurl_multi 来并发跑多个请求。

在 PHP 里我怎么按请求轮换 IP? 变换代理用户名。在裸 cURL 里,每次调用前把 CURLOPT_PROXYUSERPWD 设为新的用户名。在 Guzzle 里,按请求传 proxy 选项。两者都能让一个共享的客户端或句柄服务多个身份。

抓取该用 cURL 还是 Guzzle? Guzzle 用一个小依赖就给你异步池、中间件、重试和 PSR-7;裸 cURL 无依赖、每次调用略快一点。想要并发和结构就用 Guzzle,写紧凑、极简的脚本就用裸 cURL。上面的代理配置和那些坑对两者都适用,因为 Guzzle 就跑在 cURL 之上。

底线

一旦你尊重每个客户端的规矩,PHP 加住宅代理就很稳:在裸 cURL 里,设好代理主机和 CURLOPT_PROXYUSERPWD,并且对 HTTPS 永远别忘 CURLOPT_HTTPPROXYTUNNEL;在 Guzzle 里,传 proxy 选项、让它替你打隧道。复用一个客户端或句柄让连接保持温热,用一个有界的池并发跑请求而不是一次一个,按目标主机限制并发,并检查传输错误、而不只是状态码。用变换代理用户名来切换地理或会话。

把这些做对,两条路径就都会跑出 PHP 在规模上应有的样子。把它们指向住宅 gateway,并记住池的质量决定了你到底会不会频繁重试(IP 信誉)。定价页面有按 GB 计费的套餐,可以拿它对着你自己的目标试用。

准备好开始了吗?

试用 Shifter 住宅代理,205M+ 个 IP,195+ 个国家,低至 $0.75/GB。

立即开始