先介绍一下背景知识。
创新互联建站成立于2013年,是专业互联网技术服务公司,拥有项目做网站、成都做网站网站策划,项目实施与项目整合能力。我们以让每一个梦想脱颖而出为使命,1280元册亨做网站,已为上家服务,为册亨各地企业和个人服务,联系电话:13518219792
使用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 用户的报告开始的。他们已经设置 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 响应如何在服务器上构建以及客户端如何解析来研究这一点。
在 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 响应的正文(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 的了解。
分享题目:调试Go中奇怪的http.ResponseRead行为
转载来于:http://www.shufengxianlan.com/qtweb/news47/43547.html
网站建设、网络推广公司-创新互联,是专注品牌与效果的网站制作,网络营销seo公司;服务项目有等
声明:本网站发布的内容(图片、视频和文字)以用户投稿、用户转载内容为主,如果涉及侵权请尽快告知,我们将会在第一时间删除。文章观点不代表本网站立场,如需处理请联系客服。电话:028-86922220;邮箱:631063699@qq.com。内容未经允许不得转载,或转载时需注明来源: 创新互联