背景
很多团队从 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
}
这里最重要的不是代码本身,而是策略:
- 重试次数有限
- backoff 递增
- 受上层 context 控制
熔断的目标不是“优雅”,而是止损
当某个下游持续失败时,继续把流量打过去通常没有意义。
这时候熔断器的价值就出来了:
- 错误率高到阈值,先快速失败
- 过一段时间再探测是否恢复
- 恢复后再逐步放量
Go 里可以用像 sony/gobreaker 这样的库:
cb := gobreaker.NewCircuitBreaker(gobreaker.Settings{
Name: "user-service",
ReadyToTrip: func(counts gobreaker.Counts) bool {
return counts.Requests >= 20 && counts.TotalFailures >= 10
},
Timeout: 10 * time.Second,
})
result, err := cb.Execute(func() (interface{}, error) {
return client.GetUser(ctx, req)
})
熔断器最大的意义,是把“下游已经不稳定”这个事实尽早暴露给上游,而不是继续无限等待。
连接池和并发限制也要一起看
gRPC 基于 HTTP/2,多路复用能力不错,但这不代表你可以完全不管连接和并发。
几个常见问题:
- 一个 client 连接挂了,所有流量都受影响
- 单连接上并发太高,尾延迟抖动明显
- 下游服务实例变更,连接刷新不及时
工程上常见做法:
- 让连接生命周期可管理
- 为关键下游单独做限流或并发保护
- 把连接状态打进监控指标
服务端也要明确失败语义
很多 gRPC 服务端错误返回得很随意,最后客户端根本不知道该怎么处理。
应该尽量用明确的 status code:
if err == ErrUserNotFound {
return nil, status.Error(codes.NotFound, "user not found")
}
if err == ErrRateLimited {
return nil, status.Error(codes.ResourceExhausted, "rate limited")
}
if err != nil {
return nil, status.Error(codes.Internal, "internal error")
}
这样客户端才能根据错误语义决定:
- 直接失败
- 退避重试
- 降级返回
一个更完整的客户端调用示意
func (c *UserClient) GetUser(ctx context.Context, userID string) (*pb.User, error) {
callCtx, cancel := context.WithTimeout(ctx, 300*time.Millisecond)
defer cancel()
var resp *pb.User
err := callWithRetry(callCtx, func(retryCtx context.Context) error {
result, err := c.breaker.Execute(func() (interface{}, error) {
return c.client.GetUser(retryCtx, &pb.GetUserRequest{UserId: userID})
})
if err != nil {
return err
}
resp = result.(*pb.User)
return nil
})
if err != nil {
return nil, err
}
return resp, nil
}
这段代码说明一个基本思路:
- 最外层是 deadline
- 中间层是有限重试
- 再往里是熔断保护
顺序不是唯一标准,但“多个保护层协同工作”的思路是必要的。
常见坑点
1. 所有接口统一超时
读用户信息和导出报表的耗时预期明显不同,用一套超时参数通常不合理。
2. 重试不区分幂等性
写接口如果没有幂等保障,自动重试可能直接制造重复数据。
3. 熔断阈值拍脑袋设
阈值太敏感会频繁抖动,太宽松又起不到保护作用,最好基于真实流量和错误率观察调整。
4. 只保护客户端,不保护服务端
客户端会超时、重试、熔断,服务端也同样需要限流、超时和资源隔离。
总结
Go gRPC 服务治理,真正有价值的不是把框架跑起来,而是把失败路径设计清楚。
我更关注这几个问题:
- 请求多久必须结束
- 哪些错误允许重试
- 下游坏掉时怎么快速止损
- 过载时如何保护自己和别人
这些问题处理得越清楚,微服务系统在真实流量下就越像“工程系统”,而不是一堆互相调用的进程。
服务治理的核心不是让请求尽量成功,而是让系统在失败时依然可控。