Skip to content

微服务错误语义:HTTP状态、业务错误码、异常分类、可重试与结果未知

微服务调用失败时,最怕的不是看到异常,而是异常没有语义。同样是“失败”,可能表示参数错、权限拒绝、库存不足、限流、熔断、连接失败、读取超时、下游 500、响应解码失败、业务已提交但响应丢失。它们的处理方式完全不同:有的不能重试,有的可以退避重试,有的必须查询事实,有的应快速降级,有的要报警人工处理。

本页解决一个核心问题:

调用方看到一个状态码、错误码或异常时,怎样判断业务是否执行、是否可重试、是否结果未知、是否能降级,以及怎样把这个语义稳定写进接口契约。

相关知识:接口字段和 Schema 兼容见API与事件契约治理;超时、重试和熔断见微服务稳定性治理;幂等和事实查询见幂等设计

一、学习目标

学完本页应能做到:

  1. 区分传输错误、协议错误、框架错误、业务错误和数据错误。
  2. 解释 HTTP 状态码、业务错误码和异常类的分工。
  3. 设计稳定错误响应:codemessagedetailstraceIdretryablestate
  4. 判断哪些错误可重试,哪些错误不能重试。
  5. 解释为什么超时通常是结果未知,而不是失败。
  6. 说明 Feign ErrorDecoder、gRPC Status、Dubbo RpcException、MQ消费异常分别怎样分类。
  7. 设计强依赖和弱依赖的降级语义,避免伪成功。
  8. 在线上根据错误分类、Trace、业务事实和重试 Attempt 排查问题。

二、错误要先分层

mermaid
flowchart TD
    A["一次远程调用失败"] --> B["传输层错误"]
    A --> C["协议层错误"]
    A --> D["框架层错误"]
    A --> E["业务层错误"]
    A --> F["数据层错误"]
    B --> G["连接失败、TLS失败、超时、连接重置"]
    C --> H["HTTP 404、429、503、504、gRPC Status"]
    D --> I["序列化失败、解码失败、熔断拒绝"]
    E --> J["库存不足、余额不足、权限拒绝"]
    F --> K["唯一键冲突、锁等待、死锁、版本冲突"]

分层的意义是决定处理动作:

常见现象是否一定可重试处理方向
传输层Connect Timeout、Connection Refused部分可重试换实例、退避、检查网络
协议层429、503、504、gRPC UNAVAILABLE看语义遵守 Retry-After、预算内重试
框架层熔断打开、Bulkhead满、解码失败多数不可立即重试降级、修配置、查契约
业务层库存不足、参数错误、权限拒绝通常不可重试返回明确业务失败
数据层唯一键冲突、死锁、锁等待分类处理幂等复用、有限重试或人工处理

把所有异常都包装成 RuntimeException 或统一 500,调用方就无法知道该重试、降级、提示用户还是进入对账。

三、HTTP状态码和业务错误码怎么分工

HTTP 状态码表达协议和网关层大类,业务错误码表达领域结果。

HTTP状态常见含义业务处理
200请求被服务正常处理仍要看业务 code
400请求格式或参数非法不重试,修请求
401未认证重新登录或刷新 Token
403无权限不重试,检查授权
404资源或路由不存在不盲目重试
409业务状态冲突或版本冲突幂等复用、刷新状态
429被限流按 Retry-After 退避
500服务内部错误看是否瞬时、是否已提交
503服务暂不可用可在预算内退避重试
504网关等待下游超时下游可能已执行,写请求查事实

业务响应建议保持稳定结构:

json
{
  "success": false,
  "code": "STOCK_UNKNOWN",
  "message": "库存状态暂时无法确认",
  "traceId": "1f9a0c...",
  "retryable": false,
  "state": "UNKNOWN",
  "details": {
    "orderId": "O1001"
  }
}

字段含义:

字段作用
success便于前端或调用方快速判断大类
code稳定程序判断,不随文案变化
message面向人,可国际化,不应用于程序分支
traceId排查关联,不是幂等键
retryable是否允许调用方自动重试
stateSUCCESSFAILEDUNKNOWNDEGRADED 等状态
details受控补充信息,不能放敏感堆栈

四、SUCCESS、FAILED、UNKNOWN 必须分开

远程写操作至少有三种结果:

mermaid
flowchart TD
    A["调用方发起写请求"] --> B{"调用方收到什么"}
    B -- "明确成功响应" --> C["SUCCESS"]
    B -- "明确业务失败响应" --> D["FAILED"]
    B -- "超时、断连、响应丢失" --> E["UNKNOWN"]
    E --> F["用幂等键查询事实"]
    F --> G["补记成功、明确失败或人工处理"]

为什么超时通常是 UNKNOWN

mermaid
flowchart TD
    A["订单服务调用库存冻结"] --> B["库存服务收到请求"]
    B --> C["库存冻结本地事务提交"]
    C --> D["响应返回途中网络抖动"]
    D --> E["订单服务Read Timeout"]
    E --> F["订单服务不知道库存是否已冻结"]

此时如果订单服务换一个新请求号再调一次库存,就可能重复冻结。正确做法是使用同一个业务幂等号查询库存事实。

状态含义动作
SUCCESS已确认成功推进后续流程
FAILED已确认失败返回失败或允许用户修正后重试
UNKNOWN调用方无法确认事实查询事实、补偿、对账
DEGRADED降级返回,不是完整事实标记降级,不能当正常成功持久化
ACCEPTED已受理异步处理查询任务状态或等待事件
CONFLICT状态冲突或重复请求幂等复用或刷新状态

五、哪些错误可以重试

重试必须同时满足四个条件:

  1. 错误是瞬时的。
  2. 操作可幂等或没有副作用。
  3. 剩余 Deadline 足够。
  4. 当前重试预算未耗尽。
错误是否建议自动重试原因
参数校验失败重试同样请求不会成功
权限拒绝权限不变时重试无意义
库存不足这是明确业务拒绝
唯一键冲突不直接重试可能是幂等重复,应查首次结果
连接建立失败可有限重试请求通常未到达业务
连接池等待超时谨慎本地资源不足,重试可能更糟
Read Timeout写请求不盲目重试下游可能已提交
429限流可按退避策略必须遵守 Retry-After 或限流窗口
503临时不可用可有限重试可能换实例恢复
解码失败通常不重试契约不兼容,重试不会修复
死锁可有限重试数据库可能建议事务重试

六、不同框架里的错误语义

技术错误入口重点
FeignHTTP状态、ErrorDecoder、Decoder异常HTTP非2xx和业务code要统一映射
Gateway404、429、502、503、504请求可能没到业务服务
Resilience4jCallNotPermittedExceptionBulkheadFullExceptionTimeoutException区分熔断拒绝、隔离拒绝和超时
SentinelFlowExceptionDegradeExceptionParamFlowExceptionblockHandler通常表示业务未执行
DubboRpcException、业务异常、超时Consumer超时不代表Provider未执行
gRPCStatus.Code、Trailers、DeadlineUNKNOWN不应承载所有业务错误
MQ消费异常、ACK失败、DLQ消费成功和ACK成功之间仍可能重复

Feign 常见错误:

text
HTTP 500 -> ErrorDecoder -> SystemException
HTTP 429 -> ErrorDecoder -> RateLimitedException
HTTP 200 + code != OK -> Decoder 或统一响应检查 -> BusinessException
响应体不是预期JSON -> DecodeException -> ContractException

如果团队使用 HTTP 200 表达所有业务失败,Feign 的 ErrorDecoder 不会自动触发,必须在 Decoder 或业务响应检查里统一处理。两套机制混用会让调用方很难判断错误语义。

七、JDK 8 Demo:错误分类和重试判断

java
public final class ErrorSemanticsDemo {

    enum CallState {
        SUCCESS, FAILED, UNKNOWN, DEGRADED
    }

    enum ErrorKind {
        NONE,
        VALIDATION,
        AUTH,
        BUSINESS_REJECT,
        RATE_LIMITED,
        CONNECT_FAILED,
        READ_TIMEOUT,
        DECODE_FAILED,
        CIRCUIT_OPEN
    }

    static final class CallResult {
        final CallState state;
        final ErrorKind errorKind;
        final boolean retryable;
        final String code;

        CallResult(CallState state, ErrorKind errorKind, boolean retryable, String code) {
            this.state = state;
            this.errorKind = errorKind;
            this.retryable = retryable;
            this.code = code;
        }
    }

    static CallResult classify(String operation, int httpStatus, String businessCode) {
        if (httpStatus == 200 && "OK".equals(businessCode)) {
            return new CallResult(CallState.SUCCESS, ErrorKind.NONE, false, "OK");
        }
        if (httpStatus == 400) {
            return new CallResult(CallState.FAILED, ErrorKind.VALIDATION, false, "BAD_REQUEST");
        }
        if (httpStatus == 401 || httpStatus == 403) {
            return new CallResult(CallState.FAILED, ErrorKind.AUTH, false, "AUTH_DENIED");
        }
        if (httpStatus == 429) {
            return new CallResult(CallState.FAILED, ErrorKind.RATE_LIMITED, true, "RATE_LIMITED");
        }
        if ("READ_TIMEOUT".equals(businessCode) && operation.startsWith("WRITE_")) {
            return new CallResult(CallState.UNKNOWN, ErrorKind.READ_TIMEOUT, false, "RESULT_UNKNOWN");
        }
        if (httpStatus == 503) {
            return new CallResult(CallState.FAILED, ErrorKind.CONNECT_FAILED, true, "TEMP_UNAVAILABLE");
        }
        return new CallResult(CallState.FAILED, ErrorKind.BUSINESS_REJECT, false, "BUSINESS_FAILED");
    }

    public static void main(String[] args) {
        CallResult result = classify("WRITE_FREEZE_STOCK", 200, "READ_TIMEOUT");
        System.out.println(result.state + "," + result.retryable + "," + result.code);
    }
}

Demo 重点:

  1. 写操作 Read Timeout 被分类为 UNKNOWN,不是 FAILED
  2. retryable=true 不代表立刻无限重试,还要看 Deadline、幂等和预算。
  3. 业务错误和系统错误分开,避免库存不足触发熔断或系统重试。

八、错误响应契约设计原则

原则解释
稳定code程序判断依赖 code,不依赖 message
code分层AUTH_VALIDATION_RATE_BUSINESS_SYSTEM_
message面向人可以调整文案,不影响调用方逻辑
不泄露内部细节SQL、堆栈、主机名、密钥不能返回给外部
traceId必带便于跨服务排查
retryable谨慎只表达自动重试建议,不替代业务判断
state表达事实UNKNOWNDEGRADED 必须进入契约
版本兼容新错误码和新枚举要有 UNKNOWN 兜底

错误码示例:

codeHTTPretryablestate说明
OK200falseSUCCESS成功
VALIDATION_FAILED400falseFAILED参数错误
AUTH_DENIED403falseFAILED权限不足
RATE_LIMITED429trueFAILED限流拒绝,按退避
STOCK_NOT_ENOUGH409falseFAILED明确库存不足
STOCK_UNKNOWN200或503falseUNKNOWN库存状态未知
RECOMMEND_DEGRADED200falseDEGRADED推荐降级
SYSTEM_TEMP_UNAVAILABLE503trueFAILED临时不可用

九、商业场景

9.1 库存冻结超时

错误做法:

text
库存接口超时 -> 订单服务认为失败 -> 换请求号再次冻结

正确做法:

text
库存接口超时 -> 返回UNKNOWN -> 使用同一requestId查询冻结流水 -> 成功则推进订单,失败则释放,未知则待确认

9.2 推荐服务失败

推荐是弱依赖。推荐失败可以:

  1. 返回空推荐。
  2. 设置 degraded=true
  3. 记录 RECOMMEND_DEGRADED
  4. 不影响订单主数据展示。

但不能把推荐失败写成订单失败。

9.3 ES查询为空

ES 查询为空不一定表示数据库没有数据,可能是 ES 同步延迟、索引更新失败、别名切换错误或分片查询异常。搜索接口应区分:

状态含义
EMPTY_RESULT确认查到但没有匹配
SEARCH_UNAVAILABLE搜索服务不可用
INDEX_LAGGING搜索视图落后
QUERY_INVALID查询参数非法

关键业务详情页不能只依赖 ES 判断资源不存在,应回源数据库事实。

十、线上排查 Runbook

10.1 接口返回成功但用户说没成功

  1. 查响应中的 codestatedegraded,确认是不是伪成功。
  2. traceId 查全链路,确认强依赖是否成功。
  3. 用业务幂等号查数据库事实源。
  4. 查是否降级结果被上游缓存或持久化。
  5. 查 MQ、ES、短信等异步依赖是否还在重试或死信。

10.2 熔断器突然打开

  1. 看失败样本是 HTTP 5xx、超时、解码失败还是业务错误码。
  2. 检查是否把库存不足、参数错误、权限拒绝这类业务失败计入系统失败。
  3. 检查慢调用比例是否因下游 P99、连接池等待或GC升高。
  4. 检查最近发布是否改变错误码、HTTP状态或异常包装。
  5. 修正异常分类后灰度,不要直接强制关闭熔断。

10.3 重试风暴

  1. 按逻辑请求数和物理 Attempt 数对比。
  2. 查 Gateway、Feign、HTTP Client、Mesh 是否多层重试。
  3. 查错误是否真的可重试,尤其写请求 Read Timeout。
  4. 对 429、503 使用退避和抖动。
  5. 对永久业务错误直接失败,不进入原地重试。

十一、面试标准回答

text
微服务错误语义要分层看:传输错误、协议错误、框架错误、业务错误和数据错误不能混在一起。
HTTP状态表达协议大类,稳定业务code表达领域结果,异常类用于服务内部控制流,三者要有统一映射。

最重要的是区分SUCCESS、FAILED和UNKNOWN。读取超时、断连或响应丢失时,写请求可能已经在下游
提交,所以不能直接当失败重试;应使用同一幂等键查询事实,再补偿或对账。参数错误、权限拒绝、
库存不足这类明确业务失败不能重试;连接失败、503、死锁这类瞬时错误可以在幂等、Deadline和
重试预算内有限重试。

降级也必须保留业务语义。推荐失败可以返回空推荐并标记degraded,库存和支付不能返回伪成功。
线上排查要看错误码、HTTP状态、异常类型、Trace、物理Attempt、下游事实和补偿状态。

十二、关联知识点

知识点入口
API与事件契约治理错误模型与兼容发布
微服务稳定性治理超时、重试、熔断
微服务依赖治理强弱依赖与降级语义
分布式幂等幂等键与事实查询
Spring Cloud Feign错误解码与调用链
gRPCStatus、Deadline与结果未知
Dubbo超时、重试与UNKNOWN