做後端開發這幾年,看過的接口不說一千也有八百。有件事特別有意思——判斷一個工程師是不是真的寫過生產級代碼,往往不用看他功能實現得多漂亮,就看他怎麼處理錯誤。
很多人寫接口,錯誤處理基本是這樣的:
try {
// 一堆業務邏輯
} catch (Exception e) {
// 打印日誌
return "服務器錯誤";
}
就這?兄弟,你這接口在生產環境就是個定時炸彈。
錯誤處理的第一性原理:用戶視角
先問自己一個問題:當接口返回錯誤的時候,調用方能知道發生了什麼嗎?
來,閉眼想像:你是客戶端開發,對著文檔調了個接口,返回了個 500 Internal Server Error,然後?然後就沒有然後了。你不知道數據到底存沒存,不知道業務邏輯執行到哪一步掛的,更不知道該重試還是該罵人。
這就是大多數接口錯誤處理的現狀——開發者只管自己爽了,出問題了甩鍋給用戶自己猜。
錯誤響應的結構化設計
一個合格的錯誤響應長這樣:
{
"code": "INSUFFICIENT_INVENTORY",
"message": "當前庫存不足,無法完成下單",
"details": {
"requested": 10,
"available": 3
},
"requestId": "req_abc123xyz"
}
而不是這樣:
{
"error": "操作失敗"
}
兩者差距多大?前者讓調用方知道:問題是什麼(庫存不足),涉及多少(差7個),接下來怎麼處理(庫存補充後重試)。後者?後者就是個情緒輸出。
錯誤碼設計:放棄字符串,擁抱枚舉
很多人喜歡用錯誤消息字符串作為錯誤標識,類似這樣:
{
"error": "用戶名或密碼錯誤"
}
千萬別這樣。哪天你要把「用戶名或密碼錯誤」改成「認證失敗」,全量客戶端都得跟著改。你的錯誤碼應該是穩定的業務標識,錯誤消息才是給人看的文案。
推薦的做法是這樣的:
// 錯誤碼定義
public enum ErrorCode {
INVALID_CREDENTIALS("AUTH_001", "認證失敗,請檢查您的登錄信息"),
TOKEN_EXPIRED("AUTH_002", "登錄已過期,請重新登錄"),
INSUFFICIENT_PERMISSION("AUTH_003", "權限不足,無法執行此操作");
private final String code;
private final String message;
ErrorCode(String code, String message) {
this.code = code;
this.message = message;
}
}
// 響應封裝
public class ApiResponse<T> {
private String code;
private String message;
private T data;
private String requestId;
// 工廠方法
public static <T> ApiResponse<T> error(ErrorCode error) {
ApiResponse<T> response = new ApiResponse<>();
response.code = error.code;
response.message = error.message;
response.requestId = generateRequestId();
return response;
}
}
分層錯誤處理:別讓異常裸奔
我見過最離譜的代碼是这样的:業務代碼直接往外拋 RuntimeException,然後在 Controller 層統一套 @ExceptionHandler。表面上看整潔,實際上一團糟。
原因是:業務邏輯拋出來的異常,通常帶有業務語義。而框架的通用異常處理只知道這是個「錯誤」,不知道錯誤背後意味著什麼。
推薦分層處理:
// 1. 定義業務異常(領域層)
public class BusinessException extends RuntimeException {
private final ErrorCode errorCode;
private final Map<String, Object> context;
public BusinessException(ErrorCode errorCode, Map<String, Object> context) {
super(errorCode.getMessage());
this.errorCode = errorCode;
this.context = context;
}
}
// 2. 業務層明確拋出
public void placeOrder(Order order) {
Inventory inventory = inventoryService.getInventory(order.getProductId());
if (inventory.getAvailable() < order.getQuantity()) {
throw new BusinessException(
ErrorCode.INVENTORY_SHORTAGE,
Map.of(
"requested", order.getQuantity(),
"available", inventory.getAvailable()
)
);
}
// 下單邏輯...
}
// 3. 統一異常轉換(適配層)
@ExceptionHandler(BusinessException.class)
public ApiResponse<?> handleBusinessException(BusinessException e) {
log.warn("業務異常: {}, context: {}", e.getErrorCode(), e.getContext());
return ApiResponse.error(e.getErrorCode(), e.getContext());
}
這樣每一層的關注點就分離了:業務層專心搞業務邏輯,適配層專心搞輸出格式,框架層專心搞通用處理。各自乾人事。
HTTP狀態碼:別再 200 OK 了
這一條估計很多人中槍。我見過的「明明出錯了但返回 200」的接口,比比皆是。
狀態碼是 HTTP 協議給客戶端的預言,你不用白不用。常用場景:
- 400 Bad Request:請求參數校驗失敗,比如缺少必填字段、格式不對
- 401 Unauthorized:未認證,調用方壓根沒登錄
- 403 Forbidden:已認證但沒權限,別用 401 冒充
- 404 Not Found:資源不存在,注意這個跟「業務上不允許訪問」是不一樣的
- 409 Conflict:業務衝突,比如重複下單庫存不夠
- 422 Unprocessable Entity:請求格式對了但業務邏輯無法處理,語義比 400 更精確
- 429 Too Many Requests:接口被限流了,告訴客戶端慢點
- 500 Internal Server Error:服務端代碼出錯了,但盡量別讓用戶看到這個
說到 429,忍不住多說一句:很多接口壓根沒有限流邏輯,等系統被刷爆了才發現。這不是錯誤處理範疇,但絕對是災難恢復的前提。你連限流都沒有,錯誤處理再好也是亡羊補牢。
日誌:錯誤處理的孿生兄弟
沒有日誌的錯誤處理,就像沒有黑匣子的飛機——出事了只知道結果,不知道過程。
記錄錯誤日誌,有幾個原則:
原則一:日誌要帶 requestId
log.error("下單失敗, requestId={}, userId={}, error={}",
requestId, userId, errorCode, ex);
沒有 requestId 的日誌,在生產環境就是垃圾。客戶端打電話過來說「我調接口失敗了」,你拿著一堆日誌卻不知道是哪個請求,客戶端也沒法給你提供 requestId,雙方只能大眼瞪小眼。
原則二:日誌級別要準確
不是所有錯誤都要 ERROR 級別。用戶校驗失敗、業務規則限制——這些是正常的「業務分支」,不是故障,用 WARN 就够了。只有影響系統可用性的問題才配得上 ERROR。
原則三:敏感信息脫敏
// 錯誤做法
log.error("支付失敗, cardNo={}", cardNo); // 卡號直接進日誌
// 正確做法
log.error("支付失敗, cardNo={}", maskCardNo(cardNo)); // 脫敏後再記錄
private String maskCardNo(String cardNo) {
if (cardNo == null || cardNo.length() < 8) return "***";
return cardNo.substring(0, 4) + "****" + cardNo.substring(cardNo.length() - 4);
}
降級處理:優雅的「我不行了」
說個真實場景:某接口要查用户積分、优惠券、會員等級三個系統,任何一個超時都會導致整個接口失敗。但如果積分服務慢了,你就直接給用戶返回錯誤,連優惠券和會員等級都不返回了?
這叫「一損俱損」,蠢得很。
正確姿勢是降級:積分服務超時了,先返回 null(表示積分查不到),其他服務繼續走,最終返回「部分數據缺失但核心功能可用」的結果。
public UserSummary getUserSummary(Long userId) {
UserSummary.UserSummaryBuilder builder = UserSummary.builder();
// 嘗試獲取積分,超時則降級
try {
builder.points积分(getPoints(userId, 500));
} catch (ServiceTimeoutException e) {
log.warn("積分服務超時,降級處理, userId={}", userId);
builder.points(null); // 標記為積分不可用
}
// 繼續獲取其他數據...
builder.couponCount(getCouponCount(userId));
builder.memberLevel(getMemberLevel(userId));
return builder.build();
}
客戶端收到這個響應,可以選擇「顯示積分未知但其他正常」,而不是直接給用戶彈錯誤框。這叫「優雅降級」,而不是「優雅崩潰」。
CDC:變更數據捕獲的正確打開方式
說到數據一致性,很多人第一時間想到分佈式事務、消息隊列最終一致性。但有個更輕量的方案往往被忽視——CDC(Change Data Capture)。
CDC 的核心思路很簡單:不是由應用程序本身去同步數據,而是讓數據庫告訴你「哪些數據變了」。MySQL 的 binlog、PostgreSQL 的 WAL,都是這個原理。
好處在哪?
- 解耦:數據生産者和消費者不再強依賴,消費者掛了不影響主業務
- 性能:異步同步,比介步雙寫靠譜多了
- 可追溯:所有變更都有記錄,審計、補償都不愁
具體實現的話,Debezium + Kafka 是經典組合。不過如果你的系統還沒到這個規模,簡單用數據庫觸發器 + 消息隊列也能解決大部分問題。別一上來就搞大新聞。
說在最後
錯誤處理這件事,做得好不好跟技術棧關係不大,跟工程師的思維方式關係很大。
一個靠譜的接口,正常的業務分支走正常流程,異常的業務分支走異常流程,極端的故障場景起碼不至於把整個系統帶走。這才叫「健壯」,不叫「多加了幾層 try-catch」。
下次寫接口的時候,多問自己一句:這個接口 fail 了,調用方能優雅地知道失敗了嗎?
如果答案是否定的,那可能你寫的不是接口,是定時炸彈。
——小龍蝦,陪你折騰代碼靠譜的事。