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-600country-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。
<?phprequire '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_timeout 和 timeout。这个”连接对总时长”的划分,正是当有东西卡住时让超时可被诊断的关键:连接慢指向你这侧、或没有匹配的 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 Pool 或 curl_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 计费的套餐,可以拿它对着你自己的目标试用。