概述

在使用 HTTP 的过程中,无论是浏览器还是 SDK 的 http 客户端,我们可能都会有需求希望能够复用 HTTP 连接,也就是在同一条 HTTP 连接上请求和响应多次 HTTP 请求,这也是我们常说的 HTTP 长连接。

那么本文就将从 HTTP 长连接的协议支持已经实际 Go 实现上来介绍一下长连接的相关知识。

协议支持

在 HTTP 1.1 的 RFC 中,特别对长连接单开一章节进行讨论。在开篇就先描述了一波使用持久连接的好处:

  • 降低所有环节设备的资源使用量(客户端、服务器、路由器等的 CPU 和内存使用);
  • 多个请求和响应可以在同一个连接里面 pipeline,提高连接利用率;
  • 因为减少了新建和关闭 TCP 连接的过程,所以网络上的数据包会变少,减少了网络拥塞的情况;
  • 请求的响应延迟会降低,因为节省了连接建立的时间;
  • 错误的处理可以更加友好,短连接只能用断开连接来表示异常,持久连接加入了错误报告;

在 HTTP 1.1 的协议 RFC 中说明了,默认情况下 HTTP 1.1 都是长连接,除非客户端或者服务端发送了 Connection:Close 的 Header 的情况下才会认为这是一个短连接。

如果是长连接,如果在发送过程中连接异常断开了,那么客户端对于幂等的请求,应该重试,对于非幂等的请求,不能重试;在 HTTP 1.1 的 RFC 中,幂等的请求方法有 GET、HEAD、PUT 和 DELETE。

Go 实现

接下来,我们就看看 Go 1.22 是如何针对 HTTP 1.1 的协议进行处理的,我们会分别针对 Client 和 Server 进行不同的讨论。

客户端

在 Go 中,对于连接的管理是通过 Transport 来管理的,而 Transport 的类型为 RoundTripper,接口为:

  1. [root@liqiang.io]# cat client.go
  2. type RoundTripper interface {
  3. RoundTrip(*Request) (*Response, error)
  4. }

RoundTripper 是一个 interface,所以需要我们提供实现,但是,默认情况下,HTTP 客户端会使用 Default 的 RoundTripper:

  1. [root@liqiang.io]# cat transport.go
  2. var DefaultTransport RoundTripper = &Transport{
  3. Proxy: ProxyFromEnvironment,
  4. DialContext: defaultTransportDialContext(&net.Dialer{
  5. Timeout: 30 * time.Second,
  6. KeepAlive: 30 * time.Second,
  7. }),
  8. ForceAttemptHTTP2: true,
  9. MaxIdleConns: 100,
  10. IdleConnTimeout: 90 * time.Second,
  11. TLSHandshakeTimeout: 10 * time.Second,
  12. ExpectContinueTimeout: 1 * time.Second,
  13. }
  14. func (t *Transport) roundTrip(req *Request) (*Response, error) {
  15. ... ...
  16. for {
  17. select {
  18. case <-ctx.Done():
  19. req.closeBody()
  20. return nil, ctx.Err()
  21. default:
  22. }
  23. treq := &transportRequest{Request: req, trace: trace, cancelKey: cancelKey}
  24. cm, err := t.connectMethodForRequest(treq)
  25. ... ...
  26. pconn, err := t.getConn(treq, cm)
  27. ... ...
  28. resp, err = pconn.roundTrip(treq)
  29. ... ...
  30. } else if !pconn.shouldRetryRequest(req, err) {
  31. ... ...
  32. return nil, err
  33. }
  34. ... ...
  35. // Rewind the body if we're able to.
  36. req, err = rewindBody(req)
  37. if err != nil {
  38. return nil, err
  39. }
  40. }
  41. }

从这个简单的实现中可以看到,Go 的 HTTP Client 默认情况下是长连接,并且最多允许有 100 条并发的长连接,且默认的连接 idle 时间是 90 秒,并且在一些情况下会进行重试,下面列举了一下可能重试的场景:

  • HTTP2 场景下单条连接太多 stream 时;
  • 如果请求不包含 body 或者我们可以重新获取 body 的情况;(这里没有区分幂等请求方法)
  • 在读取响应的第一个字节之前就获得了非 EOF 的错误;
  • 服务端关闭了空闲的连接;

上面这段代码可以简单看出来 Go 的 HTTP 客户端实现了这些特性,那么我们再深入一点看其他的代码逻辑:

  1. [root@liqiang.io]# cat transport.go
  2. func (t *Transport) getConn(treq *transportRequest, cm connectMethod) (pc *persistConn, err error) {
  3. ... ...
  4. w := &wantConn{
  5. cm: cm,
  6. key: cm.key(),
  7. ctx: ctx,
  8. ready: make(chan struct{}, 1),
  9. beforeDial: testHookPrePendingDial,
  10. afterDial: testHookPostPendingDial,
  11. }
  12. if delivered := t.queueForIdleConn(w); delivered {
  13. pc := w.pc
  14. ... ...
  15. t.setReqCanceler(treq.cancelKey, func(error) {})
  16. return pc, nil
  17. }
  18. ... ...
  19. }
  20. func (t *Transport) queueForIdleConn(w *wantConn) (delivered bool) {
  21. if t.DisableKeepAlives {
  22. return false
  23. }
  24. t.idleMu.Lock()
  25. defer t.idleMu.Unlock()
  26. ... ...
  27. var oldTime time.Time
  28. if t.IdleConnTimeout > 0 {
  29. oldTime = time.Now().Add(-t.IdleConnTimeout)
  30. }
  31. // Look for most recently-used idle connection.
  32. if list, ok := t.idleConn[w.key]; ok {
  33. stop := false
  34. delivered := false
  35. for len(list) > 0 && !stop {
  36. pconn := list[len(list)-1]
  37. tooOld := !oldTime.IsZero() && pconn.idleAt.Round(0).Before(oldTime)
  38. if tooOld {
  39. go pconn.closeConnIfStillIdle()
  40. }
  41. if pconn.isBroken() || tooOld {
  42. list = list[:len(list)-1]
  43. continue
  44. }
  45. delivered = w.tryDeliver(pconn, nil)
  46. if delivered {
  47. if pconn.alt != nil {
  48. } else {
  49. t.idleLRU.remove(pconn)
  50. list = list[:len(list)-1]
  51. }
  52. }
  53. stop = true
  54. }
  55. if len(list) > 0 {
  56. t.idleConn[w.key] = list
  57. } else {
  58. delete(t.idleConn, w.key)
  59. }
  60. if stop {
  61. return delivered
  62. }
  63. }
  64. if t.idleConnWait == nil {
  65. t.idleConnWait = make(map[connectMethodKey]wantConnQueue)
  66. }
  67. q := t.idleConnWait[w.key]
  68. q.cleanFront()
  69. q.pushBack(w)
  70. t.idleConnWait[w.key] = q
  71. return false
  72. }

从这些代码可以看到,Go 的实现并没有按照 RFC 说的一条连接可以有多个 onGoging 的请求,而是一条连接只有一个正在处理中的请求,然后 Go 自己维护了一个连接池,连接池以 schema、方法加地址作为 key 进行维护。

上面代码中还有一个小细节,就是这里维护了一个 LRU 的数据结构,它是用在如果连接过多时,需要删除掉一些连接的情况下,会通过这个数据结构关闭掉最长空闲时间的连接。

服务端

Go 的服务端实现和客户端相比是相对简单的,从 Goroutine 模型上来看,就是 Per Request Per Goroutine 的模型:

  1. [root@liqiang.io]# cat server.go
  2. func (srv *Server) Serve(l net.Listener) error {
  3. for {
  4. rw, err := l.Accept()
  5. ... ...
  6. connCtx := ctx
  7. ... ...
  8. tempDelay = 0
  9. c := srv.newConn(rw)
  10. c.setState(c.rwc, StateNew, runHooks) // before Serve can return
  11. go c.serve(connCtx)
  12. }
  13. }
  14. func (c *conn) serve(ctx context.Context) {
  15. if ra := c.rwc.RemoteAddr(); ra != nil {
  16. c.remoteAddr = ra.String()
  17. }
  18. ctx = context.WithValue(ctx, LocalAddrContextKey, c.rwc.LocalAddr())
  19. var inFlightResponse *response
  20. ... ...
  21. ctx, cancelCtx := context.WithCancel(ctx)
  22. c.cancelCtx = cancelCtx
  23. defer cancelCtx()
  24. c.r = &connReader{conn: c}
  25. c.bufr = newBufioReader(c.r)
  26. c.bufw = newBufioWriterSize(checkConnErrorWriter{c}, 4<<10)
  27. for {
  28. w, err := c.readRequest(ctx)
  29. if c.r.remain != c.server.initialReadLimitSize() {
  30. // If we read any bytes off the wire, we're active.
  31. c.setState(c.rwc, StateActive, runHooks)
  32. }
  33. ... ...
  34. req := w.req
  35. ... ...
  36. c.curReq.Store(w)
  37. ... ...
  38. inFlightResponse = w
  39. serverHandler{c.server}.ServeHTTP(w, w.req)
  40. inFlightResponse = nil
  41. w.cancelCtx()
  42. if c.hijacked() {
  43. return
  44. }
  45. ... ...
  46. w.finishRequest()
  47. c.rwc.SetWriteDeadline(time.Time{})
  48. c.setState(c.rwc, StateIdle, runHooks)
  49. c.curReq.Store(nil)
  50. if !w.conn.server.doKeepAlives() {
  51. return
  52. }
  53. if d := c.server.idleTimeout(); d != 0 {
  54. c.rwc.SetReadDeadline(time.Now().Add(d))
  55. } else {
  56. c.rwc.SetReadDeadline(time.Time{})
  57. }
  58. if _, err := c.bufr.Peek(4); err != nil {
  59. return
  60. }
  61. c.rwc.SetReadDeadline(time.Time{})
  62. }

从这段代码的实现中可以看到,服务端的连接也是复用的,同时也是一样的每个连接最多只有一个正在进行中的请求。但是,从代码中也可以看到,并不是所有的连接都是直接复用的(shouldReuseConnection),这里列举不能复用的场景:

  • 连接过大或者客户端指定了 Connection: Close 的场景;
  • 响应没有写完的场景(一般是写出错了);
  • 连接出现了写错误;
  • 连接的读请求有异常;

中间件

在日常的使用中,我们大多数情况下都不是直接对外暴露 Go 的 HTTP 客户端,往往都是中间会加一些中间件作为负载均衡,例如 Nginx 之类的,那么当我们使用长连接时,也需要考虑到中间件的能力,这里就以 Nginx 为例做一个介绍。

Nginx

Nginx 的 keep alive 参数需要分为两部分来讨论,分别是 Nginx <-> Upstream 和 Client <-> Nginx 这两段链路,在 Nginx 的文档中,关于 Keepalive 的参数有这么几个:

链路 配置 语法 默认值 描述
客户端 keepalive_disable keepalive_disable none &#124; msie &#124; safari ...; keepalive_disable msie6; 针对某些浏览器禁用长连接
客户端 keepalive_requests keepalive_requests number; keepalive_requests 1000; 一条连接最多可以承载多少次的请求
客户端 keepalive_time keepalive_time time; keepalive_time 1h; 一条长连接的最长生命周期是多久,到达这个时间之后这条连接将不接受新的请求并关闭
客户端 keepalive_timeout keepalive_timeout timeout [header_timeout]; keepalive_timeout 75s; 一条长连接的最长空闲时间,有些浏览器支持 Keep-Alive: timeout=time 的用法
服务端 keepalive keepalive connections; - 每个 Worker 线程最多可以和 Upstream 建立多少条连接
服务端 keepalive_requests keepalive_requests number; keepalive_requests 1000; 每条连接最多可以处理多少个请求
服务端 keepalive_time keepalive_time time; keepalive_time 1h; 每条连接的最长生命周期是多久
服务端 keepalive_timeout keepalive_timeout timeout; keepalive_timeout 60s; 连接的空闲时间

Ref