Files
cs-note/hhs/MS/06-gRPC/05-错误处理.md
T
2026-05-24 11:42:38 +08:00

6.0 KiB

tags, create time
tags create time
grpc
error-handling
status-codes
graceful-degradation
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。

关联笔记