微服务错误语义:HTTP状态、业务错误码、异常分类、可重试与结果未知
微服务调用失败时,最怕的不是看到异常,而是异常没有语义。同样是“失败”,可能表示参数错、权限拒绝、库存不足、限流、熔断、连接失败、读取超时、下游 500、响应解码失败、业务已提交但响应丢失。它们的处理方式完全不同:有的不能重试,有的可以退避重试,有的必须查询事实,有的应快速降级,有的要报警人工处理。
本页解决一个核心问题:
调用方看到一个状态码、错误码或异常时,怎样判断业务是否执行、是否可重试、是否结果未知、是否能降级,以及怎样把这个语义稳定写进接口契约。
相关知识:接口字段和 Schema 兼容见API与事件契约治理;超时、重试和熔断见微服务稳定性治理;幂等和事实查询见幂等设计。
一、学习目标
学完本页应能做到:
- 区分传输错误、协议错误、框架错误、业务错误和数据错误。
- 解释 HTTP 状态码、业务错误码和异常类的分工。
- 设计稳定错误响应:
code、message、details、traceId、retryable、state。 - 判断哪些错误可重试,哪些错误不能重试。
- 解释为什么超时通常是结果未知,而不是失败。
- 说明 Feign
ErrorDecoder、gRPC Status、Dubbo RpcException、MQ消费异常分别怎样分类。 - 设计强依赖和弱依赖的降级语义,避免伪成功。
- 在线上根据错误分类、Trace、业务事实和重试 Attempt 排查问题。
二、错误要先分层
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 | 网关等待下游超时 | 下游可能已执行,写请求查事实 |
业务响应建议保持稳定结构:
{
"success": false,
"code": "STOCK_UNKNOWN",
"message": "库存状态暂时无法确认",
"traceId": "1f9a0c...",
"retryable": false,
"state": "UNKNOWN",
"details": {
"orderId": "O1001"
}
}字段含义:
| 字段 | 作用 |
|---|---|
success | 便于前端或调用方快速判断大类 |
code | 稳定程序判断,不随文案变化 |
message | 面向人,可国际化,不应用于程序分支 |
traceId | 排查关联,不是幂等键 |
retryable | 是否允许调用方自动重试 |
state | SUCCESS、FAILED、UNKNOWN、DEGRADED 等状态 |
details | 受控补充信息,不能放敏感堆栈 |
四、SUCCESS、FAILED、UNKNOWN 必须分开
远程写操作至少有三种结果:
flowchart TD
A["调用方发起写请求"] --> B{"调用方收到什么"}
B -- "明确成功响应" --> C["SUCCESS"]
B -- "明确业务失败响应" --> D["FAILED"]
B -- "超时、断连、响应丢失" --> E["UNKNOWN"]
E --> F["用幂等键查询事实"]
F --> G["补记成功、明确失败或人工处理"]为什么超时通常是 UNKNOWN?
flowchart TD
A["订单服务调用库存冻结"] --> B["库存服务收到请求"]
B --> C["库存冻结本地事务提交"]
C --> D["响应返回途中网络抖动"]
D --> E["订单服务Read Timeout"]
E --> F["订单服务不知道库存是否已冻结"]此时如果订单服务换一个新请求号再调一次库存,就可能重复冻结。正确做法是使用同一个业务幂等号查询库存事实。
| 状态 | 含义 | 动作 |
|---|---|---|
| SUCCESS | 已确认成功 | 推进后续流程 |
| FAILED | 已确认失败 | 返回失败或允许用户修正后重试 |
| UNKNOWN | 调用方无法确认事实 | 查询事实、补偿、对账 |
| DEGRADED | 降级返回,不是完整事实 | 标记降级,不能当正常成功持久化 |
| ACCEPTED | 已受理异步处理 | 查询任务状态或等待事件 |
| CONFLICT | 状态冲突或重复请求 | 幂等复用或刷新状态 |
五、哪些错误可以重试
重试必须同时满足四个条件:
- 错误是瞬时的。
- 操作可幂等或没有副作用。
- 剩余 Deadline 足够。
- 当前重试预算未耗尽。
| 错误 | 是否建议自动重试 | 原因 |
|---|---|---|
| 参数校验失败 | 否 | 重试同样请求不会成功 |
| 权限拒绝 | 否 | 权限不变时重试无意义 |
| 库存不足 | 否 | 这是明确业务拒绝 |
| 唯一键冲突 | 不直接重试 | 可能是幂等重复,应查首次结果 |
| 连接建立失败 | 可有限重试 | 请求通常未到达业务 |
| 连接池等待超时 | 谨慎 | 本地资源不足,重试可能更糟 |
| Read Timeout | 写请求不盲目重试 | 下游可能已提交 |
| 429限流 | 可按退避策略 | 必须遵守 Retry-After 或限流窗口 |
| 503临时不可用 | 可有限重试 | 可能换实例恢复 |
| 解码失败 | 通常不重试 | 契约不兼容,重试不会修复 |
| 死锁 | 可有限重试 | 数据库可能建议事务重试 |
六、不同框架里的错误语义
| 技术 | 错误入口 | 重点 |
|---|---|---|
| Feign | HTTP状态、ErrorDecoder、Decoder异常 | HTTP非2xx和业务code要统一映射 |
| Gateway | 404、429、502、503、504 | 请求可能没到业务服务 |
| Resilience4j | CallNotPermittedException、BulkheadFullException、TimeoutException | 区分熔断拒绝、隔离拒绝和超时 |
| Sentinel | FlowException、DegradeException、ParamFlowException | blockHandler通常表示业务未执行 |
| Dubbo | RpcException、业务异常、超时 | Consumer超时不代表Provider未执行 |
| gRPC | Status.Code、Trailers、Deadline | UNKNOWN不应承载所有业务错误 |
| MQ | 消费异常、ACK失败、DLQ | 消费成功和ACK成功之间仍可能重复 |
Feign 常见错误:
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:错误分类和重试判断
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 重点:
- 写操作 Read Timeout 被分类为
UNKNOWN,不是FAILED。 retryable=true不代表立刻无限重试,还要看 Deadline、幂等和预算。- 业务错误和系统错误分开,避免库存不足触发熔断或系统重试。
八、错误响应契约设计原则
| 原则 | 解释 |
|---|---|
| 稳定code | 程序判断依赖 code,不依赖 message |
| code分层 | AUTH_、VALIDATION_、RATE_、BUSINESS_、SYSTEM_ |
| message面向人 | 可以调整文案,不影响调用方逻辑 |
| 不泄露内部细节 | SQL、堆栈、主机名、密钥不能返回给外部 |
| traceId必带 | 便于跨服务排查 |
| retryable谨慎 | 只表达自动重试建议,不替代业务判断 |
| state表达事实 | UNKNOWN、DEGRADED 必须进入契约 |
| 版本兼容 | 新错误码和新枚举要有 UNKNOWN 兜底 |
错误码示例:
| code | HTTP | retryable | state | 说明 |
|---|---|---|---|---|
OK | 200 | false | SUCCESS | 成功 |
VALIDATION_FAILED | 400 | false | FAILED | 参数错误 |
AUTH_DENIED | 403 | false | FAILED | 权限不足 |
RATE_LIMITED | 429 | true | FAILED | 限流拒绝,按退避 |
STOCK_NOT_ENOUGH | 409 | false | FAILED | 明确库存不足 |
STOCK_UNKNOWN | 200或503 | false | UNKNOWN | 库存状态未知 |
RECOMMEND_DEGRADED | 200 | false | DEGRADED | 推荐降级 |
SYSTEM_TEMP_UNAVAILABLE | 503 | true | FAILED | 临时不可用 |
九、商业场景
9.1 库存冻结超时
错误做法:
库存接口超时 -> 订单服务认为失败 -> 换请求号再次冻结正确做法:
库存接口超时 -> 返回UNKNOWN -> 使用同一requestId查询冻结流水 -> 成功则推进订单,失败则释放,未知则待确认9.2 推荐服务失败
推荐是弱依赖。推荐失败可以:
- 返回空推荐。
- 设置
degraded=true。 - 记录
RECOMMEND_DEGRADED。 - 不影响订单主数据展示。
但不能把推荐失败写成订单失败。
9.3 ES查询为空
ES 查询为空不一定表示数据库没有数据,可能是 ES 同步延迟、索引更新失败、别名切换错误或分片查询异常。搜索接口应区分:
| 状态 | 含义 |
|---|---|
EMPTY_RESULT | 确认查到但没有匹配 |
SEARCH_UNAVAILABLE | 搜索服务不可用 |
INDEX_LAGGING | 搜索视图落后 |
QUERY_INVALID | 查询参数非法 |
关键业务详情页不能只依赖 ES 判断资源不存在,应回源数据库事实。
十、线上排查 Runbook
10.1 接口返回成功但用户说没成功
- 查响应中的
code、state、degraded,确认是不是伪成功。 - 用
traceId查全链路,确认强依赖是否成功。 - 用业务幂等号查数据库事实源。
- 查是否降级结果被上游缓存或持久化。
- 查 MQ、ES、短信等异步依赖是否还在重试或死信。
10.2 熔断器突然打开
- 看失败样本是 HTTP 5xx、超时、解码失败还是业务错误码。
- 检查是否把库存不足、参数错误、权限拒绝这类业务失败计入系统失败。
- 检查慢调用比例是否因下游 P99、连接池等待或GC升高。
- 检查最近发布是否改变错误码、HTTP状态或异常包装。
- 修正异常分类后灰度,不要直接强制关闭熔断。
10.3 重试风暴
- 按逻辑请求数和物理 Attempt 数对比。
- 查 Gateway、Feign、HTTP Client、Mesh 是否多层重试。
- 查错误是否真的可重试,尤其写请求 Read Timeout。
- 对 429、503 使用退避和抖动。
- 对永久业务错误直接失败,不进入原地重试。
十一、面试标准回答
微服务错误语义要分层看:传输错误、协议错误、框架错误、业务错误和数据错误不能混在一起。
HTTP状态表达协议大类,稳定业务code表达领域结果,异常类用于服务内部控制流,三者要有统一映射。
最重要的是区分SUCCESS、FAILED和UNKNOWN。读取超时、断连或响应丢失时,写请求可能已经在下游
提交,所以不能直接当失败重试;应使用同一幂等键查询事实,再补偿或对账。参数错误、权限拒绝、
库存不足这类明确业务失败不能重试;连接失败、503、死锁这类瞬时错误可以在幂等、Deadline和
重试预算内有限重试。
降级也必须保留业务语义。推荐失败可以返回空推荐并标记degraded,库存和支付不能返回伪成功。
线上排查要看错误码、HTTP状态、异常类型、Trace、物理Attempt、下游事实和补偿状态。十二、关联知识点
| 知识点 | 入口 |
|---|---|
| API与事件契约治理 | 错误模型与兼容发布 |
| 微服务稳定性治理 | 超时、重试、熔断 |
| 微服务依赖治理 | 强弱依赖与降级语义 |
| 分布式幂等 | 幂等键与事实查询 |
| Spring Cloud Feign | 错误解码与调用链 |
| gRPC | Status、Deadline与结果未知 |
| Dubbo | 超时、重试与UNKNOWN |
