
一段段帶著異常堆棧的報錯信息七零八落地散落在每個Controller里每個方法都塞滿了try-catch返回給前端的錯誤結構五花八門有時是{“msg”:“失敗”}有時是{“message”:“系統錯誤”}甚至還有直接把Exception的toString丟出去的。這絕不是少數SpringBoot項目的真實寫照。統一異常處理不是為了少寫幾行代碼而是為了把“出錯”這件事變得可預測、可控制、可解釋。異常處理不是后端開發的收尾工作而是接口契約的第一道防線。別再用try-catch包裹一切很多開發者習慣在業務方法里加一層try-catch捕獲后返回一個Result對象試圖讓調用方“感知”錯誤。這種做法最大的問題是讓業務代碼和錯誤處理邏輯糾纏在一起。你會在一個查詢訂單的方法里看到catch (NullPointerException e) { return Result.error(500, 訂單不存在) }這種荒謬的代碼——空指針被當成了業務分支。把異常捕獲在業務層等于把錯誤處理的責任打散到了每一處業務邏輯里最終無人能說清楚系統到底有哪些異常邊界。正確的做法是業務代碼只管拋出異常無論是業務異常如訂單不存在、庫存不足還是系統異常如數據庫連接失敗、文件讀寫錯誤都讓它們向上傳播。統一異常處理的第一原則就是“讓異常飛一會兒”直到它們抵達全局異常處理器。這樣Controller里的方法可以保持干凈只關心正常流程錯誤分支全部交給一個集中地。看到這里你可能會擔心難道業務代碼里連判斷都不做了當然要做判斷但判斷后的動作是throw new OrderNotFoundException(orderId)而不是return Result.error()。前者保留了異常的類型、上下文和堆棧后者則把錯誤降級成了一個普通返回值丟失了一切診斷價值。讓異常處理器成為唯一的出口SpringBoot提供了RestControllerAdvice和ExceptionHandler的組合這正是統一異常處理的正統機制。一個全局異常處理器可以捕獲所有Controller層拋出的異常并根據異常類型返回統一的響應體。寫一個全局異常處理器并不難難的是讓它成為系統里唯一處理異常的出口。這意味著你要克制住到處添加局部try-catch的沖動也要避免在Service層捕獲后吞掉異常或重新包裝成模糊的運行時異常。一個清晰的做法是定義一個GlobalExceptionHandler類內部針對不同異常類型編寫處理方法。對業務異常返回對應的錯誤碼和提示信息對參數校驗異常返回字段級別的錯誤詳情對未知異常返回兜底的“系統繁忙”提示并記錄完整日志。每個異常處理方法只做三件事提取上下文、記錄日志、構造標準響應體。不要在這個類里寫業務邏輯也不要讓它依賴具體的Controller或者Service。它就像一個路由器輸入是異常對象輸出是HTTP響應。它的存在讓其他人再也無需關心“異常應該怎么返回”這個問題。業務異常與系統異常必須分家如果你的項目里只有一種Exception類所有錯誤都用它來表示那么統一異常處理很快就會演化成一場災難。因為調用方無法區分“這個錯誤是可預期的業務規則沖突”還是“系統出了不可恢復的故障”。業務異常和系統異常的邊界就是產品與技術的邊界。建議至少設計兩類異常BizException業務異常和SystemException系統異常兩者都繼承自一個自定義的BaseException。業務異常里攜帶錯誤碼、錯誤消息、可能還有導致錯誤的業務參數。系統異常則通常由底層技術框架拋出比如數據庫異常、Redis連接異常、第三方接口超時等。對業務異常響應碼通常是200但業務碼為非零對系統異常HTTP狀態碼應如實反映錯誤性質如500。這種區分讓你在前端拿到響應后可以立刻判斷是彈提示框還是跳錯誤頁。更進一步系統異常應該包裝成統一的自定義異常后拋出而不是裸拋NullPointerException或SQLException。“裸拋”系統底層異常等于把你的數據庫表結構或緩存策略泄露給了前端調用者。雖然最終返回給用戶的消息是統一的但異常鏈上的堆棧信息可能包含敏感細節。全局處理器要負責把這些底層異常轉換為SystemException并輸出到服務端日志而前端只能看到“系統繁忙”。錯誤碼是契約不是字符串很多項目用code字段表示錯誤但隨便定義幾個數字比如1代表成功-1代表失敗。這種錯誤碼體系既無法表達錯誤的具體場景也不具備擴展性。錯誤碼應當是一份精心維護的契約文檔而不是隨手寫的魔數。推薦用“模塊編號 錯誤場景編號”的格式例如1101表示“訂單模塊-訂單不存在”2203表示“庫存模塊-庫存不足”。每個錯誤碼對應一個標準的message和可能的補救動作提示。錯誤碼的管理要集中在一個常量類或枚舉類中并且和全局異常處理器聯動。比如BizException構造時傳入ErrorCodeOrderEnum.ORDER_NOT_FOUND處理器再從枚舉中提取message返回。不要在異常拋出位置寫死message字符串否則一旦產品要求修改提示語你就要chase所有出現該字符串的地方。當你把錯誤碼作為唯一的契約前端就能根據錯誤碼做精準的交互邏輯而不是用正則匹配message里的關鍵詞。同時錯誤碼必須覆蓋所有可能的異常分支。有些異常比如參數校驗失敗Spring默認返回MethodArgumentNotValidException其結構包含fields、rejectedValue等。你應當用統一的錯誤碼如PARAM_ERROR包裝它并把字段名和校驗信息放進data字段而不是直接把異常內部的DefaultMessage丟給前端。參數校驗異常是接口開發中最常見、最容易被忽視的異常不加處理就會露出Spring的默認響應結構。全局處理器里要專門為MethodArgumentNotValidException、BindException、ConstraintViolationException編寫處理方法把這些異常轉換成統一格式。參數校驗異常別讓它裸奔當你在Controller里寫Valid RequestBody UserDTO userDTO時一旦參數校驗失敗Spring會拋出MethodArgumentNotValidException。如果不做任何處理Spring的默認錯誤響應是400狀態返回一個比較啰嗦的JSON。很多項目在開發初期不管這些前端拿到什么就是什么但到了聯調階段前端會很痛苦。參數校驗異常的處理是衡量一個項目異常處理規范程度的試金石。建議在全局處理器中捕獲MethodArgumentNotValidException遍歷bindingResult.getFieldErrors()提取每個字段的field和defaultMessage組裝成一個ListFieldErrorVO放進統一響應體的data里。這樣前端可以精確地告訴用戶“郵箱格式不正確”“密碼長度不能少于6位”。如果你用的是Validated配合DTO上的NotNull等注解同樣要處理ConstraintViolationException它的異常結構不同于前者不要漏掉。這還引出一個關鍵點統一異常處理不是只處理你自己拋出的異常還要處理框架拋出的、Spring Security拋出的、甚至JDK發起的異常。每一個出口都要堵上。當異常發生在過濾器里怎么辦RestControllerAdvice只能捕獲進入DispatcherServlet后的異常也就是Controller層及其調用鏈上的異常。但如果異常發生在Filter中比如自定義的認證過濾器里拋出了AuthenticationException或者OncePerRequestFilter里讀取請求體時發生了IOException全局異常處理器是捕獲不到的。這是一個常見的陷阱你以為統一異常處理已經覆蓋了所有卻漏了過濾器這個最前置的關卡。解決方案有兩種。第一種在Filter里自己try-catch然后手動寫入響應。但這樣你沒法把錯誤碼、標準響應體統一封裝除非你在Filter里硬編碼JSON序列化這很丑陋。第二種把攔截器鏈的異常通過HandlerExceptionResolver轉發給全局處理器。具體做法是注入HandlerExceptionResolver在Filter的catch塊中調用resolver.resolveException(request, response, handler, exception)。這樣異常就能復用ExceptionHandler中的邏輯響應體格式保持一致。過濾器的異常處理不是例外而是全局異常處理的一部分。此外還有DispatcherServlet中的processDispatchResult階段可能出現的異常比如視圖解析失敗或者響應已經被提交后拋出的異常這些情況很難再返回自定義JSON。一個務實的做法是在網關層或反向代理層兜底統一對非200響應做結構轉換。對于大多數微服務場景SpringBoot只處理自己能處理的那一段真正對外的統一要交給網關。異步任務里的異常不會自己消失Async方法拋出的異常默認情況下不會自動進入RestControllerAdvice因為全局異常處理器監聽的是請求線程的異常傳播路徑而異步線程有自己的調用棧。異步任務里的異常如果沒有處理會直接跑到線程池的Thread.UncaughtExceptionHandler甚至被靜默吞掉。這是很多系統出現“偶發數據不一致”卻查不到日志的根源。解決異步異常有幾個層次。如果異步任務在線程池中執行你可以為線程池設置UncaughtExceptionHandler在那里記錄日志和上報監控。更好的方式是在異步任務中顯式地捕獲異常并通過一個回調接口或Future的get()方法獲取異常。如果你用了Async那么調用方應該具備拿到異步結果或異常的能力而不是“發出去就不管了”。對于消息消費場景比如RocketMQ或Kafka的consumer建議單獨建立一套“消息異常處理機制”把反序列化失敗、業務處理失敗的消息發送到死信隊列而不是嘗試在全局異常處理器中統一處理。異步世界里的異常注定和同步請求的異常是兩條路但都要有明確的歸屬。對于使用CompletableFuture組合調用的場景注意異常傳播的特殊性。whenComplete、exceptionally是捕獲異步異常的常規手段但如果你只是join()異常會被包裝為CompletionException。全局處理器不會自動解開這層包裝你需要手動處理。異步異常處理的核心思想是不要假設異常會自動被誰接住所有離線任務都要有兜底的異常捕獲和記錄。日志要打在正確的地方統一異常處理不只是“返回一個好看的JSON”它還承擔著記錄日志的功能。但這個日志不是簡單的log.error(e.getMessage(), e)。錯誤日志要包含請求的方法、URL、參數、用戶ID如果有、異常類名、錯誤碼、對應的堆棧。只有把這些上下文信息都記錄下來事后排查問題才不至于去猜是哪條請求觸發的。建議在全局異常處理器的“未知異常”處理分支中用log.error記錄完整堆棧在業務異常處理分支中用log.warn記錄因為業務異常屬于可預期情況未必需要報警。區分日志級別是異常處理規范化的體現。同時要避免在業務代碼中反復打印同樣的日志。比如在Service里捕獲異常打印了一次又在Controller里打印了一次到了全局處理器又打一次最終日志文件膨脹且排查困難。建議的日志策略是底層異常必須打印在“第一次捕獲到該異常的地方”全局處理器只記錄未被捕獲的異常和異常處理結果。這樣同一個異常的日志不會重復且總有記錄。某些情況下全局處理器可能會在返回前將異常信息脫敏。例如異常消息中包含手機號或身份證號必須在寫入日志前進行脫敏處理。日志不是給用戶看的但用戶可能通過下載日志文件看到泄露數據。所以全局異常處理器在構造日志時要主動過濾掉敏感字段這比事后審計要便宜得多。把異常處理變成可觀測的一部分統一異常處理的最終目標是讓異常變成系統可觀測性的一部分而不是埋藏在雪花般的日志文件里。每個被處理器接住的異常都應該可以關聯到一個全局唯一的traceId。如果在網關層或Filter里引入了traceId生成器如MDC那么異常日志就可以與調用鏈日志串聯起來。異常響應體中帶上traceId是給用戶最好的“憑證”。當用戶反饋“訂單支付失敗”前端把traceId提交過來后端就能直接定位到那幾行日志。更進一步建議對異常類型和錯誤碼做監控統計。比如每分鐘記錄“訂單模塊-庫存不足”觸發了多少次如果超過閾值就發出告警。錯誤碼的分布就是系統健康度的晴雨表。沒有統一的異常處理你很難做到這種統計因為錯誤散落在各處格式都不一致。有了全局異常處理器你可以在其中埋點把異常類型、錯誤碼、請求路徑、耗時等信息發送到Prometheus或自定義計數器里。異常處理不該是一個被動的“兜底”而應該是一個主動的“感知層”。統一異常處理背后體現的是對系統邊界感的尊重。你不可能讓異常不出現但你可以讓異常出現時系統依然保持優雅。最大的風險不是代碼有bug而是bug發生時你連發生了什么都不知道。別再到處寫try-catch了讓全局異常處理器成為你的遮陽傘但不要指望它能擋住所有地方的雨。過濾器、異步任務、消息隊列每一個異常逃逸點都值得你重新審視一遍——這就是統一異常處理遠不止一個注解那么簡單。