我不会起名字322· 后端 / 算法 / 数据库
📘 技术栈 | 🧩 力扣 Hot100 | 🐹 Go 项目 | 🟥 Redis | 🐬 MySQL
文章目录
- gRPC 状态码全是 Unknown:错误透传与 status 包装的正确写法
- 一、为什么全是 Unknown
- 二、正确写法:用 `status.Error`
- 三、客户端怎么正确判断错误
- 四、跨服务调用时的错误翻转
- 错误做法:直接往上抛
- 正确做法:重新包装
- 五、携带额外错误信息:status details
- 六、哪些错误"不该"是 Unknown
- 七、一个常见的整体错误:panic 默认变成 Unknown
- 八、带走物
gRPC 状态码全是 Unknown:错误透传与 status 包装的正确写法
一、为什么全是 Unknown
gRPC 的错误是状态码 + 消息的组合。但很多人遇到的情况是:不管服务端返回什么错误,客户端收到的都是Unknown。
rpc error: code = Unknown desc = 数据库连接失败"数据库连接失败"这个消息传过来了,但状态码是Unknown而不是Internal。
原因是:gRPC 只会把*status.Status类型的错误当作状态码传递,其他任何 error 都会被包装成codes.Unknown。
// ❌ 这样返回,客户端收到的是 Unknownreturnnil,errors.New("数据库连接失败")// ❌ 这样也是 Unknown(fmt.Errorf 包装的普通 error)returnnil,fmt.Errorf("查询失败: %w",err)gRPC 的设计是:你返回的 error 必须能转换成 status。转换函数(status.FromError)的规则是:
funcFromError(errerror)(s*Status,okbool){iferr==nil{returnnil,true}// 只有实现了 GRPCStatus() 接口的才 ok=trueifse,ok:=err.(interface{GRPCStatus()*Status});ok{returnse.GRPCStatus(),true}// 其他一律 Unknown,消息保留原 error 的字符串returnNew(codes.Unknown,err.Error()),false}所以你看到的 “desc = 数据库连接失败” 其实是原始 error 的字符串,被塞进了 Unknown 的 message 里。
二、正确写法:用status.Error
import"google.golang.org/grpc/status"import"google.golang.org/grpc/codes"// ✅ 正确:显式指定状态码returnnil,status.Error(codes.Internal,"数据库连接失败")// ✅ 带格式化returnnil,status.Errorf(codes.NotFound,"订单 %d 不存在",id)// ✅ 参数错误ifreq.Id==""{returnnil,status.Error(codes.InvalidArgument,"id 不能为空")}包一层业务错误转换函数是个好习惯:
// 把领域错误映射成 gRPC 状态码functoGRPCError(errerror)error{iferr==nil{returnnil}switch{caseerrors.Is(err,domain.ErrNotFound):returnstatus.Error(codes.NotFound,err.Error())caseerrors.Is(err,domain.ErrInvalidParam):returnstatus.Error(codes.InvalidArgument,err.Error())caseerrors.Is(err,domain.ErrPermissionDenied):returnstatus.Error(codes.PermissionDenied,err.Error())caseerrors.Is(err,context.DeadlineExceeded):returnstatus.Error(codes.DeadlineExceeded,err.Error())default:returnstatus.Error(codes.Internal,err.Error())}}// handler 里统一用func(s*Server)GetOrder(ctx context.Context,req*pb.GetReq)(*pb.Order,error){o,err:=s.svc.Get(ctx,req.Id)iferr!=nil{returnnil,toGRPCError(err)// 一行搞定}returntoProto(o),nil}这样客户端就能按状态码做区分处理了。
三、客户端怎么正确判断错误
绝对不要用字符串匹配:
// ❌ 脆弱:服务端改一下文案就失效ifstrings.Contains(err.Error(),"不存在"){// ...}用status.Code():
import"google.golang.org/grpc/status"resp,err:=client.GetOrder(ctx,req)iferr!=nil{switchstatus.Code(err){casecodes.NotFound:returnnil,ErrOrderNotFound// 转成自己的领域错误casecodes.InvalidArgument:returnnil,ErrBadParamcasecodes.DeadlineExceeded:returnnil,ErrTimeoutcasecodes.Unavailable:returnnil,ErrServiceDown// 可重试casecodes.Internal:returnnil,ErrServerErrordefault:returnnil,err}}取原始消息:
st,ok:=status.FromError(err)ifok{log.Printf("code=%s msg=%s",st.Code(),st.Message())}四、跨服务调用时的错误翻转
这是最容易出错的地方:服务 A 调服务 B,B 返回NotFound,A 要怎么返回给它的调用方?
错误做法:直接往上抛
// ❌ 直接把下游的错误透传出去,语义可能不对func(s*AServer)GetDetail(ctx context.Context,req*pb.Req)(*pb.Detail,error){order,err:=s.orderClient.GetOrder(ctx,&pb.GetOrderReq{Id:req.OrderId})iferr!=nil{returnnil,err// 下游的 NotFound 直接变成 A 的 NotFound}...}问题:B 的NotFound(订单不存在)和 A 的NotFound(详情不存在)是两回事。而且如果你直接透传,调用方无法区分"订单不存在"和"详情不存在"。
正确做法:重新包装
func(s*AServer)GetDetail(ctx context.Context,req*pb.Req)(*pb.Detail,error){order,err:=s.orderClient.GetOrder(ctx,&pb.GetOrderReq{Id:req.OrderId})iferr!=nil{// 保留下游状态码用于日志/排查,但对外重新定义语义log.Printf("调用 order 服务失败 code=%s: %v",status.Code(err),err)switchstatus.Code(err){casecodes.NotFound:// 订单不存在 → 对 A 的调用方来说,这是"详情不存在"returnnil,status.Errorf(codes.NotFound,"详情不存在: 订单 %s 未找到",req.OrderId)casecodes.Unavailable:// 下游不可用 → 对 A 的调用方来说是"服务暂时不可用"(可重试)returnnil,status.Error(codes.Unavailable,"依赖服务暂时不可用")casecodes.DeadlineExceeded:returnnil,status.Error(codes.DeadlineExceeded,"下游响应超时")default:returnnil,status.Error(codes.Internal,"内部错误")}}...}核心原则:每一层都要把下游的错误"翻译"成本层的语义,而不是原样透传。同时把下游的原始错误记进日志,方便排查。
五、携带额外错误信息:status details
status.Error只能带一个字符串。如果需要结构化信息(比如字段级校验错误),用details:
import"google.golang.org/grpc/status"import"google.golang.org/protobuf/types/known/anypb"import"google.golang.org/genproto/googleapis/rpc/errdetails"funcbadRequest(field,descstring)error{st:=status.New(codes.InvalidArgument,"参数校验失败")// 带上结构化的字段错误br:=&errdetails.BadRequest{FieldViolations:[]*errdetails.BadRequest_FieldViolation{{Field:field,Description:desc},},}st,_=st.WithDetails(br)returnst.Err()}客户端解析:
st,_:=status.FromError(err)for_,d:=rangest.Details(){ifbr,ok:=d.(*errdetails.BadRequest);ok{for_,v:=rangebr.FieldViolations{log.Printf("字段 %s: %s",v.Field,v.Description)}}}WithDetails会返回新的 status,要重新赋值(上面那行st, _ = st.WithDetails(br))。而且如果 details 的类型客户端没有注册,WithDetails会返回错误。
六、哪些错误"不该"是 Unknown
对照表,选状态码时参考:
| 场景 | 状态码 | 可重试 |
|---|---|---|
| 参数缺失/格式错 | InvalidArgument | ❌ |
| 资源不存在 | NotFound | ❌ |
| 已存在(重复创建) | AlreadyExists | ❌ |
| 无权限 | PermissionDenied | ❌ |
| 未认证 | Unauthenticated | ❌(先刷新 token) |
| 限流 | ResourceExhausted | ✅ 带退避 |
| 服务暂时不可用 | Unavailable | ✅ |
| 超时 | DeadlineExceeded | ⚠️ 谨慎 |
| 下游挂了但本服务正常 | Internal或Unavailable | 视情况 |
| 未捕获的 panic | Internal | ❌ |
"该重试的"和"不该重试的"必须分开——这是重试策略能正确工作的前提(详见重试风暴那篇)。
七、一个常见的整体错误:panic 默认变成 Unknown
如果 handler 里 panic 了(而且没有 recovery 拦截器),gRPC 会恢复并返回Unknown,原始 panic 信息丢失:
rpc error: code = Unknown desc = panic: runtime error: index out of range所以必须加 recovery 拦截器,并且显式转成Internal:
funcrecoveryInterceptor(ctx context.Context,reqinterface{},info*grpc.UnaryServerInfo,handler grpc.UnaryHandler)(respinterface{},errerror){deferfunc(){ifr:=recover();r!=nil{buf:=make([]byte,4096)n:=runtime.Stack(buf,false)log.Printf("PANIC %s: %v\n%s",info.FullMethod,r,buf[:n])err=status.Errorf(codes.Internal,"内部错误: %v",r)}}()returnhandler(ctx,req)}八、带走物
服务端三条规则:
1. 永远用 status.Error / status.Errorf 返回错误,不要裸 errors.New 2. 建一个 toGRPCError 把领域错误映射成状态码 3. 加 recovery 拦截器,把 panic 转成 Internal(默认是 Unknown 且丢信息)客户端两条规则:
1. 用 status.Code(err) 判断,绝不字符串匹配 2. 跨层调用时把下游错误"翻译"成本层语义,同时把原始错误记进日志一句话结论:Unknown的本质是"服务端返回了一个 gRPC 不认识的 error"——只要用status.Error显式指定状态码,它就会消失;而状态码的准确与否,直接决定了调用方的重试和容错逻辑能不能正确工作。