微服务架构的风险
微服务架构将应用逻辑拆分成服务,服务之间通过网络交互。由于是通过网络调用,而不是在进程中调用,因此这给需要在多个物理和逻辑组件间进行协作的系统带来了潜在的问题和复杂性。分布式系统变得越来越复杂,也导致网络特定故障发生的可能性增大。
相比传统应用庞大的结构,微服务架构最大的一个优点是团队能独立地设计、开发和部署各自的服务。团队能掌控各自服务的整个生命周期。这也意味者团队无法控制服务的依赖关系,因为这些依赖的服务可能是由其他团队管理。在微服务架构体系下,我们要牢记提供的服务由于是其他人控制,因此可能会由于发布、配置、和其他变更等原因,从而导致服务暂时不可用,而且组件之间互相独立。
优雅的服务降级
微服务架构最大的优点之一就是当组件出现故障时,能隔离这些故障并且能做到优雅地服务降级。比如,在图片分享应用中,当出现故障时,用户可能无法上传图片,但他们依然能浏览、编辑和分享已上传的图片。
微服务故障独立(理论上)
在大多数情况下,是很难实现上图这种优雅地服务降级的,因为在分布式环境下,应用都是互相依赖的,开发者需要实现若干错误处理的逻辑(该部分在本文稍后部分讨论)去应对短暂的故障和中断。
服务互相依赖,如果无故障转移的逻辑,则会同时失效
变更管理
Google的网站可靠性团队发现大概70%的故障都是由于变更而引起的。当对服务进行修改时—例如发布代码的新版本或者改变一些配置,则总会有可能引起故障或者引入新的错误。
在微服务架构中,服务是互相依赖的。这就是为什么你需要减少故障并且尽可能降低它们的负面影响。为了应对变更带来的问题,你可以实施变更策略管理并且实现其自动回滚。
比如,当部署新的代码或者修改配置时,应该分步将这些变更部署到服务实例群中的部分实例中,并且进行监控,如果发现关键指标出现问题则能自动进行回滚。
变更管理-回滚部署
另一个解决方案是运行两套生产环境。部署的时候只部署变更的应用到其中一套环境中,并且在验证了新发布的版本符合预期后,才将负责均衡的流量指向新的应用,这种方法称为“蓝-绿发布”或者“红-黑发布”。
回退代码并不是坏事情。你不应该在生产环境中部署有问题的代码,并且应该琢磨哪里出错了。当必要时候应该果断回退代码,这越早越好。
健康检查和负载均衡
因为故障或部署、自动扩展等原因,服务实例会不停启动,重新启动及停止。这使得服务暂时或一直停用。为了避免发生这些问题,在负载均衡中应该在路由中设置忽略这些实例,因为它们无法为子系统或用户提供服务。
我们可以通过外部观察去判断应用实例是否健康。你可以多次调用Get /health的端点(endpoint)或者通过自身服务的报告获得相关信息。现在的服务发现解决方案会持续从实例中收集健康信息,并且设置负载均衡的路由,让其只指向健康的实例组件。
自我修复
自我修复能帮助恢复应用。我们讨论下当应用遇到崩溃状态后,如何通过相关的步骤去自我修复。在大多数情况下,是通过外部系统监控实例的状态,当服务出现故障一段时间后则会重启服务。在大多数情况下,自我修复的功能是相当有用的,然而,在某些情况下由于不断地重启服务会带来相关的问题。例如当服务过载或者数据库连接超时,则会导致应用不能反馈正确的服务健康状态。
对于一些场景-比如数据库链接丢失,这个时候实现高级的自我修复功能是颇为棘手的。在这种情况下,需要为应用添加额外的逻辑去处理这些特例,并且让外部系统知道服务的实例不需要立即重新启动。
故障转移缓存(Failover Caching)
因为网络问题和系统中的变更,服务通常会出现故障。然而,这些故障中断大多是暂时的,这要归功于自我修复和高级负载平衡的功能,我们应该找到一个解决方案,能使服务即使在出现故障的时候也能工作。这就是故障转移缓存(Failover Caching),它能帮助为我们的应用提供必需的数据。
失效转移缓存通常使用两个不同的过期日期:其中更短的日期指示在正常情况下能使用缓存的时间,而更长的一个日期则指示在故障失效的时候,能使用缓存中的数据时长。
故障转移缓存
特别需要提醒的是,只有当提供过时的数据比没有数据更好的情况下,才能使用故障转移缓存。
要设置缓存和故障转移缓存,可以在HTTP中使用标准响应头。
例如,使用max-age头可以指定某个资源为新资源的最大时间(译者注:意即设定max-age后,浏览器不再发送请求到服务器)。可以使用stale-if-error 头去确定在出现故障的情况下,从缓存获取资源的时间长短。
现在的CDN和负载均衡器提供了各种缓存和故障转移的解决方案,但是你也可以在你的公司中建立一个共享库,其中包括这些标准的可靠性解决方案。
重试逻辑(Retry Logic)
在某些情况下,我们可能无法缓存数据,或者想对数据进行变更,但是操作最终失败了。在这种情况下,我们就可以选择重试操作,因为我们可以预期资源将在一段时间后恢复,或者负载均衡会将请求发送到健康的实例上。
你应该小心地为应用程序和客户端添加重试逻辑,因为更大量的重试操作可能会使事情变得更糟,甚至阻止应用程序恢复。
在分布式系统中,微服务系统重试可能会触发多个其他请求或重试操作,并导致级联效应。为减少重试带来的影响,你应该减少重试的数量,并使用指数退避算法(exponential backoff algorithm)来持续增加重试之间的延迟时间,直到达到最大限制。
由于重试是由客户端(浏览器,其他微服务等)发起的,并且客户端在处理请求前后是不知道草走失败的,你应该为你的应用程序提供幂等处理能力。例如,当你重试购买操作时,不应该向客户收两次钱。给每个事务使用唯一的幂等键(idempotency-key)是解决重试问题的方法。
限流器和负载开关(Rate Limiters and Load Shedders)
限流是指在一段时间内,定义某个客户或应用可以接收或处理多少个请求的技术。例如,通过限流,你可以过滤掉产生流量峰值的客户和微服务,或者可以确保你的应用程序在自动扩展(Auto Scaling)失效前都不会出现过载的情况。
你还可以阻止较低优先级的流量,以便为关键事务提供足够的资源。
限流器可以阻止流量峰值
另外有一种限流器,称为 “并发请求限流器(concurrent request limiter)”。当你有一些比较昂贵和重要的端点(endpoint),希望它不应该被调用超过指定的次数,但仍然想要提供流量服务时,这个限流器就十分有用了。
使用负载开关可以确保对于关键的事务总能提供足够的资源保障。它为高优先级的请求保留一些资源,并且不允许低优先级的事务去占用这些资源。负载开关会根据系统的整体状态做出决定,而不是基于单个用户的请求桶(request bucket)大小。负载设备有助于你的系统恢复,因为它们在持续发生故障事件时,依然能保持核心功能正常工作。
关于更多限流器和负载开关的知识,建议读者参考Stripe的相关文章。
快速且单独失效(Fail Fast and Independently)
在微服务体系架构中,我们希望服务可以快速、单独地失效。为了在服务层面隔离故障,我们可以使用隔板模式(bulkhead pattern)。可以在本文稍后看到相关介绍。
我们也希望我们的组件能够快速失效(fail fast),因为我们不希望等到断开的实例直到超时。没有什么比挂起的请求和无响应的界面更令人失望。这不仅浪费资源,而且还会让用户体验变得更差。我们的服务是互相调用的,所以在这些延迟叠加前,应该特别注意防止那些超时的操作。
你想到的第一个办法,可能是对每个服务的调用都定义超时的级别。这种做法的问题是,你不能真正知道到底什么是恰当的超时值,因为当网络故障和其他问题发生时,某些情况下只会影响一两次操作。在这种情况下,如果只有其中一些发生超时,你可能不想拒绝所有这些请求。
我们可以说,通过使用超时(timeout)来实现微服务中的快速失败是一种反模式,这是应该避免的。可以使用基于操作的成功/失败统计次数的熔断模式,而不是使用超时。
舱壁模式(Bulkheads)
在工业领域中,常使用舱壁将船划分为几个部分,以便在有某部分船体发生破裂时,其他部分依然能密封安然无恙。
舱壁的概念也可以在软件开发中用于隔离资源。
通过使用舱壁模式,我们可以保护有限的资源不被用尽。例如,如果我们有两种类型的操作的话,它们都是和同一个数据库实例进行通信,并且数据据库限制连接数,这时我们可以使用两个连接池而不是使用一个共享的连接池。由于这种客户端和资源分离,超时或过度使用池的操作不会令所有其他操作失效。
泰坦尼克号沉没的主要原因之一是其舱壁设计失败,水可以通过上面的甲板倒在舱壁的顶部,最后整个船淹没。
泰坦尼克号故障的舱壁
断路器(Circuit Breakers)
为了限制操作的持续时间,我们可以使用超时。超时可以防止挂起操作并保证系统可以响应。然而,在微服务架构通信中使用静态、微调的超时是一种反模式,因为我们处于高度动态的环境中,几乎不可能确定在每种情况下都能正常工作的准确的时间限制。
我们可以使用断路器来处理错误,而不是使用小型和特定基于事务的静态超时机制。断路器以现实世界的电子元件命名,因为它们的行为是都是相同的。你可以保护资源,并通过使用断路器协助它们进行恢复。断路器在分布式系统中非常有用,因为重复的故障可能会导致雪球效应,并使整个系统崩溃。
当在短时间内多次发生指定类型的错误时,断路器会开启。开启的断路器可以拒绝接下来更多的请求 – 就像防止真实的电子流动一样。断路器通常在一定时间后关闭,以便为底层服务提供足够的空间来恢复。
请记住,并不是所有的错误都应该触发断路器。例如,你可能希望忽略客户端问题,比如4xx响应代码的请求,但要包括5xx服务器端故障。一些断路器还可以有半开关状态。在这种状态下,服务发送第一个请求以检查系统的可用性,同时让其他请求失败。如果这个第一个请求成功,则将断路器恢复到关闭状态并继续接受流量。否则,保持打开状态。
断路器
故障测试(Testing for Failures)
你应该持续地测试系统的常见问题,以确保你的服务可在各类故障环境下运行。你应经常测试故障,以让你的团队对可能发生的事故有所准备。
关于测试,你可以使用外部服务来识别服务实例组,并随机终止运行组中的一个实例。通过使用这个方法,可以针对单个实例故障进行测试,你甚至可以关闭整个服务组来模拟云提供商层面的故障中断。
总结
实施和运维可靠的服务并不容易。这需要你付出很多努力,还要花费公司更多的成本。
可靠性有很多层次和方面,因此针对你的团队找出合适的解决方案是相当重要的。你应该将可靠性成为业务决策流程中的一个因素,并为此分配足够的预算和时间。
要点
- 动态环境和分布式系统-如微服务将导致更高的故障机会。
- 服务应单独失效,实现优雅的服务降级以提升用户体验。
- 70%的问题是由变更引起的,恢复可用代码并不总是坏事。
- 快速,单独地失败。团队无法控制其服务依赖关系。
- 架构模式和技术,如缓存、隔离技术、断路器和限流器有助于构建可靠的微服务。