发生什么了?

我们的服务器每时每刻都在发出对第三方的http请求;我们采用一个十分老版本的beego.httplib库来发送这些请求。

例如我们希望发起一次对{url}的POST请求,我们可以这样写

req := httplib.Post(url)
response, err := req.DoRequest()

一切都岁月静好,直到某天我认识到,“需要把对第三方http请求监控起来了”。不得不说,这肯定不是一个坏主意,这其实是应该做的,而且也很容易做到。谁又能想到,随之而来的EOF问题,让我们动用最聪慧的头脑也花了好几天才解决呢?

具体来说,加上对外http请求监控后,每隔一段时间,就会有一个EOF问题抛出来,时间间隔不定,触发的url,body也不定。最可恨的是,除了“EOF”三个字符以外,我们什么也不知道,到底哪里读写产生了EOF问题?

为什么EOF

在网上查找了大量相关信息后,我们基本可以判定,这个 EOF 问题应该是http/1.1 keep-alive机制带来的。

sequenceDiagram
    participant reqA
    participant reqB
    reqA->>connPool: 申请一个keepalive conn
    connPool->>conn: 创建
    conn->>connPool: 加入池
    connPool->>reqA: 返回conn
    reqA->>conn: 发起请求
    conn->>+Server: 发起请求 keep-alive
    Server-->>conn: 回包
    reqB->>connPool: 申请一个keepalive conn
    connPool->>conn: 检查
    conn->>connPool: 是健康的 
    Server->>-conn: 服务端超时,关闭连接
    connPool->>reqB: 返回conn
    reqB->>conn: 发起请求,产生EOF

简单解释一下上面发生了什么,如果一个http请求打开了keep-alive,它所用的tcp连接会被持久化在本地,等待下一次请求来使用,直到server端关闭该连接。作为客户端无法知道连接什么时候会被关闭,于是存在一种情况,当一个新的请求获取到一个持久化连接的前后,服务器关闭了连接,此时使用该连接就会导致EOF问题。

看看我们做了什么

EOF问题已经很明确,接下来让我们fix它吧。okay,该怎么fix这个问题?系统库导致了这个问题,也许我们该修改系统库,编译它,然后发布属于我们的私有化golang?等等,仔细想想,在我们打算监控http请求前,是没有EOF问题的,也许我们可以把一切配置都改回来,同时继续监控http请求。

第一步,先看看我们到底做了什么改动:

// 去掉了无关代码片段

type LoggingRoundTripper struct {
    Proxy http.RoundTripper
}

func (lrt LoggingRoundTripper) RoundTrip(req *http.Request) (res *http.Response, err error) {
    ...... // 读取request body
    res, err = lrt.Proxy.RoundTrip(req)
    ...... // 读取response body
    ...... // 日志记录 request body, response body等
    return res, err
}

httplib.SetDefaultSetting(httplib.BeegoHTTPSettings{
        ......
        Transport:        LoggingRoundTripper{http.DefaultTransport},
    })

我们编写了一个实现http.RoundTripper接口的日志类LoggingRoundTripper,它在请求前后读取对应信息,然后把信息输出到日志。接下来用http系统库默认的RoundTripper构造了一个LoggingRoundTripper,用于设置httplib默认的http.RoundTripper。

看起来我们改动了httplib默认的RoundTripper,也许是这里产生了问题,让我们深入看看。

func (b *BeegoHTTPRequest) DoRequest() (resp *http.Response, err error) {
    ......
    if trans == nil {
        // create default transport
        trans = &http.Transport{
            TLSClientConfig:     b.setting.TLSClientConfig,
            Proxy:               b.setting.Proxy,
            Dial:                TimeoutDialer(b.setting.ConnectTimeout, b.setting.ReadWriteTimeout),
            MaxIdleConnsPerHost: 100,
        }
    } else {
        ......
    }
    ......
}

好吧,它其实没有默认设置,是个空值;可它每次进行请求时都匪夷所思地新建了一个http.Transport,用完及弃。上面提到的持久化连接正是通过http.Transport维护的,也就是说这些持久化连接根本没有第二次被使用的机会,所以不会有EOF问题。

真是令人头大的实现,即使在写这篇文章的时候,我也没想明白为什么要这么做?(我查阅了最新的beego.httplib代码,虽然代码结构变了,但transport依然在被日复一日地创建着)

该解决问题了

fix#1 关闭 keepalive

我们可不会采用httplib一样新建无数transport的办法。幸运的是,transport有个参数 DisableKeepAlives 可以控制keepalive开关。

fix#2 设置 keepalive header

不知道是谁发现了这样一个神秘header,它竟然可以设置keepalive时间,像这样 timeout=60 代表60秒的保活时间,简直是解决 EOF 问题的最好办法。立刻设置上,发布,然后,,,EOF又出现了。也许这里有一些复杂的历史原因,但实践告诉我们,别指望这个header

fix#3 让 POST 请求重试

类似的 EOF 问题,golang 在 1.6 左右对 GET/HEAD/OPTION 等请求添加了重试,但别的method不行。也许是上天眷顾,写这篇文章时恰巧找到了一个issue,而且是POST相关的。如果你使用 golang 1.12或更新的版本,你打算开启 keepalive,而且你的请求是幂等的,那么为你的请求加上 header Idempotency-Key 或 X-Idempotency-Key,http系统库会帮你重试这些标记为幂等的请求。

重回岁月静好

还等什么呢,赶快去检查你的http请求有没有开启keepalive吧。

Ref

  1. 类似的 EOF issue: https://github.com/golang/go/issues/19943#issuecomment-421092421
  2. keepalive header: https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Kee...
  3. golang改动支持任意method重试:https://go-review.googlesource.com/c/go/+/147457

iamrockrepublic
1 声望0 粉丝