嗨,我是程序员Spectre。先介绍一下背景知识。使用Dolt[1],您可以将本地MySQL兼容数据库推送和拉取到远程数据库。可以使用doltremoteCLI命令管理遥控器,该命令支持多种类型的遥控器[2]。您可以使用单独的目录作为Dolt遥控器、s3存储桶或任何实现ChunkStoreService协议缓冲区定义的grpc服务。remotesrv是Dolt的ChunkStoreService开源实现。它还提供了一个简单的HTTP文件服务器,用于在远程和客户端之间传输数据。本周早些时候,我们遇到了一个有趣的问题,该问题与DoltCLI和remotesrvHTTP文件服务器之间的交互有关。要解决这个问题,需要了解HTTP/1.1协议并深入研究Golang源代码。在这篇博文中,我们将讨论Golang的net/http包如何自动设置Transfer-EncodingHTTP响应标头,以及它如何改变http.Response.BodyRead客户端调用的行为。一个奇怪的DoltCLI错误这项调查始于Dolt用户的报告。他们设置了remotesrv来托管他们的Dolt数据库,并使用DoltCLI将更改拉到他们的本地克隆。虽然推送工作正常,但拉动似乎取得了一些进展,但失败并出现可疑错误:吞吐量低于最小允许值此特定错误很可疑,因为它表明Dolt客户端以每秒1024字节的最低速率从HTTP文件服务器失败用于remotesrv下载数据。我们最初的假设是并行下载导致下载路径出现某种拥塞。但不是这样的。研究发现,此错误仅在大量下载时发生,并且是序列化的,因此不太可能出现拥塞。我们更深入地研究了如何衡量吞吐量,并发现了一些令人惊讶的事情。我们如何衡量吞吐量让我们从Golang的io.Reader接口的概述开始。此接口允许您从某些源读取字节并写入某些缓冲区b:func(T)Read(b[]byte)(nint,errerror)作为其规范的一部分,它保证读取中的字节数不会超过len(b)个字节,在b中读取的字节数始终返回为n。只要b足够大,特定的Read调用可以返回0字节、10字节甚至134,232,001字节。如果读取器用完了要读取的字节,它会返回一个文件结束(EOF)错误,您可以测试该错误。当您使用net/http包在Golang中进行HTTP调用时,响应主体是一个io.Reader。您可以使用Read读取body上的字节。考虑到io.Reader规范,我们知道在对Read的任何特定调用期间都可以检索从0到整个文字的任何位置。在我们的研究过程中,我们发现134,232,001字节的下载没有达到我们的最低吞吐量,原因尚不明显。使用Wireshark[3]我们可以看到数据传输足够快,问题似乎出在DoltCLI如何测量吞吐量。下面是一些描述如何测量吞吐量的伪代码:,err:=r.Reader.Read(bs)r.ms<-measurement{n,time.Now()}returnn,err}funcReadNWithMinThroughput(rio.Reader,nint64,min_bpsint64)([]字节,error){ms:=make(chanmeasurement)deferclose(ms)r=throughputReader{r,ms}bytes:=make([]byte,n)gofunc(){for{select{case_,ok:=<-ms:if!ok{return}//将样本添加到样本窗口。case<-time.After(1*time.Second):}//通过选择样本窗口计算吞吐量,//将读取的采样字节相加,然后除以窗口长度。如果//吞吐量小于|min_bps|,则取消我们的上下文。}}()_,err:=io.ReadFull(r,bytes)returnbytes,err}}上面的代码揭示了我们问题的罪魁祸首。请注意,如果单个Read调用花费很长时间,则不会有吞吐量样本到达,最终我们的测量代码将报告0字节的吞吐量并抛出错误。小型下载完成但大型下载始终失败的事实进一步支持了这一点。但是我们如何防止这些大读取以及导致某些读取大而其他读取小的原因是什么?让我们通过剖析HTTP响应是如何在服务器上构建以及客户端是如何解析的来研究这个问题的。编写HTTP响应在Golang中,您使用http.ResponseWriter将数据返回给客户端。您可以使用编写器来编写标头和文本,但是有很多低级逻辑控制实际写入的标头以及文本的编码方式。例如,在http文件服务器中,我们从不设置Content-Type或Transfer-Encoding标头。我们只是用缓冲区调用Write来保存我们需要返回的数据。但是,如果我们使用curl检查响应标头:=>curl-sSL-D-http://localhost:8080/dolthub/test/53l5...-o/dev/nullHTTP/1.1200OKDate:Wed,09Mar202201:21:28GMTContent-Type:application/octet-streamTransfer-Encoding:chunked我们可以看到Content-Type和Transfer-Encoding标头都设置好了!此外,Transfer-Encoding设置为chunked!这就是我们从net/http/得到的在server.go[4]上找到的一条评论解释了这一点://TheLifeOfAWriteislikethis:////Handlerstarts.没有发送标头。处理程序可以//写一个标题,或者只是开始写。在发送标头之前写入//发送一个隐式空的200OK标头。////如果处理程序没有预先声明Content-Length,我们要么//进入分块模式,要么,如果处理程序在此之前完成运行//分块缓冲区大小,我们计算一个Content-Length并将其//发送到标头中。////同样,如果处理程序未设置Content-Type,我们从//的初始块中嗅探输出。这是维基百科[5]对分块传输编码的解释:分块传输编码是超文本传输??协议(HTTP)1.1版中可用的流数据传输机制。在分块传输编码中,数据流被分成一系列不重叠的“块”。这些块彼此独立发送和接收。在任何给定时间,发送方和接收方都不需要知道当前正在处理的块之外的数据流。每个块前面都有其大小(以字节为单位)。当接收到零长度块时,传输结束。Transfer-Encoding标头中的chunked关键字用于指示分块传输。1994年提出了一种早期形式的分块传输编码。[1[6]]HTTP/2不支持分块传输编码,它为数据流提供了自己的机制。[2[7]]。读取HTTP响应要读取http响应的主体,net/http提供的Response.Body是一个io.Reader。它还具有隐藏HTTP实现细节的逻辑。无论使用何种传输编码,提供的io.Reader仅返回请求中最初写入的字节。它会自动“取消分块”分块响应。我们更详细地研究这种“去块”,以了解为什么这会导致大量读取。写入和读取块如果查看chunkedWriter实现,您会发现每次写入都会产生一个新块,无论其大小如何://将数据内容作为一个块写入Wire.func(cw*chunkedWriter)Write(data[]byte)(nint,errerror){//不要发送长度为0的数据。它看起来像用于分块编码的EOF。如果len(data)==0{return0,nil}如果_,err=fmt.Fprintf(cw.Wire,"%x\r\n",len(data));err!=nil{return0,err}如果n,err=cw.Wire.Write(data);err!=nil{return}ifn!=len(data){err=io.ErrShortWritereturn}if_,err=io.WriteString(cw.Wire,"\r\n");err!=nil{return}ifbw,ok:=cw.Wire.(*FlushAfterChunkWriter);ok{err=bw.Flush()}return}在remotesrv中我们首先将请求的数据加载到缓冲区中,然后调用Write一次。所以我们通过网络发送1个块。在chunkedReader中我们看到,一次Read调用将读取来自网络的整块:func(cr*chunkedReader)Read(b[]uint8)(nint,errerror){forcr.err==nil{ifcr.checkEnd{ifn>0&&cr.r.Buffered()<2{//我们有一些数据。尽早返回(根据io.Reader//合同)而不是在//阅读更多内容时潜在地阻塞。中断}如果_,cr.err=io.ReadFull(cr.r,cr.buf[:2]);cr.err==nil{ifstring(cr.buf[:])!="\r\n"{cr.err=errors.New("malformedchunkedencoding")break}}else{ifcr.err==io.EOF{cr.err=io.ErrUnexpectedEOF}break}cr.checkEnd=false}ifcr.n==0{ifn>0&&!cr.chunkHeaderAvailable(){//我们已经读够了。不要潜在地阻止//读取新的块头。break}cr.beginChunk()continue}iflen(b)==0{break}rbuf:=bifuint64(len(rbuf))>cr.n{rbuf=rbuf[:cr.n]}varn0int/*Dhruv注释:ThisReadcall直接调用阅读|net.Conn|如果|rbuf|大于底层|bufio.Reader|的缓冲区大小。*/n0,cr.err=cr.r.Read(rbuf)n+=n0b=b[n0:]cr.n-=uint64(n0)//如果我们在块的末尾,读取接下来的两个//字节来验证它们是“\r\n”。如果铬。n==0&&cr.err==nil{cr.checkEnd=true}elseifcr.err==io.EOF{cr.err=io.ErrUnexpectedEOF}}returnn,cr.err}由于来自我们的HTTP来自文件服务器的每个请求都作为单个块提供和读取,因此Read调用的返回时间完全取决于所请求数据的大小。在我们下载大量数据(134,232,001字节)的情况下,这些读取调用始终超时。解决问题我们有两个候选解决方案来解决这个问题。我们可以通过分解http.ResponseWriterWrite调用来生成更小的块,或者我们可以显式设置Content-Length标头,这将完全绕过分块传输编码。我们决定通过使用io.Copy分解http.ResponseWriterWrite。io.Copy产生最多32*1024(32,768)字节的写入。为了使用它,我们重构了我们的代码,为io.Reader提供所需的数据,而不是一个大缓冲区。使用io.Copy是在io.Reader和io.Writer之间传递数据的惯用模式。您可以在此处查看包含这些更改的PR[8]。结论综上所述,我们发现在写入响应时,如果未设置Content-Length并且写入的大小大于分块缓冲区大小,http.ResponseWriter将使用分块传输编码。相应地,当我们读取响应时,chunkReader会尝试从net.Conn中读取整个chunk。由于remotesrv写入了一个非常大的块,因此在DoltCLI上调用Read总是花费太长时间并导致抛出整个错误。我们通过编写更小的块来解决这个问题。很高兴使用net/http包和其他Golang标准库。由于大部分标准库都是用Go本身编写的,并且可以在Github上查看,所以很容易阅读源代码。尽管手头的具体问题几乎没有文档,但只花了一两个小时就找到了根本原因。我个人很高兴继续在Dolt上工作并加深我对Go的了解。原文链接:https://www.dolthub.com/blog/2022-03-09-debugging-http-body-read-behavior/References[1]Dolt:https://github.com/dolthub/dolt[2]输入遥控器:https://docs.dolthub.com/concepts/dolt/remotes[3]Wireshark:https://www.wireshark.org/[4]net/http/server.go:https://img.ydisp.cn/news/20220902/2deuyip0qo4.gohttps://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
