關於重試,這個模式應該是一個很普遍的設計模式了。當我們把單體應用服務化,尤其是微服務化,本來在一個進程內的函數調用就成了遠程調用,這樣就會涉及到網絡上的問題。
網絡上有很多的各式各樣的組件,如 DNS 服務、網卡、交換機、路由器、負載均衡等設備,這些設備都不一定是穩定的。在數據傳輸的整個過程中,只要任何一個環節出了問題,最後都會影響系統的穩定性。
重試的場景
所以,我們需要一個重試的機制。但是,我們需要明白的是,“重試”的語義是我們認爲這個故障是暫時的,而不是永久的,所以,我們會去重試。
設計重試時,我們需要定義出什麼情況下需要重試,例如,調用超時、被調用端返回了某種可以重試的錯誤(如繁忙中、流控中、維護中、資源不足等)。
而對於一些別的錯誤,則最好不要重試,比如:業務級的錯誤(如沒有權限、或是非法數據等錯誤),技術上的錯誤(如:HTTP 的 503 等,這種原因可能是觸發了代碼的 bug,重試下去沒有意義)。
重試的策略
關於重試的設計,一般來說,都需要有個重試的最大值,經過一段時間不斷的重試後,就沒有必要再重試了,應該報故障了。在重試過程中,每一次重試失敗時都應該休息一會兒再重試,這樣可以避免因爲重試過快而導致網絡上的負擔加重。
在重試的設計中,我們一般都會引入,Exponential Backoff 的策略,也就是所謂的 " 指數級退避 "。在這種情況下,每一次重試所需要的休息時間都會成倍增加。這種機制主要是用來讓被調用方能夠有更多的時間來從容處理我們的請求。這其實和 TCP 的擁塞控制有點像。
如果我們寫成代碼應該是下面這個樣子。
首先,我們定義一個調用返回的枚舉類型,其中包括了 5 種返回錯誤——成功 SUCCESS、維護中 NOT_READY、流控中 TOO_BUSY、沒有資源 NO_RESOURCE、系統錯誤 SERVER_ERROR。
public enum Results { SUCCESS, NOT_READY, TOO_BUSY, NO_RESOURCE, SERVER_ERROR}
接下來,我們定義一個 Exponential Backoff 的函數,其返回 2 的指數。這樣,每多一次重試就需要多等一段時間。如:第一次等 200ms,第二次要 400ms,第三次要等 800ms……
public static long getWaitTimeExp(int retryCount) {
long waitTime = ((long) Math.pow(2, retryCount) );
return waitTime;
}
下面是真正的重試邏輯。我們可以看到,在成功的情況下,以及不屬於我們定義的錯誤下,我們是不需要重試的,而兩次重試間需要等的時間是以指數上升的。
public static void doOperationAndWaitForResult() {
// Do some asynchronous operation.
long token = asyncOperation();
int retries = 0;
boolean retry = false;
do {
// Get the result of the asynchronous operation.
Results result = getAsyncOperationResult(token);
if (Results.SUCCESS == result) {
retry = false;
} else if ( (Results.NOT_READY == result) ||
(Results.TOO_BUSY == result) ||
(Results.NO_RESOURCE == result) ||
(Results.SERVER_ERROR == result) ) {
retry = true;
} else {
retry = false;
}
if (retry) {
long waitTime = Math.min(getWaitTimeExp(retries), MAX_WAIT_INTERVAL);
// Wait for the next Retry.
Thread.sleep(waitTime);
}
} while (retry && (retries++ < MAX_RETRIES));
}
重試設計的重點
- 要確定什麼樣的錯誤下需要重試;
- 重試的時間和重試的次數。這種在不同的情況下要有不同的考量。有時候,而對一些不是很重要的問題時,我們應該更快失敗而不是重試一段時間若干次。比如一個前端的交互需要用到後端的服務。這種情況下,在面對錯誤的時候,應該快速失敗報錯(比如:網絡錯誤請重試)。而面對其它的一些錯誤,比如流控,那麼應該使用指數退避的方式,以避免造成更多的流量。
- 如果超過重試次數,或是一段時間,那麼重試就沒有意義了。這個時候,說明這個錯誤不是一個短暫的錯誤,那麼我們對於新來的請求,就沒有必要再進行重試了,這個時候對新的請求直接返回錯誤就好了。但是,這樣一來,如果後端恢復了,我們怎麼知道呢,此時需要使用我們的熔斷設計了。
- 重試還需要考慮被調用方是否有冪等的設計。如果沒有,那麼重試是不安全的,可能會導致一個相同的操作被執行多次。
- 對於有事務相關的操作。我們可能會希望能重試成功,而不至於走業務補償那樣的複雜的回退流程。對此,我們可能需要一個比較長的時間來做重試,但是我們需要保存請求的上下文,這可能對程序的運行有比較大的開銷,因此,有一些設計會先把這樣的上下文暫存在本機或是數據庫中,然後騰出資源來做別的事,過一會再回來把之前的請求從存儲中撈出來重試。