返回
Featured image of post 漫谈应用程序重试策略及其实现

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

AI 摘要
最近在项目联调中面临稳定性、文档维护和部署效率等问题,引发对如何更好地处理事务的思考。文章探讨了在开发过程中保证Web服务稳定性、降低文档维护成本以及提高多环境服务部署效率的挑战。通过一个简单的业务场景和代码演进展示了在面对复杂代码和重试策略时的思考过程。提出了Try-Catch-Redo、Try-Catch-Redo-Retry、Retry-Builder和装饰器/AOP等重试策略,并强调了重试策略核心理念。最后,介绍了一个简单的Retry实现,旨在优雅处理重试问题。文章强调了设计模式的重要性以及对应用程序重试策略的综合考虑。

最近随项目组对整个项目进行联调,在联调过程中暴露出各种问题,让我不得不开始反思,怎么样更好地去做好一件事情,譬如说在开发过程中如何保证 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.126.1
Theme Stack designed by Jimmy
已创作 274 篇文章,共计 1038468 字