调试 Go 中奇怪的 http.Response Read 行为

开发 前端
我们发现在写入响应时,如果不设置 Content-Length并且写入的大小大于分块缓冲区大小,http.ResponseWriter 将使用分块传输编码。相应地,当我们读取响应时,chunkReader将尝试从 net.Conn 读取整个块。

大家好,我是程序员幽鬼。

先介绍一下背景知识。

使用Dolt[1],你可以push和pull本地 MySQL 兼容的数据库到远程。远程可以使用 dolt remoteCLI 命令进行管理,它支持多种类型的 remotes[2]。你可以将单独的目录用作 Dolt 远程、s3 存储桶或任何实现ChunkStoreService protocol buffer 定义的 grpc 服务。remotesrv是 Dolt 的开源实现ChunkStoreService。它还提供一个简单的 HTTP 文件服务器,用于在远程和客户端之间传输数据。

本周早些时候,我们遇到了一个与 Dolt CLI 和 remotesrv HTTP 文件服务器之间的交互相关的有趣问题。为了解决这个问题,需要了解HTTP/1.1协议并深入挖掘 Golang 源代码。在这篇博客中,我们将讨论 Golang 的net/http包如何自动设置Transfer-EncodingHTTP 响应的标头以及如何改变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-Typeor 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-Encodingheaders 都设置好了!此外,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/dol

t[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

责任编辑:武晓燕 来源: 幽鬼
相关推荐

2018-01-24 18:00:21

LinuxDebianvim

2014-01-14 09:10:53

GoHTTP内存泄漏

2022-03-07 15:05:58

HTTPHi3861数据解析

2020-09-09 07:55:51

TS开源符号

2015-08-14 09:21:09

gdb工具调试 Go

2021-02-02 11:02:20

React任务饥饿行为优先级任务

2009-09-11 12:07:12

C# WinForm控

2023-08-14 08:00:00

Go 标准库HTTP 路由器

2015-12-21 14:56:12

Go语言Http网络协议

2010-05-06 15:35:14

Unix操作系统

2017-04-10 20:21:39

Go语言源码分析Handler

2023-12-04 07:07:36

HTTP请求

2017-04-10 13:26:06

Go语言源码

2023-09-08 08:09:29

项目程序线程

2009-07-20 16:31:45

Response.WrASP.NET

2023-03-29 08:18:16

Go调试工具

2023-08-14 08:34:14

GolangHttp

2023-03-06 08:37:58

JavaNIO

2021-07-28 08:53:53

GoGDB调试

2015-09-15 13:48:01

网络协议HTTP Client
点赞
收藏

51CTO技术栈公众号