远程调用的容错模式 - pragmatists


我们生活在一个不完美的世界里,失败是不可避免的。我们依赖的系统迟早会失败。我们无法采取任何措施来阻止它,但我们有能力减轻级联故障。我们只需要在我们的工具箱中添加一些工具。
 
超时
必须了解任何资源池都可能耗尽,我们的责任是防止这种情况发生。最常用的池是连接池和线程池。从池中租用的每个资源最终都必须被释放。请记住,池是共享的,消费者租用资源的时间越长,池就越有可能耗尽,其他消费者就会饿死。
这就是为什么快速释放资源非常重要的原因。非常危险的情况是在调用远程服务时独占访问资源。不幸的是,这种情况很常见,因为网络通信的标准是使用连接池。此外,如果我们使用阻塞 API 并且线程是从池中租用的,则其他人无法使用它。如果我们运气不好,远程调用可能永远不会结束。如果发生这种情况,我们的代码不能永远等待。为了解决这个问题,我们需要使用适当的技术。最简单但非常强大的是操作超时。我们的目标是保护自己免受无法及时响应的依赖问题的影响。
它是如何工作的?使用底层网络的每个操作都应该有一些上限,操作必须在该上限内完成。例如,当一个简单的数据库查询通常需要 500 毫秒才能完成时,将超时设置为 5 秒是合理的。如果在那段时间内没有成功,那么我们有责任终止操作并抛出超时异常。理想情况下,连接应该关闭。
如何猜测最佳超时值是多少?不幸的是,没有明确的规则。具有响应时间的历史数据非常有帮助。基于它,很容易计算正常操作系统的基线。如果我们向基线添加一些边距,我们就会得到超时值。
如果我们在数据库访问层有超时,我们会睡得更好。
 
重试
某些类别的远程调用问题可以通过重试来解决。这听起来像是一个非常幼稚的策略,但它对于瞬态错误非常有效。但是我们怎么知道错误是暂时的呢?我们永远不知道,但我们可以使用非常简单的算法来决定重试是否有意义。例如,如果 HTTP 服务器以状态 400(错误请求)响应,我们知道这是客户端问题,再次发送相同的请求是没有意义的。
另一方面,状态 500(内部服务器错误)和 503(服务不可用)是再试一次的完美候选者。当然,并不是每个网络通信都使用 HTTP 协议。从套接字级别的角度来看,我认为每次遇到网络级别的问题时都值得重试。重试的另一个好方法是超时异常(请参阅超时模式)。如果我们没有在合理的时间内收到回复,则可能是通信链接已断开。再次尝试将建立另一个连接,操作可能会成功。
另一种检测瞬态错误的协议无关技术是使用漏桶设计模式。长话短说,想象一下我们有一个底部有洞的桶。当我们倒水时,它会从孔中漏出,但是如果孔很小并且我们倒水的速度足够快,它就永远不会变空。
现在让我们尝试将这个想法应用到计算机科学中。
当远程调用成功时,就像往桶里倒水一样。当它们失败时,就好比是从孔中漏水。除非桶中有一些水,否则这些错误是暂时的。如果桶变空了,因为错误太接近或者错误太多,这是非暂时性错误的标志,没有理由重试。
我们可以通过调整桶的大小或孔的大小来控制阈值以检测非瞬态错误。通过增加漏斗的尺寸或减小孔的直径,您可以增加容错率。容差越大,后面的错误就被归类为非暂时性的。
你可能会问,我们应该多久重试一次?我会让你再次失望。没有明确的规则。
快速重试很可能再次失败。理想情况下,我们应该等待一段时间而不阻塞任何资源,例如线程。如果适用,请使用队列来安排另一次尝试。不幸的是,当我们处于同步调用的上下文中时,它不能很好地发挥作用,因为它显着延迟了上游服务正在等待的响应。
理想情况下,重试模式应遵循指数退避策略。例如,1 秒后第二次尝试,然后是 2 秒后,然后是 4 秒后,依此类推。该算法的目的是快速成功操作,但如果不可能,它会给一些时间从长期中断中恢复。在安排另一次尝试时向延迟添加一些随机因素也非常重要。重试模式可能如下所示:1s、2.1s、3.8s、8.05s。如果每个人都使用没有随机因素的相同模式并且他们同时开始尝试,则调用将干扰并阻碍损坏组件的恢复过程。
另一个问题是我们应该尝试多少次?这取决于外部服务最终正确回复的情况和可能性。
 
断路器
断路器是一种开关,用于保护电路免受其他设备超出功率使用的影响。它首先失败,并打开电路以避免烧毁房屋。在软件工程中,我们可以使用类似的机制在某些组件运行不正常时将其切断,并让它们有时间恢复。断路器可以实现为客户端代理,以捕获对外部服务的每个调用。
断路器是一种状态机,由三种状态组成:

  • 关闭
  • 打开
  • 半开

关闭状态意味着一切正常,没有异常情况发生。通过断路器的每个呼叫都被转发到其目的地。如果呼叫失败,断路器会记住它。一旦错误数量超过阈值,断路器就会断开电路。这意味着后续调用会快速完成并且不会到达目的地。我们必须让系统利益相关者参与进来,让他们决定快速完成意味着什么。最常用的选项是:
  • 快速失败
  • 从缓存中返回最后一个好的响应
  • 当主要服务不可用时调用辅助服务

断路器最终必须闭合。常用的策略是在适当的时间后切换到半开状态。这意味着一些呼叫被转发到其目的地。如果它们成功,断路器进入闭合状态,否则进入断开状态。
我们可能想知道这个问题的答案是什么:“断路器什么时候应该从断开状态变为闭合状态?”。理想情况下,它应该测量故障密度。一种策略是使用前面提到的 Leaky Buckets 设计模式。
断路器和超时一起玩得很好。如果我们收到超时异常,则意味着我们等待的时间太长,等待更长时间是没有意义的。如果我们怀疑后续请求也会失败,那么长时间等待和从池中租用资源(如线程和连接)是没有意义的。最好放弃并立即失败。
参考案例研究,断路器的引入缓解了“案例 3”中提到的功能切换管理服务不稳定的问题。解决方案是添加一个代理来包装不可靠服务的客户端。代理是一个断路器。在成功调用下游服务后,它将结果存储在内部缓存中。在对下游服务的调用失败后,或者当断路器打开时(例如由于经常性超时),它立即返回先前存储在缓存中的值。我们的目标是在下游服务出现问题时停止错误传播并减少响应时间。
 
隔板Bulkheads
Bulkheads 是一个源自船舶建造的词。它代表分隔水密隔间的隔板,在泄漏的情况下可以容纳水。他们防止淹没整个船体。在计算机程序中使用相同的技术,我们可以通过只牺牲一个部分来保护整个系统免受故障。
例子:
  • 运行服务的四台服务器。即使一个实例突然停止,另一个实例也可以处理流量。
  • 跨区域或数据中心运行服务。即使一个地区或数据中心不可用,该服务在其他地区也能正常工作。
  • 为不同类型的问题提供单独的连接池。即使不太重要的管理工作使其自己的工作池饱和,也不会对关键的客户请求产生任何影响。如果我们的系统中有独占连接池,我就不必提及“案例 2”。

 
概括
我们太乐观了,无意中忽略了网络不可靠的事实。现在,我们知道我们错了,这对我们来说是宝贵的一课。不幸的是,我们不得不为此付出代价。从别人的错误中学习总是更好的。阅读本文后,您将有机会从我们的错误中吸取教训。
我的愿望是鼓励您明智地使用这些模式。每次考虑使用任何模式时,都必须考虑利弊。一切视情况而定。这就是为什么我没有为您准备好食谱,但您可以使用案例研究来寻找类比并获得启发。无论你做什么,保持开放的心态至关重要!