為什麼需要逾時控制?
很多連鎖故障的場景下的一個常見問題是伺服器正在消耗大量資源處理那些早已經超過用戶端截止時間的請求,這樣的結果是,伺服器消耗大量資源沒有做任何有價值的工作,回複已經逾時的請求是沒有任何意義的。
逾時控制可以說是保證服務穩定性的一道重要的防線,它的本質是快速失敗(fail fast),良好的逾時控制政策可以盡快清空高延遲的請求,盡快釋放資源避免請求的堆積。
服務間逾時傳遞
如果一個請求有多個階段,比如由一系列 RPC 調用組成,那麼我們的服務應該在每個階段開始前檢查截止時間以避免做無用功,也就是要檢查是否還有足夠的剩餘時間處理請求。
一個常見的錯誤實作方式是在每個 RPC 服務設定一個固定的逾時時間,我們應該在每個服務間傳遞逾時時間,逾時時間可以在服務調用的最上層設定,由初始請求觸發的整個 RPC 樹會設定同樣的絕對截止時間。例如,在服務請求的最上層設定逾時時間為3s,服務A請求服務B,服務B執行耗時為1s,服務B再請求服務C這時逾時時間剩餘2s,服務C執行耗時為1s,這時服務C再請求服務D,服務D執行耗時為500ms,以此類推,理想情況下在整個調用鍊裡都采用相同的逾時傳遞機制。
如果不采用逾時傳遞機制,那麼就會出現如下情況:
- 服務A給服務B發送一個請求,設定的逾時時間為3s
- 服務B處理請求耗時為2s,并且繼續請求服務C
- 如果使用了逾時傳遞那麼服務C的逾時時間應該為1s,但這裡沒有采用逾時傳遞是以逾時時間為在配置中寫死的3s
- 服務C繼續執行耗時為2s,其實這時候最上層設定的逾時時間已截止,如下的請求無意義
- 繼續請求服務D
如果服務B采用了逾時傳遞機制,那麼在服務C就應該立刻放棄該請求,因為已經到了截止時間,用戶端可能已經報錯。我們在設定逾時傳遞的時候一般會将傳遞出去的截止時間減少一點,比如100毫秒,以便将網絡傳輸時間和用戶端收到回複之後的處理時間考慮在内。
程序内逾時傳遞
不光服務間需要逾時傳遞程序内同樣需要進行逾時傳遞,比如在一個程序内串行的調用了Mysql、Redis和服務B,設定總的請求時間為3s,請求Mysql耗時1s後再次請求Redis這時的逾時時間為2s,Redis執行耗時500ms再請求服務B這時候逾時時間為1.5s,因為我們的每個中間件或者服務都會在配置檔案中設定一個固定的逾時時間,我們需要取剩餘時間和設定時間中的最小值。
context實作逾時傳遞
context原理非常簡單,但功能卻非常強大,go的标準庫也都已實作了對context的支援,各種開源的架構也實作了對context的支援,context已然成為了标準,逾時傳遞也依賴context來實作。
我們一般在服務的最上層通過設定初始context進行逾時控制傳遞,比如設定逾時時間為3s
ctx, cancel := context.WithTimeout(context.Background(), time.Second*3)
defer cancel()
當進行context傳遞的時候,比如上圖中請求Redis,那麼通過如下方式擷取剩餘時間,然後對比Redis設定的逾時時間取較小的時間
dl, ok := ctx.Deadline()
timeout := time.Now().Add(time.Second * 3)
if ok := dl.Before(timeout); ok {
timeout = dl
}
服務間逾時傳遞主要是指 RPC 調用時候的逾時傳遞,對于 gRPC 來說并不需要要我們做額外的處理,gRPC 本身就支援逾時傳遞,原理和上面差不多,是通過 metadata 進行傳遞,最終會被轉化為 grpc-timeout 的值,如下代碼所示
grpc-go/internal/transport/handler_server.go:79
if v := r.Header.Get("grpc-timeout"); v != "" {
to, err := decodeTimeout(v)
if err != nil {
return nil, status.Errorf(codes.Internal, "malformed time-out: %v", err)
}
st.timeoutSet = true
st.timeout = to
}
逾時傳遞是保證服務穩定性的一道重要防線,原理和實作都非常簡單,你們的架構中實作了逾時傳遞了嗎?如果沒有的話就趕緊動起手來吧。
go-zero 中的逾時傳遞
go-zero 中可以通過配置檔案中的
Timeout
配置
api gateway
和
rpc
服務的逾時,并且會在服務間自動傳遞。
之前的 一文搞懂如何實作 Go 逾時控制 裡面有講解逾時控制如何使用。
參考
《SRE:Google運維解密》