引言
重试是提高系统可用性的重要手段,我们经常在业务代码中看到大量重试逻辑,是否有办法可以无侵入的实现重试,业务代码完全无感知。
业务重试
常见业务代码
func ExampleRPCSend(ctx context.Context, msg string) error {
fmt.Printf("\nsend msg %v", msg)
return errors.New("rpc err")
}
func RetrySend(ctx context.Context, msg string) error {
var err error
for i := 0; i < 3; i++ {
err = ExampleRPCSend(ctx, msg)
if err == nil {
break
} else {
time.Sleep(10 * time.Millisecond)
}
}
return err
}
使用示例
err := RetrySend(ctx, "example")
显然这是可以复用的通用逻辑,那么是否可以优雅的不在代码里显示使用呢。
简单的封装
func RetryFun(ctx context.Context, fn func(ctx context.Context) error) error {
var err error
for i := 0; i < 3; i++ {
err = fn(ctx)
if err == nil {
break
} else {
time.Sleep(10 * time.Millisecond)
}
}
return err
}
业务例子的使用示例
err = RetryFun(ctx, "example", ExampleRPCSend)
局限
需要重试的方法签名被限定了,只能适用一种签名的重试方法。
通用方法封装
参考博文
可以轻松写出通用重试的方法,对err的判断和重试时间退避稍微封装一下
func Decorate(decoPtr, f interface{}) error {
fn := reflect.ValueOf(f)
decoratedFunc := reflect.ValueOf(decoPtr).Elem()
logicFunc := func(in []reflect.Value) []reflect.Value {
ret := make([]reflect.Value, 0)
for i := 0; i < 3; i++ {
ret = fn.Call(in)
if !needRetryErr(ret) {
break
} else {
time.Sleep(10 * time.Millisecond)
}
}
return ret
}
v := reflect.MakeFunc(fn.Type(), logicFunc)
decoratedFunc.Set(v)
return nil
}
对于错误判断单独封装,并不是所有错误都需要去重试,而是应该有选择的重试。后面还会说的这个判断有利于避免重试雪崩。
这里假设方法按go的通常情况,最后一个返回值是error
var RetryBizCode = []string{"err_01","err_02"}
func needRetryErr(out []reflect.Value) bool {
// 框架返回的错误,网络错误
if err, ok := out[len(out)-1].Interface().(error); ok && err != nil {
return true
}
// BizCode业务错误码,需要重试的错误码
if isContain(GetBizCode(out), RetryBizCode) {
return true
}
return false
}
使用示例
retryFun := ExampleRPCSend
Decorate(&retryFun, ExampleRPCSend)
err := retryFun(ctx, "example")
相比业务重试和简单封装,这种使用方式更"丑"了。
中间件封装
可以把重试逻辑封装为中间件,直接在中间件里实现。
func RpcRetryMW(next endpoint.EndPoint) endpoint.EndPoint {
return func(ctx context.Context, req interface{}) (resp interface{}, err error) {
if !retryFlag(ctx) {
return next(ctx, req)
}
// rpc装饰
decoratorFunc := next
if err := Decorate(&decoratorFunc, next); err != nil {
return next(ctx, req)
}
return decoratorFunc(ctx, req)
}
}
框架层面加上这个中间件即可实现rpc调用的重试
AddGlobalMiddleWares(RpcRetryMW)
中间件的retryflag可以自定义,这样就可以只重试需要的场景,或者反向定义,不重试某几个场景。
func retryFlag(ctx context.Context) bool {
...
return true
}
有判断flag,就需要有设置flag,通常我们会在场景的入口,也就是请求第一个触达的服务上,进行setFlag操作
func SetFlag(ctx context.Context, flag string) context.Context {
return context.WithValue(ctx, flagKey, flag)
}
调用链路很长,如何让flag传递呢,service端需要增加中间件,rpc的base上需要传递标记。
func CtxFlagMW(next endpoint.EndPoint) endpoint.EndPoint {
return func(ctx context.Context, req interface{}) (resp interface{}, err error) {
flag, ok := getFlagFromReq(ctx, req)
if ok {
setFlag(ctx, flag)
}
return next(ctx, req)
}
}
Use(CtxFlagMW)
协助业务幂等
重试必须在下游满足幂等的情况下进行,否则会带来数据错乱。如果业务接口本身是幂等的,那么就可以直接使用,但是对于大部分业务接口都是不幂等的,如何介入自动重试呢
通过分析,我们业务不幂等主要是写库操作,重试如果能判断写库已操作过,跳过写库可以满足大部分场景的幂等。而判断写库已操作过,可以通过数据库的本地事务,在业务的库里创建一个本地事务日志表,记录已写的本地事务。
代码实现
func TransactionDecorator(ctx context.Context, txFunc func(ctx context.Context) error) error {
// 未接入的场景,走原逻辑不变
if retryFlag(ctx) {
return TransactionManager(ctx, txFunc)
}
// 生成本地账本
event := genEventFromCtx(ctx)
// 若本地账本已存在,则进行空补偿,跳过写库逻辑
if ExistEvent(ctx, event) {
return nil
}
// 本地账本不存在,则事务写入业务数据和本地账本
return TransactionManager(ctx, func(ctx context.Context) error {
// 业务逻辑
if err := txFunc(ctx); err != nil {
return err
}
// 写入本地账本
if err := SaveEvent(ctx, event); err != nil {
return err
}
return nil
})
}
业务幂等只能是协助加一些手段,具体接入还需要业务判断是否足够,不能说保证了写库操作唯一就是保证了幂等。
预防重试雪崩
重试最大的风险就是带来请求的累积,把下游压垮。我们会从以下几方面预防重试雪崩效应。
1. needRetryErr方法,把不需要重试的错误拦截掉,直接返回,避免业务逻辑错误重试。
2. retryFlag方法,针对需要重试的场景设置标志,有重试标志才进行重试,不是把服务所有请求都重试,可以避免无效请求。
3.非超时错误的情况下,保证请求不放大。
有两种做法,一种如上通用做法,在调用下游出错的点上重试,等重试结束才向上返回。
第二种做法是先向上返回成功,内部进行重试。第二种做法的局限性大一些,适用的场景更少。在通用方法封装上如何实现向上先返回成功,具体实现可参考博文
4.超时情况下,可能多个服务都同时感知到超时,如何保证请求不放大?
超时错误,例如A->B->C->D,A,B,C同时感知到错误,那么都会发起重试,显然就放大了请求。那么想办法只让一个服务重试呢
needRetryErr方法可以识别错错误类型,也就是可以感知到超时错误。
SetFlag方法本身是在入口场景调用,那么可以设置入口场景标记entranceFlag,而在中间件CtxFlagMW中,只传递retryFlag,不传递entranceFlag,那么就只有A服务会有entranceFlag标记
needRetryErr方法判断err类型为超时,则retryFlag判断ctx内有retryFlag 以及 entranceFlag ,两个标记都有才发起重试,则能保证整条链路只在入口服务A处发起重试。
综上超时情况下,请求也能保证不放大,只有1个服务在重试。