1 拒绝裸奔:生产级 gRPC 项目的骨架与潜规则
很多兄弟刚开始搞 gRPC,照着官方文档那个 HelloWorld 跑通了,就觉得自己行了。其实那玩意儿离生产环境差了十万八千里。在真实业务里,你如果真的把 Server 和 Client 写在一个main.go里,或者 Proto 文件随便乱扔,后期维护能让你哭出声。
今天咱们不讲虚的,直接上工程化的干货。
也就是说是要有规矩
你要明白,gRPC 不仅仅是写代码,更多时候是在定义契约。Proto 文件就是你跟别的团队(前端、客户端、或者写 的兄弟)撕逼的依据。
咱们先看个推荐的目录结构,这可是无数个通宵踩坑总结出来的:
my-service/ ├── api/ │ └── v1/ │ └── order.proto <-- 版本化管理,别一股脑都堆在一起 ├── cmd/ │ └── server/ │ └── main.go <-- 入口文件要干净,别写逻辑 ├── internal/ <-- Go 里的 private,强制逻辑隔离 │ ├── config/ │ ├── data/ <-- 也就是 DAO 层,操作 DB/Redis │ ├── biz/ <-- 业务逻辑层 (UseCase) │ └── service/ <-- 实现 gRPC 接口的地方 ├── pkg/ <-- 公共库,给别人用的 └── Makefile <-- 没这玩意儿,你是想累死谁?重点来了,Proto 文件的编写有个极容易被忽视的细节——option go_package。
很多人瞎写这个路径,导致生成的pb.go文件甚至没法被正确 import。规范的做法是,你的 go_package 路径应该和你的 git 仓库路径完全对齐。
看看order.proto该怎么写:
syntax = "proto3"; package api.v1; // 这里的路径要跟你 go mod init 的名字对应上,别瞎填 option go_package = "github.com/yourname/my-service/api/v1;v1"; service OrderService { rpc CreateOrder (CreateOrderRequest) returns (CreateOrderReply); } message CreateOrderRequest { int64 user_id = 1; // 钱这种东西,千万别用 float/double,用 string 或者 int64(分) // 否则精度丢失教你做人 int64 amount_cents = 2; } message CreateOrderReply { string order_id = 1; }为什么一定要用 Makefile?
因为protoc的命令参数太长太恶心了!而且团队里每个人安装的插件版本可能都不一样。你需要一个标准化的构建流程。
把这个扔到你的项目根目录:
.PHONY: api api: protoc --proto_path=. \ --proto_path=./third_party \ --go_out=paths=source_relative:. \ --go-grpc_out=paths=source_relative:. \ api/v1/*.proto这样,任何时候你或者你的同事改了接口,敲一下make api就完事了。别让你的同事去手动敲protoc -I ...,那是对人类脑力的浪费。
初始化 Server 的正确姿势
别直接在main函数里grpc.NewServer()然后就开始写逻辑。我们要优雅。
利用 的Option模式来封装你的 Server 启动逻辑,这样以后加中间件(拦截器)的时候才不会动大手术。
// internal/server/grpc.go package server import ( v1 "github.com/yourname/my-service/api/v1" "google.golang.org/grpc" "google.golang.org/grpc/keepalive" "time" ) // NewGRPCServer 初始化 gRPC 服务端 // 注意:这里我们还没加拦截器,下一章讲超时和重试的时候会塞满这里 func NewGRPCServer(c *conf.ServerConfig, orderService v1.OrderServiceServer) *grpc.Server { var opts = []grpc.ServerOption{ // 这是一个保命配置! // 也就是常说的 KeepAlive // 很多时候你的服务在 LVS/Nginx 后面,长连接如果不发心跳 // 会被中间的负载均衡器切断,导致 "Connection reset by peer" 错误 grpc.KeepaliveParams(keepalive.ServerParameters{ MaxConnectionIdle: 15 * time.Minute, Time: 20 * time.Second, // Ping 的间隔 Timeout: 10 * time.Second, // 等待 Pong 的超时 }), } srv := grpc.NewServer(opts...) v1.RegisterOrderServiceServer(srv, orderService) return srv }这里提到的Keepalive是个巨坑。如果你的 gRPC 服务跑在 K8s Service 或者阿里云 SLB 后面,默认配置下,连接空闲一段时间(比如几分钟)就会被网关悄悄干掉,但客户端还以为连接是好的,下次发请求直接报错。必须配置心跳,谁不配谁尴尬。
2 这里的黎明静悄悄:全链路超时控制详解
如果说微服务有什么必须遵守的铁律,那一定是:永远不要相信网络,永远不要相信下游。
在一个复杂的微服务调用链中(A -> B -> C -> DB),如果 C 服务卡死了,或者 DB 锁表了,没有超时控制的话,A 和 B 的线程/协程就会一直挂在那等待。几千个请求进来,你的协程池瞬间爆满,内存飙升,最后 OOM(内存溢出),整个系统雪崩。
这就是级联故障。防止它的第一道防线,就是 Timeout。
客户端:不要把生命交给默认值
gRPC 的 客户端,默认的 context 是没有超时时间的!这意味着如果服务端不回包,客户端能等到天荒地老。
绝对禁止使用context.Background()直接发起 RPC 调用。
方案一:手动控制(太累)
// 这种写法太啰嗦,每个调用都要写 ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) defer cancel() resp, err := client.CreateOrder(ctx, req)方案二:拦截器统一注入(推荐)
我们可以写一个客户端拦截器(Interceptor),给所有发出的请求强行加上一个默认超时时间。如果业务层自己传了带超时的 context,就以业务层的为准;如果没有,就兜底。
// pkg/interceptor/timeout.go func UnaryClientTimeout(defaultTimeout time.Duration) grpc.UnaryClientInterceptor { return func(ctx context.Context, method string, req, reply interface{}, cc *grpc.ClientConn, invoker grpc.UnaryInvoker, opts ...grpc.CallOption) error { // 检查 context 是否已经设置了 Deadline if _, ok := ctx.Deadline(); !ok { var cancel context.CancelFunc ctx, cancel = context.WithTimeout(ctx, defaultTimeout) defer cancel() } // 继续执行请求 return invoker(ctx, method, req, reply, cc, opts...) } }在初始化客户端连接时挂载上去:
conn, err := grpc.Dial(target, grpc.WithUnaryInterceptor(UnaryClientTimeout(5 * time.Second)), // 默认5秒,别太长 grpc.WithInsecure(), )服务端:你的逻辑真的停得下来吗?
客户端设置了超时,比如 1 秒。过了 1 秒客户端报错context deadline exceeded溜了。
但是!服务端的代码还在跑!
如果不做处理,服务端依然会继续查询数据库、做复杂计算,最后把结果算出来——然而并没有人接收。这简直是浪费计算资源。
在 的 gRPC 实现中,客户端取消或超时,服务端的ctx.Done()通道会被关闭。
数据库查询的超时传递
最典型的错误写法:
func (s *OrderService) CreateOrder(ctx context.Context, req *pb.CreateOrderRequest) (*pb.CreateOrderReply, error) { // 错误示范:在 DAO 层直接用 context.Background() // db.WithContext(context.Background()).Create(...) // 正确示范:必须把 gRPC 的 ctx 传给数据库驱动 // GORM 或 sqlx 都支持 err := s.data.DB.WithContext(ctx).Create(&Order{...}).Error // 如果 ctx 超时,数据库驱动检测到 context 结束,会尝试取消正在执行的 SQL return &pb.CreateOrderReply{...}, err }复杂计算的超时检测
如果你的逻辑是一段纯 CPU 密集型计算或者循环,你需要手动“探针”:
func (s *OrderService) HeavyCalculation(ctx context.Context, req *pb.Request) (*pb.Reply, error) { for i := 0; i < 100000; i++ { // 每隔一阵子检查一下是不是该收工了 select { case <-ctx.Done(): // 客户端已经等不及了,或者是链路超时了 // 记录个日志赶紧撤 log.Println("client gave up, aborting calculation") return nil, ctx.Err() // 返回 context.Canceled 或 DeadlineExceeded default: // 正常干活 complexLogic(i) } } return &pb.Reply{}, nil }这里的坑:超时传递(Propagation)
想象一个场景:A -> B -> C。 A 给 B 设置了 3 秒超时。 B 接到请求,处理了 1 秒,然后调用 C。 这时候,B 调用 C 的时候,超时时间应该剩多少?
gRPC 的 Context 会自动通过 HTTP/2 的 Header (grpc-timeout) 传递 Deadline。
但是,这依赖于你在 B 服务中调用 C 时,直接复用了接收到的那个ctx。
// B 服务内部 func (s *BService) CallC(ctx context.Context, req *pb.Request) { // 正确:复用 ctx,gRPC 会自动计算剩余时间传给 C // 如果 A 给的 3s,B 耗时 1s,那么传给 C 的时候,Header 里就只剩 2s 了 clientC.DoSomething(ctx, req) }如果你在 B 服务里手欠,写了context.WithTimeout(context.Background(), 10*time.Second)去调 C,那就完蛋了。C 以为自己有 10 秒,其实 A 只等 3 秒。A 超时断开后,B 和 C 还在那傻乐呵地空跑。
所以,原则上保持 Context 的全链路透传,除非你有非常明确的理由要重置超时时间(比如异步任务)。
3 重试的艺术:在不确定的世界里寻找确定性
网络抖动是常态。有时候一个包丢了,重试一下就好了。但是重试是把双刃剑。用得好,SLA(服务可用性)从 99.9% 提升到 99.99%;用不好,直接把下游服务打死,这就是传说中的“重试风暴”。
到底什么时候该重试?
不是所有错误都能重试。
可以重试:网络超时、连接断开、ResourceExhausted(资源耗尽,或许等会儿就好了)、Unavailable(服务暂不可用)。
绝对不能重试:InvalidArgument(参数错误,试一万次也是错)、NotFound(找不到)、PermissionDenied(没权限)、AlreadyExists(已存在)。
gRPC 的生态里,grpc-ecosystem/go-grpc-middleware提供了非常棒的重试拦截器。但咱们最好理解原理。
客户端重试配置
从 gRPC v1.40+ 开始,官方支持通过 Service Config 来配置重试,这比写拦截器更“原生”。你可以在建立连接的时候定义 JSON 配置。
var retryPolicy = `{ "methodConfig": [{ "name": [{"service": "api.v1.OrderService"}], "retryPolicy": { "MaxAttempts": 4, // 最多试4次(含第一次) "InitialBackoff": "0.1s", // 第一次重试等 0.1s "MaxBackoff": "1s", // 最长等待 1s "BackoffMultiplier": 2.0, // 指数退避:0.1 -> 0.2 -> 0.4 ... "RetryableStatusCodes": ["UNAVAILABLE", "RESOURCE_EXHAUSTED"] } }] }` // 建立连接时注入 conn, err := grpc.Dial(target, grpc.WithDefaultServiceConfig(retryPolicy), grpc.WithInsecure(), )看见那个BackoffMultiplier(指数退避)了吗?这个非常重要!
如果服务端挂了,千万别让所有客户端同一时间发起重试(哪怕是每隔1秒也不行)。指数退避加上随机抖动(Jitter),能让重试请求在时间轴上散开,给服务端喘息的机会。gRPC 内部实现了 Jitter,不用操心,但参数一定要配对。
幂等性:重试的前提
这是面试常考,实战常死的地方。
如果你的接口不是幂等的,千万别开启重试!
比如CreateOrder(创建订单)。 场景:
客户端发请求。
服务端收到,扣款成功,订单落库成功。
服务端回包的时候,网络断了。
客户端收到超时错误。
客户端触发重试机制,又发了一次
CreateOrder。后果:用户被扣了两次钱,生成了两个订单。老板把你叫进办公室喝茶。
怎么解决?
在 gRPC 层面解决不了业务的幂等性。你必须在业务协议里带上idempotency_key(幂等键)。
修改咱们的 Proto:
Protocol Buffers
message CreateOrderRequest { // 客户端生成的唯一 ID (UUID),重试时保持不变 string request_id = 3; // ... 其他字段 }在服务端逻辑里(伪代码):
func (s *OrderService) CreateOrder(ctx context.Context, req *pb.CreateOrderRequest) (...) { // 1. 拿 request_id 去 Redis 里查,是不是处理过了? if s.redis.Exists(req.RequestId) { return s.redis.GetResult(req.RequestId) // 直接返回上次的结果 } // 2. 正常处理业务,开启事务 // ... // 3. 处理成功,把结果和 request_id 存入 Redis,设置过期时间(比如24小时) s.redis.Set(req.RequestId, result) return result, nil }只有加上了这个逻辑,你的 gRPC 重试机制才是安全的。否则,只读接口(Get/List)可以大胆重试,写接口(Create/Update)一定要慎之又慎。
4 别再返回 "系统错误" 了:gRPC 的错误码哲学
很多从传统的 HTTP JSON API 转过来的兄弟,保留了一个坏习惯:不管发生什么,HTTP 状态码永远是 200,然后在 JSON body 里放一个code: -1和msg: "error"。
在 gRPC 的世界里,这种做法是反模式。
gRPC 协议本身设计了一套非常严谨的status code规范,它是 ogle 内部几十年分布式系统经验的结晶。你必须学会“讲 gRPC 的方言”,而不是用 gRPC 的通道传 HTTP 的土话。
为什么标准库的 error 不够用?
标准库的error只是一个接口,通常只包含一个字符串。但在跨服务调用中,客户端需要根据错误类型做决策:
是数据库连接断了?(应该重试)
还是用户传的 ID 不存在?(绝对不要重试,直接弹窗提示用户)
还是配额用完了?(退避一段时间再试)
如果你只返回一个字符串,客户端难道要去正则匹配字符串内容吗?这太脆弱了。
拥抱 google.golang.org/grpc/status
在服务端,你应该这样返回错误:
import ( "google.golang.org/grpc/codes" "google.golang.org/grpc/status" ) func (s *OrderService) GetOrder(ctx context.Context, req *pb.GetOrderRequest) (*pb.GetOrderReply, error) { if req.Id == "" { // 参数错误,使用 InvalidArgument return nil, status.Error(codes.InvalidArgument, "order_id cannot be empty") } order, err := s.data.GetOrder(ctx, req.Id) if err != nil { if err == sql.ErrNoRows { // 资源不存在,使用 NotFound return nil, status.Error(codes.NotFound, "order not found") } // 内部故障,使用 Internal // 记得打日志!这里只返回模糊信息给客户端 log.Errorf("db error: %v", err) return nil, status.Error(codes.Internal, "internal storage error") } return &pb.GetOrderReply{Order: order}, nil }常用的几个黄金 Status Code:
INVALID_ARGUMENT: 客户端传参错了。客户端需要修 bug。
NOT_FOUND: 资源没找到。
ALREADY_EXISTS: 创建资源时冲突了。
PERMISSION_DENIED: 认证过了,但权限不足。
UNAUTHENTICATED: 还没认证(没登录)。
RESOURCE_EXHAUSTED: 限流了,或者配额满了。
UNAVAILABLE: 服务正在重启,或者上游挂了。这是最适合重试的错误码。
5 降维打击:使用 Rich Error Model 传递结构化细节
上面讲的 Status Code 解决了“错误类型”的问题,但有时候还不够。
比如,用户注册时,你返回INVALID_ARGUMENT,用户想知道到底是“用户名太短”还是“邮箱格式不对”?如果你把这些拼在字符串里invalid username or email,前端解析起来简直是灾难。
gRPC 的杀手锏来了:Error Details。
ogle 定义了一套标准的错误详情模型(google.golang.org/genproto/googleapis/rpc/errdetails),它允许你在错误中附加强类型的额外信息。
服务端:如何附加详细信息
我们需要构造一个status.Status对象,往里面塞 Details,然后转换成 error 返回。
import ( "google.golang.org/grpc/codes" "google.golang.org/grpc/status" "google.golang.org/genproto/googleapis/rpc/errdetails" ) func (s *UserService) Register(ctx context.Context, req *pb.RegisterRequest) (*pb.RegisterReply, error) { // 假设校验失败 st := status.New(codes.InvalidArgument, "invalid parameters") // 定义具体的违规细节 // BadRequest_FieldViolation 是 Google 预定义好的结构 v := &errdetails.BadRequest{ FieldViolations: []*errdetails.BadRequest_FieldViolation{ { Field: "username", Description: "username must be at least 6 characters", }, { Field: "email", Description: "email format is invalid", }, }, } // 将细节附加到 status 中 stWithDetails, err := st.WithDetails(v) if err != nil { // 如果附加失败(极其罕见),降级返回原始错误 return nil, st.Err() } return nil, stWithDetails.Err() }客户端:如何优雅地提取细节
客户端收到 error 后,不仅要判断 code,还可以提取出 details 里的结构化数据,直接映射到前端表单的错误提示上。
resp, err := client.Register(ctx, req) if err != nil { // 1. 将 error 转回 status st, ok := status.FromError(err) if !ok { // 这是一个普通的 go error,不是 gRPC error log.Fatal("unknown error:", err) } // 2. 判断错误码 if st.Code() == codes.InvalidArgument { // 3. 遍历 Details,提取具体信息 for _, detail := range st.Details() { switch t := detail.(type) { case *errdetails.BadRequest: for _, violation := range t.FieldViolations { fmt.Printf("Field: %s, Error: %s\n", violation.Field, violation.Description) // 这里可以直接把 violation 塞给前端 UI 组件 } case *errdetails.DebugInfo: // 如果是调试环境,可能服务端返回了堆栈信息 fmt.Println("Debug Stack:", t.StackEntries) } } } }这就是工业级的错误处理。不再有模糊不清的字符串解析,一切都是强类型的契约。
6 拒绝 Boilerplate:封装属于你的 Error 包
如果每次抛出错误都要写上面那一坨st.WithDetails,开发人员大概率会因为偷懒而放弃。作为架构师或核心开发,你需要封装一个公共库pkg/errors。
我们需要定义自己的业务错误枚举,并自动映射到 gRPC code。
// pkg/xerr/errors.go package xerr import ( "fmt" "github.com/pkg/errors" // 推荐使用 wrap 库 "google.golang.org/grpc/codes" "google.golang.org/grpc/status" ) // 定义业务错误码,通常是 int const ( ServerCommonError uint32 = 100001 RequestParamError uint32 = 100002 UserNotFound uint32 = 200001 ) // Map 映射:业务码 -> gRPC Code + 默认 msg var commonErrors = map[uint32]struct { grpcCode codes.Code message string }{ UserNotFound: {codes.NotFound, "用户不存在"}, // ... } // New 生成一个带堆栈的 error func New(errorCode uint32, msg string) error { // 这里可以结合 errdetails 逻辑封装 // 为了简化演示,我们只返回基础的 cfg, ok := commonErrors[errorCode] if !ok { return status.Error(codes.Unknown, msg) } // 这里其实有个坑: // 你需要把 errorCode 塞进 errdetails.ErrorInfo 里 // 这样客户端才能拿到 200001 这个具体的业务码 // 而不仅仅是 codes.NotFound return status.Error(cfg.grpcCode, msg) }这里的关键点:除了 gRPC 的标准 Code(16个),你一定还需要传递业务错误码(比如 100001)。最标准的做法是利用errdetails.ErrorInfo:
// google/rpc/error_details.proto message ErrorInfo { string reason = 1; // 比如 "USER_NOT_FOUND" string domain = 2; // 比如 "myapp.user.service" map<string, string> metadata = 3; // 其他 KV }把这个封装好,你的业务代码就会变得非常清爽:
if user == nil { return nil, xerr.NewErrCode(xerr.UserNotFound) }7 拦截器的另一面:错误日志与监控
错误发生了,除了返回给客户端,服务端自己必须留底。这时候不要在每个 Service 方法里打日志,太 Low 了。
用UniaryServerInterceptor(一元拦截器)统一处理。
func ErrorLogInterceptor() grpc.UnaryServerInterceptor { return func(ctx context.Context, req interface{}, info *grpc.UnaryServerInfo, handler grpc.UnaryHandler) (interface{}, error) { resp, err := handler(ctx, req) if err != nil { st, _ := status.FromError(err) // 过滤掉一些不需要报警的错误 // 比如 NotFound 或者 InvalidArgument 通常是用户的问题,不是系统的锅 if st.Code() != codes.NotFound && st.Code() != codes.InvalidArgument { log.Errorf("Method: %s, Req: %+v, Code: %s, Err: %v", info.FullMethod, req, st.Code(), st.Message()) // 这里甚至可以埋点上报到 Prometheus // metrics.ReportError(st.Code()) } else { // 对于预期内的错误,打个 Info 或 Warn 就可以了,避免日志爆炸 log.Warnf("Method: %s, User Error: %v", info.FullMethod, st.Message()) } } return resp, err } }这个图示应该展示:请求进入 -> 业务逻辑报错 -> Error Wrapper 包装 -> 拦截器捕获日志 -> 返回给客户端 -> 客户端提取 Details。
一个常被忽视的细节:Panic 恢复
gRPC 的协程如果 Panic 了,整个 进程会直接挂掉!这对于生产环境是不可接受的。 虽然拦截器可以捕获 error,但捕获不了 panic。你需要一个专门的Recovery Interceptor。
grpc-ecosystem/go-grpc-middleware提供了现成的recovery拦截器,一定要加上!并且要配置好恢复后的行为(比如返回codes.Internal错误,而不是直接断开连接)。
import "github.com/grpc-ecosystem/go-grpc-middleware/recovery" // 在 NewServer 时加上 grpc_recovery.WithRecoveryHandler(func(p interface{}) (err error) { log.Errorf("panic triggered: %v", p) return status.Errorf(codes.Internal, "panic error: %v", p) }),到目前为止,你的微服务已经具备了健壮的骨架(目录结构)、防雪崩的能力(超时与重试)以及清晰的表达能力(错误码与 Details)。
8 上帝视角:OpenTelemetry 全链路追踪实战
微服务最大的痛点是什么?是调用链断层。
A 服务调 B,B 调 C,C 查 DB。如果 C 慢了,A 的日志只告诉你“超时”,B 的日志只告诉你“C 响应慢”。你根本没法一眼看出整个请求的生命周期。
ogle 的 Dapper 论文提出了解决方案,现在业界唯一的标准就是OpenTelemetry (OTel)。别再去研究 Zipkin 或 Jaeger 的原生 SDK 了, 语言层面直接用 OTel 的 SDK,后端接 Jaeger 或 Grafana Tempo 都可以。
核心原理:Context 的接力棒
Tracing 的魔法全在于context.Context。
当请求进入 A 服务时,OTel 会生成一个TraceID(全剧唯一)和一个SpanID(当前节点)。当 A 调用 B 时,gRPC 的拦截器会自动把这个TraceID塞进 HTTP/2 的 Headers 里传给 B。
这也意味着:你在写代码时,如果有任何一个地方把 Context 丢了(比如用了context.Background()去调下游),链路就断了!这是新手最容易犯的罪。
拦截器注入:一行代码开启追踪
初始化 OTel 的代码也就是那几十行 Boilerplate(通常放在pkg/tracer里),这里不占篇幅。关键是 gRPC 的拦截器配置。
我们需要引入go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc。
服务端配置:
import "go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc" // NewGRPCServer 初始化时加入 Option srv := grpc.NewServer( grpc.UnaryInterceptor(otelgrpc.UnaryServerInterceptor()), // 自动提取 TraceID grpc.StreamInterceptor(otelgrpc.StreamServerInterceptor()), )客户端配置:
conn, err := grpc.Dial(target, grpc.WithUnaryInterceptor(otelgrpc.UnaryClientInterceptor()), // 自动注入 TraceID grpc.WithStreamInterceptor(otelgrpc.StreamClientInterceptor()), grpc.WithInsecure(), )业务代码里的“埋点”
有时候,光知道 A 调 B 还不够,你还想知道 B 内部的一个本地函数calculatePrice()耗时多久。这时候需要手动开启 Span。
func (s *OrderService) CreateOrder(ctx context.Context, req *pb.Request) (*pb.Reply, error) { // 1. 获取全局 Tracer tr := otel.Tracer("my-service-name") // 2. 开启一个新的 Span,命名为 "logic-step-1" // 注意:一定要复用 ctx,并接收新的 ctxSpan ctxSpan, span := tr.Start(ctx, "logic-step-1") defer span.End() // 别忘了关! // 3. 在这个 Span 里干活,甚至可以打 tag span.SetAttributes(attribute.String("order.user_id", req.UserId)) // 4. 调用下游时,必须传 ctxSpan,而不是原始 ctx err := s.repo.Save(ctxSpan, req) return &pb.Reply{}, err }9 日志的救赎:TraceID 与日志的自动化绑定
有了 Tracing 系统(如 Jaeger),你看到了某个请求报错了。但你想看那报错时的具体日志 StackTrace,怎么办?
如果你去 Kibana 里搜日志,还得根据时间戳猜。最高效的做法是:把 TraceID 自动打进每一行日志里。
当你在 Jaeger 看到 TraceID 是a1b2c3d4,直接复制到 Kibana 一搜,该请求所有服务的所有日志全部出来。
改造 Logger
假设你用的是 Uber 的zap库(这也是目前的性能标杆),我们需要封装一个 helper 函数,从 Context 里掏出 TraceID。
// pkg/log/logger.go import ( "go.opentelemetry.io/otel/trace" "go.uber.org/zap" "context" ) // Ctx 包装带 TraceID 的日志记录器 func Ctx(ctx context.Context) *zap.Logger { span := trace.SpanFromContext(ctx) if !span.IsRecording() { // 如果当前没有 span,返回默认 logger return zap.L() } // 自动把 trace_id 和 span_id 挂到日志字段里 return zap.L().With( zap.String("trace_id", span.SpanContext().TraceID().String()), zap.String("span_id", span.SpanContext().SpanID().String()), ) }使用效果:
func (s *OrderService) CreateOrder(ctx context.Context, req *pb.Request) { // 以前写法:log.Info("create order") -> 这种日志是垃圾,不知道是谁调的 // 现在的写法: log.Ctx(ctx).Info("create order", zap.String("user_id", req.UserId)) }输出的 JSON 日志就会变成这样:
{ "level": "info", "ts": 1698765432.123, "msg": "create order", "trace_id": "80f198ee56343ba864fe8b2a57d3eff7", "span_id": "e457b5a2e4d86bd1", "user_id": "10086" }这样,你的日志系统和追踪系统就打通了。这是生产环境排查问题的核武器。
10 拒绝“只看代码”:基于 Prometheus 的黄金指标监控
日志和 Tracing 是用来查问题的(Debugging),而监控(Metrics)是用来发现问题的(Alerting)。
在 gRPC 服务中,你需要关注 ogle SRE 提出的四个黄金指标:
Latency: 请求延迟(P99, P95)。
Traffic: 流量(QPS)。
Errors: 错误率(非 0 的 gRPC code 占比)。
Saturation: 饱和度(CPU/内存,或者 routine 数量)。
集成 Prometheus
虽然 OTel 也支持 Metrics,但在 生态里,直接用github.com/grpc-ecosystem/go-grpc-prometheus依然是最成熟、最轻量的方案。
服务端埋点:
import "github.com/grpc-ecosystem/go-grpc-prometheus" // 1. 初始化 Server 时加入拦截器 srv := grpc.NewServer( grpc.StreamInterceptor(grpc_prometheus.StreamServerInterceptor), grpc.UnaryInterceptor(grpc_prometheus.UnaryServerInterceptor), ) // 2. 注册 Server 后,开启监控收集 v1.RegisterOrderServiceServer(srv, orderService) grpc_prometheus.Register(srv) // 这一步很关键!它会扫描所有注册的 service // 3. 启动一个 HTTP 端口暴露 /metrics // gRPC 是 HTTP/2,Prometheus 拉取数据通常走 HTTP/1.1 go func() { http.Handle("/metrics", promhttp.Handler()) http.ListenAndServe(":9090", nil) }()关键:如何根据 Error Code 做报警?
默认的监控指标里包含grpc_server_handled_total,它带有一个标签grpc_code。
在 Grafana 里配置报警规则时,千万别只看code != OK。我们在上一章说过,NotFound或者InvalidArgument这种错误通常是客户端的问题,不需要半夜把你叫醒。
有效的报警 PromQL 示例:
# 计算最近 1 分钟内,服务端报错(排除掉用户侧错误)的比例 sum(rate(grpc_server_handled_total{grpc_code=~"Unknown|Internal|Unavailable|DeadlineExceeded|DataLoss"}[1m])) / sum(rate(grpc_server_handled_total[1m])) > 0.05