深度分析 [go的HttpClient讀取Body超時]

故障現場

本人負責的主備集羣,發出的 HttpClient 請求有 30%概率超時, 報context deadline exceeded (Client.Timeout or context cancellation while reading body)
異常

Kibana 顯示 Nginx 處理請求的耗時request_time在[5s-1min]區間波動, upstream_response_time在 2s 級別。

所以我們認定是 Nginx 向客戶端回傳 50M 的數據,發生了網絡延遲。
於是將 HttpClient Timeout 從 30s 調整到 60s, 上線之後明顯改善。

But 昨天又出現了一次同樣的超時異常

time="2022-01-05T22:28:59+08:00" ....
time="2022-01-05T22:30:02+08:00" level=error msg="service Run error" error="region: sz,first load self allIns error:context deadline exceeded (Client.Timeout or context cancellation while reading body)"

線下覆盤

Kibana 顯示 Nginx 處理請求的耗時request_time才 32s, 遠不到我們對於 Http 客戶端設置的 60s 超時閾值。

這裏有必要穿插 Nginx Access Log 的幾個背景點

  1. Nginx 寫日誌的時間
    根據nginx access log官方,NGINX writes information about client requests in the access log right after the request is processed. 也就是說 Nginx 是在端到端請求被處理完之後才寫入日誌。
  2. Nginx Request_Time upstream_response_time
  • $upstream_response_time – The time between establishing a connection and receiving the last byte of the response body from the upstream server
    從 Nginx 向後端建立連接開始到接受完數據然後關閉連接爲止的時間
  • $request_time – The total time spent processing a request
    Nginx 從接受用戶請求的第一個字節到發送完響應數據的時間

從目前的信息看,Nginx 從接受請求到發送完響應流,總共耗時 32s。
那剩下的 28s,是在哪裏消耗的。

三省吾身

這是我抽離的 HttpClient 的實踐, 常規的不能再常規。

package main

import (
	"bytes"
	"encoding/json"
	"io/ioutil"
	"log"
	"net/http"
	"time"
)

func main() {
	  c := &http.Client{Timeout: 10 * time.Second}
	  body := sendRequest(c, http.MethodPost)
  	log.Println("response body length:", len(body))
}

func sendRequest(client *http.Client, method string) []byte {
	  endpoint := "http://mdb.qa.17usoft.com/table/instance?method=batch_query"
	  expr := "idc in (logicidc_hd1,logicidc_hd2,officeidc_hd1)"
	  jsonData, err := json.Marshal([]string{expr})
	  response, err := client.Post(endpoint, "application/json", bytes.NewBuffer(jsonData))
	  if err != nil {
		  log.Fatalf("Error sending request to api endpoint, %+v", err)
	  }
	  defer response.Body.Close()
	  body, err := ioutil.ReadAll(response.Body)
	  if err != nil {
		  log.Fatalf("Couldn't parse response body, %+v", err)
	  }
	  return body
}

核心就兩個動作

  • 調用Get、Post、Do方法發起 Http 請求, 如果無報錯,則表示服務端已經處理了請求
  • iotil.ReadAll表示客戶端準備從網卡讀取 Response Body (流式數據), 超時異常正是從這裏爆出來的

go 的 HttpClient Timeout 定義
Timeout specifies a time limit for requests made by this Client. The timeout includes connection time, any redirects, and reading the response body. The timer remains running after Get, Head, Post, or Do return and will interrupt reading of the Response.Body.

HttpClient Timeout包括連接、重定向(如果有)、從Response Body讀取的時間,內置定時器會在Get,Head、Post、Do 方法之後繼續運行,直到讀取完Response.Body.

報錯內容:"Client.Timeout or context cancellation while reading body" 讀取 Response Body 超時,

潛臺詞是:服務器已經處理了請求,並且開始向客戶端網卡寫入數據。

根據我有限的網絡原理/計算機原理,與此同時,客戶端會異步從網卡讀取 Response Body。

寫入和讀取互不干擾,但是時空有重疊

所以[讀取 Body 超時]位於圖中的紅框區域,這就有點意思了。

  • 之前我們有 30%概率[讀取 Body 超時],確實是因爲 Nginx 回傳 50M 數據超時,這在 Nginx request_time 上能體現。

  • 本次 Nginx 顯示 request_time=32s, 卻再次超時,推斷 Nginx 已經寫完數據,而客戶端還沒有讀取完 Body

至於爲什麼沒讀取完,這就得吐槽iotil.ReadAll的性能了。
客戶端使用 iotil.ReadAll 讀取大的響應體,會不斷申請內存(源碼顯示會從 512B->50M),耗時較長,性能較差、並且有內存泄漏的風險, 下一篇我會講解針對大的響應體替換iotil.ReadAll的方案


爲了模擬這個偶發的情況,我們可在Postiotil.ReadAll前後加入時間日誌。

$ go run main.go
2022/01/07 20:21:46 開始請求: 2022-01-07 20:21:46.010
2022/01/07 20:21:47 服務端處理結束: 2022-01-07 20:21:47.010
2022/01/07 20:21:52 客戶端讀取結束: 2022-01-07 20:21:52.010
2022/01/07 20:21:52 response body length: 50575756

可以看出,當讀取大的響應體時候,客戶端iotil.ReadAll的耗時並不算小,這塊需要開發人員重視。

我們甚至可以iotil.ReadAll之前time.Sleep(xxx), 就能輕鬆模擬出生產環境的讀取 Body 超時。

我的收穫

  1. Nginx Access Log 的時間含義
  2. go 的 HttpClient Timeout 包含了連接、請求、讀取 Body 的耗時
  3. 通過對[讀取 Body 超時異常]的分析,我梳理了端到端的請求耗時、客戶端的行爲耗時的時空關係, 這個至關重要。
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章