背景

很多团队从 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 连接挂了,所有流量都受影响
  • 单连接上并发太高,尾延迟抖动明显
  • 下游服务实例变更,连接刷新不及时

工程上常见做法:

  1. 让连接生命周期可管理
  2. 为关键下游单独做限流或并发保护
  3. 把连接状态打进监控指标

服务端也要明确失败语义

很多 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 服务治理,真正有价值的不是把框架跑起来,而是把失败路径设计清楚。

我更关注这几个问题:

  • 请求多久必须结束
  • 哪些错误允许重试
  • 下游坏掉时怎么快速止损
  • 过载时如何保护自己和别人

这些问题处理得越清楚,微服务系统在真实流量下就越像“工程系统”,而不是一堆互相调用的进程。


服务治理的核心不是让请求尽量成功,而是让系统在失败时依然可控。