要点
- 动态的环境和分布式的系统,比如微服务,它们出现故障的几率更大。
- 发生故障的服务应该被隔离开来,实现优雅的服务降级,提升用户体验。
- 70% 的故障都是因为代码变更引起的,所以有时候回退代码并不算是什么坏事。
- 如果发生故障,就要让它们快速而独立的发生。一个团队无法控制他们服务的依赖项。
- 缓存、隔板、回路断路器和速率限定器这些架构模式有助于构建可靠的微服务。
微服务架构通过定义良好的边界让失效隔离成为可能,但每一个分布式系统都存在同样的问题——网络、硬件或应用程序层面都有可能出现故障。因为服务之间存在依赖关系,所以任何一个组件出现了问题都会影响到组件依赖者。为了最小化局部故障所带来的影响,我们需要构建具有容错能力的服务,可以优雅地应对某些类型的故障。
这篇文章基于RisingStack 的Node.js 咨询和开发经验,介绍构建高可用微服务系统的常用技术和架构模式。
如果你不熟悉这篇文章所介绍的模式,并不代表你现在所做的就是错的,毕竟构建一个可靠的系统需要付出额外的代价。
微服务架构的风险
微服务架构将业务逻辑分散到了各个微服务当中,微服务间通过网络层进行通信。网络通信带来了额外的延迟和复杂性,需要多个物理组件和逻辑组件共同协作。分布式系统的额外复杂性增加了出现网络故障的几率。
微服务架构相比单体架构最大的优势之一在于,不同的团队可以独立地设计、开发和部署他们的服务。他们可以完全掌控自己的微服务生命周期。当然,这也意味着他们无法控制服务依赖项,因为依赖项的控制权掌握在其他团队手中。在采用微服务架构时,我们要时刻铭记,发布、配置等方面的问题可能会导致服务提供者出现短暂的不可用。
优雅的服务降级
通过微服务架构可以实现失效隔离,也就是说,在组件发生故障时可以实现优雅的服务降级。例如,在图片共享应用发生故障时,用户可能无法上传新的图片,但他们仍然可以浏览、编辑和分享已有的图片。
图:理论上的微服务失效隔离
在大多数情况下,实现这种优雅的服务降级是很困难的,因为在分布式系统里,应用之间相互依赖,为了应对临时的故障,需要应用到一些失效备援方案(稍后会提到)。
图:相互依赖的服务,在没有失效备援方案的情况下就会全部失效。
变更管理
Google 的网站可靠性团队发现,70% 的故障都是由系统变更引起的。更改服务、部署新代码、变更配置,这些都有可能引入新的缺陷或造成服务失效。
在微服务架构里,服务之间是相互依赖的。所以我们要最小化出现故障的几率,限制故障所造成的负面影响。我们需要良好的变更管理策略和自动回滚机制。
例如,在部署新代码时,或者在对配置做出变更时,要先在一小部分服务实例上进行,然后监控它们,一旦发现关键性度量指标出现异常,马上自动回滚。
图:变更管理——回退部署
另一个解决方案就是运行两套生产环境。在部署的时候只部署到其中一个生产环境,只有在确认这个环境没问题了之后才能将负载均衡器指向这个环境。这种部署方式被称为蓝绿部署或者红黑部署。
回退代码并不是件坏事。你总不可能一边把有问题的代码留在生产环境里,一边想着到底发生了什么问题。所以,在必要的时候回退代码,越快越好。
健康监测和负载均衡
服务实例总是因为各种原因(故障、部署或自动伸缩)经历着启动、重启、停止这样的过程。这个过程会让服务暂时或永久地不可用,为了避免出现问题,负载均衡器需要忽略出现问题的服务实例,因为它们已经不具备为用户或其他子系统提供服务的能力。
应用的健康状态可以通过外部的观察来获得,比如通过不断重复地调用 /health 端点来得知应用的状态,或者让应用报告自己的状态。服务发现机制会持续地收集服务实例的健康信息,负载均衡器应该被配置成只将流量路由给健康的服务实例。
自愈
自愈能力能够让应用在发生故障时进行自我恢复。如果一个应用能够通过一系列步骤从一个故障状态中恢复,那么就可以说它具备了自愈能力。在大多数情况下,这是通过一个外部系统来实现的。这个系统监控服务实例的健康状态,如果服务长时间处于不健康状态,系统就会将它重启。在大多数时候自愈能力是很有用的,不过如果持续不断的重启应用也会造成一些问题。在应用超载或数据库连接出现超时的时候通常会发生这种情况。
要实现高级的自愈方案会比较麻烦,比如在发生数据库连接超时的情况下,你需要在应用程序里添加额外的逻辑,让外部系统知道此时不需要重启服务实例。
失效备援缓存
服务总会因各种原因发生失效,比如网络问题等。不过,大部分这样的错误都是临时性的,而系统的自愈能力和高级负载均衡特性可以让应用实例在出现这些问题时仍然能够提供服务能力。失效备援缓存在这个时候就可以派上用场,它可以为应用程序提供必要的数据。
失效备援缓存通常会使用两个不同的过期时间,一个是短期时间,表示正常情况下的缓存过期时间,另一个是长期时间,表示在发生故障期间的缓存过期时间。
图:失效备援缓存
不过需要注意的是,失效备援缓存的数据可能是已经过期的数据,所以要确保这对于你的应用程序来说是可接受的。
可以通过 HTTP 的标准响应头来设置缓存或失效备援缓存。例如,通过 max-age 指定资源的最长过期时间,通过 stale-if-error 指定在发生故障时缓存的有效时间。
现代的 CDN 和负载均衡器提供了各种各样的缓存和失效备援机制,你也可以为自己的公司创建适合自己使用的缓存解决方案。
重试
在某些情况下,我们无法缓存数据,或者我们想要更新缓存内容但更新失败。在这个时候,我们可以进行重试,因为我们认为相关资源稍后会重新恢复过来,或者负载均衡器会将请求发送给正常的实例。
在应用程序里添加重试逻辑的时候要十分小心,因为大量的重试操作会让事情变得更糟糕,甚至导致应用程序无法从故障中恢复。
在分布式系统中,一个微服务系统可能会触发多个请求或重试操作,从而发生级联效应。为了降低重试带来的影响,应该要限制重试的次数,可以使用指数退避(exponential backoff)算法来逐步增加重试之间的延迟,直到达到重试的上限。
重试是由客户端(比如浏览器、其他微服务,等等)发起的,而客户端并不会知道之前请求处理是否成功,所以在进行重试时要注意处理幂等性问题。例如,在重试一个购买操作时就不应该重复计费。可以为每个事务使用唯一的幂等性键,这样有助于解决幂等性问题。
速率限定和负载倒注器(Shedder)
速率限定规定了应用程序在一个时间窗口内能够接收或处理的请求个数。通过速率限定,你可以在流量高峰期过滤掉一些用户请求或发出请求的微服务,确保你的应用程序不会出现过载。
你也可以因此限制低优先级的流量,将更多的资源优先用在处理更关键的事务上。
图:速率限定器限制流量高峰
另一种速率限定器叫作并发请求限定器(concurrent request limiter),它在某些情况下会很有用。比如,你不希望某些端点被多次调用,但同时又想为所有的流量提供服务。
使用负载倒注器可以确保总是存在足够的资源来处理关键性的事务。它为高优先级的请求保留了一些资源,这些资源不会被用在低优先级的事务上。负载倒注器基于整个系统的状态来决定如何保留资源,而不是基于请求桶大小。负载倒注器有助于系统在发生故障时进行恢复,因为在发生故障的时候,它们仍能保证系统核心功能正常运行。 Stripe 的文章里详细介绍了速率限定器和负载倒注器。
快速而独立地失效
在微服务架构里,如果服务发生失效,我们就要让它们快速而独立发生。我们可以应用隔板(bulkhead)模式在服务层面对问题进行隔离。稍后会对隔板模式进行更多的介绍。
如果服务组件发生失效,那么就要让它尽快失效,因为我们不想浪费太多时间等它发生超时。没有什么比一个被挂起的请求和无响应的界面更让人感到沮丧的了,它不仅浪费了资源,也给用户体验造成影响。在服务生态系统里,服务之间相互调用,我们要防止挂起的请求操作造成雪崩效应。
你可能首先会想到为每个服务调用定义二级超时时间,但问题是,你无法确切地知道多长的超时时间才是最合适的,因为有时候网络故障等问题只会影响到一两个操作。很显然,如果是这种情况,那么就不应该因为少数的请求过时就拒绝其他请求。
可以说,在微服务架构里通过使用超时来实现快速失效机制是一种反模式,所以应该尽量避免这么做。相反,我们可以使用回路断路器(circuit breaker)模式,它基于成功操作和失败操作的统计结果来决定服务是否已经失效。
隔板
在造船业,隔板被用于将船隔成多个部分,如果船体发生泄漏,发生泄漏的部分就可以被单独封闭。
隔板的概念也被应用在软件开发里,用于分隔资源。
通过应用隔板模式,我们可以防止有限的资源被耗尽。例如,如果我们有两种针对数据库的操作,我们可以使用连个连接池,而不是一个。这样一来,如果有些操作超时或者过度使用了连接池,就不会对另一个连接池上的操作造成影响。
回路断路器
为了限定操作的时长,我们可以为操作定义超时时间。超时机制可以防止出现长时间的挂起操作,保证系统可以正常响应。不过,在微服务架构里使用固定的超时机制是一种反模式,因为我们的环境是高度动态的,所以几乎不可能为每一种情况定义正确的限定时间。
我们可以使用回路断路器,而不是使用固定的超时机制。回路断路器的名字源于真实世界的电子元件,因为它们的行为极其相似。它们可以保护资源,有助于进行系统恢复。在分布式系统里,它们能够起到很大的作用,特别是当重复性的故障造成雪崩效应进而威胁到整个系统的时候。
如果某种类型的错误在一定时间段内多次发生,回路断路器就会断开。断开的回路断路器会阻止后续的请求,就像电子元件里的断路器一样。回路断路器会在一段时间之后关闭,给底层的服务更多的空间进行恢复。
要记住,并不是所有的错误都需要触发回路断路器。例如,你可能想要忽略客户端的一些问题,比如那些包含 4xx 响应码的请求,但同时想要保留 5xx 的服务器端错误。有些回路断路器会出现半开闭状态。这个时候,服务会发送一个请求来检测系统的可用性,同时拒绝其他的请求。如果检测系统可用性的请求成功返回,那么就会关闭断路器,继续处理后续的请求。否则的话,它就保持打开状态。
图:回路断路器
做好故障测试
我们应该持续不断地针对各种常见问题对我们的系统进行测试,确保我们的服务能够应对各种故障。
例如,我们可以使用一个外部服务来识别一组服务实例,然后随机地终止其中的一个实例。这样就知道该如何应对单个实例故障,当然,也可以关闭整组服务来模拟云服务中断。
Netflix 的 ChaosMonkey 是一个非常受欢迎的弹性测试工具。
总结
实现和运行可靠的服务并不是一件简单的事情。你需要付出很多的努力,你的公司也因此要付出很高的代价。
可靠性可以分为多种层次,也涉及到多个方面的内容,你要找到适合自己团队的解决方案。你应该把可靠性作为业务决策的一个考量因素,并为它分配足够的预算和时间。
查看英文原文: Designing a Microservices Architecture for Failure
感谢郭蕾对本文的审校
给InfoQ 中文站投稿或者参与内容翻译工作,请邮件至 editors@cn.infoq.com 。也欢迎大家通过新浪微博( @InfoQ , @丁晓昀),微信(微信号: InfoQChina )关注我们。
评论