返回

漫谈应用程序重试策略及其实现

最近随项目组对整个项目进行联调,在联调过程中暴露出各种问题,让我不得不开始反思,怎么样更好地去做好一件事情,譬如说在开发过程中如何保证 Web 服务的稳定性,在敏捷开发中如何降低文档维护的成本,以及如何提高多环境服务部署的效率等等。我为什么会考虑这些问题呢?通常我们都是在约定好接口后并行开发的,因此在全部接口完成以前,所有的服务都是以渐进的形式进行集成的,那么如何保证服务在集成过程中的稳定性呢?尤其当我们面对开发/测试/生产三套环境时,如何提高服务部署的效率呢?当接口发生变更的时候,如何让每一个人都知悉变化的细节,同时降低人员维护文档的成本呢?这些问题或许和你我无关,甚至这不是一个技术问题,可恰恰这是我们时常忽视的问题,我是我想要写这篇文章的一个重要原因。

代码越来越复杂

面对这种问题,尤其是当你发现,它并不是一个纯粹的技术问题的时候。选择一件你喜欢的事情的去做,固然可以令你开心;而选择一件你不喜欢的事情去做,则可以令你成长。我们每一个人都不是人类学家,可生命中 80%的时间都在研究人类。当你接收到一条别人的讯息时,不管这个讯息本身或对或错,在生而为人的角色预设中,你都必须去提供一个响应,甚至是比对方期望更高的一个响应。可是服务器会返回 403、404 或者 500 甚至更多的状态码,人生有时候并没有机会去选择 Plan B 或者 Plan C。所以,即使所面临境地再艰难,能不能勇敢地再去尝试一次,说服对方或者选择妥协,就像一段代码被修改得面目全非,可人类本来就是喜欢皆大欢喜的动物,总希望别人都认认真真,而自己则马马虎虎,因为“认真你就输了”,有谁喜欢输呢?

好了,现在假设我们有这样一个业务场景,我们需要调用一个 WebAPI 来获取数据,然后对这些数据做相关处理。这个 API 接口被设计为返回 JSON 数据,因此,这个“简单”的业务场景通过以下代码来实现:

def extract(url):
    text = requests.get(url).content.decode('utf-8')
    json_data = json.loads(text)
    data = json_data['raw_data']
    return data

这个代码非常简单吧!可是过了十天半个月,每次解析 JSON 数据的时候随机出现异常,经验丰富的同事建议增加 try…except,并在捕获到异常以后返回 None。于是,extract()方法被修改为:

def extract(url):
    text = requests.get(url).content.decode('utf-8')
    try:
        json_data = json.loads(text)
        data = json_data['raw_data']
        return data
    except Exception:
        print("JSON数据无效,重试!")
        return None

修改后的代码,果然比修改前稳定啦,可是负责后续流程的同事开始抱怨,现在代码中出现大量判断返回值是否为 None 的代码片段,甚至在 Web API 返回正确结果的情况下,依然会返回 None,为此,机智的同事再次修改代码如下:

def extract(url):
	text = requests.get(url).content.decode('utf-8')
	try:
        json_data = json.loads(text)
        data = json_data['raw_data']
        return data
    except Exception:
        print("JSON数据无效,重试!")
        return extract(url)

可以预见的是,使用递归可能会导致递归的深度问题,假如调用者传入一个错误的 URL,将导致这里无限递归下去,于是考虑限制重试的次数;增加重试次数的限制以后,发现每次重试需要有一个时间间隔……更不必说要在这里增加日志记录,以及在特定场景下需要将异常抛出,由此可见这段简单的代码会变得越来越复杂,如下所示:

def extract(url):
    text = requests.get(url).content.decode('utf-8')
    try:
        json_data = json.loads(text)
        data = json_data['raw_data']
        return data
    except Exception:
        for i in range(3):
            print('正在进行第{0}次重试'.format(str(i))
            result = extract(url)
            if(result!=None):
                return result

可以注意到,这是一个非常合理的代码演进过程。在这个演进过程中,一段非常简单的代码变得越来越复杂。在我写下这篇文章前,我亲眼目睹了这种复杂的代码,是如何难以复用以及集成的,日志记录、异常处理等流程和正常流程“混合”在一起,甚至你不得不通过函数的返回值来判断是否异常,我一直在想怎么样去解决这些“corner”问题,就像人们一致认为:王垠博士擅长解决的是理想状态下的纯问题。而现实世界中存在着各种各样的“corner”问题,这或许就是学术界与工业界的区别,那么怎么样去更好地解决这一切问题呢?

应用程序重试策略

既然我们可以预见到这些问题的存在,那么,现在让我们正式切入今天这篇博客的主题,即应用程序重试策略。我们在这里以一种渐进式的方式,向大家展示了一个简单的应用程序,是如何因为异常处理变得越来越复杂的,这里我们选择重试,是因为现实世界本身存在不稳定性,即使我们现在有各种自动化工具来替代传统运维。就像有时候你怀疑是代码出现 Bug,实际上则是服务器在某一段时间内宕机,当这种事情就发生在你身边的时候,你不得不去着手解决这些“corner”问题,而这恰好是人生的无奈之处。

Try-Catch-Redo 策略

这应该是我们最容易想到的一种重试策略了,其思路是对函数的返回值和异常进行处理,其缺点是无法解决重试无效的问题。这里我们将问题简化为对异常进行处理,其基本的代码实现如下:

private void Retry()
{
  try
  {
    var result = DoWork();
    if(!result){
      //重试一次
      Thread.Sleep(1000);
      DoWork();
    }
  }
  catch(Exception e)
  {
    //重试一次
    Thread.Sleep(1000);
    DoWork();
  }
}

可以注意到,这种策略最多可以重试一次,因此如果重试后无效,这个策略就变得毫无意义起来,我们需要寻找一种更好的方式。

Try-Catch-Redo-Retry 策略

考虑到第一种策略无法解决重试无效的问题,我们在此基础上增加对重试次数以及重试间隔的控制,这就是 Try-Catch-Redo-Retry 策略,其基本实现如下:

private void Retry()
{
    //最大重试次数为5次
    int times = 5;
    //重试间隔为10秒
    int interval = 10;
    //存储异常的列表
    var exceptions = new List<Exception>();
  
    while(true)
    {
        try
        {
            var result = DoWrok();
            if(result) return result;
        }
        catch(Exception ex)
        {
            if(--times<=0) throw new AggregateException(exceptions);
            exceptions.Add(ex);
            Thread.Sleep(TimeSpan.FromSeconds(interval));
        }
    }
}

可以注意到,通过 while(true)结构的确可以增加重试的次数。问题在于:如果不设置合理的循环跳出条件,就有可能造成逻辑上的死循环。尤其当循环体内的逻辑执行时间较长时,会增加用户的等待时间,这看起来亦非良策啊!

Retry-Builder 策略

Try-Catch-Redo 和 Try-Catch-Redo-Retry 这两种策略理解起来非常容易,可这两种策略都有一个致命的缺陷,即正常逻辑和重试逻辑重度耦合。我们希望采用一种更优雅的方法,以一种非侵入式的方式给正常逻辑增加重试重试逻辑。需要考虑的是,在确保重试次数和重试间隔可配置的前提下,支持自定义重试源,即可以捕捉一个或多个异常以及对返回值进行处理。在这里推荐三个框架,分别是 Java 中的Spring-RetryGuava-Retrying、.NET 中的Polly。其中 Spring-Retry 是基于 Throwable 类型的重试机制,即针对可捕获异常执行重试策略,并提供相应的回滚策略;而 Guava-Retrying 提供了更为丰富的重试源定义,譬如多个异常或者多个返回值;而 Polly 则提供了除重试以外的断路器、超时、隔板隔离、缓存、回退等多种策略。这三者的相似之处在于,通过一个 Factory 来创建满足不同重试策略的 Retryer,然后由 Retryer 来通过回调来执行重试逻辑,我不喜欢 Java 中回调函数写法,所以这里以 Polly 为例:

try
{
    var retryTwoTimesPolicy = Policy
        .Handle<DivideByZeroException>()
        .Retry(3, (ex, count) =>
        {
            Console.WriteLine("执行失败! 重试次数 {0}", count);
            Console.WriteLine("异常来自 {0}", ex.GetType().Name);
        });
    retryTwoTimesPolicy.Execute(() =>
    {
        var a = 0;
        var b = 1/a;
    });
}
catch (DivideByZeroException e)
{
    Console.WriteLine($"Excuted Failed,Message: ({e.Message})");
}

可以看到,写出一段语义化的代码是多么的重要,因为我相信大家都看懂了。这里的 Policy 承担了 RetryBuilder 的角色,它定义了这样一种策略:当程序引发 DivideByZeroException 时进行重试,重试次数为 3 次,并且以匿名函数的方式指定了重试时的回调函数;而创建的 retryTowTimesPolicy 承担了 Retryer 的角色,它通过 Execute()方法来定义要执行的重试逻辑。当 3 次都重试失败时就会引发 DivideByZeroException 并在最外层函数中被捕捉到。我经常听到有人说设计模式没有用,我想说因为你从来都不知道什么叫做大道至简,引入无数个中间层是无法让你直接看到代码定义,可计算机领域里有一句名言,“任何一个问题都可以通过引入一个中间层来得到解决”。

装饰器/AOP 策略

我从来不惮于将各种重复的工作自动化,这并不是我喜欢在别人面前炫技,而是因为在现实生活中我是一个懒惰的人,甚至是每天早上 10 点开站会这样的事情,我都愿意让计算机程序去提前通知我做好准备。我并不是一个不懂得自律的人,仅仅是因为我觉得我们可以用这个时间去做些别的事情。AOP 是一种可以在运行时期间动态修改代码的技术,我们自然可以想到给所有的函数都加上异常处理和重试的特性,幸运的是 Python 中的有这样一个第三方库:Tenacity,它可以帮助我们优雅地实现重试:

from tenacity import retry
from json.decoder import JSONDecodeError

@retry(retry=retry_if_exception_type(JSONDecodeError), wait=wait_fixed(5), stop=stop_after_attempt(3))
def extract(url):
    text = requests.get(url).content.decode('utf-8')
    json_data = json.loads(text)
    data = json_data['raw_data']
    return data

通过@retry 这个装饰器函数,我们就可以知道,这里设计的重试策略是:当引发 JSONDecodeError 这个异常时,每隔 5 秒中重试一次,最大重试次数为 3 次。Python 中的装饰器,本质上就是高阶函数的概念,修饰器函数对被修饰函数进行“操作”后返回一个新的函数,这个特性在.NET 中可以通过委托/匿名方法/lambda 来实现,结合 Unity、AspectCore 等 AOP 框架,相信大家完全可以将这个特性移植到.NET 中来,当语言的差别变得微乎其微的时候,原理的重要性不言而喻。

重试策略核心理念

好了,截止到目前,我们分析了四种不同的重试策略,并且这四种重试策略是随着我们认知的加深而逐渐递进的。那么,通过这四种不同的重试策略,我们能否梳理出一个相对完整的应用程序重试策略呢?换言之,当为应用程序增加重试相关的功能时,我们都需要考虑哪些因素,因为使用这些框架会是非常简单的过程,而更重要的则是我们逐步演进的思考过程。当我们所依赖的是一个不稳定的场景,譬如远程调用、数据加载、数据上传等场景时,或者是在异常场景中需要重试以满足业务稳定性的要求等等,就可以考虑使用重试策略。这里简单地做一下梳理:

  • 重试逻辑与正常逻辑解耦,整个设计是非侵入式的。
  • 支持自定义策略,譬如重试次数、重试间隔、重试源、重试超时时间等。
  • 支持自定义断言,即可以使用 Predict或者类似表达式来定义返回值满足的条件。
  • 支持多种异常,即可以针对特定的 Exception 或者自定义的 Exception 进行拦截。
  • 断言实例和异常实例,作为正常逻辑和重试逻辑两者间的媒介进行交互。
  • 通过命令模式,由 Retryer 对象完成对正常逻辑的调用,同时在内部封装重试逻辑。

一个简单的 Retry 实现

好了,熟悉我写作风格的朋友,一定知道我不喜欢空泛地讲一套理论,我更喜欢通过“造轮子”的这种方式,以加深对一个事物或者原理的认识。对于今天这篇文章,我的初衷是想告诉大家如何优雅地去实现 Retry,因为在现实中我们总会遇到各种各样的枷锁,这些枷锁约束着你写出糟糕的代码,我们比别人用心甚至更努力,反而常常被认为是有代码洁癖或者是炫技,可不管怎么样,人生是我们自己的,如果没有办法说服别人在项目中使用这些技术,那我们就在项目以外的地方去使用,或者是告诉别人我们有一种相对优雅的设计,如果这个设计恰好对别人有用,对我们来说就是一种莫大的幸福。参考 Polly 的 API 风格,这个 Retry 被我设计成了下面的样子:

try
{
    var result = Retry.Default
    .Times(3)
    .Interval(2)
    .Catch<DivideByZeroException>()
    .Reject((count,ex)=>
    {
        var message = string.format("第{0}次重试,异常来自:{1}", count, ex.Message);
        Trace.WriteLine(message);
    })
    .Execute<int>(()=>
    {
        var m = 0;
        return 3 / m;
    });
}
catch(Exeption ex)
{
    Trace.WriteLine(ex.Message);
}

我承认它和 Polly 非常地像,不过我并没有去看 Polly 是如何实现的,目前它的实现完全来自这篇文章中我们提到的这些策略。我在为它增加了针对返回值的断言支持,通过 Return 方法来实现,而对异常的支持则是通过 Catch 方法来实现,除此以外,它支持异步方法调用,我们熟悉的 Task/async/await 这些 API 都可以使用。目前它还是一个玩具儿,因为我发现最难的部分,其实是断言或者说自定义表达式的设计,对于线程安全相关的问题,我会在慢慢地去完善它,如果你对它感兴趣的话,可以通过这里访问:RetryIt。好了,感谢大家关注我的博客,今天这篇先写到这里啦,欢迎大家在博客中留言!

Built with Hugo v0.110.0
Theme Stack designed by Jimmy
已创作 265 篇文章,共计 1000919 字