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{} 实例是不必要的。可能的解决方案是:

  1. 使用单个 http.Client{} 实例来发送多个请求。
  2. 为每个请求创建 http.client 实例,并使用 HTTP 头 Connection: close 来避免连接复用。

我更倾向于第一种方法,即使用单例 http.Client{} 实例。然而,在提出这个解决方案之前,我有几个问题,需要您的帮助以获得更清晰的理解:

  1. 我们可以使用单个 http.Client{} 实例发送多个请求吗?具体来说,单个 http.Client{} 是否支持并发请求?
  2. 如果我们使用单个 http.Client{} 发送并发请求,我知道它有自己的连接池。在完成 TLS 握手并处理完一个请求后,它会将连接保持打开 90 秒,允许后续请求复用同一连接。将连接保持打开这么长时间是否存在安全隐患

谢谢。


更多关于Golang中http.Client和transport的使用与探讨的实战教程也可以访问 https://www.itying.com/category-94-b0.html

11 回复

如果你想复用本地端口,可以在 resp.Body.Close() 之前尝试使用 io.ReadAll(resp.Body)

io.ReadAll(resp.Body)

更多关于Golang中http.Client和transport的使用与探讨的实战系列教程也可以访问 https://www.itying.com/category-94-b0.html


  1. 这是最初的设计方式。
  2. 长连接是否存在安全问题?这只会略微增加性能开销。这种设计是为了节省向同一目标发起多个请求时建立连接的时间,从而更快地响应。

这似乎只是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。但核心原则不变:应该复用客户端实例,而不是为每个请求都创建一个新的。

回到顶部