调试 Go 中奇怪的 http.Response Read 行为
争做团队核心程序员,关注「幽鬼」
大家好,我是程序员幽鬼。
先介绍一下背景知识。
使用Dolt[1],你可以push
和pull
本地 MySQL 兼容的数据库到远程。远程可以使用 dolt remote
CLI 命令进行管理,它支持多种类型的 remotes[2]。你可以将单独的目录用作 Dolt 远程、s3 存储桶或任何实现ChunkStoreService
protocol buffer 定义的 grpc 服务。remotesrv
是 Dolt 的开源实现ChunkStoreService
。它还提供一个简单的 HTTP 文件服务器,用于在远程和客户端之间传输数据。
本周早些时候,我们遇到了一个与 Dolt CLI 和 remotesrv
HTTP 文件服务器之间的交互相关的有趣问题。为了解决这个问题,需要了解HTTP/1.1
协议并深入挖掘 Golang 源代码。在这篇博客中,我们将讨论 Golang 的net/http
包如何自动设置Transfer-Encoding
HTTP 响应的标头以及如何改变http.Response.Body
Read
客户端调用的行为。
一个奇怪的 Dolt CLI 错误
这项调查是从 Dolt 用户的报告开始的。他们已经设置 remotesrv
好托管他们的 Dolt 数据库,并使用 Dolt CLI 将pull
更改上传到本地克隆。虽然push
工作得很好,pull
似乎取得了一些进展,但因可疑错误而失败:
throughput below minimum allowable
这个特殊错误是可疑的,因为它表明 Dolt 客户端未能以每秒 1024 字节的最小速率从remotesrv
的 HTTP 文件服务器下载数据。我们最初的假设是并行下载会导致下载路径出现某种拥塞。但不是这样。研究发现,此错误仅发生在大型下载中,并且是序列化的,因此不太可能出现拥塞。我们更深入地研究了吞吐量是如何测量的,并发现了一些令人惊讶的东西。
我们如何测量吞吐量
让我们从 Golang 的io.Reader
接口概述开始。该接口允许你将Read
来自某个源的字节并写入某个缓冲区b
:
func (T) Read(b []byte) (n int, err error)
作为其规约的一部分,它保证读取的字节数不会超过 len(b)
个字节,并且读取b
的字节数始终以n
返回。只要 b
足够大,特定 Read
调用可以返回 0 个字节、10 个字节甚至 134,232,001 个字节。如果读取器用完了要读取的字节,它会返回一个你可以测试的文件结束 (EOF) 错误。
当你使用net/http
包在 Golang 中进行 HTTP 调用时,响应 body 是一个 io.Reader
。你可以使用Read
读取 body 上的字节。考虑到io.Reader
规约,我们知道,在任何特定调用Read
期间可以检索从 0 从到整个正文的任何位置。
在我们的研究中,我们发现 134,232,001 字节的下载量未能达到我们的最低吞吐量,但原因并没有立即显现。使用Wireshark[3],我们可以看到数据传输速度足够快,而且问题似乎在于 Dolt CLI 如何测量吞吐量。
下面是一些描述如何测量吞吐量的伪代码:
type measurement struct {
N int
T time.Time
}
type throughputReader struct {
io.Reader
ms chan measurement
}
func (r throughputReader) Read(bs []byte) (int, error) {
n, err := r.Reader.Read(bs)
r.ms <- measurement{n, time.Now()}
return n, err
}
func ReadNWithMinThroughput(r io.Reader, n int64, min_bps int64) ([]byte, error) {
ms := make(chan measurement)
defer close(ms)
r = throughputReader{r, ms}
bytes := make([]byte, n)
go func() {
for {
select {
case _, ok := <-ms:
if !ok {
return
}
// Add sample to a window of samples.
case <-time.After(1 * time.Second):
}
// Calculate the throughput by selecting a window of samples,
// summing the sampled bytes read, and dividing by the window length. If the
// throughput is less than |min_bps|, cancel our context.
}
}()
_, err := io.ReadFull(r, bytes)
return bytes, err
}
}
上面的代码揭示了我们问题的罪魁祸首。请注意,如果单个Read
调用需要很长时间,则不会有吞吐量样本到达,最终我们的测量代码将报告吞吐量为 0 字节并抛出错误。小型下载已完成,但较大的下载始终失败这一事实进一步支持了这一点。
但是我们如何防止这些大Reads
的以及导致一些读取量大而另一些读取量小的原因呢?
让我们通过剖析 HTTP 响应如何在服务器上构建以及客户端如何解析来研究这一点。
编写 HTTP 响应
在 Golang 中,你用 http.ResponseWriter
向客户端返回数据。你可以使用 writer 来编写标头和正文,但是有很多底层逻辑可以控制实际写入的标头以及正文的编码方式。
例如,在 http 文件服务器中,我们从不设置Content-Type
or Transfer-Encoding
标头。我们只是调用一次带缓冲区的Write
,来保存我们需要返回的数据。但是如果我们用 curl 检查响应头:
=> curl -sSL -D - http://localhost:8080/dolthub/test/53l5... -o /dev/null
HTTP/1.1 200 OK
Date: Wed, 09 Mar 2022 01:21:28 GMT
Content-Type: application/octet-stream
Transfer-Encoding: chunked
我们可以看到Content-Type
和Transfer-Encoding
headers 都设置好了!此外,Transfer-Encoding
设置为chunked
!
这是我们从 net/http/server.go[4]找到的一条评论, 解释了这一点:
// The Life Of A Write is like this:
//
// Handler starts. No header has been sent. The handler can either
// write a header, or just start writing. Writing before sending a header
// sends an implicitly empty 200 OK header.
//
// If the handler didn't declare a Content-Length up front, we either
// go into chunking mode or, if the handler finishes running before
// the chunking buffer size, we compute a Content-Length and send that
// in the header instead.
//
// Likewise, if the handler didn't set a Content-Type, we sniff that
// from the initial chunk of output.
这是维基百科[5]对分块传输编码的解释:
分块传输编码是超文本传输协议 (HTTP) 版本 1.1 中可用的流式数据传输机制。在分块传输编码中,数据流被分成一系列不重叠的“块”。这些块彼此独立地发送和接收。在任何给定时间,发送者和接收者都不需要知道当前正在处理的块之外的数据流。
每个块前面都有其大小(以字节为单位)。当接收到零长度块时,传输结束。Transfer-Encoding 头中的 chunked 关键字用于表示分块传输。1994 年提出了一种早期形式的分块传输编码。[ 1[6] ] HTTP/2 不支持分块传输编码,它为数据流提供了自己的机制。[ 2[7] ]
读取 HTTP 响应
要读取 http 响应的正文(body),net/http
提供的 Response.Body
是一个 io.Reader
. 它还具有隐藏 HTTP 实现细节的逻辑。无论使用何种传输编码,提供的io.Reader
仅返回最初写入请求中的字节。它会自动“de-chunks”分块的响应。
我们更详细地研究了这种“de-chunks”,以了解为什么这会导致大的Read
.
写和读块
如果你看一下chunkedWriter
实现,你会发现每个 Write
都会产生一个新的块,而不管它的大小:
// Write the contents of data as one chunk to Wire.
func (cw *chunkedWriter) Write(data []byte) (n int, err error) {
// Don't send 0-length data. It looks like EOF for chunked encoding.
if len(data) == 0 {
return 0, nil
}
if _, err = fmt.Fprintf(cw.Wire, "%x\r\n", len(data)); err != nil {
return 0, err
}
if n, err = cw.Wire.Write(data); err != nil {
return
}
if n != len(data) {
err = io.ErrShortWrite
return
}
if _, err = io.WriteString(cw.Wire, "\r\n"); err != nil {
return
}
if bw, ok := cw.Wire.(*FlushAfterChunkWriter); ok {
err = bw.Flush()
}
return
}
在remotesrv
中,我们首先将请求的数据加载到缓冲区中,然后调用 Write
一次。所以我们通过网络发送 1 个大块。
在chunkedReader
中我们看到,一次 Read
调用将读取来自网络的整个块:
func (cr *chunkedReader) Read(b []uint8) (n int, err error) {
for cr.err == nil {
if cr.checkEnd {
if n > 0 && cr.r.Buffered() < 2 {
// We have some data. Return early (per the io.Reader
// contract) instead of potentially blocking while
// reading more.
break
}
if _, cr.err = io.ReadFull(cr.r, cr.buf[:2]); cr.err == nil {
if string(cr.buf[:]) != "\r\n" {
cr.err = errors.New("malformed chunked encoding")
break
}
} else {
if cr.err == io.EOF {
cr.err = io.ErrUnexpectedEOF
}
break
}
cr.checkEnd = false
}
if cr.n == 0 {
if n > 0 && !cr.chunkHeaderAvailable() {
// We've read enough. Don't potentially block
// reading a new chunk header.
break
}
cr.beginChunk()
continue
}
if len(b) == 0 {
break
}
rbuf := b
if uint64(len(rbuf)) > cr.n {
rbuf = rbuf[:cr.n]
}
var n0 int
/*
Annotation by Dhruv:
This Read call directly calls Read on |net.Conn| if |rbuf| is larger
than the underlying |bufio.Reader|'s buffer size.
*/
n0, cr.err = cr.r.Read(rbuf)
n += n0
b = b[n0:]
cr.n -= uint64(n0)
// If we're at the end of a chunk, read the next two
// bytes to verify they are "\r\n".
if cr.n == 0 && cr.err == nil {
cr.checkEnd = true
} else if cr.err == io.EOF {
cr.err = io.ErrUnexpectedEOF
}
}
return n, cr.err
}
由于来自我们的 HTTP 文件服务器的每个请求都作为单个块提供和读取,因此Read
调用的返回时间完全取决于请求数据的大小。在我们下载大量数据(134,232,001 字节)的情况下,这些Read
调用始终超时。
解决问题
我们有两个候选的解决方案来解决这个问题。我们可以通过分解http.ResponseWriter
Write
调用来生成更小的块,或者我们可以显式地设置Content-Length
将完全绕过块传输编码的标头。
我们决定通过使用 io.Copy
分解http.ResponseWriter
Write
。io.Copy
产生Write
最多 32 * 1024 (32,768) 字节 。为了使用它,我们重构了我们的代码以为io.Reader
提供所需的数据而不是大缓冲区。使用 io.Copy
是一种在io.Reader
和io.Writer
之间传递数据的惯用模式。
你可以在此处[8]查看包含这些更改的 PR 。
结论
总之,我们发现在写入响应时,如果不设置 Content-Length
并且写入的大小大于分块缓冲区大小,http.ResponseWriter
将使用分块传输编码。相应地,当我们读取响应时,chunkReader
将尝试从 net.Conn
读取整个块。由于remotesrv
编写了一个非常大的块,Dolt CLI 上 Read
的调用总是花费太长时间并导致抛出整个错误。我们通过编写更小的块来解决这个问题。
使用该net/http
包和其他 Golang 标准库很愉快。由于大多数标准库都是用 Go 本身编写的,并且可以在 Github 上查看,因此很容易阅读源代码。尽管手头的具体问题几乎没有文档,但只用了一两个小时就可以挖掘到根本原因。我个人很高兴能继续在 Dolt 上工作并加深我对 Go 的了解。
原文链接:https://www.dolthub.com/blog/2022-03-09-debugging-http-body-read-behavior/
参考资料
[1]Dolt: https://github.com/dolthub/dolt
[2]类型的 remotes: https://docs.dolthub.com/concepts/dolt/remotes
[3]Wireshark: https://www.wireshark.org/
[4]net/http/server.go: https://github.com/golang/go/blob/a987aaf5f7a5f64215ff75ac93a2c1b39967a8c9/src/net/http/server.go#L1538-L1561
[5]维基百科: https://en.wikipedia.org/wiki/Chunked_transfer_encoding
[6][1: https://en.wikipedia.org/wiki/Chunked_transfer_encoding#cite_note-1
[7][2: https://en.wikipedia.org/wiki/Chunked_transfer_encoding#cite_note-2
[8]你可以在此处: https://github.com/dolthub/dolt/pull/2933
往期推荐
欢迎关注「幽鬼」,像她一样做团队的核心。