背景 很多团队从 REST 切到 gRPC 后,第一感受通常都不错:
接口定义清晰 代码生成省心 性能和序列化效率更好 但线上跑久了会发现,真正决定服务质量的不是 protobuf 文件写得多漂亮,而是这些问题处理得怎么样:
超时怎么设 失败要不要重试 下游抖动时怎么自保 连接和并发要怎么控 这些内容不处理好,gRPC 只是让调用更快地失败而已。
超时必须从调用入口就带上 Go 里最好的习惯之一,就是把超时放进 context.Context。
func (s *OrderService) GetUser(ctx context.Context, userID string) (*pb.User, error) { callCtx, cancel := context.WithTimeout(ctx, 300*time.Millisecond) defer cancel() return s.userClient.GetUser(callCtx, &pb.GetUserRequest{ UserId: userID, }) } 为什么一定要带超时?
因为不带超时的 RPC,本质上就是把失败时间交给网络、内核和对端服务决定。你无法控制,也无法稳定预期。
线上更糟的是,请求可能层层调用:
API -> Order Service -> User Service -> Profile Service 如果每一层都没有明确 deadline,慢请求会像雪球一样越滚越大。
重试不是默认开启就完事 很多人一看到失败就想自动重试,但重试最危险的地方在于:如果失败原因是过载,重试可能会让故障更严重。
适合重试的场景通常是:
短暂网络抖动 连接瞬时中断 明显的临时性错误 不适合盲目重试的场景:
已经超时很久的请求 非幂等写操作 下游明显处于过载状态 一个更稳的客户端封装通常像这样:
func callWithRetry(ctx context.Context, fn func(context.Context) error) error { var lastErr error backoffs := []time.Duration{50 * time.Millisecond, 100 * time.Millisecond, 200 * time.Millisecond} for _, backoff := range backoffs { if err := fn(ctx); err == nil { return nil } else { lastErr = err } select { case <-time.After(backoff): case <-ctx.Done(): return ctx.Err() } } return lastErr } 这里最重要的不是代码本身,而是策略:
...