Golang中http.Client和transport的使用与探讨
Golang中http.Client和transport的使用与探讨
最近,网站报告了一个问题。在调查根本原因时,我在日志中发现了以下错误:“connectex: Only one usage of each socket address (protocol/network address/port) is normally permitted.” 在审查我们的实现后,我发现服务在每次向服务器发送 HTTP/HTTPS 请求时都会创建一个新的 http.Client{} 实例。
据我理解,为每个请求创建一个新的 http.Client{} 实例是不必要的。可能的解决方案是:
- 使用单个
http.Client{}实例来发送多个请求。 - 为每个请求创建 http.client 实例,并使用 HTTP 头 Connection: close 来避免连接复用。
我更倾向于第一种方法,即使用单例 http.Client{} 实例。然而,在提出这个解决方案之前,我有几个问题,需要您的帮助以获得更清晰的理解:
- 我们可以使用单个
http.Client{}实例发送多个请求吗?具体来说,单个http.Client{}是否支持并发请求? - 如果我们使用单个
http.Client{}发送并发请求,我知道它有自己的连接池。在完成 TLS 握手并处理完一个请求后,它会将连接保持打开 90 秒,允许后续请求复用同一连接。将连接保持打开这么长时间是否存在安全隐患?
谢谢。
更多关于Golang中http.Client和transport的使用与探讨的实战教程也可以访问 https://www.itying.com/category-94-b0.html
如果你想复用本地端口,可以在 resp.Body.Close() 之前尝试使用 io.ReadAll(resp.Body)。
io.ReadAll(resp.Body)
更多关于Golang中http.Client和transport的使用与探讨的实战系列教程也可以访问 https://www.itying.com/category-94-b0.html
- 这是最初的设计方式。
- 长连接是否存在安全问题?这只会略微增加性能开销。这种设计是为了节省向同一目标发起多个请求时建立连接的时间,从而更快地响应。
这似乎只是Windows配置的问题。某些配置限制了最大连接数。这是网络层的问题,而不是 http.Client 的问题。在我的Ubuntu系统上测试时没有出现错误。
确实如此,我的 Windows 机器支持 16384 个端口。但理想情况下,http.Client 应该复用连接,如果我没理解错的话,这意味着端口也应该被复用,而不是使用新的端口。我无法理解它为什么会以这种方式运行。
你的测试代码存在一些并发风险,我做了一些修改:
func test() {
go func() {
_ = http.ListenAndServe(":7777", http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) {
_, _ = writer.Write([]byte("hello"))
}))
}()
transport := &http.Transport{
MaxIdleConns: 100,
IdleConnTimeout: time.Minute,
}
client := &http.Client{Transport: transport}
var count uint64
var wg sync.WaitGroup
for i := 0; i < 100000; i++ {
wg.Add(1)
go func() {
defer wg.Done()
resp, err := client.Get("http://localhost:7777")
if err != nil {
panic(err)
}
defer resp.Body.Close()
if resp.StatusCode == http.StatusOK {
fmt.Println(atomic.AddUint64(&count, 1))
}
}()
time.Sleep(time.Millisecond * 1)
}
wg.Wait()
fmt.Println("all", count)
return
}
在我的测试用例中,没有出现错误。
目前,最合理的解释是你在短时间内发出了大量请求(100,000个),这可能导致连接复用不够充分,因为新的连接在实际被复用之前就已经被创建了。
通过分批处理,可以更直观地观察到复用效果。
func test() {
go func() {
listen, err := net.Listen("tcp", ":7777")
if err != nil {
panic(err)
}
defer listen.Close()
listen = &tListener{listen}
_ = http.Serve(listen, http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) {
_, _ = writer.Write([]byte("hello"))
}))
}()
client := &http.Client{Transport: http.DefaultTransport}
var wg sync.WaitGroup
for i := 0; i < 100000; i++ {
wg.Add(1)
go func() {
defer wg.Done()
resp, err := client.Get("http://localhost:7777")
if err != nil {
panic(err)
}
defer resp.Body.Close()
if resp.StatusCode == http.StatusOK {
}
}()
if i%100 == 0 {
time.Sleep(1 * time.Second)
}
}
wg.Wait()
return
}
peakedshout:
go func() {
_ = http.ListenAndServe(":7777", http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) {
_, _ = writer.Write([]byte("hello"))
}))
}()
transport := &http.Transport{
MaxIdleConns: 100,
IdleConnTimeout: time.Minute,
}
client := &http.Client{Transport: transport}
var count uint64
感谢您花时间理解代码并提供了更好的方法。
您可能已经看到了我之前的回复。我误解了那段代码,请忽略它。我运行了您的代码,并在大约相同的计数处遇到了相同的错误。以下是控制台日志:
16146 16147 16148 16149 16150 panic: Get “http://localhost:7777”: dial tcp [::1]:7777: connectex: Only one usage of each socket address (protocol/network address/port) is normally permitted.
goroutine 145233 [running]: main.main.func2() C:/PDI/repo/opt/optctrl/app/optctrl.go:71 +0x12b created by main.main in goroutine 1 C:/PDI/repo/opt/optctrl/app/optctrl.go:67 +0x376 panic: Get “http://localhost:7777”: dial tcp [::1]:7777: connectex: Only one usage of each socket address (protocol/network address/port) is normally permitted.
goroutine 145149 [running]: main.main.func2() C:/PDI/repo/opt/optctrl/app/optctrl.go:71 +0x12b
请求完成后,连接可能没有被放回活跃连接池。
type tListener struct {
net.Listener
}
var x int
func (t *tListener) Accept() (net.Conn, error) {
x++
fmt.Println(x)
return t.Listener.Accept()
}
func (t *tListener) Close() error {
return t.Listener.Close()
}
func (t *tListener) Addr() net.Addr {
return t.Listener.Addr()
}
func test() {
go func() {
listen, err := net.Listen("tcp", ":7777")
if err != nil {
panic(err)
}
defer listen.Close()
listen = &tListener{listen}
_ = http.Serve(listen, http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) {
_, _ = writer.Write([]byte("hello"))
}))
}()
transport := &http.Transport{
MaxIdleConns: 100,
IdleConnTimeout: time.Minute,
}
client := &http.Client{Transport: transport}
//var count int64
var wg sync.WaitGroup
for i := 0; i < 100000; i++ {
wg.Add(1)
go func() {
defer wg.Done()
//atomic.AddInt64(&count, 1)
resp, err := client.Get("http://localhost:7777")
if err != nil {
panic(err)
}
defer resp.Body.Close()
if resp.StatusCode == http.StatusOK {
//fmt.Println(atomic.AddInt64(&count, -1))
}
}()
time.Sleep(time.Millisecond * 10)
}
wg.Wait()
return
}
我在macOS M3上运行了测试,发现调整请求间隔(例如10毫秒)有效地减缓了新连接的增长速度。
如果你想进行全面调查,可以尝试像这样从服务器端统计连接数。
var count int64
type tConn struct {
net.Conn
once sync.Once
}
func newConn(raw net.Conn, err error) (net.Conn, error) {
if err == nil {
fmt.Println(atomic.AddInt64(&count, 1))
return &tConn{Conn: raw}, nil
}
return nil, err
}
func (c *tConn) Close() error {
c.once.Do(func() {
fmt.Println(atomic.AddInt64(&count, -1))
})
return c.Conn.Close()
}
type tListener struct {
net.Listener
}
func (t *tListener) Accept() (net.Conn, error) {
return newConn(t.Listener.Accept())
}
func test() {
go func() {
listen, err := net.Listen("tcp", ":7777")
if err != nil {
panic(err)
}
defer listen.Close()
listen = &tListener{listen}
_ = http.Serve(listen, http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) {
_, _ = writer.Write([]byte("hello"))
}))
}()
transport := &http.Transport{
MaxIdleConns: 100,
IdleConnTimeout: time.Minute,
}
client := &http.Client{Transport: transport}
//var count int64
var wg sync.WaitGroup
for i := 0; i < 100000; i++ {
wg.Add(1)
go func() {
defer wg.Done()
//atomic.AddInt64(&count, 1)
resp, err := client.Get("http://localhost:7777")
if err != nil {
panic(err)
}
defer resp.Body.Close()
if resp.StatusCode == http.StatusOK {
//fmt.Println(atomic.AddInt64(&count, -1))
}
}()
time.Sleep(time.Millisecond * 1)
}
wg.Wait()
return
}
你好,
感谢你的回复。
我尝试通过创建单个 http.Client 来测试其行为,但令我惊讶的是,我遇到了类似/相同的问题。
以下是我为测试更改而编写的代码:
func main() {
go CreateServer()
url := "http://127.0.0.1:1111" // 目标 URL(可以指向一个虚拟服务器)
fmt.Println("Starting to generate requests…")
transport := &http.Transport{
MaxIdleConns: 100,
IdleConnTimeout: time.Minute,
}
client := &http.Client{Transport: transport}
// 模拟快速请求,不重用连接
for i := 0; i < 100000; i++ {
go func() {
// 发送请求
httpReq, _ := http.NewRequest("GET", url, nil)
//httpReq.Header.Add("Connection", "close")
resp, err := client.Do(httpReq)
log.Printf("Count : %d", i)
if err != nil {
log.Println("Error:", err)
return
}
// 故意不关闭响应体
// 这模拟了套接字/资源耗尽
//_ = resp
resp.Body.Close()
log.Print("response closed")
}()
time.Sleep(1 * time.Millisecond) // 请求间最小延迟
}
// 让 goroutine 运行一段时间
time.Sleep(10 * time.Second)
fmt.Println("Done generating requests.")
os.Exit(111)
}
func CreateServer() {
http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
fmt.Fprintln(w, "Hello, World!")
})
http.ListenAndServe(":1111", nil)
}
这是日志,你可以看到请求在第 16107 次时失败。
OPTCTRL:2025/01/09 15:56:41.480822 optctrl.go:85: response closed
OPTCTRL:2025/01/09 15:56:41.491882 optctrl.go:76: Count : 15905
OPTCTRL:2025/01/09 15:56:41.491882 optctrl.go:85: response closed
OPTCTRL:2025/01/09 15:56:41.511168 optctrl.go:76: Count : 16108
OPTCTRL:2025/01/09 15:56:41.511168 optctrl.go:78: Error: Get "http://127.0.0.1:1111": dial tcp 127.0.0.1:1111: connectex: No connection could be made because the target machine actively refused it.
OPTCTRL:2025/01/09 15:56:41.559336 optctrl.go:76: Count : 15906
OPTCTRL:2025/01/09 15:56:41.559872 optctrl.go:85: response closed
我是不是遗漏了什么?
谢谢。
是的,单个 http.Client 实例完全可以用于发送多个并发请求。这是 Go 中 HTTP 客户端的标准用法,也是推荐的做法。http.Client 在设计上是并发安全的,其内部的连接池(由 Transport 管理)会高效地处理并发连接。
关于安全隐患:保持连接空闲 90 秒(IdleConnTimeout 的默认值)是 HTTP/1.1 和 HTTP/2 连接复用的常见行为,旨在提升性能。这本身并不直接引入安全漏洞。安全风险主要取决于连接是否使用了 TLS(HTTPS)以及服务器端的配置。只要使用 HTTPS,连接就是加密的。复用已建立的 TLS 连接与新建一个 TLS 连接相比,在安全性上没有区别。关键在于,连接池中的空闲连接仍然是经过身份验证和加密的通道。
以下是一个使用单例 http.Client 发送并发请求的示例:
package main
import (
"fmt"
"io"
"net/http"
"sync"
"time"
)
// 创建全局的单例 HTTP 客户端
var httpClient = &http.Client{
Timeout: time.Second * 30,
// Transport 可以自定义,这里使用默认的。默认配置已包含连接池。
// Transport: &http.Transport{
// MaxIdleConns: 100,
// IdleConnTimeout: 90 * time.Second,
// TLSHandshakeTimeout: 10 * time.Second,
// },
}
func makeRequest(url string, wg *sync.WaitGroup) {
defer wg.Done()
resp, err := httpClient.Get(url)
if err != nil {
fmt.Printf("Error fetching %s: %v\n", url, err)
return
}
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
if err != nil {
fmt.Printf("Error reading body from %s: %v\n", url, err)
return
}
fmt.Printf("Fetched %s, status: %s, body length: %d\n", url, resp.Status, len(body))
}
func main() {
urls := []string{
"https://httpbin.org/get",
"https://httpbin.org/ip",
"https://httpbin.org/user-agent",
}
var wg sync.WaitGroup
for _, url := range urls {
wg.Add(1)
go makeRequest(url, &wg) // 并发发送请求
}
wg.Wait()
fmt.Println("All requests completed.")
}
在这个示例中,多个 Goroutine 共享同一个 httpClient 实例并发地发送 HTTP 请求。客户端内部的 Transport 会管理连接池,复用空闲的 TCP 连接,从而避免了你遇到的“Only one usage of each socket address”错误。这种错误通常是由于短时间内创建了大量客户端,导致端口耗尽或连接未能正确关闭引起的。
对于需要自定义超时、代理或 TLS 配置的场景,你可以自定义 http.Transport 并将其赋值给 http.Client.Transport。但核心原则不变:应该复用客户端实例,而不是为每个请求都创建一个新的。

