一次线上事故引发的对 http 请求的结束的思考

633 阅读4分钟

这是我参与11月更文挑战的第1天,活动详情查看:2021最后一次更文挑战

引言

近期在一次日常上线后,发现系统 SLA 下降很多,通过监测平台发现,SLA 下降的主要原因是接口超时。经过排查,发现最后原因是因为 mesh 某个组件升级后,对于 Transfer-Encoding: chunked 的 http 请求,会丢掉结束符,然后导致 http server 认为传输一直未结束,一直尝试读取后面的数据,导致最终请求超时。

思考与总结

引言中的事故引起了我对 http 请求如何结束极大的好奇,通过查阅相关资料之后,总结如下:

http 的连接

http 链接可以分为长连接与短连接,在 http/1.0 时代,默认使用的是短连接,而从 http/1.1 开始,默认使用长连接。那么什么又是短连接,什么又是长连接呢?
短连接,顾名思义,客户端和服务端每进行一次 http 操作,双方就会建立一次连接,操作结束,则连接断开。举个例子,对于一个 web 页面,包含了 javascript 文件,图像文件,css 文件等资源,浏览器每请求一个资源,就会建立一次 http 连接。
对于使用长连接的 http/1.0 协议,header 头会添加 Connection: keep-alive,从 http/1.1 开始,不再需要 keep-alive 这个取值,除非显示加上 Connection: close,否则默认就是长连接。在使用长连接的情况下,打开一个 web 页面加载各项资源,一般都会使用一开始建立好的那条连接。当然,长连接不等于永久连接,而是保持一段时间。
我们知道,tcp 是传输层协议,http 是应用层协议,因此 http 连接的本质还是 tcp 连接。tcp 每次连接开始会有三次握手的过程,连接结束会有四次挥手的过程。长连接可以省去大量的 tcp 连接建立、关闭所消耗的各种资源。对于频繁请求资源的用户而言,长连接是比较划算的。
当然,万事无绝对,这里还有一个问题,就是长连接需要持续多久呢?就会需要相应的连接存活检测,这也会消耗一部分资源。在长连接的应用场景下,客户端一般不会主动关闭连接,这也就会导致服务端建立的连接越来越多,直到服务端崩溃。因此服务端需要采取一些策略,比如关闭一些长时间没有读写连接,来避免服务端压力过大。

http 的结束

上面我们了解 http 的连接相关的内容,结合引言部分,那么 http 请求是如何结束的呢。
对于短连接,可以根据连接是否关闭来界定请求或者响应实体的边界,但是对于长连接,这种方法就不那么有效了,因为尽管可能所有的数据都已经传输完毕了,但是服务端并不知道所有数据是否都收到了,然后就只能白白等待,直到最终超时。
为了解决长连接遇到的这个问题,最直观的办法就是告诉服务端,这次发送的内容有多少,于是就有了另一个 header 头 Content-Length,服务端可以根据这个 header 头的值,判断这个请求是否已经传输完毕。俗话说,理想总是美好的,那么我们要怎么才能知道 Content-Length 的取值呢?对于很多网络请求,这个值不好精确计算,取大了,会造成资源浪费,且请求 pending,取小了,会造成请求截断,丢失信息。因此,我们迫切需要一个新的方法来界定请求或者响应的边界。
千呼万唤始出来,Transfer-Encoding header 头便应运而生,根据目前的 RFC 规范,这个 header 只有 chunked 一个取值。在头部加入 Transfer-Encoding: chunked 后,就表示这个报文采用了分块编码。报文采用一系列分块来传输,每个分块包含十六进制的长度值和数据,长度值占一行,长度不包括它结尾的 CRLF(\r\n)。最后一个分块长度值必须是 0,对应分块没有具体内容,表示实体结束。因此可以认为,当读到 \r\n0\r\n 时,表示结束。http 传输数据的结束判断流程如下所示:

graph TB
A["http 传输数据"] -->B{"是否是长连接?"}
B -->|否| C["接收数据"] --> E{"recv()=-1?"} -->|否| C
B -->|是| D{"Content-Length 是否存在?"}
E -->|是| F{"http 数据传输完毕"}
D -->|是| G["接收 Content-Length 长度的数据"] --> H{"接收完毕?"}
H -->|是| F
H -->|否| G
D -->|否| I["Transfer-Encoding: chunked"] --> K["接收数据"] --> J{"检测到 \r\n0\r\n?"}
J -->|否| K
J -->|是| F

Golang 中的 net/http 实现正如上面流程所示,具体代码如下:

...
t.ContentLength = rr.outgoingLength()
if t.ContentLength < 0 && len(t.TransferEncoding) == 0 && t.shouldSendChunkedRequestBody() {
   t.TransferEncoding = []string{"chunked"}
}
...
// outgoingLength reports the Content-Length of this outgoing (Client) request.
// It maps 0 into -1 (unknown) when the Body is non-nil.
func (r *Request) outgoingLength() int64 {
   if r.Body == nil || r.Body == NoBody {
      return 0
   }
   if r.ContentLength != 0 {
      return r.ContentLength
   }
   return -1
}