6.0 KiB
6.0 KiB
tags, create time
| tags | create time | ||||
|---|---|---|---|---|---|
|
2026-05-07 16:00 |
错误处理与状态码规范
概述
良好的错误处理决定了微服务系统的可观测性和恢复速度。gRPC 设计了 16 个标准状态码,每个都有明确的语义边界。正确使用它们,可以让调用方精准判断该重试、该降级、还是直接报错。
[!question] 先看一个反面教材
以下两个服务的错误处理方式,哪个更好?
服务 A:所有异常一律返回
InternalError: something went wrong服务 B:参数校验失败返回
InvalidArgument,资源不存在返回NotFound,数据库超时返回Unavailable
答案是显而易见的。但现实工程中,服务 A 的比例远高于服务 B——原因是很多开发者不了解状态码的准确含义,或者嫌麻烦直接 return nil, fmt.Errorf(...)。
gRPC 状态码一览
gRPC 定义了 16 个标准状态码,与 HTTP 状态码不完全映射,有自己的语义体系:
graph TB
OK["OK - 成功"]
subgraph "客户端错误 4xx 对应"
CANCELLED["CANCELLED - 客户端取消"]
UNKNOWN["UNKNOWN - 未知错误"]
INVALID_ARG["INVALID_ARGUMENT - 参数无效"]
DEADLINE_EX["DEADLINE_EXCEEDED - 超时"]
NOT_FOUND["NOT_FOUND - 资源不存在"]
ALREADY_EXIST["ALREADY_EXISTS - 重复创建"]
PERMISSION_DENIED["PERMISSION_DENIED - 权限不足"]
UNAUTH["UNAUTHENTICATED - 未认证"]
end
subgraph "服务端错误 5xx 对应"
RESOURCE_EXP["RESOURCE_EXHAUSTED - 资源耗尽"]
UNAVAIL["UNAVAILABLE - 服务不可用"]
DATA_LOSS["DATA_LOSS - 数据损坏"]
end
subgraph "未实现"
UNIMP["UNIMPLEMENTED - 方法未实现"]
INTERNAL_ERR["INTERNAL - 内部错误"]
UNIMP ~~~ UNAVAIL
INTERNAL_ERR ~~~ DATA_LOSS
UNIMP --- UNAVAIL
end
状态码速查表
| 业务含义 | 推荐状态码 | HTTP 等价 |
|---|---|---|
| 参数校验失败 | InvalidArgument |
400 |
| 资源不存在 | NotFound |
404 |
| 重复创建 | AlreadyExists |
409 |
| 权限不足 | PermissionDenied |
403 |
| 未认证 | Unauthenticated |
401 |
| 超时 | DeadlineExceeded |
504 |
| 服务挂了/连接断开 | Unavailable |
503 |
| 限流 | ResourceExhausted |
429 |
| 方法未定义 | Unimplemented |
501 |
| 代码 bug | Internal |
500 |
正确的错误处理方式
// ❌ 错误示范:用普通 error 包装,丢失 gRPC 语义
return nil, fmt.Errorf("failed to get order: %w", db.ErrNotFound)
// ✅ 正确示范:使用 status.Error 保持 gRPC 协议一致性
if errors.Is(err, db.ErrNotFound) {
return nil, status.Errorf(codes.NotFound, "order %s not found", id)
}
// ✅ 更精细:带上详情(status details)——客户端可以程序化解析
detail := &errdetails.BadRequest{
FieldViolations: []*errdetails.BadRequest_FieldViolation{{
Field: "user_id",
Description: "must be a valid UUID",
}},
}
return nil, status.New(codes.InvalidArgument, "validation failed").WithDetails(detail).Err()
为什么要用 status.Error
| 维度 | fmt.Errorf | status.Error |
|---|---|---|
| 客户端获取状态码 | 需要 parse 字符串 | status.Code(err) 直接获取 |
| 跨语言一致 | 各语言行为不一致 | gRPC 协议标准化 |
| 携带结构化错误 | 不支持 | 通过 WithDetails 携带 proto detail |
| 配合拦截器重试 | 无法识别 | 拦截器根据 codes.* 决定重试 |
状态码详情 (Status Details)
gRPC 允许在错误响应中附加结构化详情,这对自动化运维尤其重要:
// 超时场景中附带重试信息
retryInfo := &errdetails.RetryInfo{
RetryDelay: durationpb.New(2 * time.Second),
}
// 权限场景中附带帮助链接
help := &errdetails.Help{
Links: []*errdetails.Help_Link{{
Url: "https://internal.wiki/perm-error",
Description: "如何申请访问权限",
}},
}
status.New(codes.PermissionDenied, "no access").
WithDetails(retryInfo, help).
Err()
客户端可以程序化提取这些信息:
st := status.Convert(err)
for _, d := range st.Details() {
switch detail := d.(type) {
case *errdetails.RetryInfo:
time.Sleep(detail.RetryDelay.AsDuration())
// 执行重试
}
}
客户端优雅降级
resp, err := client.CreateOrder(ctx, req)
switch status.Code(err) {
case codes.NotFound:
// 降级:尝试加载缓存数据
return fallbackFromCache(ctx, id)
case codes.DeadlineExceeded, codes.Unavailable:
// 降级:返回友好提示或部分数据
return partialOrder(id), nil
default:
return nil, err
}
[!keypoint] 黄金法则
- 永远用
codes.*而不用fmt.Errorf做 gRPC 返回值- 不要吞掉所有错误一律返回
Internal—— 这会让排查问题无从下手- 客户端根据状态码决定重试还是降级,而不是所有错都 retry
何时返回 Internal
Internal 是最不应该被使用的状态码。只在以下情况使用:
| 场景 | 示例 |
|---|---|
| 代码逻辑 bug | panic recover、nil pointer dereference |
| 不可恢复的内部状态 | 数据库连接池耗尽、配置文件解析失败 |
| 子服务报错但不确定原因 | 下游返回的 code 不在预期范围内 |
[!warning] Internal 不等于"随便的错"
如果你的日志里有大量的
Internal错误,这通常意味着上游的错误分类不够细,掩盖了真正的根因。每次遇到不知道该怎么映射的错误时,优先考虑是否能归入现有的某个 code。
关联笔记
- 04-拦截器 — 拦截器根据错误码决定是否触发自动重试
- 07-最佳实践 — 重试策略中与错误状态的配合配置
- 02-服务治理/容错模式 — 熔断器在收到特定错误码后触发熔断
- 02-服务治理/分布式追踪 — 错误链路在追踪系统中的标注方式