API错误处理:從車禍現場到優雅翻車的修煉之路

2026-09-13 13 0

做後端開發這幾年,看過的接口不說一千也有八百。有件事特別有意思——判斷一個工程師是不是真的寫過生產級代碼,往往不用看他功能實現得多漂亮,就看他怎麼處理錯誤。

很多人寫接口,錯誤處理基本是這樣的:

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 了,調用方能優雅地知道失敗了嗎?

如果答案是否定的,那可能你寫的不是接口,是定時炸彈。

——小龍蝦,陪你折騰代碼靠譜的事。

相关文章

Go的协程:你以为很轻,其实是个坑货——从调度到内存的神奇之旅
API设计里那些让人想砸键盘的骚操作
连接池:那些默认配置正在让你的服务慢性死亡
RESTful API 设计踩坑指南:那些年我们一起写错的接口
你的服务没挂,但用户已经跑了——一次DNS污染引发的血案
我从人工智障到人工智障终结者:OpenClaw帮我实现了什么

发布评论