本文作者是Enchant的架构师,他最近研究了Netflix、SoundCloud、谷歌、亚马逊、Spotify等公司的微服务实践,并根据自己的理解总结出了一套适用于现代Web和云技术的微服务实战经验。本文是其中的第二篇,重点介绍服务的交互以及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可由面向用户的聚合服务,或由任何需要发出请求,但该请求并非传入请求直接导致的意外结果的服务生成。任何足够随机的字符串(例如UUID)都可用作这个用途。
在最终一致的世界里,服务可通过订阅事件馈送源(Feed)的方式与其他服务同步数据。
虽然听起来很简单,但魔鬼往往隐藏在细节里。数据库和事件流通常是两个不同系统,这使得你非常难以用原子级方式同时写入这两个系统,进而难以确保最终一致性。
可以使用本地数据库事务封装数据库操作,同时将其写入事件表。随后事件发布程序会从事件表读取。但并非所有数据库都支持此类事务,事件发布程序可能要从数据库提交日志中读取信息,但并非所有数据库都能暴露此类日志。
... 或者就保持不一致的状态,稍后再修复吧
分布式系统很难实现一致性。就算以分布式一致性为核心特性的数据库系统也要很多额外操作才能实现。与其打这样一场硬仗,其实也可以考虑使用某种尽可能足够好的同步解决方案,并在事后通过专门的过程找出并修复不一致的地方。
这种方式也能实现最终一致性,只不过“不一致的窗口期”可能会略微长于通过复杂的方式跨越不同系统(数据库和事件流)实现一致性时的窗口期。
每块数据都应该有一个单一数据源(Single source of truth)
就算要跨越多个服务复制某些数据,也应该让一个服务始终成为任何其他数据的单一数据来源。对数据的所有更新需要在这个数据源上进行,同时这个数据源也可在未来用于进行一致性验证时的记录来源。
如果某些服务需要强一致怎么办?
首先需要复查服务边界是否正确设置。如果服务需要强一致,通常将数据共置在一个服务(以及一个数据库)这样的做法更合理,这样可用更简单方式提供事务保障。
如果确认服务边界设置无误但依然需要强一致,则要检查一下分布式事务,这种机制很难妥善实现,同时可能会在两个服务间产生强耦合。建议将其作为最后的手段。
所有API请求需要进行身份认证。这样服务团队才能更好地分析使用模式,并获得用于管理不同使用模式下对请求进行限制所需的标识符。
这种标识符是服务团队为使用该服务的用户提供的,具备唯一性的API密钥。必须具备某种颁发和撤销此类API密钥的方法。这些方法可内建于服务模板,或通过集中化身份认证服务在平台层面上提供,这样还可让服务团队以自助服务的方式管理自己的密钥。
在能够“快速失败”后,还需要能以自动方式对某些类型的请求进行重试。对于异步通信这一能力更为重要。
故障后的服务恢复上线后,如果有大量其他服务正在同一个重试窗口内重试,此时很容易给系统造成巨大压力。这种情况也叫惊群效应(Thundering herd),使用随机化的重试窗口可轻松避免这种问题。如果基础架构没有实施断路开关,建议将随机化重试窗口与指数退避(Exponential backoff)配合使用以便让请求进一步分散。
遇到持久的故障又该怎么办?
有时候故障可能是格式有误的请求造成的,并非目标服务故障所致。这种情况下无论重试多少次都不会成功。当多次重试失败后,应将此类请求发送至一个死信队列(Dead queue)以便事后分析。
服务间的通信只能通过已确立的通信协议进行,不能有例外。如果发现有服务直接与其他服务的数据库通信,肯定是哪里做错了。
另外要主意:如果能对服务通信方式做出通用假设(Universal assumption),就能更容易地为防火墙后的服务组件提供更稳妥的保护。
当一个团队使用另一个团队提供的服务时,他们通常会假设这些服务是免费的。虽然可以免费使用,但对其他团队以及组织来说,依然会产生成本。为了更高效地利用现有资源,团队需要了解不同服务的成本。
有一种很强大的方式可以帮我们做到这一点:为用到的其他服务提供服务发票(Service invoice)。发票中不要只列出用到的其他服务,而是列出实际成本金额。服务开发和运维成本可转嫁给服务的用户,而服务实际成本应包含开发成本、基础架构成本,以及使用其他服务的成本。这样就可以将总成本均摊计算出每个请求的价格,并可随着请求数量和成本的变化定期(例如每年一次或两次)调整。
如果使用其他服务的成本完全透明,开发者将能更好地了解怎样做对自己的服务或整个组织是最有益的。
谈到其他服务,要做的工作还有很多。例如:发现、身份认证、断路开关、连接池,以及超时。与其让每个团队完全从零开始自行重写这一套机制,可考虑将其与合理的默认值一起封装到客户端库中。
客户端库不能包含与任何服务有关的业务逻辑。其范围应该仅限于辅助性的内容,例如连接性、传输、日志,以及监控。另外要提防共享客户端库可能造成的风险。