Skip to content

❓ 能复用 Client 吗

问题

我在一个长运行的服务里要查很多次 IP,需要每次查询都 NewClient() 新建一个 Client 吗?同一个 Client 能不能多次调用、跨 goroutine 复用?

简答

能,而且应该复用——Client 是线程安全的,底层会复用连接池,长期保持同一个实例性能最好。

详解

🎨 一图抵千言

下图对比「复用同一个 Client」与「每次新建 Client」两条路径,直观展示连接池复用带来的差异。

✅ 为什么能复用

Client 在每次发起请求时都会用 http.NewRequestWithContext 新建一个独立的 *http.Request,方法之间不共享任何可变状态:

go
// api.go 内部,每次调用都新建 request
req, err := newGetRequest(ctx, c.BaseURL, ip, format)
c.applyAuth(req)   // 只改本次 request 的 header / query
c.setHeaders(req)  // 只改本次 request 的 User-Agent
resp, err := c.doRequest(req)

字段层面也分得很清楚:

字段可变性说明
HTTPClient创建后只读底层 *http.Client + Transport,天然并发安全
BaseURL / APIKey / UserAgent创建后只读配置项,运行期不改动
Retries / RateLimiter读取即用RateLimiter<-chan time.Time,多 goroutine 自动排队
*http.Request每次新建不在 Client 上存留

因此多个 goroutine 同时调用 client.GetIPInfo(...) 完全安全,无需额外加锁

🚀 复用 = 复用连接池

NewClient 默认创建的 *http.Client 底层是 Go 标准库的 http.Transport,它自带连接池

  • 🤝 复用 TCP / TLS 连接,省掉重复握手开销
  • ♻️ 空闲连接按 host 缓存,下次请求直接拿
  • ⏱ 默认 IdleConnTimeout 自动回收闲置连接

如果你每次都 NewClient(),等于每次都建一个新的 http.Client + 新的 Transport,连接池形同虚设,每次查询都要重新 DNS、TCP、TLS 握手——在高频场景下既慢又浪费资源。

go
// ❌ 反例:每次请求都新建,连接池用不上
func lookup(ip string) (*ipapi.IPInfo, error) {
    client := ipapi.NewClient() // 别这么干
    return client.GetIPInfo(context.Background(), ip, "json")
}
go
// ✅ 正例:复用同一个实例,连接池持续生效
var client = ipapi.NewClient() // 进程级单例

func lookup(ip string) (*ipapi.IPInfo, error) {
    return client.GetIPInfo(context.Background(), ip, "json")
}

🧪 在 HTTP 服务里复用

最常见的场景:把 Client 做成包级变量或注入到依赖里,handler 里并发调用:

go
package main

import (
    "context"
    "fmt"
    "net/http"
    "os"

    "github.com/cyberspacesec/ipapi.co-skills/pkg/ipapi"
)

// 进程启动时建一次,后续所有请求复用
var client = ipapi.NewClient(
    ipapi.WithAPIKey(os.Getenv("IPAPI_KEY")),
)

func ipHandler(w http.ResponseWriter, r *http.Request) {
    // 多个请求并发进来,安全复用同一个 client
    info, err := client.GetClientIPInfo(r.Context(), "json")
    if err != nil {
        http.Error(w, err.Error(), http.StatusInternalServerError)
        return
    }
    fmt.Fprintf(w, "你的 IP %s 来自 %s\n", info.IP, info.CountryName)
}

func main() {
    http.HandleFunc("/ip", ipHandler)
    http.ListenAndServe(":8080", nil)
}

🧵 批量并发查询

复用同一个 Client 配合 goroutine 做批量查询,再用 sync.WaitGroup 收敛:

go
func lookupMany(ips []string) {
    var wg sync.WaitGroup
    client := ipapi.NewClient() // 建一次

    for _, ip := range ips {
        wg.Add(1)
        go func(ip string) {
            defer wg.Done()
            // 并发复用 client,安全
            info, err := client.GetIPInfo(context.Background(), ip, "json")
            if err != nil {
                fmt.Printf("%s 查询失败: %v\n", ip, err)
                return
            }
            fmt.Printf("%s%s\n", ip, info.CountryName)
        }(ip)
    }
    wg.Wait()
}

💡 高并发记得配限流

并发复用 Client 时,配合 RateLimiter 通道控制 QPS,避免把免费额度或服务端打爆。

🔄 什么时候该换一个 Client

复用是默认选择,下面这些情况才需要新建或替换:

场景做法
切换 API Key / 认证方式新建带不同 WithAPIKey / WithAPIKeyQuery 的实例
不同超时档位新建带 WithCustomHTTPClient 的实例,或共享 Transport 只换 Timeout
不同基地址(自建镜像)新建后改 BaseURL
测试隔离每个用例独立 Client,避免相互污染

⚠️ 运行期不要改字段

APIKeyBaseURLUserAgent 这类字段设计为创建后只读。运行期并发改写会造成数据竞争,要换配置就新建一个 Client,而不是热改现有实例。

📦 多个 Client 共享 Transport

如果确实需要多个 Client(比如不同超时),可以共享同一个 Transport 来复用底层连接池:

🔬 为什么共享 Transport 也能复用连接池?

Go 标准库的 http.Transport 持有连接池(idleConn map),连接是按 host 维度缓存的,与上层的 *http.Client 无关。多个 *http.Client 只要指向同一个 Transport,就会共用同一池空闲连接。

本 SDK 的 applyAuth 只改本次 *http.Request 的 header/query,不触碰 Transport,所以不同 API Key 的 Client 共享同一个 Transport 也不会串号——每个请求的认证信息随 request 独立发送,连接复用仅作用于传输层。

注意:TransportMaxIdleConnsPerHost 默认只有 2,高并发场景务必显式调大,否则连接池会频繁「建-拆」而不是复用。

go
transport := &http.Transport{
    MaxIdleConns:        100,
    MaxIdleConnsPerHost: 20,
    IdleConnTimeout:     90 * time.Second,
}

// 两个 Client,不同超时,但共享连接池
fast := ipapi.NewClient(ipapi.WithCustomHTTPClient(
    &http.Client{Transport: transport, Timeout: 5 * time.Second},
))
slow := ipapi.NewClient(ipapi.WithCustomHTTPClient(
    &http.Client{Transport: transport, Timeout: 30 * time.Second},
))

相关

基于 MIT 许可证发布