知识

如何在 Go 中用 net/http 和 Colly 使用住宅代理

Go 里的代理:transport 配置、会毁掉连接复用的 body 排空与 MaxIdleConnsPerHost 两个坑、按请求的地理轮换,以及 Colly 的 SetProxyFunc。

Chris Collins

Chris Collins

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

Go 天生适合做抓取:并发便宜、标准库扎实,而且部署时只有一个静态二进制文件。把一个住宅代理接进 net/http,确实就是三行。真正让人搭进去一天的,是这三行之后的一切,因为 Go 的 HTTP 客户端有几处锋利的边角,会悄无声息地摧毁连接复用;而经由代理,那就会变成你很难解释的延迟和超时。

这是与用 Python 使用住宅代理用 Playwright同一系列的 Go 篇:能跑的代码,加上在 Go 里真正要紧的那些陷阱。

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

一段话讲清 gateway 模型

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

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

country-us 定位美国,sid 固定一个粘性会话,ttl 把那个 IP 保持 N 秒。去掉 sid/ttl,每一条新连接都会轮换。密码是恒定的。

与 Chromium(它会忽略内联的代理凭据)不同,Go 的 net/http 能正确处理代理 URL 里的凭据,会替你发送 Proxy-Authorization。所以 http://user:pass@host:port 直接就能用。

最小版本

package main
import (
"fmt"
"io"
"net/http"
"net/url"
"os"
"time"
)
func main() {
user, pass := os.Getenv("SHIFTER_USER"), os.Getenv("SHIFTER_PASS")
proxyURL, err := url.Parse(fmt.Sprintf("http://%s-country-us:%s@p.shifter.io:443", user, pass))
if err != nil {
panic(err)
}
client := &http.Client{
Transport: &http.Transport{Proxy: http.ProxyURL(proxyURL)},
Timeout: 30 * time.Second,
}
resp, err := client.Get("https://api.ipify.org")
if err != nil {
panic(err)
}
defer resp.Body.Close()
body, _ := io.ReadAll(resp.Body)
fmt.Println(string(body)) // 一个美国住宅 IP
}

这就是全部的集成。接下来是决定它性能好坏的那些部分。

坑 1:复用客户端,绝不要每个请求造一个

这是最常见、也最昂贵的 Go 错误,而代理会让它更糟。

http.Clienthttp.Transport 就是被设计成长生命周期、供共享使用的。它们对多个 goroutine 的并发使用是安全的,而连接池就住在 Transport 里。每个请求都造一个新客户端,那每个请求都要付一次全新的 TCP 握手外加一次穿过代理的 TLS 握手,而这恰恰是延迟指南让你消除的那部分开销。更糟的是,你还在泄漏空闲连接。

// 坏:每个请求一个新客户端。零复用,泄漏连接。
func fetch(u string) (*http.Response, error) {
client := &http.Client{Transport: &http.Transport{Proxy: http.ProxyURL(proxyURL)}}
return client.Get(u)
}
// 好:一个客户端,放在包级或注入,在 goroutine 之间共享。
var client = &http.Client{ /* 只配置一次,见下文 */ }

在启动时构建一次,然后把它传下去。

坑 2:排空并关闭 body,否则得不到复用

只有当响应 body 被完整读取并关闭时,Go 才会把连接还回池里。不读就关,连接会被丢弃,于是哪怕你的客户端是共享的,你也悄无声息地失去了池化。

resp, err := client.Get(u)
if err != nil {
return err
}
defer resp.Body.Close()
// 如果你不需要 body,也要把它排空,好让连接可以被复用。
// defer 调用是后进先出的,所以这次排空会在上面的 Close *之前*执行,
// 而这正是你想要的顺序。
defer io.Copy(io.Discard, resp.Body)

如果你在正常解析 body(io.ReadAll、一个读到 EOF 的 JSON decoder),那没问题。陷阱在于提前 return:在非 200 状态时不排空就退出,会让连接变得不可用;而在一个会撞上大量封锁的抓取器里,那就是你的大部分流量。

坑 3:MaxIdleConnsPerHost 默认是 2

这一条会咬到每一个 Go 抓取器。http.TransportMaxIdleConnsPerHost 默认值是 2。对着一个主机跑 50 个 goroutine,其中 48 个就会不停地新建又丢弃连接,每一个都要付一次穿过代理的完整握手。

把它至少设到你按主机的并发数:

transport := &http.Transport{
Proxy: http.ProxyURL(proxyURL),
MaxIdleConns: 200,
MaxIdleConnsPerHost: 50, // >= 你按主机的并发数
IdleConnTimeout: 90 * time.Second,
// 细粒度超时胜过一个笼统的 Client.Timeout。
TLSHandshakeTimeout: 10 * time.Second,
ResponseHeaderTimeout: 30 * time.Second,
ExpectContinueTimeout: 1 * time.Second,
}
client := &http.Client{Transport: transport}

注意这里故意没有 Client.TimeoutClient.Timeout 是一个覆盖拨号、TLS、头部和 body 的单一总预算。而 transport 的细粒度超时,让你能把慢的连接和慢的响应区分开,这正是让超时可被诊断的那个 connect-对-read 的区分。用按请求的 context.WithTimeout 来设总的截止时间。

轮换地理与会话

因为定位住在用户名里,不同的身份就意味着不同的代理 URL。两种做法:

按请求,通过 Proxy 函数。 Transport.Proxy 是每个请求都会被调用的,所以你可以在那里变换身份。Go 会按代理 URL 来池化连接,所以这样依然高效:

func proxyFor(country, sid string) (*url.URL, error) {
u := fmt.Sprintf("%s-country-%s", os.Getenv("SHIFTER_USER"), country)
if sid != "" {
u += fmt.Sprintf("-sid-%s-ttl-600", sid)
}
return url.Parse(fmt.Sprintf("http://%s:%s@p.shifter.io:443", u, os.Getenv("SHIFTER_PASS")))
}
// 从请求上下文里读取定位,让每个 job 自己挑身份。
type ctxKey string
const identityKey ctxKey = "identity"
type identity struct{ Country, SID string }
transport := &http.Transport{
Proxy: func(r *http.Request) (*url.URL, error) {
if id, ok := r.Context().Value(identityKey).(identity); ok {
return proxyFor(id.Country, id.SID)
}
return proxyFor("us", "")
},
MaxIdleConnsPerHost: 50,
}

然后按 job 附上身份:

ctx := context.WithValue(req.Context(), identityKey, identity{Country: "de", SID: "job-42"})
resp, err := client.Do(req.WithContext(ctx))

或者按身份一个 transport,缓存在一个 map 里,适用于你有一小组固定身份时(比如每个国家一个)。两种都行;当身份是按工作条目划分时,上下文这套扩展性更好。

给每个逻辑工作单元它自己的 sid,并在单元之间轮换、而不是在流程中途轮换(粘性 vs 轮换讲了各自适用的场景,而负载均衡那篇讲了如何把工作映射到身份)。

并发,按主机

Go 让你可以轻而易举地起 10,000 个 goroutine,也同样轻而易举地被封。把并发按目标主机加以约束,而不是全局约束,好让脆弱的目标不被猛击、宽松的目标也不被限住:

import "golang.org/x/sync/semaphore"
var limits = map[string]*semaphore.Weighted{
"tough-site.example": semaphore.NewWeighted(4),
"open-site.example": semaphore.NewWeighted(32),
}
func fetch(ctx context.Context, host, u string) error {
sem := limits[host]
if err := sem.Acquire(ctx, 1); err != nil {
return err
}
defer sem.Release(1)
// ... 执行请求
return nil
}

越过一个目标的容忍度之后,更多的并行买来的是封锁,而不是吞吐量(如何避免被封)。

Colly

Colly 是 Go 里标准的抓取框架,一行就能接上代理:

c := colly.NewCollector(colly.AllowedDomains("example.com"))
proxyURL := fmt.Sprintf("http://%s-country-us:%s@p.shifter.io:443", user, pass)
if err := c.SetProxy(proxyURL); err != nil {
log.Fatal(err)
}

要做按请求的地理或粘性会话,用 SetProxyFunc,思路和上面的 Transport.Proxy 函数一样:

c.SetProxyFunc(func(r *http.Request) (*url.URL, error) {
// 按请求挑国家/会话,例如从 URL 或一个 job map 里取。
return proxyFor("de", "job-42")
})

Colly 有它自己的按域名限流器,用它,而不是自己再造一个:

c.Limit(&colly.LimitRule{
DomainGlob: "*",
Parallelism: 8,
Delay: 200 * time.Millisecond,
RandomDelay: 200 * time.Millisecond, // 抖动,好让请求不像节拍器
})

而当你想要前面那套池化和超时设置时,就设一个自定义 transport:

c.WithTransport(transport)

Colly 的 OnError 是放重试逻辑的地方。重试之前先分类,并在遇到封锁时更换身份、而不是对同一个出口重试,负载均衡那篇里的分类表可以直接套用。

验证你确实在走代理

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

resp, _ := client.Get("http://ip-api.com/json")
defer resp.Body.Close()
b, _ := io.ReadAll(resp.Body)
fmt.Println(string(b)) // 期望是目标国家的一个住宅 IP

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

常见问题

Go 支持代理 URL 里的凭据吗? 支持。与基于 Chromium 的浏览器不同,Go 的 net/http 能正确处理 http://user:pass@host:port 并替你发送 Proxy-Authorization。因为 gateway 把定位编码在用户名里,所以你需要的就只有这些。

为什么我的 Go 抓取器经由代理还是很慢,明明只用了一个客户端? 最有可能的是你没有排空响应 body(于是连接从不回到池里),或者 MaxIdleConnsPerHost 还停在默认的 2、而你却跑着很多 goroutine。把这两点都修掉,按请求的握手开销就基本消失了。

在 Go 里我怎么按请求轮换 IP? 变换代理用户名。要么从 Transport.Proxy 返回一个不同的 URL(从请求上下文里读取身份),要么按身份各留一个 transport。Go 是按代理 URL 池化连接的,所以按请求变换依然高效。

我该用 Client.Timeout 还是 transport 的超时? 两者都用,各司其职。transport 的细粒度超时(TLSHandshakeTimeoutResponseHeaderTimeout)让你能把慢连接和慢响应区分开;用按请求的 context.WithTimeout 来设总截止时间。单独一个笼统的 Client.Timeout 会掩盖到底是哪个阶段失败了。

我可以改用 http.ProxyFromEnvironment 吗? 可以,它会读取 HTTP_PROXY/HTTPS_PROXY/NO_PROXY,这在容器里很方便。当所有请求都用同一个身份时,它是个不错的默认;但那样你没法按请求变换地理或会话,所以当你需要定位时,就用一个显式的 Proxy 函数。

底线

一旦你尊重标准库的规矩,Go 加住宅代理就是一个很强的组合:共享一个长生命周期的客户端、永远排空并关闭 body、把 MaxIdleConnsPerHost 提到与你的并发相匹配,并且优先用 transport 的细粒度超时、而不是一个笼统的总值。用变换代理用户名来切换地理或会话,按主机约束并发,并让 Colly 的限流器和 SetProxyFunc 在框架层面做同样的事。

把这些做对,那三行的集成就会跑出 Go 应有的样子。把它指向住宅 gateway,并记住池的质量决定了你到底会不会频繁重试(IP 信誉)。定价页面有按 GB 计费的套餐,可以拿它对着你自己的目标试用。

准备好开始了吗?

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

立即开始