知识

如何在 C# 中用 HttpClient 使用住宅代理

C# 里的代理:带 NetworkCredential 的 WebProxy、SocketsHttpHandler 与 PooledConnectionLifetime、IHttpClientFactory,以及用于地理轮换的按身份客户端。

Chris Collins

Chris Collins

2026年8月2日 · 3 分钟阅读

.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-600

country-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 的应用里,就干脆别手动管理 HttpClientIHttpClientFactory 替你处理 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 的生命周期搞对。用带 PooledConnectionLifetimeSocketsHttpHandler,或在 DI 应用里用 IHttpClientFactory,好让你既复用连接、又不把陈旧 DNS 钉死、也不泄漏套接字。靠按身份缓存一个客户端来轮换地理与会话,并用一个 SemaphoreSlim 给并发封顶。

把这些做对,.NET 处理并发采集的能力不输任何东西。把它指向住宅 gateway,并记住池的质量决定了你到底会不会频繁重试(IP 信誉)。定价页面有按 GB 计费的套餐,可以拿它对着你自己的目标试用。

准备好开始了吗?

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

立即开始