過去這一年,我維護的幾個後端服務裡,出現頻率最高的故障不是代碼邏輯錯誤,而是“上游又抖了”。第三方支付回調偶爾 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 次重試 | delay | 1000ms |
| 第 2 次重試 | delay × multiplier | 2000ms |
| 第 3 次重試 | delay × multiplier² | 4000ms |
| 第 4 次重試 | delay × multiplier³,但受 maxDelay 限制 | 8000ms |
maxDelay的作用是防止等待時間無限膨脹。假設配置了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-only:
@Transactional(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, RetryCallback<T, E> callback) { String methodName = (String) context.getAttribute("methodName"); log.info("開始執行重試監控,method={}", methodName); return true; } @Override public <T, E extends Throwable> void onError(RetryContext context, RetryCallback<T, E> callback, Throwable throwable) { log.warn("第 {} 次重試失敗,異常={}", context.getRetryCount(), throwable.getMessage()); } @Override public <T, E extends Throwable> void close(RetryContext context, RetryCallback<T, 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 知識重要得多。工具只是放大你的設計意圖,如果你的設計意圖本身有問題,工具只會讓問題更明顯。