Go 天生适合做抓取:并发便宜、标准库扎实,而且部署时只有一个静态二进制文件。把一个住宅代理接进 net/http,确实就是三行。真正让人搭进去一天的,是这三行之后的一切,因为 Go 的 HTTP 客户端有几处锋利的边角,会悄无声息地摧毁连接复用;而经由代理,那就会变成你很难解释的延迟和超时。
这是与用 Python 使用住宅代理和用 Playwright同一系列的 Go 篇:能跑的代码,加上在 Go 里真正要紧的那些陷阱。
下面的一切都使用 Shifter 的住宅 gateway:一个端点 p.shifter.io:443,所有定位都编码在用户名里。换成别家供应商,就换掉主机和凭据;形态是一样的。
一段话讲清 gateway 模型
代理用户名同时承载你的认证和你的定位。你不是靠切换端点来换国家或换会话,而是靠改用户名字符串:
customer-USERNAME-country-us-sid-abc123-ttl-600country-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.Client 和 http.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.Transport 的 MaxIdleConnsPerHost 默认值是 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.Timeout。Client.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 stringconst 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 的细粒度超时(TLSHandshakeTimeout、ResponseHeaderTimeout)让你能把慢连接和慢响应区分开;用按请求的 context.WithTimeout 来设总截止时间。单独一个笼统的 Client.Timeout 会掩盖到底是哪个阶段失败了。
我可以改用 http.ProxyFromEnvironment 吗?
可以,它会读取 HTTP_PROXY/HTTPS_PROXY/NO_PROXY,这在容器里很方便。当所有请求都用同一个身份时,它是个不错的默认;但那样你没法按请求变换地理或会话,所以当你需要定位时,就用一个显式的 Proxy 函数。
底线
一旦你尊重标准库的规矩,Go 加住宅代理就是一个很强的组合:共享一个长生命周期的客户端、永远排空并关闭 body、把 MaxIdleConnsPerHost 提到与你的并发相匹配,并且优先用 transport 的细粒度超时、而不是一个笼统的总值。用变换代理用户名来切换地理或会话,按主机约束并发,并让 Colly 的限流器和 SetProxyFunc 在框架层面做同样的事。
把这些做对,那三行的集成就会跑出 Go 应有的样子。把它指向住宅 gateway,并记住池的质量决定了你到底会不会频繁重试(IP 信誉)。定价页面有按 GB 计费的套餐,可以拿它对着你自己的目标试用。