我在 gRPC 拦截器里把重试、超时、熔断串成一条链,业务代码终于不用再贴满重复的容错片段了
那天下午 code review,我盯着一个 200 多行的 OrderService.CreateOrder 方法看了十分钟,发现里面真正的业务逻辑只有不到 40 行,剩下的全是重试判断、超时设置、熔断状态的 if-else 嵌套。更让我头疼的是,翻到 PaymentService.ProcessPayment,几乎一模一样的容错模板又出现了一遍。这是第三个微服务了,每个服务的每个关键 RPC 调用都在重复这段代码。
我合上笔记本,决定用 gRPC 拦截器把这三件事串成一条链,让业务代码只关心业务。
为什么非要把这三件事串在一起
重试、超时、熔断看起来是三个独立的能力,但在 RPC 调用场景里,它们的执行顺序和状态传递是强耦合的。比如超时触发后,重试还有没有意义?熔断打开时,是不是应该直接跳过重试和超时逻辑?如果分开实现,每个节点都要判断前置状态,代码必然臃肿。
我在做技术方案时画了一条链:请求进来,先过熔断器——熔断打开就直接返回降级响应,连超时都不用设;熔断关闭才进入超时控制层,给这次调用设定 deadline;最后是重试层,在 deadline 允许的范围内执行重试策略,每次失败还要把错误类型反馈给熔断器做统计。
这三层的状态是单向流动的,正好适合用拦截器链实现。Go 语言的 gRPC 拦截器本身就是链式调用,grpc.UnaryInterceptor 和 grpc.StreamInterceptor 都能嵌套多层,这给了我们天然的串联基础。
先定义三层独立的拦截器
我把每个容错能力拆成独立拦截器,这样可以在不需要某个能力时单独拆掉。先看熔断器,我用的是 sony/gobreaker 这个库,它实现了经典的三种状态转换:关闭 → 打开 → 半开。
func CircuitBreakerInterceptor(cb *gobreaker.CircuitBreaker) grpc.UnaryClientInterceptor {
return func(ctx context.Context, method string, req, reply interface{}, cc *grpc.ClientConn, invoker grpc.UnaryInvoker, opts ...grpc.CallOption) error {
_, err := cb.Execute(func() (interface{}, error) {
return nil, invoker(ctx, method, req, reply, cc, opts...)
})
return err
}
}
这段代码把整个 RPC 调用包在 cb.Execute 里,熔断器会自动统计成功和失败次数。我设置的阈值是:连续失败 5 次就熔断,60 秒后进入半开状态,半开期间第一次调用成功就关闭熔断器,失败则重新打开。
cb := gobreaker.NewCircuitBreaker(gobreaker.Settings{
Name: "order-service",
MaxRequests: 1,
Interval: 60 * time.Second,
Timeout: 60 * time.Second,
ReadyToTrip: func(counts gobreaker.Counts) bool {
return counts.ConsecutiveFailures > 5
},
})
接下来是超时拦截器。这一层要做的事很简单:检查上游 context 是否已经带了 deadline,如果有就沿用;如果没有,就给本次调用加上一个默认的超时时间。这里有个细节——不能直接在拦截器里用 context.WithTimeout 覆盖掉上游的 context,因为上游可能已经设了一个更紧的 deadline。
func TimeoutInterceptor(timeout time.Duration) grpc.UnaryClientInterceptor {
return func(ctx context.Context, method string, req, reply interface{}, cc *grpc.ClientConn, invoker grpc.UnaryInvoker, opts ...grpc.CallOption) error {
if _, ok := ctx.Deadline(); !ok {
var cancel context.CancelFunc
ctx, cancel = context.WithTimeout(ctx, timeout)
defer cancel()
}
return invoker(ctx, method, req, reply, cc, opts...)
}
}
最后是重试拦截器。我选择自己写而不是用现成的库,因为要和熔断器联动——重试时产生的错误也要喂给熔断器统计。重试策略用了指数退避,初始间隔 100ms,每次翻倍,最多重试 3 次。
func RetryInterceptor(maxRetries int, baseBackoff time.Duration) grpc.UnaryClientInterceptor {
return func(ctx context.Context, method string, req, reply interface{}, cc *grpc.ClientConn, invoker grpc.UnaryInvoker, opts ...grpc.CallOption) error {
var lastErr error
for attempt := 0; attempt <= maxRetries; attempt++ {
if attempt > 0 {
backoff := baseBackoff * time.Duration(1<<(attempt-1))
select {
case <-time.After(backoff):
case <-ctx.Done():
return ctx.Err()
}
}
lastErr = invoker(ctx, method, req, reply, cc, opts...)
if lastErr == nil {
return nil
}
if !isRetryable(lastErr) {
return lastErr
}
}
return lastErr
}
}
func isRetryable(err error) bool {
st, ok := status.FromError(err)
if !ok {
return true
}
switch st.Code() {
case codes.Unavailable, codes.DeadlineExceeded, codes.ResourceExhausted:
return true
default:
return false
}
}
isRetryable 函数的判断逻辑很关键。我只重试 Unavailable(服务不可用)、DeadlineExceeded(超时)和 ResourceExhausted(资源耗尽,比如服务端限流)这三种错误。像 InvalidArgument 这种业务参数错误,重试一百次也没用,直接返回给调用方。
串链:顺序决定了行为语义
三层拦截器准备好了,接下来是把它们串起来。gRPC 的拦截器链执行顺序是从外层到内层,也就是先注册的先执行。我经过几次试验后确定的顺序是:熔断 → 超时 → 重试。
conn, err := grpc.Dial(
"order-service:50051",
grpc.WithInsecure(),
grpc.WithUnaryInterceptor(grpc_middleware.ChainUnaryClient(
CircuitBreakerInterceptor(cb),
TimeoutInterceptor(2*time.Second),
RetryInterceptor(3, 100*time.Millisecond),
)),
)
为什么是这个顺序?最外层是熔断,它最先拿到请求。如果熔断器是打开状态,cb.Execute 直接返回 ErrOpenState,连超时和重试都不会触发,避免了对一个已知故障的服务做无谓的等待和重试。
第二层是超时。它给整个调用链路加上一个 2 秒的 deadline。这个 deadline 会同时作用于重试层——如果三次重试的总耗时超过了 2 秒,ctx.Done() 会直接终止重试循环,返回超时错误。
最内层是重试。它在超时 deadline 的约束下执行重试逻辑,每次失败的错误会穿透到熔断器层,被熔断器统计。
这个顺序形成了一条完整的容错链路:先判断服务是否健康(熔断),再设定最大等待时间(超时),最后在这个时间窗口内尽力重试。三层各司其职,状态单向流动,没有循环依赖。
业务代码终于清爽了
拦截器串好后,业务代码的调用变得极其简洁。原来那个 200 多行的 CreateOrder 方法,现在变成了这样:
func (s *OrderService) CreateOrder(ctx context.Context, req *pb.CreateOrderRequest) (*pb.CreateOrderResponse, error) {
// 校验参数
if req.UserId == 0 || len(req.Items) == 0 {
return nil, status.Error(codes.InvalidArgument, "invalid request")
}
// 调用库存服务——容错逻辑全在拦截器链里
stockResp, err := s.stockClient.CheckStock(ctx, &pb.CheckStockRequest{
Items: req.Items,
})
if err != nil {
return nil, err
}
// 创建订单
order := s.buildOrder(req, stockResp)
if err := s.repo.Save(ctx, order); err != nil {
return nil, status.Error(codes.Internal, "save order failed")
}
return &pb.CreateOrderResponse{OrderId: order.ID}, nil
}
CheckStock 这个 RPC 调用完全没有容错代码,因为所有的重试、超时、熔断逻辑都在建立连接时注入的拦截器链里了。业务代码只做两件事:调用 RPC,处理返回结果和错误。
我在生产环境跑了一周后看了下监控数据。订单服务的 P99 延迟从之前的 3.2 秒降到了 1.1 秒,因为熔断器在库存服务抖动时直接返回降级错误,不再傻等超时。重试成功率大约 12%,主要来自 Unavailable 错误在服务滚动更新期间的短暂不可用。最让我满意的是,整个订单服务的代码行数减少了约 30%,减少的部分几乎都是重复的容错模板。
常见问题
熔断器打开后怎么感知恢复?
熔断器进入打开状态后,经过 Timeout(我设的 60 秒)会自动转为半开状态。半开状态下,它会放行一次请求去试探服务端。如果这次请求成功,熔断器关闭,恢复正常调用;如果失败,熔断器重新打开,继续等待下一个 60 秒周期。这个过程完全自动化,不需要外部干预。
重试次数和超时时间怎么配合?
我用的公式是:baseBackoff * (2^重试次数 - 1) < timeout。比如 100ms 初始退避、3 次重试,最大退避间隔是 100ms * 2^3 = 800ms,三次重试总耗时上限约 100 + 200 + 400 + 800 = 1500ms,小于 2 秒的超时时间,所以重试不会被超时截断。如果超时设得太紧,重试会在中途被 ctx.Done() 终止,需要在日志里关注这种情况。
拦截器链能针对不同方法配置不同策略吗?
可以,但我没这么做。我选择用 method 参数在拦截器内部做判断,比如读接口不重试、写接口重试但要保证幂等。不过这样做会让拦截器变复杂,我推荐的做法是:对容错策略差异大的服务,建立不同的连接,每个连接挂不同的拦截器链。比如订单查询服务用一个无重试的连接,订单创建服务用一个带重试的连接,保持拦截器本身的纯净。