Spring @Retryable与@Recover实战:重试原理、退避策略与幂等性避坑 過去這一年我維護的幾個後端服務裡出現頻率最高的故障不是代碼邏輯錯誤而是“上游又抖了”。第三方支付回調偶爾 502、短信供應商偶爾超時、分佈式鏈路裡某個節點偶爾拒絕連接。這些問題的共同點是短暫、偶發、但一旦發生就會讓整個業務請求失敗。Spring 框架的Retryable注解和Recover注解正是為這種場景準備的。我剛開始接觸這兩個注解時以為只是“失敗了多調幾次方法”那麼簡單後來真正把它們用到生產環境才發現背後有 AOP 代理、退避策略、兜底回調、冪等性設計等一系列問題。這篇文章不會只復述官方文檔而是把我自己的配置方式、踩過的坑、以及設計重試邏輯時的思考過程整理出來。無論你是剛接觸 Spring Boot 的開發者還是已經在用Retryable但遇到過“明明標了注解卻沒生效”這類怪問題的人這篇都值得花幾分鐘看完。1. 為什麼要為方法調用加一層“自動重新嘗試”重試機制的業務價值1.1 哪些場景必須考慮重試我見過不少團隊把重試邏輯寫得極其隨意要麼完全不處理要麼用for循環包住整個方法體。其實是否該重試關鍵看失敗的性質是不是“暫時的”。最典型的場景是調用第三方 HTTP 服務。比如對接支付渠道銀行接口偶爾會因為網關切換、數據庫鎖競爭返回 5xx調用短信供應商高峰期可能出現連接超時對接大模型 API 時限流錯誤429和網關超時504更是家常便飯。這些失敗都有一個特點過一小段時間再試大概率能成功。另一類典型場景是數據庫樂觀鎖衝突。多個線程同時更新同一條記錄時後提交的一方會拿到版本號不匹配的異常。這種情況不重試用戶就會看到“操作失敗”簡單重試一次往往就能成功提交。還有一類是消息隊列消費場景。消費邏輯裡要查詢外部數據、寫入多張表臨時性的網絡抖動或連接池耗盡會讓消費失敗。如果不做重試消息就會進入死信隊列需要人工干預。這些場景的共性一目瞭然不是業務規則不允許而是基礎設施暫時不穩定。對待暫時性故障最樸素也最有效的手段就是“等一會兒再試”。1.2 設計重試前先想清楚的三件事我給很多同事 review 代碼時都會問三個問題這三個問題想不清楚重試機制越寫越亂。第一哪些異常允許觸發重試。這是最重要的一條。業務校驗異常比如參數為空、餘額不足不應該重試因為重試一百次結果都一樣只是白白消耗資源。只有那些代表“暫時性故障”的異常比如網絡超時、連接拒絕、限流、樂觀鎖衝突才值得進入重試流程。第二重試多少次、間隔多久。有人覺得重試次數越多越好這在生產環境是災難。假設下游服務已經故障重試 10 次只會讓下游更慢最終拖垮整個調用鏈。合理的做法是設置一個上限比如 3~5 次配合遞增的等待時間給下游恢復的時間窗口。第三重試全部失敗後做什麼。很多人設計重試時只考慮成功路徑忽略了最終失敗的處理。是要把異常拋給上層還是返回一個默認值還是把任務丟進延遲隊列繼續重試這個決定會直接影響用戶體驗和數據一致性。1.3 自己寫 for 循環與框架的本質區別我知道很多人會說“這點邏輯我自己寫個 for 循環不就完了為什麼要引入框架”確實最樸素的寫法長這樣public OrderResult syncOrder(Long orderId) { int maxAttempts 3; for (int attempt 1; ; attempt) { try { return doSync(orderId); } catch (RemoteAccessException e) { if (attempt maxAttempts) { throw e; } Thread.sleep(1000L * attempt); } } }這種寫法在一個方法裡沒問題但你把同樣的邏輯複製到十個方法裡試試。重試策略不統一、日誌打點缺失、退避時間隨意寫死、異常處理混亂這些問題會隨著業務擴張迅速爆發。Spring Retry 的價值不在於“幫你省掉幾行 for 循環”而在於它把重試策略、退避策略、異常過濾、監控回調全部抽象成了一層統一的基礎設施。你只需要用Retryable聲明“這個方法要重試”剩下的交給 AOP 攔截器處理業務代碼裡完全看不到重試痕跡。這才是它真正的意義。2. 認識 Retryable 的底層原理AOP 代理與 RetryTemplate 的協作2.1 Spring 是怎麼攔截到被註解方法的調用很多人用Retryable很久卻不知道它是怎麼生效的。其實 Spring 對Retryable的處理和Transactional一模一樣靠的是AOP 代理。當你在配置類上加上EnableRetry後Spring 容器啟動時會掃描所有帶Retryable注解的 Bean。發現目標之後容器不會直接把原始 Bean 交給調用方而是生成一個代理對象。這個代理對象在方法調用前後插入攔截邏輯其中核心攔截器就是RetryOperationsInterceptor。攔截器內部會解析注解上的屬性重試次數、退避參數、異常類型組裝出一個RetryTemplate。RetryTemplate才是真正幹活的類它負責維護重試上下文、執行方法、判斷是否重試、控制退避等待。理解這個機制對排查問題至關重要。如果你的 Retryable 方法沒有生效大概率就是因為調用發生在代理之外這個坑後面專門講。2.2 攔截器內部狀態機一次調用的完整重試流程把整個執行過程拆開看其實是一個非常清晰的小狀態機。我用調用第三方支付査詢接口的例子說明調用方進入代理攔截器先創建一個RetryContext此時重試計數為 0。攔截器調用真實方法。方法正常返回攔截器直接把結果返回給調用方整個流程結束不發生任何重試。方法拋出異常攔截器把異常交給RetryPolicy判斷這個異常類型在不在可重試列表中當前重試次數有沒有到上限如果判斷可以重試攔截器調用BackOffPolicy按照配置的delay、multiplier計算出需要等待的時間然後讓當前線程睡上一段時間。等待結束重試計數加 1再次執行方法。如果異常持續出現重複 4~6 步直到達到maxAttempts上限。達到上限後攔截器檢查是否有對應的Recover方法。有就調用兜底邏輯返回兜底結果沒有就把最後一次異常直接拋給調用方。你可以把這個過程理解成點外賣第一次下單失敗了系統自動等 1 秒再下一單最多下 3 單3 單都失敗就給你一個“抱歉商家暫時無法接單”的兜底提示。2.3 EnableRetry 到底開啟了什麼EnableRetry這個注解看起來不起眼但它是一切生效的前提。它的本質是引入一個RetryConfiguration註冊類這個類實現了BeanPostProcessor在 Bean 初始化後掃描Retryable方法為它們生成攔截器並織入代理。這裡有個值得注意的屬性proxyTargetClass。Spring AOP 默認使用 JDK 動態代理要求目標類必須實現接口如果目標類沒有接口就需要將proxyTargetClass設置為true讓 Spring 改用 CGLIB 生成子類代理。Spring Boot 項目裡很多 Service 類既沒有接口又同時被Transactional、Retryable修飾所以比較穩妥的做法是顯式開啟EnableRetry(proxyTargetClass true)不加這句你在某些場景下可能遇到“代理創建失敗”的報錯尤其是多個註解疊加時更容易暴露問題。3. Retryable 參數逐個講重試閾值、退避策略與異常過濾3.1 maxAttempts 與 include/exclude 的配合邏輯先看一個我最常用的配置寫法Retryable( retryFor {RemoteAccessException.class, TimeoutException.class}, noRetryFor {IllegalArgumentException.class}, maxAttempts 4 ) public OrderResult syncOrder(Long orderId) { // 調用第三方支付査詢接口 }maxAttempts表示總嘗試次數注意是包含第一次的。maxAttempts 4意味著方法最多被執行 4 次也就是第一次失敗後最多重試 3 次。這個概念很多人搞混我見過有人把maxAttempts當成“重試次數”結果實際重試次數比預期多了一次。retryFor舊版叫include或value指定哪些異常觸發重試默認是所有異常。noRetryFor舊版叫exclude指定哪些異常不重試遇到這些異常直接拋出。兩者同時存在時noRetryFor的優先級更高這符合直覺——你先說“什麼都重試”再補充“這幾個例外”。異常匹配規則繼承了 Spring 的一貫風格支持異常的子類。比如retryFor裡寫了RemoteAccessException那麼RemoteAccessException的所有子類拋出時都會觸發重試。所以設計異常體系時盡量讓“暫時性異常”有一個統一的父類這樣retryFor配置可以寫得非常簡潔。Spring Retry 2.0 之後新增了retryFor和noRetryFor屬性替代了舊版的include和exclude。舊屬性依然能用但新項目建議直接用新寫法避免以後遷移成本。3.2 Backoff 延遲退避delay、multiplier、maxDelay 的計算規則如果重試之間完全沒有間隔那麼系統在面對下游故障時會瞬間打出密集請求這和攻擊一個掛掉的服務沒有區別。所以絕大多數生產場景都需要配置退避策略。Retryable( retryFor {RemoteAccessException.class}, maxAttempts 5, backoff Backoff(delay 1000, multiplier 2, maxDelay 8000) ) public String callThirdParty(String param) { // 調用第三方服務 }Backoff的默認行為是固定延遲所有重試之間等待相同時間。配置了multiplier後會變成指數退避計算規則如下重試次數等待時間計算實際等待第 1 次重試delay1000ms第 2 次重試delay × multiplier2000ms第 3 次重試delay × multiplier²4000ms第 4 次重試delay × multiplier³但受 maxDelay 限制8000msmaxDelay的作用是防止等待時間無限膨脹。假設配置了delay 1000, multiplier 3第五次重試時理論要等 81000ms如果沒有maxDelay限制用戶請求會被拖死。設一個上限超過之後就固定在最大值等待。還有一個隱藏參數random可以讓等待時間在一定範圍內隨機抖動。這在分佈式系統裡非常有用因為當大量請求同時失敗時如果大家都按照相同的指數退避節奏重試會形成“重試風暴”再次打垮下游。設置Backoff(delay 1000, multiplier 2, maxDelay 8000, random true)Spring 會在計算結果基礎上增加隨機因子讓請求分散開。3.3 有狀態重試 stateful 到底解決什麼問題Retryable默認的stateful false意思是每次重試都是獨立嘗試彼此之間不共享上下文。這在絕大多數場景下已經夠用。但有一種典型場景必須用stateful true數據庫樂觀鎖衝突。假設用戶提交操作時後端收到的異常是ObjectOptimisticLockingFailureException異常裡攜帶了最新的版本號。如果重試時不修正版本號重試幾次都是白費。有狀態重試會讓攔截器在同一個RetryContext裡跨多次請求保存狀態你可以在RetryContext中取出上一次失敗的信息用於修正參數後再次提交。有狀態重試對Recover方法簽名有特殊要求第一個參數必須是Throwable類型不能是具體異常子類。這個限制讓很多人在配置時摸不着頭腦實際上它是為了讓攔截器在恢復時能安全拿到完整的異常鏈。不過說實話stateful true在生產代碼裡用得不算多因為實現成本高、心智負擔重。更常見的樂觀鎖重試方案是自己在 Service 層寫一個循環捕獲異常後手動刷新實體再重試。本文不展開但知道有這個特性面試或架構選型時會更有底氣。4. Recover 兜底重試全部失敗之後的“後手”4.1 Recover 的方法簽名規則與參數綁定重試不是萬能的總有重試到極限仍然失敗的時候。Recover注解就是為“最終失敗”準備的兜底邏輯。Retryable( retryFor {RemoteAccessException.class}, maxAttempts 3, backoff Backoff(delay 1000) ) public OrderResult syncOrder(Long orderId) { // 調用第三方支付査詢接口 } Recover public OrderResult recoverSyncOrder(RemoteAccessException e, Long orderId) { log.error(訂單同步最終失敗orderId{}, orderId, e); return OrderResult.failed(上游服務暫時不可用); }Recover方法的簽名規則可以總結成三句話必須和 Retryable 方法在同一個類中這不是可以商量的建議而是硬性要求。第一個參數必須是異常類型這個異常類型需要能接收原方法實際拋出的異常一般是父類或同類型。後續參數要和原方法參數一一對應比如原方法是syncOrder(Long orderId)兜底方法在異常類型之後也要接一個Long orderId。攔截器在調用兜底方法時會把原方法的參數按順序傳進來。返回值類型必須和原方法一致。比如原方法返回OrderResult兜底方法也必須返回OrderResult否則攔截器無法完成類型轉換。4.2 多個 Recover 的匹配優先級一個類裡可能有多個Retryable方法也可以配置多個Recover方法。這時攔截器怎麼知道該調用哪一個Spring 的匹配邏輯是根據實際拋出的異常類型在所有Recover方法中找到“第一個參數類型能接收該異常”的方法。如果多個方法都能接收則會選擇異常類型最精確的那個。舉個例子Recover public OrderResult recover(RemoteAccessException e, Long orderId) { ... } Recover public OrderResult recover(SocketTimeoutException e, Long orderId) { ... }原方法拋出SocketTimeoutException它是RemoteAccessException的子類時攔截器會優先調用第二個方法。只有當找不到精確匹配時才會退而求其次選擇父類參數的兜底方法。這個機制設計得很巧妙但也能看出潛在風險如果你的Recover方法第一個參數聲明得太大比如Exception它會攔截所有異常的兜底容易掩蓋真正的問題。我會建議兜底方法參數盡量精確到具體異常類型除非你明確要統一處理。4.3 Recover 拿不到返回值時異常如何處理這是很容易被忽略的點只要配置了匹配的 Recover 方法重試耗盡後攔截器會直接返回兜底方法的結果不會再把異常拋給上層調用方。很多事故就是這麼產生的。有人給方法加瞭解決兜底兜底方法裡只打了個 error 日誌然後返回一個空的默認對象。上層調用方拿到空對象還以為業務正常繼續走後續流程最後才在數據上發現異常。我的建議是兜底方法的返回值要具備“可區分性”。如果你返回的是Result對象就在結果裡帶上明確的錯誤碼如果你返回的是實體對象就讓它為null並讓上層強制判斷。兜底方法裡應該包含完整的錯誤日誌、監控打點、以及必要的告警通知確保重試失敗這件事不會被悄無聲息地吞掉。如果重試耗盡後你想讓異常繼續向上拋那就不要配Recover。Spring 會在重試結束後直接拋出最後一次異常這也是一種合法的設計取決於你希望上層如何感知失敗。5. 我踩過的重試的坑自調用、事務嵌套與冪等性5.1 同類內部調用導致的重試失效與三種繞開方式這是Retryable失效最常見的原因沒有之一。Service public class OrderService { public void process(Long orderId) { // 同類內部的 this 調用不會走代理 this.syncOrder(orderId); } Retryable( retryFor {RemoteAccessException.class}, maxAttempts 3 ) public OrderResult syncOrder(Long orderId) { // 調用第三方 } }Retryable靠 AOP 代理攔截而this.syncOrder()調用的是原始對象的方法繞過了代理重試自然不生效。這個問題和Transactional內部自調用失效是同一類問題。繞開方式有三種我按推薦程度排序第一種把需要重試的方法獨立到另一個 Bean 中。這是結構上最清晰的做法把“第三方調用”和“業務流程”拆成兩個 Service業務 Service 注入第三方調用 Service調用時自然經過代理。第二種注入自身代理。Spring 支持在 Bean 內部注入自己Service public class OrderService { Autowired Lazy private OrderService self; public void process(Long orderId) { self.syncOrder(orderId); } }Lazy在這裡很關鍵它可以避免構造器循環依賴。第三種使用AopContext.currentProxy()。需要先在配置類上開啟EnableAspectJAutoProxy(exposeProxy true)然後在方法裡獲取當前代理對象((OrderService) AopContext.currentProxy()).syncOrder(orderId);這種方式代碼侵入性強也不利於單元測試我一般只在沒有辦法的遺留代碼裡用。5.2 重試與 Transactional 的執行順序當一個方法同時標註Transactional和Retryable時問題就複雜了。Spring 的攔截器是有執行順序的。通常情況下事務攔截器和重試攔截器的順序遵循Order如果沒有特別指定比較常見的實際順序是重試攔截器先執行然後再進入事務攔截器。假設方法拋出了一個觸發重試的異常此時事務攔截器已經把事務標記為rollback-only。重試攔截器捕獲異常後再次執行方法這次成功了事務攔截器嘗試提交卻發現事務已經被標記為rollback-only最終還是拋出UnexpectedRollbackException。這種情況比“重試無效”更隱蔽因為你看到的是“重試成功了但整體還是失敗”。我吃過一次虧之後現在總結出兩個原則不要把Retryable和Transactional簡單疊加在同一個方法上。事務方法應該只做“數據庫操作”重試方法應該只做“可重試的外部調用”兩者分層。如果確實需要重試的同時保證數據庫一致可以把事務註解上的noRollbackFor設置為觸發重試的異常類型讓事務在遇到這類異常時不標記rollback-onlyTransactional(noRollbackFor RemoteAccessException.class) Retryable(retryFor RemoteAccessException.class, maxAttempts 3) public void updateAndNotify(Long orderId) { updateOrder(orderId); remoteNotify(orderId); }但這種寫法依賴攔截器順序屬於比較脆弱的方案能不用盡量不用。5.3 重試放大了“非冪等操作”的破壞力這條是原則性問題值得每一個寫重試邏輯的人刻在腦子裡重試能解決問題的前提是這個操作是冪等的。什麼叫冪等同一個請求執行一次和執行一百次最終結果是相同的。查詢操作天然冪等狀態機推進操作比如把訂單從“待支付”改成“已支付”只要加了狀態校驗也可以算冪等。但“扣款”“發放優惠券”“追加庫存”這類操作直接重試就會造成嚴重後果。我維護的一個老項目裡曾經有人在扣費方法上加了Retryable然後某次上游響應超時但實際扣費已經成功重試一次用戶就被扣了兩次錢。這不是個別案例而是重試機制設計中最高頻的事故類型。應對方案通常有幾種只對明確冪等的方法開啟重試比如查詢、狀態校驗、冪等鍵驅動的寫操作。在業務層引入冪等表或請求號。每次重試都帶上相同的請求號後端通過請求號去重第二次執行直接返回第一次的結果。重試前先查詢當前狀態如果發現“已經處理過了”就跳過後續操作。始終記住重試是一個放大鏡它會把非冪等操作的問題放大好幾倍。在加Retryable之前先問自己一句這個方法被調用第二次真的沒問題嗎6. 進階實戰自定義重試策略與 Spring Boot 整合6.1 全局 RetryTemplate 的定制註冊 Bean 的方式注解配置能覆蓋大部分場景但有些需求用注解寫不出來。比如重試次數要從配置中心動態讀取、異常分類規則很複雜、或者要為不同的調用方準備不同的退避策略。這種時候直接註冊一個自定義的RetryTemplateBean 更合適。Configuration public class RetryConfig { Bean public RetryTemplate retryTemplate() { RetryTemplate template RetryTemplate.builder() .maxAttempts(5) .exponentialBackoff(1000, 2, 10000) .retryOn(RemoteAccessException.class) .withListener(new ObservableRetryListener()) .build(); return template; } }RetryTemplate.builder()是 Spring Retry 2.0 提供的流式構建方式比手動 set 屬性更直觀。exponentialBackoff(1000, 2, 10000)對應Backoff(delay 1000, multiplier 2, maxDelay 10000)的指數退避策略。有個容易踩的坑Spring Boot 的RetryAutoConfiguration會自動註冊一個默認的RetryTemplate實例。如果你再手動定義一個同名 Bean可能出現注入衝突。解決方式有兩種要麼把你的 Bean 命名為retryTemplate要麼加上Primary註解聲明優先級。Bean Primary public RetryTemplate customRetryTemplate() { return RetryTemplate.builder() .maxAttempts(4) .fixedBackoff(1500) .build(); }這樣Autowired RetryTemplate時就會注入你的自定義實例。6.2 帶監控打點的自定義 RetryListener生產環境裡重試不應該是一個黑盒。誰在重試、重試了幾次、最終成功還是失敗這些信息必須能觀測到。Spring Retry 提供了RetryListener接口可以在重試的不同階段掛鉤。一個完整的監控接口實現是這樣的public class ObservableRetryListener implements RetryListener { Override public T, E extends Throwable boolean open(RetryContext context, RetryCallbackT, E callback) { String methodName (String) context.getAttribute(methodName); log.info(開始執行重試監控method{}, methodName); return true; } Override public T, E extends Throwable void onError(RetryContext context, RetryCallbackT, E callback, Throwable throwable) { log.warn(第 {} 次重試失敗異常{}, context.getRetryCount(), throwable.getMessage()); } Override public T, E extends Throwable void close(RetryContext context, RetryCallbackT, E callback, Throwable throwable) { if (throwable null) { log.info(重試最終成功總嘗試次數{}, context.getRetryCount() 1); } else { log.error(重試最終失敗總嘗試次數{}, context.getRetryCount() 1, throwable); } } }關鍵點是open方法返回false可以禁止本次重試context.getRetryCount()返回當前重試次數close方法的throwable參數為null表示最終成功非null表示重試耗盡或拋出了不可重試異常。要把這個監控掛到注解上可以用Retryable(listeners {observableRetryListener})這裡的listeners屬性引用的是容器中的 Bean 名稱。把這個監控和 Micrometer、Prometheus 對接之後就能在監控大屏上實時看到每個接口的重試率、重試成功率和重試失敗率這對判斷下游服務的健康狀態非常有幫助。6.3 集成實例調用第三方大模型 API 時的重試配置最近這段時間我項目裡對接了不少大模型 API訪問量和錯誤率都比傳統接口高不少。大模型 API 的錯誤特點很鮮明限流錯誤 429 極其常見網絡超時 504 也不少但參數錯誤 400 永遠不會因為重試而變好。針對這種場景我目前的配置是這樣Service public class AiCompletionService { Retryable( retryFor {TooManyRequestsException.class, SocketTimeoutException.class}, noRetryFor {IllegalArgumentException.class, BadRequestException.class}, maxAttempts 4, backoff Backoff(delay 500, multiplier 2, maxDelay 5000), listeners observableRetryListener ) public CompletionResult complete(String prompt) { // 調用遠程大模型 API耗時可能幾秒到幾十秒 return aiClient.complete(prompt); } Recover public CompletionResult recoverComplete(TooManyRequestsException e, String prompt) { log.warn(大模型接口限流重試耗盡返回緩存結果prompt{}, prompt); return CompletionResult.fromCache(prompt); } Recover public CompletionResult recoverComplete(SocketTimeoutException e, String prompt) { log.error(大模型接口超時重試耗盡請檢查服務狀態prompt{}, prompt); return CompletionResult.fallback(prompt); } }這裡有兩個設計細節值得說明。第一retryFor裡同時包含了限流異常和超時異常但它們的處理策略不同。限流時Backoff的退避時間天然合適因為上游希望你等一會再來超時時等待時間太短可能沒用但指數退避至少保證不會加劇服務端壓力。第二兩個Recover方法分別對應不同異常返回不同的兜底策略。限流時返回緩存結果因為緩存可能存在超時時返回 fallback 結果因為你無法確定請求是否已經在服務端生效用一個明確的 fallback 比返回模糊的緩存更安全。另外要注意maxAttempts 4配合delay 500, multiplier 2意味著最壞情況下整個過程要等 500 1000 2000 4000 7500ms。如果大模型響應本身就要十幾秒這個等待時間完全可以接受。但如果調用的是一個需要快速響應的用戶請求就要考慮把maxAttempts調低或者在用戶側做異步化處理。我在實際接入大模型 API 的項目裡還習慣在Recover方法中把失敗請求的信息推送到一個告警隊列因為大模型服務的穩定性直接影響終端用戶體驗不能讓兜底結果“悄無聲息”地返回給用戶就算完事。最後分享一個小技巧我會在Retryable方法裡通過RetrySynchronizationManager.getContext()拿到當前重試上下文把重試次數放進 MDC這樣日誌系統裡就能看到每次請求是第幾次嘗試public CompletionResult complete(String prompt) { RetryContext context RetrySynchronizationManager.getContext(); if (context ! null context.getRetryCount() 0) { MDC.put(retryCount, String.valueOf(context.getRetryCount())); } // 調用遠程大模型 API }這個做法在排查“為什麼某個請求重試了三次才成功”時特別管用幾行代碼就能讓日誌信息量大幅提升。重試這個話題說到底考驗的是對系統邊界的認知。哪些失敗是暫時的哪些失敗是永久的哪些操作可以安全重複執行哪些操作必須嚴格保證一次——這些判斷比重試框架本身的 API 知識重要得多。工具只是放大你的設計意圖如果你的設計意圖本身有問題工具只會讓問題更明顯。