前言
之前还在学校的时候,写过一篇go gRPC初体验,算是初步预习了一下grpc的用法,知道了如何使用grpc实现简单的rpc调用,当时写了一个demo,跑通了之后就觉得,应该够用了,然而到了公司之后才发现,grpc,远不止于此。。。于是我决定好好总结一下grpc的一些进阶知识,结合公司项目里的实际用法,分享给大家,帮助初学者们更好的掌握grpc。本篇,就先介绍一下grpc的retry拦截器。
为什么要retry
retry很好理解,就是重试,为什么需要重试?首先,grpc常用于移动端和跨机房调用的场景,这两种场景的共同点就是,客户端和服务器之间需要经过一段无法保证质量的网络链路,这时候,为了保证请求到达的成功率,重试就很有必要。另一方面,当某一时刻qps突然很高的时候,服务器可能出现短暂的服务不可用,这时候,设置一个带有随机退避时间的重试,就可以解决问题。
何谓拦截器
重试要怎么实现呢?既然已经用了grpc这种成熟的框架,那自然就不用我们自己再去实现。grpc实现重试的方法,是使用Interceptor,翻译过来就是拦截器,这里第一次看可能会很疑惑,重试和拦截器有什么关系??为了理解拦截器的概念,我们可以画一个简单的图,抽象一下grpc的调用过程。
首先,没有拦截器的时候,一次rpc调用是这样的:
首先,我们会调用protoc生成的接口,将请求发给对端,收到对端的回复后,grpc接口会将结果返回,这样就完成了一次调用。
而有了拦截器,上面的过程就变成了这样:
可以看到,拦截器会在grpc接口收到回复之前先收到回复,也就是拦截了回复,拦截回复之后,拦截器就可以对回复进行一些处理,比如身份验证,消息内容校验,当然,还可以进行重试, 比如回复中的code为5xx时,拦截器先不返回,而是重新发送一次请求,直到code为200了再返回给grpc接口,这样对于应用程序而言,就在毫不知情的情况下,提高了调用的成功率。
retry拦截器的具体实现
retry拦截器的作用很好理解,但它是怎么工作的呢?这一节我们就来深入源码,看看retry拦截器的具体实现。
grpc本身是支持拦截器的,但是并没有实现各种拦截器,也就是说,grpc允许你进行拦截,但是拦截之后怎么处理,要你自己实现,不过呢,像重试、身份验证这样比较常用的拦截器,已经有非常成熟的库了,所以我们直接拿来用就好了,接下来,我们就先来看看官方的retry拦截器是怎么工作的。
retry的逻辑
我们先不管调用是怎么走到拦截器这里的,先只看retry拦截器的逻辑,首先这个拦截器是这个包里实现的:
"github.com/grpc-ecosystem/go-grpc-middleware/retry"
拦截器是个什么?其实就是一个函数,这个函数会被最外层的grpc接口调用,而函数内部又会调用调用grpc的底层接口,实现真正的rpc,然后在返回外层之前,在函数内部对rpc的返回进行处理。接下来我们就看看重试拦截器的函数内部逻辑。
关于重试的逻辑是下面这段代码:
func(parentCtx context.Context, method string, req, reply interface{}, cc *grpc.ClientConn, invoker grpc.UnaryInvoker, opts ...grpc.CallOption) error {
grpcOpts, retryOpts := filterCallOptions(opts)
callOpts := reuseOrNewWithCallOptions(intOpts, retryOpts)
// short circuit for simplicity, and avoiding allocations.
if callOpts.max == 0 {
return invoker(parentCtx, method, req, reply, cc, grpcOpts...)
}
var lastErr error
for attempt := uint(0); attempt < callOpts.max; attempt++ {
if err := waitRetryBackoff(attempt, parentCtx, callOpts); err != nil {
return err
}
callCtx := perCallContext(parentCtx, callOpts, attempt)
lastErr = invoker(callCtx, method, req, reply, cc, grpcOpts...)
// TODO(mwitkow): Maybe dial and transport errors should be retriable?
if lastErr == nil {
return nil
}
logTrace(parentCtx, "grpc_retry attempt: %d, got err: %v", attempt, lastErr)
if isContextError(lastErr) {
if parentCtx.Err() != nil {
logTrace(parentCtx, "grpc_retry attempt: %d, parent context error: %v", attempt, parentCtx.Err())
// its the parent context deadline or cancellation.
return lastErr
} else {
logTrace(parentCtx, "grpc_retry attempt: %d, context error from retry call", attempt)
// its the callCtx deadline or cancellation, in which case try again.
continue
}
}
if !isRetriable(lastErr, callOpts) {
return lastErr
}
}
return lastErr
}
可以看到,这个函数里有一个for循环,循环的条件就是attempt<callOpts.max,也就是尝试次数小于我们设置的最大重试次数,在for循环里,拦截器做了以下几件事:
- 首先,通过waitRetryBackoff函数等待我们设置好的退避时间;
- 然后,调用invoker函数实现rpc调用;
- 如果调用结果为成功(lastErr == nil),不再继续循环,直接返回;
- 如果调用结果为失败,调用isContextErro函数判断error是否为上下文错误,上下文错误有两种,一种是调用者设置了context的超时值,而这个超时值已经到了;另一种是context的cancel方法被调用了,也就是被人为的停止了这次rpc调用,如果是这两种情况,也会终止重试,直接返回;
- 如果不是上下文错误,则调用isRetriable函数,判断错误是否为可重试的,这个函数会对错误码进行检查,如果是我们设置的需要重试的错误码,则继续重试
关于retry的逻辑,就先介绍这么多,下面来看看,这个逻辑是怎么嵌入到grpc的调用逻辑中的。
grpc拦截器的工作过程
拦截器的设置
首先,grpc是在DialContext函数中设置的拦截器,而这个函数是在创建grpc的连接(ClientConn),也就是说,每个grpc连接都可以有一个自己的拦截器。这里我们截取一小段DialContext的代码看一下,拦截器是怎么放进去的:
func DialContext(ctx context.Context, target string, opts ...DialOption) (conn *ClientConn, err error) {
cc := &ClientConn{
target: target,
csMgr: &connectivityStateManager{},
conns: make(map[*addrConn]struct{}),
dopts: defaultDialOptions(),
blockingpicker: newPickerWrapper(),
czData: new(channelzData),
firstResolveEvent: grpcsync.NewEvent(),
}
cc.retryThrottler.Store((*retryThrottler)(nil))
cc.ctx, cc.cancel = context.WithCancel(context.Background())
for _, opt := range opts {
opt.apply(&cc.dopts)
}
chainUnaryClientInterceptors(cc)
chainStreamClientInterceptors(cc)
首先,拦截器会通过opts参数,传入DialContext函数,然后,再通过apply函数设置到cc,也就是新建的这个grpc连接中。接下来有两个函数,分别是chainUnaryClientInterceptors和chainStreamClientInterceptors,这两个函数分别用来设置普通rpc调用和流式grpc调用的拦截器,这两种模式差别还是蛮大的,本篇就不讲了,如果不清楚grpc的流模式可以看看这篇带入gRPC:gRPC Streaming, Client and Server,我这里就仅以chainUnaryClientInterceptors为例简单介绍一下,这个函数内容是这样的:
func chainUnaryClientInterceptors(cc *ClientConn) {
interceptors := cc.dopts.chainUnaryInts
// Prepend dopts.unaryInt to the chaining interceptors if it exists, since unaryInt will
// be executed before any other chained interceptors.
if cc.dopts.unaryInt != nil {
interceptors = append([]UnaryClientInterceptor{cc.dopts.unaryInt}, interceptors...)
}
var chainedInt UnaryClientInterceptor
if len(interceptors) == 0 {
chainedInt = nil
} else if len(interceptors) == 1 {
chainedInt = interceptors[0]
} else {
chainedInt = func(ctx context.Context, method string, req, reply interface{}, cc *ClientConn, invoker UnaryInvoker, opts ...CallOption) error {
return interceptors[0](ctx, method, req, reply, cc, getChainUnaryInvoker(interceptors, 0, invoker), opts...)
}
}
cc.dopts.unaryInt = chainedInt
}
这个函数其实主要是为了将多个拦截器,串成一个拦截链,也就是说每次grpc调用都是可以组合多种拦截处理的,这个本篇就不细说了,我们这里就先把retry一个拦截器给聊清楚。如果只有一个拦截器的话,这里做的事其实很简单,就是把这个拦截器存到了cc.dopts.unaryInt这里,这样,拦截器就设置好了。
拦截器的调用
那么,这个拦截器什么时候会生效呢?我们很自然的就会想到,是在发送请求的时候。这里还是以普通rpc请求为例,普通rpc请求的发送都是调用的Invoke方法,这个方法内部是这样的:
func (cc *ClientConn) Invoke(ctx context.Context, method string, args, reply interface{}, opts ...CallOption) error {
// allow interceptor to see all applicable call options, which means those
// configured as defaults from dial option as well as per-call options
opts = combine(cc.dopts.callOptions, opts)
if cc.dopts.unaryInt != nil {
return cc.dopts.unaryInt(ctx, method, args, reply, cc, invoke, opts...)
}
return invoke(ctx, method, args, reply, cc, opts...)
}
这里你应该注意到的是其中调用unaryInt函数的地方,因为上一节说的拦截器设置,就是设置到了这里。我们先说如果没有设置拦截器,也就是cc.dopts.unaryInt != nil,那么Invoke会直接执行最后一行的invoke,也就是底层的rpc发送函数。而如果设置了拦截器,那么程序就会直接把invoke函数传入拦截器函数里,也就是上面所说的(忘了的话往上找加粗标红的那一行),拦截器只是在内部调用了grpc的底层接口,并在函数内部对返回进行处理,是的,到这里,程序其实就走到上一节贴出的retry拦截器的逻辑那里了,整个流程到这里,也就算是彻底捋清楚了。
retry拦截器的使用
基本用法
其实很多初学者最关心的就是怎么用,并不关心实现,但我想说的是,知道实现,你才能真正把它用好,所以我把使用放到了实现的下一节。
首先,DialContext这个函数应该大多数开发者都用过,这是用来创建连接的,而拦截器正是作为一个参数传入这个函数的,所以我们就来看看这个参数是怎么设置的:
import (
grpc_retry "github.com/grpc-ecosystem/go-grpc-middleware/retry"
)
retryOps := []grpc_retry.CallOption{
grpc_retry.WithMax(2),
grpc_retry.WithPerRetryTimeout(time.Second * 2),
grpc_retry.WithBackoff(grpc_retry.BackoffLinearWithJitter(time.Second/2, 0.2)),
}
retryInterceptor := grpc_retry.UnaryClientInterceptor(retryOps...)
上面几行代码,就创建了一个retry拦截器,其中UnaryClientInterceptor返回的就是我们上一节一开始讲的那个retry拦截器函数,我们为重试的逻辑设置了最大重试次数,重试间隔,和退避时间三个参数,一般情况下这三个就够用了。接下来,我们把设置好的拦截器传入DialContext函数,就完成了拦截器的设置,设置好之后,拦截器就会对所有通过这个连接发送的请求生效,不需要我们再做什么额外的工作了。
opts := []grpc.DialOption{grpc.WithUnaryInterceptor(retryInterceptor)}
grpc.DialContext(ctx, targetURL, opts)
参数设置的建议
关于参数的设置,其实这个比较考验工作经验,我作为一个刚入职的新人,只能结合我们已有的项目,给出一些简单的建议:
- 重试时间间隔,这个可以根据你的请求rtt时间来设置,如果正常情况下你的请求200ms可以返回,那么设置为1s或者2s就可以了;
- 重试次数,要看你的重试是不是有意义的,如果没有意义,重试多少次也没有用,我们的重试设置为两次以后,成功率就达到了99.99%,所以重试次数并不是越多就越好的,如果两秒一次重试,重试3次,就意味着这个调用可能要6s才能返回,这个时间可不是所有业务都能接受的;
- 退避时间,这个真的很有用,我们的项目请求失败就只在一种情况下发生,那就是qps瞬间非常高的情况下,这种时候如果没有设置退避时间,过2s再重试大量请求,大概率还是会失败,所以推荐大家设置根据重试间隔设置一个退避时间,比如重试间隔是2s,退避时间就可以设置为0.2s,确保调用不会同时打到服务端就好了。
欧克,本篇就先说到这里,retry这个东西,真的很有用,初学者一定要学会正确、合理的使用grpc的重试,提升自己的服务质量~