此文背景: 之所以发布此文,是有一个直接的原因,就是我们之前在线上遇到了一个使用timeout来判断是否失败的案例,这是真实的,结果就是效果很不好。看了本文中介绍的各种技术和架构模式,让我忽然对之前的这个案例有了一个新的认识,就是“快速失败”不应该依赖于传统的比如timeout这种超时机制来进行,也许使用本文中介绍到的技术(比如:Circuit Breakers)要更加地可靠和受控。
目录
- 微服务架构的风险
- 优雅的服务降级
- 变更管理
- 健康检查和负载平衡
- 自愈(Self-healing)
- 故障转移缓存(Failover Caching)
- 重试逻辑(Retry Logic)
- 速率限制器和负载开关(Rate Limiters and Load Shedders)
- 快速失败(Fail Fast and Independently)
- 舱壁模式(Bulkheads)
- Circuit Breakers
- 面向失败测试(Testing for Failures)
微服务架构让隔离故障变为可能,可以通过明确定义的服务边界来隔离故障。但是像在每个分布式系统中一样,网络,硬件或应用程序每个层面都有可能出错。由于服务依赖关系,任何组件都有可能暂时无法为其消费者服务。为了尽量减少部分中断的影响,我们需要构建容错服务(services),这样我们就可以优雅地来应对某些类型的中断。
本文介绍了构建和操作高可用性微服务系统的最常见技术和架构模式。
如果你不熟悉本文中的模式,那并不一定意味着你做错了。建立可靠的系统总是带来额外的成本。
微服务架构的风险
微服务架构将应用程序逻辑移动到服务,并使用网络层彼此通信。通过网络进行通信代替了内存中的调用会对需要多个物理和逻辑组件之间的协作的系统带来额外的延迟和复杂性。分布式系统的复杂性增加导致特定网络故障(network failures)的更高机率。
微服务体系架构的最大优势之一是,团队可以独立设计,开发和部署他们的服务。他们对服务的生命周期拥有完全的所有权。这也意味着团队无法控制他们的服务依赖关系,因为它更有可能由不同的团队管理。使用微服务体系架构,我们需要记住,提供者提供的服务可能由于其他人员的控制,比如发布了一个有问题的发行版本,或者由于配置和其他更改导致暂时不可用,而且组件之间彼此独立。
优雅的服务降级
微服务架构的最大优点之一是你可以隔离故障并在组件单独故障时实现优雅的服务降级。例如,在中断期间,照片共享应用程序中的客户可能无法上传新图片,但仍可以浏览,编辑和共享其现有照片。
微服务故障独立(理论上)
在大多数情况下,由于分布式系统中的应用程序相互依赖,因此很难实现这种优雅的服务降级,你需要应用几种故障转移的做法(其中一些将在本文后面介绍)才能应对暂时的故障和中断。
服务依赖于彼此,只要失败就一起失败,没有故障转移逻辑。
变更管理
Google的网站可靠性小组发现,大约70%的中断是由系统的变更引起的。当你更改服务中的某些内容时,你将部署新版本的代码或更改某些配置 - 总是有机会失败或引入新的错误。
在微服务架构中,服务依赖于彼此。这就是为什么你应该尽量减少故障并限制其负面影响。要处理变更中的问题,你可以实施变更管理策略和自动回滚(automatic rollouts)。
例如,当你部署新代码或更改某些配置时,你应该逐渐将这些更改应用于实例的一部分,监视它们,如果你发现它们对你的关键指标有负面影响,则可以自动还原部署。
变更管理 - 滚动部署
另一个解决方案可能是你运行两个生产环境。你始终只能部署其中一个,并且你在验证新版本是否符合预期之后才将负载均衡器指向新的。这称为蓝绿或红黑部署。
恢复代码不是坏事。你不应该在生产中留下破碎的代码,然后考虑出了什么问题。有必要时,恢复你的更改。越早越好。
健康检查和负载平衡(Health-check and Load Balancing)
实例由于出现故障,部署或自动scale而持续启动,重新启动和停止。它使它们暂时或永久不可用。为避免问题,你的负载平衡器应该从路由中跳过不健康的实例,因为它们无法为客户和子系统提供服务。
应用实例健康可以通过外部观察来确定。你可以通过重复调用GET /健康端点或通过自我报告来实现。现代服务发现解决方案可以不断地收集实例健康信息,并配置负载平衡器,将流量只路由到健康组件。
自愈(Self-healing)
自愈可以帮助恢复应用程序。当应用程序可以采取必要步骤从破碎的状态恢复时,我们可以认为是自愈。在大多数情况下,它由外部系统实现,该系统会监视实例运行状况,并在较长时间内处于断开状态时重新启动它们。在大多数情况下,自我修复可能非常有用,但是在某些情况下,连续重新启动应用程序会导致麻烦。当您的应用程序由于超载或其数据库连接超时而无法给出正向运行状况时,可能会发生这种情况。
实施针对特殊情况(如丢失的数据库连接)准备的高级自我修复解决方案可能很棘手。在这种情况下,你需要为应用程序添加额外的逻辑来处理边缘情况,并让外部系统知道实例不需要立即重新启动。
故障转移缓存(Failover Caching)
由于网络问题和我们系统的变化,服务通常会失败。然而,由于自我修复和高级负载平衡,大多数这些中断是临时的,我们应该找到一个解决方案,使我们的服务在发生故障的时候依然能够工作。故障转移缓存(failover caching)就是一个可以帮助并为我们的应用程序提供必要的数据的地方。
故障转移高速缓存通常使用两个不同的到期日期;更短的时间告诉你在正常情况下可以使用缓存多长时间,而更长的那个到期时间则是指在失败时使用缓存数据多长时间。
Failover Caching
必须注意的是,只有当提供过时的数据比没有数据更好的情况下,才能使用故障转移缓存。
要设置缓存和故障转移缓存,可以在HTTP中使用标准响应头。
例如,使用max-age header可以指定资源被视为新鲜的最大时间。使用stale-if-error header可以确定在出现故障的情况下从缓存获取资源的时间长短。
现在的CDN和负载平衡器提供各种缓存和故障转移行为,但你也可以为你所在的公司创建一个共享库,如果你的公司有标准的可靠性解决方案的话。
重试逻辑(Retry Logic)
在某些情况下,你可能无法缓存数据,或者想对数据进行更改,但是你的操作最终失败了。在这种情况下,我们就可以选择重试我们的操作,因为我们可以预期资源将在一段时间后恢复,或者负载平衡器将请求发送到健康的实例。
你应该小心地为你的应用程序和客户端添加重试逻辑,因为更大量的重试可能会使事情更糟,甚至阻止应用程序恢复。
在分布式系统中,微服务系统重试可能触发多个其他请求或重试,并导致级联效应。为最小化重试的影响,你应该减少重试的数量,并使用指数退避算法(exponential backoff algorithm)来持续增加重试之间的延迟,直到达到最大限制。
由于客户端(浏览器,其他微服务等)发起重试,并且客户端不知道在处理请求之前或之后操作失败,你应该为你的应用程序提供幂等处理能力。例如,当你重试购买操作时,你不应该向客户收两次钱。给每个交易使用唯一的幂等键(idempotency-key)是解决重试导致的重复操作问题的常用做法。
速率限制器和负载开关(Rate Limiters and Load Shedders)
速率限制是在一段时间内定义指定客户或应用程序可以接收或处理多少个请求的技术。例如,通过速率限制,你可以过滤掉产生流量峰值的客户和微服务,或者你可以确保你的应用程序不会超载,直到自动伸缩来拯救。
你还可以阻止较低优先级的流量,为关键事务提供足够的资源。
速率限制器可以阻止流量峰值
有一种类型的速率限制器,叫做“并发请求限制器(concurrent request limiter)”。当你有一些比较昂贵和重要的端点时,你希望这个端点不应该被调用超过指定的次数,但仍然想要提供流量时,这个就可以派上用场了。
使用负载开关可以确保总是有足够的资源提供给关键的事务。它为高优先级请求保留一些资源,不允许低优先级的事务使用它们。负载开关根据系统的整体状态做出决定,而不是基于单个用户的请求桶大小。负载设备有助于你的系统恢复,因为它们在你持续发生事件时保持核心功能的正常工作。
快速失败(Fail Fast and Independently)
在微服务体系架构中,我们希望服务可以快速,独立地失败。在服务层面隔离故障,我们可以使用隔板模式(或舱壁模式)(bulkhead pattern)。
此文之后会介绍到隔板模式(或舱壁模式)。
我们也希望我们的组件能够快速失败(fail fast),因为我们不想等待断开的实例直到超时。没有什么比挂起的请求和无响应的UI更令人失望。这不仅浪费资源,而且还会让用户体验变得糟糕。我们的服务是互相调用的,所以在这些延误已成定局之前,应该特别注意防止那些悬挂超时的操作。
你想到的第一个想法也许是对每个服务调用都采用细粒度超时。这种做法的问题是,你不能真正知道多长时间才是那个恰好的超时值,因为网络故障和其他问题发生的某些情况只会影响一次操作。在这种情况下,如果只有其中一些超时,你可能不想拒绝这些请求。
我们可以说,通过使用超时(timeout)来实现微服务中的快速失败范例是一种反模式,你应该避免(using timeouts is an anti-pattern)。嗯,别用超时,你可以使用取决于操作的成功/失败统计的断路器模式。
舱壁模式(Bulkheads)
工业中使用舱壁将船舶划分为几个部分,以便在有一部分船体发生破裂,我们就可以将有问题的船体隔离。
舱壁的概念也可以在软件开发中应用于隔离资源。
通过使用舱壁模式,我们可以保护有限的资源不被用尽。例如,如果我们有两种类型的操作的话,我们可以使用两个连接池(connection pool)而不是使用一个共享的连接池,这两种操作与同一个数据库实例通信。由于这种客户端资源分离,超时或过度使用池的操作不会使所有其他操作失效。
泰坦尼克号沉没的主要原因之一是其舱壁设计失败,水可以通过上面的甲板倒在舱壁的顶部,并将整个船体淹没。
Bulkheads in Titanic (they didn't work)
Circuit Breakers
为了限制操作的持续时间,我们可以使用超时。超时可以防止挂起操作并保证系统可以响应。然而,在微服务通信中使用静态,微调超时是一种反模式(anti-pattern),因为我们处于高度动态的环境中,几乎不可能确定在每种情况下都能正常工作的准确的时间限制。
怎么办呢?我们可以使用断路器来处理错误,而不是使用小型和特定于事务的静态超时。断路器以现实世界的电子元件命名,因为它们的行为是相同的。
你可以保护资源,并帮助他们使用断路器进行恢复。断路器在分布式系统中非常有用,因为重复的故障可能会导致雪球效应并使整个系统down掉。
当指定类型的错误在短时间内多次发生时,断路器会开启。开启的断路器可以拒绝接下来的请求 - 像防止电子流动一样。断路器通常在一定时间后关闭,为底层服务提供足够的空间来恢复。
请记住,并不是所有的错误都应该触发断路器。例如,你可能希望跳过客户端问题,比如4xx响应代码的请求,但要包括5xx服务器端故障。一些断路器还可以有半开状态。在这种状态下,服务发送第一个请求来检查系统可用性,同时让其他请求失败。如果这个第一个请求成功,那么就将断路器恢复到关闭状态并使接受流量进入。否则,保持打开。
Circuit Breaker
面向失败测试(Testing for Failures)
你应该持续地测试你的系统的常见问题,以确保你的服务可以携带各种故障生存生存。你应经常测试故障,让你的团队对事故有所准备。
针对测试,你可以使用外部服务来识别实例组,并随机终止此组中的一个实例。这是针对单个单个实例故障的测试,你甚至可以关闭整个region来模拟云提供商层面的程序中断。
最流行的测试(捣乱)解决方案之一是Netflix的ChaosMonkey。有关该方案可移步微服务-捣乱的猴子。
最后
实施和运行可靠的服务并不容易。你需要付出很多努力,还要花费公司更多的成本。
可靠性有很多层次和方面,因此找到最适合你团队的解决方案很重要。你应该使可靠性成为你的业务决策流程中的一个因素,并为其分配足够的预算和时间。
关键点
- 动态环境和分布式系统(如微服务)导致更高的失败机会。
- 服务应单独失败,实现优雅降级,提升用户体验。
- 70%的问题是由变化引起的,恢复代码不是坏事。
- 快速,独立地失败。团队无法控制其服务依赖关系。
- 架构模式和技术,如缓存,隔板,断路器和限速器有助于构建可靠的微服务。