調試 Go 中奇怪的 http.Response Read 行為
大家好,我是程序員幽鬼。
先介紹一下背景知識。
使用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