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

代码越来越复杂

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

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

1
2
3
4
5
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()方法被修改为:

1
2
3
4
5
6
7
8
9
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,为此,机智的同事再次修改代码如下:

1
2
3
4
5
6
7
8
9
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,将导致这里无限递归下去,于是考虑限制重试的次数;增加重试次数的限制以后,发现每次重试需要有一个时间间隔……更不必说要在这里增加日志记录,以及在特定场景下需要将异常抛出,由此可见这段简单的代码会变得越来越复杂,如下所示:

1
2
3
4
5
6
7
8
9
10
11
12
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策略

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

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
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策略,其基本实现如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
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为例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
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,它可以帮助我们优雅地实现重试:

1
2
3
4
5
6
7
8
9
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被我设计成了下面的样子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
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。好了,感谢大家关注我的博客,今天这篇先写到这里啦,欢迎大家在博客中留言!