原创分享 Golang net/http 性能优化

wxj95 · 2021年04月22日 · 最后由 wxj95 回复于 2021年05月08日 · 658 次阅读
本帖已被设为精华帖!

Golang net/http 性能优化

Go 语言内置net\http包十分优秀,我们通过这个包可以很方便的去实现 HTTP 的客户端和服务端。

但是在高并发的情况下,如果我们使用默认的配置,会引发一些问题,严重的话可能会使服务器崩溃。这里讲述以下两种默认配置情况下带来的一些问题。

  • #### 使用 DefaultClient
_, err := http.Get("http://www.baidu.com")
if err != nil {
   log.Fatal(err)
}
var DefaultClient = &Client{}

如果我们直接使用默认的 http,那么它是没有超时时间的。这样就会带来如下问题:

假设我们向服务端发起请求,但是服务端因为某些情况没有及时返回或者说连接中断了,那么客户端就会很长时间得不到服务端的 response。所以这个时候客户端为这一个 tcp 连接申请的资源就得不到释放,造成资源的浪费。如果在高并发的情况下,客户端可能会因为资源的限制使得服务器崩溃,比如达到最大文件描述符或者达到端口号限制等等。

解决办法是自己设置超时时间:

client := http.Client{
   Timeout: 10 * time.Second,
}
  • #### 使用默认的 DefaultTransport

如果我们在 http client 中没有设置 transport 属性,那么它就会使用默认的 transport:

var DefaultTransport RoundTripper = &Transport{
   Proxy: ProxyFromEnvironment,
   DialContext: (&net.Dialer{
      Timeout:   30 * time.Second,
      KeepAlive: 30 * time.Second,
      DualStack: true,
   }).DialContext,
   ForceAttemptHTTP2:     true,
   MaxIdleConns:          100,
   IdleConnTimeout:       90 * time.Second,
   TLSHandshakeTimeout:   10 * time.Second,
   ExpectContinueTimeout: 1 * time.Second,
}

从这个的配置中我们可以看到,http 使用了默认的连接池,关键的两个属性:

MaxIdleConns:最大空闲连接数量,默认为 100

IdleConnTimeout:空闲连接超时时间,默认为 90s

当一个 request 请求完成后,这个连接会保留,直到达到 IdleConnTimeout 设置的超时时间。如果没有达到,那么下一个请求就会复用这个连接。

这样的空闲连接最大数量是 100 个,超过 100 的还是会创建新的连接。

建立连接池的好处是能够尽可能减少服务器的资源。这个配置看上去很好啊,那为什么还是说会有问题呢?

查看源码,它还有另外一个默认配置:

// DefaultMaxIdleConnsPerHost is the default value of Transport's
// MaxIdleConnsPerHost.
const DefaultMaxIdleConnsPerHost = 2

DefaultMaxIdleConnsPerHost 为每个 host 的设定的空闲连接数量为 2。

DefaultMaxIdleConnsPerHost 设置的太小就会导致一个问题,在大量请求的情况下去访问特定的 host 的时候,长连接会退化成短链接。看如下源码:

idles := t.idleConn[key]
if len(idles) >= t.maxIdleConnsPerHost() {
   return errTooManyIdleHost
}
for _, exist := range idles {
   if exist == pconn {
      log.Fatalf("dup idle pconn %p in freelist", pconn)
   }
}
t.idleConn[key] = append(idles, pconn)
t.idleLRU.add(pconn)

从源码中我们可以看出,如果当并发量大的情况下,连接池会创建较多的 TCP 连接,并且在请求完成以后连接池尝试通过 tryPutIdleConn 归还空闲连接,对于超出 maxIdleConnsPerHost 数量的空闲长连接都不能再放回连接池了,这些连接会进入 TIME_WAIT 状态,这些 TIME_WAIT 的连接在达到 2MSL 时间后就会自动关闭。

在这种情况下,我们在服务器上就会看到大量的 TIME_WAIT 状态的 tcp 连接。在极限的情况下,服务器也可能会崩溃。

解决办法是自己设置 DefaultMaxIdleConnsPerHost

t := http.DefaultTransport.(*http.Transport).Clone()
t.MaxIdleConns = 100
t.MaxConnsPerHost = 100
t.MaxIdleConnsPerHost = 100
client := http.Client{
   Timeout:   10 * time.Second,
   Transport: t,
}

代码演示

这里我用代码演示使用 DefaultTransport 和不使用两者的 tcp 连接状态的区别,从而来验证这个逻辑。

客户端同时向服务端发起 100 个请求。

  • ###### server

这里我用 gin web 框架快速起了一个服务端

package main

import (
   "github.com/gin-gonic/gin"
)

func main() {
   r := gin.Default()
   r.GET("/ping", func(c *gin.Context) {
      c.JSON(200, gin.H{
         "message": "pong",
      })
   })
   r.Run() 
}
  • ###### client1 使用默认的 DefaultTransport
package main

import (
  "fmt"
  "io/ioutil"
  "log"
  "net/http"
)

func main() {
  client := http.Client{
  }
  for i := 0; i < 100; i++ {
      go func() {
          resp, err := client.Get("http://127.0.0.1:8080/ping")
          if err != nil {
              log.Fatal(err)
          }
          b, err := ioutil.ReadAll(resp.Body)
          if err != nil {
              log.Fatal(err)
          }
          defer resp.Body.Close()
          fmt.Printf(string(b))
      }()
  }
  select {}
}

  • ###### client2 不使用默认的 DefaultTransport
package main

import (
  "fmt"
  "io/ioutil"
  "log"
  "net/http"
  "time"
)

func main() {
  t := http.DefaultTransport.(*http.Transport).Clone()
  t.MaxIdleConns = 100
  t.MaxIdleConnsPerHost = 100
  client := http.Client{
      Timeout:   10 * time.Second,
      Transport: t,
  }
  for i := 0; i < 100; i++ {
      go func() {
          resp, err := client.Get("http://127.0.0.1:8080/ping")
          if err != nil {
              log.Fatal(err)
          }
          b, err := ioutil.ReadAll(resp.Body)
          if err != nil {
              log.Fatal(err)
          }
          defer resp.Body.Close()
          fmt.Printf(string(b))
      }()
  }
  select {}
}

使用 DefaultTransport 的 tcp 连接情况:

如图所示,在请求发出之后,我们可以看到只有两个 tcp 连接是处于 ESTABLISHED 状态,其他的都是处于 TIME_WAIT 状态。且两个处于 ESTABLISHED 状态的 tcp 连接会在 90s 之后变成 TIME_WAIT 状态。

90s 后:

不使用 DefaultTransport 的 tcp 连接情况:

如图所示,可以看出所有的 100 个 tcp 连接都是处于 ESTABLISHED 状态,这些状态在 90s 之后全部变成 TIME_WAIT 状态。

90s 之后:

总结:

虽然我们平时开发中使用默认的配置也没有遇到什么问题,但是在高并发的条件下还是会带来很多问题。

所以我们在高并发的情况下,尽量不要使用默认的配置,通过更改 HTTP 客户端的一些默认设置,以达到高性能的目的。

更多原创文章干货分享,请关注公众号
  • 加微信实战群请加微信(注明:实战群):gocnio
moss GoCN 每日新闻 (2021-04-23) 中提及了此贴 04月23日 06:14

写的很好,赞!

默认总空闲 100 单 host 空闲 2,如果请求多个 host 呢?

astaxie 将本帖设为了精华贴 04月26日 11:57
eudore 回复

对于不同的 host 还是会复用连接的,只要空闲连接数没有超过 100

使用 DefaultTransport 的 tcp 连接情况: 不使用 DefaultTransport 的 tcp 连接情况: 这个两个下面的图片是不是反了

jiayiming001 回复

感谢,已修复

个人感觉,如果要大规模调用,采用上述方法应该也是有问题的,最好的方法还是用池化技术,比如上面就直接起它 100 个客户端 (全局变量)。这时候,如果并发量大于 100 的时候,它会等 100 个默认中的某些完工后才能使用。如下伪代码,供讨论,参考。


type Pool struct {
    unUsed sync.Map        //空闲的Pool下标
    pool   []*Client //缓存池
    locker sync.Mutex
}
func NewClientPool(num int) *Pool {
    if num <= 0 {
        num = 1
    }
    if num > 100 {
        num = 100
    }
    var gp Pool

    for i := 0; i < num; i++ {
        ga := NewClinet()
        gp.pool = append(gp.pool, ga)
        gp.unUsed.Store(i, true)
    }
    return &gp
}
需要 登录 后方可回复, 如果你还没有账号请点击这里 注册