现代化Web的微服务架构最佳实践全景






微服务架构接口服务 微服务架构最佳实践_API


作者丨Vinay Sahni

的实践,作者总结出一套适用于现代化Web和云技术的实战经验,并从微服务领域的先行者(如Netflix、Soundcloud、谷歌、亚马逊、Spotify等)身上学到了很多经验。全文很长,建议收藏转发后阅读。

产品复杂度与日俱增。想继续按以前的节奏去演进产品变得越来越困难了,是时候寻找一种更好的方法了。微服务架构承诺可以让团队快速前进,但与此同时也带来了一系列全新的挑战。

微服务架构主要是为了应对复杂度。相对于单一的复杂系统,该架构由多个简单的服务组成,这些服务之间存在复杂的交互,其目标在于确保复杂度能够得到控制。

确定关键需求


微服务架构接口服务 微服务架构最佳实践_客户端_02

微服务架构自身也会导致复杂度增加。需要运维的系统数量不仅没有减少,反而变得更多。到处散布着日志文件,分布式环境中难以维持一致性,类似的问题还有很多。我们的目标在于实现一种简化复杂度的状态:知道复杂度问题无法避免,但可通过工具和过程加以控制。

明确了需求后,我打算从下列几方面着手。

  • 为团队提供最大化自主权:通过创建一种环境让团队无须与其他团队协调即可完成更多工作。
  • 优化开发速度:硬件很便宜,人工则很贵。我们需要为员工赋能,让他们更轻松快速地开发出强大的服务。
  • 以自动化为重心:人都会犯错。需要运维的系统越多意味着出错概率越大,要尽量让一切实现自动化。
  • 不危及一致性的前提下提供灵活性:让团队自由决定最适合的行事方法,同时通过一系列标准化的构建块确保长远范围内一切保持井然有序。
  • 有弹性的构建:系统发生故障的原因有很多种,而分布式系统又引入了一系列可能出现故障的新场景。为了将影响降至最低,需要确保具备相应的衡量机制。
  • 简化维护:不要只使用一套基准代码,而是可以使用多套。为确保一致性可提供必要的指导和工具。

平台

服务团队需要能自由构建必要的东西。与此同时为确保一致性并管理愈加复杂的运维工作,还要设立相应标准。这意味着需要让通信、日志、监控和部署等工作实现标准化。平台本身就是一系列标准和工具组合的产物,借助平台将能更容易地创建并运维满足标准要求的服务。

要有一个控制面板

团队该如何与平台交互?通常可能需要大量Web接口进行持续集成、监控、日志,以及文档记录。团队需要用一个控制中心作为这一切的起点。列出所有服务并链接到各种内部工具的简单控制中心就行。理想情况下,控制中心还可以从内部工具中收集数据,并在快速浏览中提供额外的价值。

对于已经在使用团队交流解决方案的组织,一种较为常见的做法是使用自定义的机器人程序将常用任务直接显示在聊天界面中。这种做法在需要触发测试和部署,或需要快速了解某个运行中服务状态的时候较为有用。通过这种做法,聊天记录也可以变成一种针对以往操作进行审计的审计轨迹。

服务的本质


微服务架构接口服务 微服务架构最佳实践_客户端_02

平台内部通常运行了很多服务。“很多”取决于具体规模,可能指十多个,数百个,甚至上千个。每个服务均通过相互独立的程序包封装了一定的业务能力。为确保服务能专注于某一具体用途,所构建的服务必须尽可能小;但为了将服务之间的交互减至最低,服务又要足够大。

独立开发和部署

每个服务都需要独立开发和部署。如果没有对API做出破坏性改动,则无需与其他服务团队进行协调。每个服务实际上都是相关团队自己的产品,有着自己的基准代码和生命周期。

如果发现需要配合其他服务一起部署,无疑是哪里做错了。

如果所有服务使用了同一套基准代码,无疑是哪里做错了。

如果每次部署一个服务前要先提醒他人注意,无疑是哪里做错了。

提防共享的库!如果对共享库进行更改需要同时更新所有服务,意味着所有服务间存在强耦合点。在引入共享库之前,一定要充分理解可能产生的后果。

私有数据的所有权

当多个服务直接读写数据库中同一张表时,对这些表做任何改动都需要协调这些相关服务的部署。这一点违背了服务相互独立这一原则。共享的数据存储很容易不经意间造成耦合。每个服务需要有自己的私有数据。

私有数据还能提供另一个优势:根据服务的具体用例选择最适合的数据库技术。

每个服务都要有自己的数据服务器吗?

不一定。每个服务需要自己的数据库,但这些数据库可共置在一台共享的数据服务器上。重点在于不应让服务知道其他服务底层数据库的存在。这样即可用一台共享数据服务器先开始开发,以后只要更改配置即可将不同服务的数据库隔离起来。

然而共享的数据服务器也可能造成一些问题。首先会形成单点故障,进而导致一大批服务同时故障,这一点绝不能掉以轻心。其次很可能因为一个服务占用太多资源而无意中对其他服务造成影响。

确定服务的边界

这个问题很复杂。每个服务应该是一种能提供某些业务能力的自治单位。

服务应当弱耦合在一起,对其他服务的依赖应尽可能低。一个服务与其他服务的任何通信都应通过公开暴露的接口(API、事件等)实现,这些接口需要妥善设计以隐藏内部细节。

服务应具备高内聚力。密切相关的多个功能应尽量包含在同一个服务中,这样可将服务之间的干扰降至最低。

服务应包含单一的界限上下文。界限上下文(Bounded context)可将某一领域的内部细节,包括该领域特定的模块封装在一起。

理想情况下,必须对自己的产品和业务有足够的了解才能确定最自然的服务边界。就算一开始确定的边界是错误的,服务之间的弱耦合也可以让你在未来轻松重构(例如合并、拆分、重组)。

等一下,那共享模型呢?

再深入看看界限上下文吧。应该尽量避免创建哑CRUD服务,这类服务只能导致紧密的耦合以及较差的内聚力。领域驱动设计所引入的界限上下文这一概念可以帮助我们确定最合理的服务边界。界限上下文可将某领域的相关内容封装在一起(例如将其封装为服务)。多个界限上下文可通过妥善定义的接口(例如API)进行通信。

虽然一些模型可以完全封装在界限上下文内部,但有些模型可能有跨越不同界限上下文的不同用例(和相关属性)。这种情况下每个界限上下文须具备与模型有关的属性。

这里可以举一个更具体的例子。例如技术服务台解决方案Enchant,该系统的核心模型是工单(Ticket),每个工单代表客户的一个支持请求。工单服务负责管理工单的整个生命周期,包含主要属性。此外还有报表服务负责预先计算并存储每个特定工单的统计信息。每个工单的报表统计信息用两种方式存储:

  • 将统计信息存储在工单服务中,因为最终工单模型和工单生命周期都由该服务所拥有。通过这种方法,报表服务用数据执行任何操作前需要首先与工单服务通信。这种服务之间的紧密耦合显得非常“啰嗦”。
  • 将统计信息存储在报表服务中,因为与数据有关的报表工作都是该服务负责处理的。通过这种方法,两个服务都将具备一个工单模型,但存储不同属性。这样数据可放置在更接近实际使用这些数据的地方。这种方式还使得报表服务能够针对报表具体用例持续进行优化,但这种方式一旦新建工单或现有工单有改动,还需要通知报表服务。

将统计信息存储在报表中的做法可以更好地满足服务要求:弱耦合、高内聚,每个服务自行负责自己的界限上下文。然而这种方法会增加复杂度。工单的改动需要通知报表服务,为此可以让报表服务订阅由工单服务提供的事件流,这样也可以让服务之间的耦合程度降至最低。

但服务到底能大到什么程度?

微服务这个词中的微字与物理规模或代码行数没有任何关系,仅代表最小化的复杂度。服务应当足够小,只承担某个单一作用,与此同时服务也要足够大,以便将服务之间的通信量降至最低。

没什么硬性规定限制服务只能是一个进程、一个虚拟机、或一个容器。服务应包含能够以自治方式实现业务能力所需的全部功能,甚至可包含外部服务,例如用于实现持久存储的数据服务器,用于异步工作进程的作业队列,甚至确保一切快速运行所需的缓存。

无状态的服务实例

无状态(Stateless)服务实例不存储与上一个请求有关的任何信息,传入请求可发送至服务的任何实例。这种做法的主要收益在于可简化运维和伸缩工作。可以在后台服务之前增加一个简单的负载均衡器,随后即可根据请求数量的变化轻松地增加或删除实例,同时故障实例的替换过程也更为简单。

话虽如此,很多服务可能依然需要用某种方式存储数据。这些数据可推送至外部服务中,例如以磁盘作为边界的数据库服务器或以内存作为边界的缓存。

最终一致性

无论怎么看,分布式系统的一致性都是个老大难问题。与其对抗这种问题,更好的方法是让分布式系统能够实现最终一致性。在最终一致的系统中,虽然不同服务在特定时间点会对数据产生有分歧的视图,但最终会通过汇聚获得一致视图。

如果能对服务进行妥善建模(例如弱耦合和高内聚),你会发现对大部分用例来说,最终一致性是一种足够好的默认设置。创建最终一致的分布式系统,这种做法在方向上与创建弱耦合系统的目标是一致的。都趋向于进行异步通信,面对下游服务的故障可以获得固有的保护。

举个例子。在Enchant中有一个工单服务(用于管理客户支持请求)和一个报表服务(用于计算工单的统计信息)。报表服务可从工单服务获得一个异步的内容更新馈送源(Feed)。这意味着当任何时候工单服务中产生更新后,报表服务可在几秒后获得相关信息。

在这几秒钟内,两个服务会对底层客户请求产生有差异的视图。对于报表这一具体用例来说,几秒钟的延迟是可接受的。此外这种方式还提供了一个附加的收益,可以保护工单服务不受报表服务故障的影响。

异步工作进程

在采用最终一致性的设置后会发现,当请求等待回应而受阻时,并不需要在这过程中将其他所有操作全部完成。任何可以等待(并且是资源或时间密集型)的操作都可以作为作业传递给异步工作进程。这种方式可以:

  1. 为主要请求的处理路径提速,因为只需要完成请求处理过程中必须要完成的部分操作。
  2. 通过分散负载更轻松地对工作进程进行伸缩,是自动伸缩配置的最佳选择,可根据待完成工作的数量对工作进程的数量进行动态调整。
  3. 减少主要服务API中出现的错误,通过异步工作进程运行的作业失败后,可直接重试而无须迫使发起请求的服务等待。

理解幂等性

上文提到过作业运行失败后可重试。自动重试失败作业的做法会遇到一个挑战:可能会无法知道失败的作业在失败前是否完成了自己的工作。为确保相关操作足够简单,必须让作业具备幂等性(Idempotent)。在这个语境下,幂等性意味着多次运行同一个作业不会产生任何消极影响。无论作业运行一次或多次,最终结果必须相同。

文档

服务(及其API)和文档一样重要。对于每个服务,一定要提供清晰易用的使用文档。理想情况下,所有服务的使用文档应放在同一个位置,并确保需要使用时服务团队不需要花费大量时间考虑文档到底在哪里。

API有变化后会怎样?

需要将已记录端点的变动情况通知给其他所依赖服务的所有者。通知系统必须了解不同服务目前的所有者是谁,并了解有关团队或所有权的全部改动。这些信息可通过平台进行追踪并提供。

负载均衡器

无状态服务的优势之一在于可同时为服务运行多个实例。由于每个实例都是无状态的,可由任何实例处理任何请求。这一特性与负载均衡器非常相符,借此可对服务进行更简单的伸缩。同时几乎所有云平台都提供了负载均衡器功能。

传统负载均衡器位于接收端,此时客户端只知道一个目标的存在。该目标接收请求并将其分散至多个(对外隐藏的)内部服务实例。另一种做法是客户端负载均衡器,例如Netflix使用Ribbon构建了这样的实现。

对于客户端负载均衡器,客户端可以知道多个可能的目标,并根据策略选择要使用的目标,例如可首选使用同一数据中心内的目标以降低请求延迟。此外还可配合使用这两种做法:首先从传统负载均衡器着手,随后在需要实现高级路由功能时添加客户端负载均衡器。客户端负载均衡器可包含在客户端库中。

网络边缘的聚合服务

如果要让数据跨越私有网络边界与外部客户端通信,将有更多额外需求需要考虑。数据需要通过某种方式编码,将往返通信的次数降至最低,为确保这些客户端只能访问需要的数据,还要提高安全措施,诸如此类。

聚合服务将负责收集来自其他服务的数据,处理任何特殊的编码或压缩要求,同时由于客户端仅需要与这一个服务通信,这样做还有助于简化安全工作的实现。

在不同的需求下,构建不同的聚合服务会更符合实际,如一个用户场景(公开API、移动客户端、桌面客户端等)一个聚合服务。如果你的需求更简单,那么一个聚合服务也就足够了。

对一个聚合服务中的业务逻辑量进行限制

由于聚合服务需要处理来自多个服务的数据,很容易无意中泄露其中包含的业务逻辑并降低服务内聚力。这种情况需要当心!任何与某个服务有关的业务逻辑应该只属于该服务。聚合服务的本意是在外部客户端和内部服务之间充当一个稀薄的粘合层。

如果某个内部服务出现故障了怎么办?

答案主要取决于具体的上下文。因此首先需要考虑下列几个问题:

  • 能否将相应功能优雅地移除,或者让端点抛出错误信息?
  • 该服务的可用性是否关键到需要将整个聚合服务停用?
  • 如果从端点中优雅地移除,客户端会向用户显示怎样的错误信息?

聚合服务的本质使得这种服务依赖(并紧密耦合于)一个或多个其他服务,因此这种服务会受到其他任何相关故障服务的影响,进而导致聚合服务故障。所以必须了解不同的故障场景并制定好应对措施。

安全性

需要结合数据所在位置或服务在整个大架构中的角色考虑服务安全需求。可能要对传输中或存储后的数据提供安全保护。可能要在服务边界或私有网络边界实施网络安全机制。很难确保万无一失,下文提供了一些值得考虑的原则:

  • 分层式安全:也叫纵深防御。这种方法不会假设网络边界防火墙已足够好了,而是分别为最重要的部分持续添加多个安全防护层。借此可让安全措施实现冗余,当某一安全层被攻陷或存在弱点时,有助于减缓攻击者的入侵速度。
  • 使用自动化的安全更新:很多情况下自动化安全更新机制所提供的收益远远超过由于此类机制的存在导致服务故障的可能性。自动更新可配合自动测试机制一起使用,这样也可以更自信地推出安全更新。
  • 加固底层操作系统:服务通常需要对底层操作系统进行最小量访问。因此操作系统可以对服务能或不能做什么施加严格的限制。这种做法有助于控制服务中可能存在的弱点。
  • 不要自行编写加密代码:这种代码很难写得足够好,并很容易产生已经写得足够好的错觉。建议始终选择知名的,并已得到广泛应用的实现。

服务的交互

微服务架构提倡有许多职责单一的小服务组成,这些服务之间互相交互。然而这就造成了一系列的问题,比如:服务之间如何发现彼此?是否采用统一的协议?如果一个服务无法与其他服务通信会怎样?我会在接下来的内容里讨论部分相关话题

通信协议

随着服务数量越来越多,在服务间使用标准化通信方法愈加重要。由于服务不一定使用相同语言编写,通信协议的选择必须不依赖具体语言和平台。此外还要同时考虑同步和异步通信。

首先,传输协议HTTP是同步通信的最佳选择。HTTP客户端几乎已得到所有语言支持,很多云平台都内建了HTTP负载均衡器,该协议本身内建了用于缓存、持久连接、压缩、身份验证以及加密所需的机制。最重要的是,围绕该协议有一个稳健成熟的工具生态体系可供使用:缓存服务器、负载均衡器、优秀的浏览器端调试器,甚至可对请求进行重播的代理。

协议较为繁琐是HTTP的不足之一,它需频繁发送纯文本头字段(Header),并频繁建立和终止连接。相比HTTP生态系统已经带来的巨大价值,我们可以辩解说接受这些不足是一个合理的权衡。

然而,时下已经有了另外一个更好的选项:HTTP/2。通过对头字段进行压缩并用一个持久连接实现多路复用请求(Multiplexing request),该协议有效解决了上述问题,同时维持与老版本客户端的向后兼容性。HTTP目前依然实用,未来一样很好用。

话虽如此,如果已达到一定规模,通过降低内部传输的开销可对底线造成较显著的改善,那么也许更适合用其他传输方式。

对于异步通信,需要实施发布订阅模式。为此有两个主要方法:

  • 使用消息代理(Broker):所有服务将事件推送至该代理,其他服务可订阅需要的事件。这种情况下将由消息代理定义所用传输协议。由于一个集中化的代理很容易造成单点故障,一定要确保此类系统具备容错性和横向伸缩能力。 
  • 使用服务所交付的Webhook:服务可暴露出一个供其他服务订阅事件使用的端点,随后该服务会将事件以Webhook形式(例如在主体中包含序列化消息的HTTP POST)提供给已订阅的目标服务。此类Webhook的交付应由服务所管理的异步工作进程发送。这种方式可避免单点故障并获得固有的横向伸缩能力,同时这样的功能也可直接构建在服务模板中。

企业服务总线(ESB)或消息传递设施呢?

一个重量级的消息传递基础设施的存在通常会鼓励将业务逻辑脱离服务本身而进入消息层。这种做法会导致服务的内聚性降低,并增加了额外一层,从而会降低服务内聚力,并增加了额一层,随着使用时间延长可能无意间导致复杂度逐渐提高。与一个服务有关的任何业务逻辑都应属于该服务,并由该服务的团队负责管理。强烈建议坚守智能的服务+哑管道这样的原则,以确保不同团队维持自治力。

接着再谈谈序列化格式

这方面有两个主要的竞争者:

  • JSON:RFC 7159定义的一种纯文本格式。 
  • Protocol Buffers:谷歌创建的一种基于二进制连接格式的接口描述语言。

JSON是一种稳定并得到广泛应用的序列化格式,浏览器包含对该格式的原生解析能力,浏览器内建调试器也能很好地显示这种内容。唯一不足在于要具备JSON解析器/序列器,好在所有语言都已提供。使用JSON最主要的麻烦在于每条信息会重复包含属性名,导致传输效率低下,但传输协议的压缩功能可缓解这一问题。

在解析和网络传输方面Protocol buffers更高效,并经历了谷歌高负荷环境的考验。取决于消息定义文件,这种格式需针对不同语言具备解析器/序列器生成器。不同语言对该格式的支持不像JSON那么广泛,不过大部分现代化语言均已支持。为了使用这种格式,服务器需要预先与客户端共享消息定义文件。

JSON更易上手更通用,Protocol buffers更精益更快速,但在.proto文件的共享和编译方面会产生些许额外开发负担。这两种格式都是不错的选择,选定一个坚持使用吧。

对于“服务异常”的定义

正如需要自动化的监控和警报机制,确定所有服务有统一的异常定义,也是一个好主意。

对HTTP传输协议来说这一点很简单。服务通常可生成200、300以及400系列的HTTP状态代码。任何500错误代码或超时通常可认定服务出现故障。这些代码也可用于反向代理和负载均衡器,如果这些组件无法与后端实例通信,通常会抛出502(Bad Gateway)或503(Service Unavailable)错误。

API的设计

好的API必须易用且易理解,可在不暴露底层实现细节的情况下提供完成任务所需的信息,这些信息数量恰恰满足需求,不多不少。同时API的演化只会对现有用户造成最少量影响。API的设计更像是一种艺术而非科学。

由于已选择HTTP作为传输协议,为了释放HTTP的全部潜力,还要将HTTP与REST配合使用。RESTful API提供了资源丰富的端点,可通过GET、POST以及PATCH等动词操作。我之前写的一篇有关RESTful API设计的文章详细介绍了对外API的设计,这篇文章的大部分内容也适用于微服务API的设计。

但是为什么服务API必须是面向资源的?

这样可以让不同服务的API实现一致性并更简洁。借此可通过更易于理解的方式检索或搜索内容,无须寻找修改资源某一特定属性所需的方法,可直接针对资源使用PATCH(部分更新)。这样可减少API上的端点数量,有助于进一步降低复杂度。

由于大部分现代化公开API都是RESTful API,因此有丰富的工具可供使用。例如客户端库、测试自动化工具,以及自省代理(Introspecting proxy)。

服务发现

在服务实例变化不定的环境中,用硬编码指定IP地址的方式是行不通的,需要通过某种发现机制让服务能相互查找。这意味着对于到底有哪些可用服务必须具备“权威信息来源”。此外还要通过某种方式借助这个权威来源发现服务实例之间的通信,并对其进行均衡。

服务注册表

服务注册表可以作为信息的权威来源。其中包含有关可用服务的信息,以及服务网络位置。考虑到该服务本身的一些关键特质(是一种单一故障点),该服务必须具备极高容错能力。

可通过两种方式将服务注册至服务注册表:

  • 自注册:服务可在启动过程中自行注册,并在生命周期的不同阶段(初始化、正在接受请求、正在关闭等)过程中发送状态信息。此外服务还要定期向注册表发送心跳信号,以便让注册表知道自己处于活跃状态。如果无法收到心跳信号,注册表会将服务标记为已关闭。这种方式最适合包含在服务模板中。 
  • 外部监控:可通过外部服务监控服务运行状况并酌情更新注册表。很多微服务平台使用这种方法,通常还会用这种外部服务负责服务的整个生命周期管理。

在大架构方面,服务注册表也可充当监控系统或系统可视化工具所用状态信息的来源。

发现和负载均衡

创建可用的注册表只解决了问题的一半,还需要实际使用这种注册表才能让服务以动态的方式相互发现!此时主要有两种方法:

  • 智能服务器:客户端将请求发送至已知负载均衡器,负载均衡器可通过注册表得知可用实例。这是一种传统做法,但可用于通过负载均衡器端点传输的所有流量。服务器端负载均衡器通常是云平台的标配。 
  • 智能客户端:客户端通过服务注册表发现实例清单并决定要连接哪个实例。这样就无须使用负载均衡器,并能提供一个额外收益:让网络流量的分散更均匀。Netflix借助Ribbon采用了这种方式,并通过该技术提供了基于策略的高级路由功能。若要使用这种方式,需要通过特定语言的客户端库实现发现和均衡功能。

使用负载均衡器和DNS实现更简单的发现机制

在大部分云平台上,获得最基本服务发现功能最简单的办法是为每个服务添加一条指向负载均衡器的DNS记录。此时负载均衡器的已注册实例清单将成为服务注册表,DNS查询将成为服务发现机制。运行状况异常的实例会自动被负载均衡器移除,并在恢复运行后重新加入。

去中心化的交互

当有多个服务需要相互协调时,主要可通过两种方法实施复杂工作流:使用集中化编排程序(Orchestrator),或使用去中心化交互。

集中化的编排程序会通过一个进程对多个服务进行协调以完成大规模工作流。服务对工作流本身及所涉及的具体细节完全不知情。编排程序会处理复杂的安排和协调,例如强制规定不同服务的运行顺序,或对某个服务请求失败后重试。为确保编排程序了解执行进展,此时通信通常是同步的。使用编排程序最大的挑战在于要在一个集中位置建立业务逻辑。

去中心化的交互中,更大规模工作流内的每个服务将完全自行负责自己的角色。服务之间通常会相互侦听,尽快完成自己的工作,如果出错则尽快重试,并在执行完毕后送出相关事件。此时通信通常是异步的,业务逻辑依然保留在相关服务中。这种方式的挑战之处在于需要追踪工作流整体的执行进度。

去中心化交互可更好地满足我们的要求:弱耦合,高内聚,每个服务自行负责自己的界限上下文。所有这些特征最终都可提高团队的自治能力。通过服务监控所有相互协调的其他服务所发出的事件,这种方法也可用被动方式对工作流整体的状态进行追踪。

版本控制

变化是不可避免的,重点在于如何妥善管理这些变化。API的版本控制能力,以及同时对多个版本提供支持的能力,这些都可大幅降低变化对其他服务团队造成的影响。这样大家将有更多时间按自己的计划更新代码。每个API都应该有版本控制机制!

虽然如此,无限期地维持老版本这本身也是一个充满挑战的工作。无论出于什么原因,对老版本的支持只需要维持数周,最多数月。这样其他团队才能获得自己需要的时间,不会进一步拖累你自己的开发速度。

将不同版本作为单独的服务来维护,这种做法如何呢?

虽然听起来挺好,但其实很糟糕。创建一个全新服务,这本身就会带来不小的开销。要监控的内容更多,可能出错的东西也更多。老版本中发现的Bug很有可能也要在新版中修复。

如果服务的所有版本需要对底层数据获得共享视图,情况将变得更复杂。虽然可以让所有服务与同一个数据库通信,但这又成了一个糟糕的主意!所有服务会与持久存储架构建立非常紧密的耦合。在任何版本中对架构所做的任何改动都会无意导致其他版本服务的中断。最终也许只能使用相互同步的多份基准代码。

那么多个版本到底该如何维护?

所有受支持的版本应共存于同一份基准代码和同一个服务实例中。此时可使用结构版本化(Versioning scheme)确定请求的到底是哪个版本。可行的情况下,老的端点应当更新以将修改后的请求中继至对应新端点。虽然同一个服务中多版本共存的局面不会降低复杂度,但可避免无意中增加复杂度,导致本就复杂的环境变得更复杂。

限制一切

一个服务如果超负荷运转,那么让它直接快速的失败,要好过拖累其他服务。所有类型的请求需要对不同情况下的使用进行一定的限制。此外还要通过某种方法,按照需要提高对使用情况的限制。这样可确保服务稳定,而负责服务的团队也将有机会对使用量的进一步激增做好规划。

虽然此类限制对不能自动伸缩的服务最重要,但对于可自动伸缩的服务最好也加以限制。你肯定不希望以“惊喜”的方式了解到设计决策中所包含的局限!然而对可自动伸缩的服务进行的限制可略微放宽一些。

为了帮助服务团队获得自助服务管理能力,限制机制的管理界面可包含在服务模板中,或在平台层面上通过集中化服务的方式提供。

连接池

请求量突然激增会使得服务对下游服务造成极大压力,这样的压力还会顺着整个链条继续向下传递。连接池有助于在请求量短时间内激增时“抚平”影响。通过合理设置连接池规模,即可即可对在任意时间内向下游发出的请求数量做出限制。可为每个需要通信的服务设置一个独立连接池,借此将下游服务中存在的故障隔离在系统的特定位置。

.. 别忘了要快速失败

如果无法从池中获得连接,此时最好能快速失败,而不要无限期堵塞。这个速度决定了其他服务要等你等多久。故障本身对团队来说也是一种预警,并会导致一些很有用的疑问:是否需要扩容了?是否下游服务中断了?

更短的超时

设想一下这样的场景:一个服务接到大量请求开始超负荷并变慢,进而对该服务的所有调用都开始变慢。这种问题会持续对上游造成影响,最终用户界面开始显得迟钝。用户请求得不到预期回应,开始四处乱点期待着能自己解决问题(遗憾的是这种事情经常发生),这种做法只会让问题进一步恶化。这就是连锁故障。很多服务会在出现故障的同时发出警报,相信我,你绝对不想就这种问题获得第一手的亲身体验。

由于有多个服务相互支撑并可能出现故障,此时确定问题的根源成了一个充满挑战的工作。故障是服务本身的内部问题造成的,还是因为某个下游服务?这种场景很适合为下游API的调用使用较短超时值。超时值使得多个服务不会“缓慢地”逐渐进入故障状态,而是可以让一个服务真正发生故障时其他服务能快速故障,并从中判断出问题根源。

因此仅使用默认的30秒超时值还不够好。要将超时值设置为下游服务认为合理的时间。举例来说,如果预计某个服务的响应时间为10-50毫秒,那么超时值只要大于500毫秒就已经不合适了。

容忍不相关的变更

服务API会逐渐演化。需要与API的使用方进行协调的变更,其发布速度会远低于无须这种协调的变更。为了将耦合程度降至最低,服务应当能容忍与之通信的服务中所产生的不相关变更。这其实意味着如果服务中加入了字段,或改动/删除了不再使用的字段,不应该导致与该服务通信的其他服务出现故障。

如果所有服务都能容忍不相关变更,就可在无须任何协调的情况下对API进行额外改动。对于比较重大但依然不相关的变更,也只需要使用该服务的团队运行自己的测试工具确认一切都能正常工作即可。

断路开关

与故障资源进行的任何通信企图都会产生成本。消耗端需要使用资源尝试发起请求,这会用到网络资源,同时也会消耗目标端的资源。

断路开关可防止发起注定会失败的请求。该机制的实现非常简单:如果到某个服务的请求出现较多失败,添加一个标记并停止在接下来一段时间里继续向这个服务发请求。但同时也要定期允许发起一个请求,借此确认该服务是否重新上线,确认上线即可取消这样的标记。

断路开关的逻辑要封装在服务模板所包含的客户端库中。

关联ID

一个用户发出的请求可能引起多个服务执行操作,因此对某一特定请求的影响范围进行调试可能会很难。此时一种简化该过程的方法是:在服务请求中包含一个关联ID。关联ID是一种唯一标识符,可用于区分每个服务传递给任意下游请求的请求来源。通过与集中化日志机制配合使用,可轻松看到请求在整个基础架构中的前进路径。

该ID可由面向用户的聚合服务,或由任何需要发出请求,但该请求并非传入请求直接导致的意外结果的服务生成。任何足够随机的字符串(例如UUID)都可用作这个用途。

维持分布式一致性

在最终一致的世界里,服务可通过订阅事件馈送源(Feed)的方式与其他服务同步数据。

虽然听起来很简单,但魔鬼往往隐藏在细节里。数据库和事件流通常是两个不同系统,这使得你非常难以用原子级方式同时写入这两个系统,进而难以确保最终一致性。

可以使用本地数据库事务封装数据库操作,同时将其写入事件表。随后事件发布程序会从事件表读取。但并非所有数据库都支持此类事务,事件发布程序可能要从数据库提交日志中读取信息,但并非所有数据库都能暴露此类日志。

... 或者就保持不一致的状态,稍后再修复吧

分布式系统很难实现一致性。就算以分布式一致性为核心特性的数据库系统也要很多额外操作才能实现。与其打这样一场硬仗,其实也可以考虑使用某种尽可能足够好的同步解决方案,并在事后通过专门的过程找出并修复不一致的地方。

这种方式也能实现最终一致性,只不过“不一致的窗口期”可能会略微长于通过复杂的方式跨越不同系统(数据库和事件流)实现一致性时的窗口期。

每块数据都应该有一个单一数据源(Single source of truth)

就算要跨越多个服务复制某些数据,也应该让一个服务始终成为任何其他数据的单一数据来源。对数据的所有更新需要在这个数据源上进行,同时这个数据源也可在未来用于进行一致性验证时的记录来源。

如果某些服务需要强一致怎么办?

首先需要复查服务边界是否正确设置。如果服务需要强一致,通常将数据共置在一个服务(以及一个数据库)这样的做法更合理,这样可用更简单方式提供事务保障。

如果确认服务边界设置无误但依然需要强一致,则要检查一下分布式事务,这种机制很难妥善实现,同时可能会在两个服务间产生强耦合。建议将其作为最后的手段。

身份认证

所有API请求需要进行身份认证。这样服务团队才能更好地分析使用模式,并获得用于管理不同使用模式下对请求进行限制所需的标识符。

这种标识符是服务团队为使用该服务的用户提供的,具备唯一性的API密钥。必须具备某种颁发和撤销此类API密钥的方法。这些方法可内建于服务模板,或通过集中化身份认证服务在平台层面上提供,这样还可让服务团队以自助服务的方式管理自己的密钥。

自动重试

在能够“快速失败”后,还需要能以自动方式对某些类型的请求进行重试。对于异步通信这一能力更为重要。

故障后的服务恢复上线后,如果有大量其他服务正在同一个重试窗口内重试,此时很容易给系统造成巨大压力。这种情况也叫惊群效应(Thundering herd),使用随机化的重试窗口可轻松避免这种问题。如果基础架构没有实施断路开关,建议将随机化重试窗口与指数退避(Exponential backoff)配合使用以便让请求进一步分散。

遇到持久的故障又该怎么办?

有时候故障可能是格式有误的请求造成的,并非目标服务故障所致。这种情况下无论重试多少次都不会成功。当多次重试失败后,应将此类请求发送至一个死信队列(Dead queue)以便事后分析。

仅通过暴露的API通信

服务间的通信只能通过已确立的通信协议进行,不能有例外。如果发现有服务直接与其他服务的数据库通信,肯定是哪里做错了。

另外要主意:如果能对服务通信方式做出通用假设(Universal assumption),就能更容易地为防火墙后的服务组件提供更稳妥的保护。

经济因素

当一个团队使用另一个团队提供的服务时,他们通常会假设这些服务是免费的。虽然可以免费使用,但对其他团队以及组织来说,依然会产生成本。为了更高效地利用现有资源,团队需要了解不同服务的成本。

有一种很强大的方式可以帮我们做到这一点:为用到的其他服务提供服务发票(Service invoice)。发票中不要只列出用到的其他服务,而是列出实际成本金额。服务开发和运维成本可转嫁给服务的用户,而服务实际成本应包含开发成本、基础架构成本,以及使用其他服务的成本。这样就可以将总成本均摊计算出每个请求的价格,并可随着请求数量和成本的变化定期(例如每年一次或两次)调整。

如果使用其他服务的成本完全透明,开发者将能更好地了解怎样做对自己的服务或整个组织是最有益的。

客户端库

谈到其他服务,要做的工作还有很多。例如:发现、身份认证、断路开关、连接池,以及超时。与其让每个团队完全从零开始自行重写这一套机制,可考虑将其与合理的默认值一起封装到客户端库中。

客户端库不能包含与任何服务有关的业务逻辑。其范围应该仅限于辅助性的内容,例如连接性、传输、日志,以及监控。另外要提防共享客户端库可能造成的风险。

开发

源代码控制

每个服务都该有自己的代码库。这样可确保签出规模尽可能小,源代码控制日志更简洁,并能对访问进行更细化的控制。服务并不是一起部署的,服务源代码也不该共置在一起。

此外还要对源代码控制实现标准化。这样可简化团队工作,并让持续集成和持续交付等工作更简单。

开发环境

开发者需要能在自己计算机上快速工作。为确保针对任何操作系统提供一致的环境,可将开发环境打包为虚拟机。

然而考虑到微服务方法的复杂度以及所涉及服务数量,让开发者通过一台计算机完成所有开发工作并不现实。此时可将在本地开发和运行的服务与云中运行的隔离环境结合在一起。这样开发者就可在自己的开发环境中快速迭代,同时配合云中运行的其他服务进行测试。需要注意的是,隔离对这种云环境来说非常关键。在开发者间共享环境只会由于非预期的变更造成大量混乱。

持续集成

需要尽快将开发中的代码与主线分支进行集成。对主线分支的更新可触发持续集成系统自动构建,构建可触发自动化测试以确认该构建是否足够完善。

自动化测试是在开发者的计算机上运行的,因此可在持续集成系统上运行更复杂和耗时的测试。这方面有很多流行的解决方案可通过计算机群集并行执行多个测试,确保能更快速完成工作。

如果所有测试都成功通过,持续集成系统会将待部署程序包发布至自动化部署系统。

这样做能获得哪些收益:

  • 代码可更快速集成,每个人可更清楚地看到变更。如果多人更改同一处代码造成冲突,也可尽快发现提早解决。
  • 更频繁地运行完整测试,可更快速发现Bug。
  • 最重要的是,由于每次迭代只需要集成少量变更,开发者可对这些变更的正确性更自信。

持续集成可改善团队快速交付高质量软件的能力。

持续交付

持续交付的目标在于更快速地发布小规模变更。此时无须一次发布大量变更,可将其拆分为小块,逐个完成并发布。这个过程中系统依然处于正常运行状态下。

为实现持续集成,需要快速完成整个构建、测试,及开发周期。这意味着要建立稳固的持续集成和自动化部署流水线。

但这样做会不会让最终用户收到尚未完工的功能?

功能开关(Feature flag)可以帮助你确保新功能只有在准备好之后才会被发布给特定的用户。这样能用小规模方式部署变更,用户不会收到尚未完工的功能。

共享库带来的风险

“无法控制何时将共享库的更新部署到使用它的服务上”是使用共享库带来的最大挑战。可能需要等待数天甚至数周其他团队才会部署更新后的库。在一个以独立方式开发和部署不同服务的环境中,任何需要所有服务同步更新的变更都是不切实际的做法。

此时最佳做法是发布弃用时间表(Deprecation schedule),并与服务团队协调,确保能及时应用更新。因此对共享库的任何变更也需要考虑向后兼容的问题。

如果还是不明白的话:共享库很适合管理诸如连接性、传输、日志,以及监控等辅助内容。与服务有关的业务逻辑也不应该放入共享的库中。

服务模板

除了核心业务逻辑,服务还管理一系列其他附加任务。例如:服务注册、监控、客户端负载均衡、限制管理、断路。团队应能通过这些模板快速实现服务自举(Bootstrap)以处理所有常见任务,并与平台进行恰当集成。

必须使用模板吗?

模板是为了加快团队工作速度,但并非必须的。但某些行为可能是必须的,例如实现注册、监控和日志所需的行为。此时更合理的做法是由团队自行决定是否要从零开始构建以满足对具体行为的要求,而非必须使用现成的模板。

那么可以为每个流行的技术栈创建一个模板吗?

虽然微服务催生了一种多语言(Polyglot)架构,但也不能因此失去理智。仅支持少量技术,这样的做法可以带来多个收益:

  • 团队无须为每个技术栈重新实现所需工具,可更轻松地专注于构建稳健的标准化工具。
  • 有助于促进跨团队代码审阅工作。
  • 最重要的是,可以让开发者更轻松地加入其他团队。

因此应该为每个受支持的技术栈提供模板。

服务的可替换性

随着所用服务数量逐渐增加,最终将面临架构设计的局限。届时应该已对具体需求和服务使用模式有了更深入了解,进而实现可扩展性更完善的解决方案。由于服务都是尽可能简单并专注的,此时服务替换工作也会变得更容易。

也许你会希望换用更专业的数据库,或换用其他语言。只要对已记录的接口(API和事件流)进行妥善维护,即可在不影响其他服务的前提下彻底更换整个实现。

或者也许想更换一切,包括API本身!此时可以创建全新服务。将原有服务的用户迁移至新服务,当原有服务不再使用时删掉即可。

部署

部署程序包

标准化的部署程序包是自动化部署流水线中重要的组成部件。部署程序包须满足下列特征:

  • 可部署到任何位置:同一个程序包应当能在不改动的情况下部署到任何环境:开发、准生产环境(Staging)或生产环境。
  • 外部配置/密信(Secret):配置和密信不应存储在程序包内,需要在启动时提供给程序包(或由其自行获取)。
  • 隔离的部署:如果多个服务共享同一组资源,很容易由于一个服务无意中消耗了大量资源导致其他服务受到影响。将部署的每个服务隔离起来可以将这种影响降至最低。

系统镜像可以很好地满足这些要求。可为每个服务创建系统镜像并进行版本控制。每次更新服务都可创建一个新镜像。通过对物理机、虚拟机,或容器创建这样的系统镜像,便可对系统所用资源(内存、CPU、网络等)进行限制和监控,并对不同服务提供一定程度的隔离。这样做的实际效果等同于在每台主机上只运行了一个服务。

铁打的基础架构,流水的镜像

当部署程序包是系统镜像时,绝不能对运行中的系统进行就地更新,而要通过新镜像构建的系统进行替换。这种方法可提高开发者的信心和系统可靠性,因为测试和生产环境的部署使用了完全相同的镜像。这种做法还能避免配置差异导致对生产环境进行的直接变更。

自动化部署

在将任何服务的任何版本部署到任何环境时,开发者需要通过一种统一方法触发自动化部署。确保全自动化,尽可能简单的部署,开发者也可更轻松、更频繁地部署小规模的变更。

我们的目标是零停机更新

如果要让服务离线才能应用更新,每次更新无异于向其他所有服务发出了一股震荡波。为避免这种细微的干扰(有可能对频繁的部署产生阻碍),需要通过某种方式在零停机前提下对服务进行更优雅的更新。

一种方法是轮流重启动,此时可对负载均衡器之后的实例挨个更新和重启。虽然听起来较为可行,但如果遇到问题需要回滚,还需要再一次进行全面的轮流重启动。

更稳妥的方法是让运行新老版本的实例并行运行,但不通过新版实例响应请求。随后将负载均衡器切换至新版实例,同时继续将老版实例运行一段时间,以备需要快速回滚。这种方法更强大,也更适合可临时获得更多资源的云环境。

功能开关

功能开关(Feature Flag)是在运行过程中打开或关闭特定功能的代码,借此可有效地实现代码部署和功能部署间的解耦。通过这种方式可在一段时间里以增量方式部署某个功能的代码,随后在准备就绪后将功能发布给用户。服务团队需要使用接口查看并管理平台的功能开关,用于查询开关的代码可包含在共享的库中。

增量式的功能发布

功能开关使得我们可以分阶段将功能发布给一组用户。例如优先将功能发布给10%的用户,或发布给特定地区的用户。借助这种方式可在影响到更大规模的用户前发现可能存在的问题,通过关闭开关还可以实现功能的快速回滚。

开关的寿命应该短一些

功能开关只须在功能成功部署前使用。长时间使用这样的开关是个糟糕的主意:会让用户支持工作变得更困难(因为不同用户会遇到不同行为),系统测试工作的难度会加大(因为存在多个代码路径),同时系统调试也变得更难。功能全面部署后,应尽快安排删除对应的开关。

只将开关封装在入口点中

功能开关的目的在于实现功能部署和代码部署之间的解耦。只需要将开关封装在相应功能的入口点,不要封装在所有相关的代码路径内。例如对于用户界面上可见的功能,可将开关放入为了进入相关功能需要在界面上点击的链接/按钮中。

配置管理

可部署在任何位置的部署程序包中不应包含与特定环境有关的选项或密信(Secret)。因此需要相互独立的解决方案。团队需要能管理配置,并以安全的方式让服务顺利启动。微服务平台通常针对这种目的提供了内建的解决方案。交付配置的主要做法包括:

  • 环境变量:将配置载入服务的环境变量中。
  • 文件系统卷:将包含密信和配置的文件系统挂载到服务中。
  • 共享的键/值存储:让服务直接访问共享的键/值存储。

使用环境变量需要注意一个问题:环境变量默认情况下非常易于外泄。此时可通过异常处理器(Exception handler)获取环境变量并将其发送至日志平台。子进程也会在启动时获得父进程的环境变量,有可能无意中导致密信外泄。为避免这种问题可在读取后将环境变量清空,但这只是个可选的额外操作。

运维

集中化的日志

服务的每个实例都会生成日志。在使用系统镜像作为部署程序包的情况下,每次部署新版本都会替换这些实例。因此任何日志都不应存储在实例中,这样做会导致下次部署后之前的日志全部丢失。

此时可通过平台为服务团队提供集中化的日志系统。所有服务可通过标准化日志格式,将自己的日志发送至同一个日志系统。这种方法为服务团队带来更大灵活性,可跨越所有服务、特定的某个服务,或服务的某个实例搜索日志。这一切操作都可在同一个位置进行。将日志发送至集中化日志系统所用的代码可包含在共享库中,或通过服务模板提供。

但要如何跨越多个其他服务追踪某一请求产生的影响?

这时候可以用关联ID。在与任何服务通信时都提供一个关联ID,并让服务将该ID保存在自己的日志项中。随后在跨越多个服务搜索某一关联ID时,就可用时间线的方式看到某个原始请求对所有服务造成的副作用。

集中化的监控

遭遇故障后,帮助我们快速了解问题影响范围和根源的工具可带来巨大价值。集中化监控应成为平台核心组件。这种工具可以让团队针对整个平台获得更深入的了解,尤其适合在解决连锁故障时使用。

考虑到高可用性,永远要在负载均衡器之后为一个服务运行多个实例。因此监控解决方案必须能将不同实例的衡量值汇总在一起。此外还要能在聚合后的衡量值中快速向下挖掘,以查看特定组件的详细信息。这一切都有助于帮助我们快速确定故障是否是服务端造成的,或是否要对服务的某个实例进行隔离。

要监控哪些度量值?

取决于不同类型的组件,需要监控的度量值也各不相同:

  • 基础架构:可在操作系统层面收集数据。文件系统操作、文件系统延迟、网络操作、内存使用、CPU使用。
  • 常规:发送至服务的请求。请求数、请求延迟、错误数(总数和每个错误代码分解数)。
  • 集成:该服务向其他服务发起的下游请求。请求数、请求延迟、错误数(总数和每个错误代码分列数)。
  • 外部服务:与第三方托管的服务或微服务平台之外管理的其他系统通信的服务。
  • 具体服务:与特定服务有关的任何其他衡量值。

除了与特定服务有关的衡量值外,其他一切信息都可通过服务模板或共享库中的代码自动捕获。通过使用自动化的捕获机制,还能为需要监控服务的团队提供有用的初始配置信息。

通过分布式追踪将线索连接在一起

虽然监控解决方案可以很好地帮我们确定特定服务内外发生了什么事,但依然很难跨越多个服务将不同线索连接在一起针对大环境获得更深入的理解。

分布式追踪系统的请求追踪功能可细分为对服务的每个请求进行追踪。随后所有数据会通过时间线进行可视化,借此即可更深入地了解某一特定请求是如何在不同服务之间流动的,并能快速发现性能瓶颈。

分布式追踪意在监控所记录的关联ID。这两者非常类似,追踪系统用于区分不同请求的ID也可以充当关联ID。

自动伸缩

无状态服务本身是易于伸缩的,只要根据需求在负载均衡器后添加更多实例即可。做出伸缩决策所需的信息(CPU/内存用量等)可通过监控平台获取。

很多微服务平台为实例数量的处理提供了声明性接口,这种功能非常易用。只须告知需要的实例数量,其他工作可由平台自行处理。在这样的平台上实现自动伸缩,只要以编程的方式更新“所需实例数量”即可。另外还可借助这一过程在现有实例故障后增加新的实例。

外部服务

服务可能还要与并非自己团队创建的系统通信,例如:数据库、缓存、消息队列、邮件交付系统等。这些系统可以托管式服务由第三方交付使用,或在自己组织内部自行托管相关服务。无论哪种方式,考虑到服务数量及不同环境可能需要自己专用的系统实例,都要确保这些系统的供应和管理也能实现自动化。

能不能直接将这些外部系统封装为平台上的服务?

使用持久存储提供数据库系统,并将其与自己的日志和监控系统集成,这种做法绝对可行,然而并非总是实用。一些系统对基础架构有特殊要求,尤其在高可用配置下。一些系统可能无法在故障后自动重启动。因此需要具体情况具体分析。

那么可以让多个服务共享同一个系统吗?

只要确保一个服务无法访问其他服务的配置或数据,就可以这样做。例如多个服务可共享一台通用数据服务器,但每个服务使用自己专用的数据库。服务不会发现同一台数据服务器上还运行了其他数据库。当某个服务需要用比其他服务更快的速度伸缩时,还可将其数据库放入一个专用的数据服务器。

这种方法的问题在于,共享资源可能难以单独进行隔离和监控。例如在一台共享数据服务器上,可能有一个服务占用大量资源并无意中影响到其他服务的性能。如果监控机制粒度不够细化,可能要花费大量时间才能确定有问题的服务。

人员

完整的生命周期所有权

服务团队需要拥有、运维并完善自己构建的服务。这些工作需要持续到服务退役那一刻,而非服务发布的那一刻。

通过这种方式,感受到由于架构设计局限所造成痛苦的团队,也将能顺利修复这些问题。在决定如何演化服务以满足未来增长的需求过程中,团队成员针对运维工作的进一步了解也能提供宝贵的意见和价值。在简化运维工作方面所做的全部努力最终都将进一步改善服务的稳定性。

自治的全栈团队

在构建大量小规模服务时,每个团队成员将成为多个服务的所有者。重点在于拥有这些服务的团队必须具备开发、部署,以及运维这些服务所需的全部技能和工具。他们的日常运维工作必须全面自治,这样才能快速响应不断变化的业务要求。

应对团队成员的流失

时不时会有人离职。遇到这种情况时,需要确保不会有服务成为“孤儿”。就算某个服务可以在很长时间内正常运行不出现任何问题,依然需要在出现问题后有人负责善后。

人员还会在组织内部流动。为整个微服务平台实施一致的开发、部署和运维实践,可以在服务所有权易手后将学习曲线降至最低。

团队能大到怎样的规模?

随着团队规模进一步扩大,交流沟通开始变得不易。团队规模应足够大,使其能自行完成相关工作而无须将大量时间浪费在交流沟通的过程中。例如亚马逊就以“两个披萨团队”广为人知,两个披萨恰好能让整个团队成员吃饱。