.NET 是后端数据采集的一匹主力:快速的异步 I/O、强类型,以及一个从容应对高并发的运行时。把一个住宅代理接进 HttpClient 只是几行,但 C# 有它自己的陷阱——一个与代理毫无关系、而与”HttpClient 该怎么用”关系重大的陷阱。把客户端的生命周期搞对,代理那部分就容易了。
这是与用 Python 使用住宅代理、用 Go、用 Node.js同一系列的 C# 篇:能跑的代码,加上 .NET 特有的那些坑。
下面的一切都使用 Shifter 的住宅 gateway:一个端点 p.shifter.io:443,所有定位都编码在用户名里。换成别家供应商,就换掉主机和凭据;形态是一样的。
一段话讲清 gateway 模型
代理用户名同时承载你的认证和你的定位。你不是靠切换端点来换国家或换会话,而是靠改用户名字符串:
customer-USERNAME-country-us-sid-abc123-ttl-600country-us 定位美国,sid 固定一个粘性会话,ttl 把那个 IP 保持 N 秒。去掉 sid/ttl,每一条新连接都会轮换。密码是恒定的。在 .NET 里,这个用户名进入一个 NetworkCredential,而不是进代理 URL。
基本搭建:WebProxy 和一个 handler
你在一个 message handler 上配置代理,并把它传给 HttpClient。凭据经由 NetworkCredential 放到一个 WebProxy 上,而不是内联进 URI。
using System.Net;
var user = Environment.GetEnvironmentVariable("SHIFTER_USER") + "-country-us";var pass = Environment.GetEnvironmentVariable("SHIFTER_PASS");
var proxy = new WebProxy("http://p.shifter.io:443"){ Credentials = new NetworkCredential(user, pass) // 不是 URL 里的 user:pass@};
var handler = new SocketsHttpHandler{ Proxy = proxy, UseProxy = true, PooledConnectionLifetime = TimeSpan.FromMinutes(2) // 见下面的坑};
var client = new HttpClient(handler) { Timeout = TimeSpan.FromSeconds(30) };
var ip = await client.GetStringAsync("https://api.ipify.org");Console.WriteLine(ip); // 一个美国住宅 IP有两件事要内化。user 字符串里包含了定位标志(-country-us),因为地理就住在那里。而凭据作为一个 NetworkCredential 放到 WebProxy 上——HttpClient 不会像浏览器那样从代理 URI 里读 user:pass@host。
在现代 .NET 上,优先用 SocketsHttpHandler(默认 handler)而不是更老的 HttpClientHandler。它是现代的、跨平台的实现,并且暴露了 PooledConnectionLifetime——你出于下面的原因需要它。
坑 1:HttpClient 的生命周期问题
这是 .NET 特有的坑,而且它朝两个相反的方向咬人。
每个请求都 new HttpClient(),你就会泄漏套接字:每个客户端持有它自己的连接池,而被释放的客户端会把套接字卡在 TIME_WAIT。在负载之下这会耗尽可用端口,你的应用便开始抛 SocketException。因此那条广为人知的建议是:复用单个 HttpClient。
但一个永远留住的单个客户端有相反的问题:它在连接池的生命周期内缓存 DNS 解析,永远注意不到一个端点的 IP 何时变了。修法不是在两者之间二选一,而是 SocketsHttpHandler 上的 PooledConnectionLifetime——它按一个时间表回收池化连接,好让你既保住连接复用、又不把陈旧的 DNS 钉死。把它设成几分钟,并复用这个客户端。
复用客户端也让连接经代理保持温热,这正是延迟指南存在的意义所要消除的那部分开销。
ASP.NET Core 的做法:IHttpClientFactory
如果你在一个基于 DI 的应用里,就干脆别手动管理 HttpClient。IHttpClientFactory 替你处理 handler 的生命周期和连接回收,并让你在一个命名客户端上把代理配置一次。
services.AddHttpClient("shifter") .ConfigurePrimaryHttpMessageHandler(() => new SocketsHttpHandler { Proxy = new WebProxy("http://p.shifter.io:443") { Credentials = new NetworkCredential( Environment.GetEnvironmentVariable("SHIFTER_USER") + "-country-us", Environment.GetEnvironmentVariable("SHIFTER_PASS")) }, UseProxy = true });
// 然后注入 IHttpClientFactory 并调用 factory.CreateClient("shifter")这个 factory 按它自己的时间表轮换底层 handler,所以你无需自己碰 PooledConnectionLifetime 就得到了池化和新鲜的 DNS。这是任何长期运行服务的推荐路。
坑 2:代理在 handler 上,所以用”按身份一个客户端”来轮换
因为代理及其凭据被烤进了 handler,你无法在单个 HttpClient 上按请求改变代理用户名。不同的身份意味着不同的 handler,因而是不同的客户端。高效的模式是按身份缓存一个客户端,好让每个会话保住它自己的连接池——和其他技术栈里”按身份一个 agent 的映射”是同一个思路。
using System.Collections.Concurrent;
static readonly ConcurrentDictionary<string, HttpClient> Clients = new();
static HttpClient ClientFor(string country, string sid){ var key = $"{country}:{sid ?? "rotate"}"; return Clients.GetOrAdd(key, _ => { var u = Environment.GetEnvironmentVariable("SHIFTER_USER") + "-country-" + country + (sid != null ? $"-sid-{sid}-ttl-600" : ""); var handler = new SocketsHttpHandler { Proxy = new WebProxy("http://p.shifter.io:443") { Credentials = new NetworkCredential(u, Environment.GetEnvironmentVariable("SHIFTER_PASS")) }, UseProxy = true, PooledConnectionLifetime = TimeSpan.FromMinutes(2) }; return new HttpClient(handler); });}给每个逻辑工作单元它自己的 sid,并在单元之间轮换、而不是在流程中途轮换(粘性 vs 轮换讲了这个区分),并按负载均衡那篇所述把工作映射到身份。缓存并复用这些客户端,别每个请求造一个。
坑 3:给你的并发封顶
.NET 让用 Task.WhenAll 一口气打出成千上万个请求变得轻而易举,而没有什么拦着你同时打开每一条连接——这会耗尽你这侧的套接字,并在目标看来像一次攻击。用一个 SemaphoreSlim 给在途请求封顶:
var gate = new SemaphoreSlim(8); // 最多 8 个并发请求
async Task<string> Fetch(HttpClient client, string url){ await gate.WaitAsync(); try { return await client.GetStringAsync(url); } finally { gate.Release(); }}按目标主机限制并发,而不只是全局限制,好让脆弱的站点不被猛击、宽松的站点也不被饿死。越过一个目标的容忍度之后,更多并行买来的是封锁,而不是吞吐量(如何避免被封)。
验证你确实在走代理
在给别的任何东西做基准或调试之前,先确认出口 IP:
var json = await client.GetStringAsync("http://ip-api.com/json");Console.WriteLine(json); // 期望是目标国家的一个住宅 IP返回你自己的 IP,说明 handler 没被应用(通常是 UseProxy 忘了设成 true,或凭据放错了对象)。挂住不动,说明本地出站被挡了。这两种情况都在超时诊断指南里有讲。
常见问题
为什么我把代理凭据放进 URL 就不管用?
HttpClient 不会从代理 URI 里读 user:pass@host。把凭据作为一个 NetworkCredential 放到 WebProxy 对象上。因为 gateway 把定位编码在用户名里,那个完整用户名(带 -country-... 标志)就是 NetworkCredential 的用户名,密码是恒定的。
HttpClientHandler 还是 SocketsHttpHandler?
在现代 .NET 上优先用 SocketsHttpHandler。它是默认的托管 handler、跨平台,并且暴露了 PooledConnectionLifetime——你需要它来避免长生命周期客户端上的陈旧 DNS。HttpClientHandler 仍然可用,并在底层委托给它。
我的 .NET 抓取器在负载下抛 SocketException,为什么?
你几乎肯定是每个请求都 new HttpClient() 并耗尽了套接字。复用单个客户端(或用 IHttpClientFactory),并设 PooledConnectionLifetime,好让复用不把陈旧 DNS 钉死。
在 C# 里我怎么按请求轮换 IP?
变换代理用户名,这意味着不同的 handler、因而是不同的客户端。在一个 ConcurrentDictionary 里按身份缓存一个 HttpClient,好让每个会话保住它自己的池,并按工作单元挑选客户端。在用户名里去掉 sid,就能在每条新连接上轮换。
IHttpClientFactory 能和代理一起用吗?
能。通过 ConfigurePrimaryHttpMessageHandler 在主 handler 上配置代理。这个 factory 替你管理 handler 的生命周期和 DNS 的新鲜度,所以在任何基于 DI 的服务里它都是最干净的选项。
底线
一旦你尊重两件事,C# 加住宅代理就很快:把凭据作为一个 NetworkCredential 放到 WebProxy 上、而不是放进 URL,并把 HttpClient 的生命周期搞对。用带 PooledConnectionLifetime 的 SocketsHttpHandler,或在 DI 应用里用 IHttpClientFactory,好让你既复用连接、又不把陈旧 DNS 钉死、也不泄漏套接字。靠按身份缓存一个客户端来轮换地理与会话,并用一个 SemaphoreSlim 给并发封顶。
把这些做对,.NET 处理并发采集的能力不输任何东西。把它指向住宅 gateway,并记住池的质量决定了你到底会不会频繁重试(IP 信誉)。定价页面有按 GB 计费的套餐,可以拿它对着你自己的目标试用。