@liuhui0803
2016-08-30T16:24:01.000000Z
字数 20559
阅读 2814
微服务
架构
最佳实践
摘要:
微服务架构主要是为了应对复杂度。该架构并未使用单一的复杂系统,而使用了一系列相互之间存在复杂交互的简单服务,其目标在于确保复杂度能够得到控制。本文作者在微服务架构的规划中总结了一套适用于现代化Web和云技术的实战经验。
正文:
产品的复杂度与日俱增。现在的产品已经越来越难以像以前那样快速进行演化,是时候寻找一种更好的方法了。微服务架构承诺可以让团队更快速地演化... 但与此同时也带来了一系列全新的挑战。
在为Enchant搭建微服务架构时,我希望总结出一套适用于现代化Web和云技术的实战经验。为确保少犯错误,我还从这一领域的先行者(如Netflix、Soundcloud、谷歌、亚马逊、Spotify等)身上学到了很多经验。
微服务架构主要是为了应对复杂度。该架构并未使用单一的复杂系统,而使用了一系列相互之间存在复杂交互的简单服务,其目标在于确保复杂度能够得到控制。
微服务架构自身也会导致复杂度增加。此时需要运维的系统数量不仅没有减少,反而变得更多。到处散布着日志文件,分布式环境中难以维持一致性,类似的问题还有很多。我们的目标在于实现一种简化复杂度的状态:知道复杂度问题无法避免,但可以通过工具和过程加以控制。
明确了要求后,我打算从下列几方面着手:
服务团队需要能自由构建必要的东西。与此同时为确保维持一致性并管理愈加复杂的运维工作,还需要设立相应的标准。这意味着需要让通信、日志、监控和部署等工作实现标准化。
平台本身就是一系列标准和工具组合的产物,借助平台将能更容易地创建并运维满足标准要求的服务。
.. 需要有控制面
团队该如何与平台交互?通常可能需要大量不同的Web接口进行持续集成、监控、日志,以及文档记录。团队需要用一个仪表盘作为这一切的起点。能列出所有服务并链接到各种内部工具的简单仪表盘就行。理想情况下,仪表盘还应该可以将通过各种内部工具收集的数据概括地显示出来。
对于已经在使用团队交流解决方案的组织,此时一种较为常见的做法是使用自定义的机器人程序将常用任务直接显示在聊天界面中。这种做法在需要触发测试和部署,或需要快速了解某个运行中服务状态等时候较为有用。通过这种做法,聊天记录也可以变成一种针对以往操作进行审计的审计轨迹。
平台内部通常运行了很多服务。取决于具体规模,“很多”可能是指十多个,数百个,甚至上千个。每个服务均通过相互独立的程序包封装了一定的业务能力。为确保服务能专注于某一具体用途,所构建的服务必须尽可能小;但为了将服务之间的交互减至最低,服务又要足够大。
服务的本质
每个服务都需要独立开发和部署。如果没有对API做出破坏性改动,则无需与其他服务团队进行协调。每个服务实际上都是相关团队自己的产品,有着自己的基准代码和生命周期。
如果发现需要配合其他服务一起部署,无疑是你哪里做错了。
如果你的所有服务使用了同一套基准代码,无疑是你哪里做错了。
如果每次部署一个服务前要先提醒他人注意,无疑是你哪里做错了。
提防共享的库!如果对共享的库中内容进行更改需要同时更新所有服务,那么你的所有服务之间就存在紧密的耦合点。在为共享库中引入任何内容之前,一定要充分理解可能产生的后果。
服务的本质
当有多个服务直接读写数据库中同一张表时,对这些表做任何改动都需要就所有服务的部署进行协调。这一点违背了服务相互独立这一主要目标。共享的数据存储很容易不经意间造成耦合。每个服务需要有自己的私有数据。
私有数据还能提供另一个优势:可以根据服务的具体用例选择最适合的数据库技术。
每个服务都需要有自己的数据服务器吗?
不一定。每个服务需要自己的数据库,但这些数据库可以共置在一台共享的数据服务器上。重点在于不应该让服务知道其他服务底层数据库的存在。这样即可用一台共享的数据服务器着手开发,以后只要更改配置即可将不同服务的数据库隔离起来。
然而共享的数据服务器也可能造成一些问题。首先会形成单点故障,进而导致一大批服务同时故障,这一点绝不能掉以轻心。其次很可能因为一个服务占用太多资源而无意中对其他服务造成影响。
服务的本质
这个问题很复杂。每个服务应该是一种能提供某些业务能力的自治单位。
服务应当弱耦合在一起,对其他服务的依赖应尽可能低。一个服务与其他服务的任何通信都应通过公开暴露的接口(API、事件等)实现,这些接口需要妥善设计以隐藏内部细节。
服务应当具备高内聚力。密切相关的多个功能应尽量包含在同一个服务中,这样可将服务之间的干扰降至最低。
服务应当包含单一的界限上下文。界限上下文(Bounded context)可将某一领域的内部细节,包括该领域特定的模块封装在一起。
理想情况下,你必须对自己的产品和业务有足够的了解才能确定最自然的服务边界。就算一开始确定的边界是错误的,服务之间的弱耦合也可以让你在未来轻松重构(例如结合、拆分、重组)。
等等,那共享模型呢?
再深入看看界限上下文吧。应该尽量避免创建“沉默”的CRUD服务,这类服务只能导致紧密的耦合以及贫瘠的内聚力。领域驱动的设计所引入的界限上下文这一概念可以帮助我们确定最合理的服务边界。界限上下文可以将某领域的相关内容封装在一起(例如我们将其封装为服务)。多个界限上下文可通过妥善定义的接口(我们使用了API)进行通信。虽然一些模型可以完全封装在界限上下文内部,但有些模型可能有不同用例(和相关属性),会跨越多个界限上下文。这种情况下每个界限上下文须具备与模型有关的属性。
这里可以举一个更具体的例子。例如技术服务台解决方案Enchant,该系统的核心模型是工单(Ticket),每个工单代表客户的一个支持请求。工单服务负责管理工单的整个生命周期,具备主要属性。此外还有报表服务负责预先计算并存储每个特定工单的相关统计信息。每个工单的报表统计信息用两种方式存储:
将统计信息存储在报表中,这种做法可以更好地满足服务要求:弱耦合、高内聚,每个服务自行负责自己的界限上下文。然而这种方法会增加复杂度。工单的改动需要通知报表服务,为此可以让报表服务订阅由工单服务提供的事件流,这样也可以让服务之间的耦合程度降至最低。
但服务到底能大到什么程度?
微服务这个词中的微字与物理规模或代码行数没有任何关系,而代表了最小化的复杂度。服务应当足够小,只承担某个单一作用,与此同时服务也要足够大,以便将服务之间的通信量降至最低。
没什么硬性规定限制服务只能是一个进程,一个虚拟机,或一个容器。服务应包含能够以自治方式实现业务能力所需的全部功能,甚至可以包含外部服务,例如用于实现持久存储的数据服务器,用于异步工作进程的作业队列,甚至确保一切快速运行所需的缓存。
服务的本质
无状态(Stateless)服务实例不存储与上一个请求有关的任何信息,传入的请求可发送至服务的任何实例。这种做法的主要收益在于可以简化运维和缩放工作。可以在一个简单的负载平衡器之后运行服务,随后即可根据请求数量的变化轻松地增加或删除实例,同时故障实例的替换过程也更为简单。
话虽如此,很多服务可能依然需要用某种方式存储数据。这些数据可推送至外部服务中,例如以磁盘作为边界的数据库服务器或以内存作为边界的缓存。
服务的本质
无论怎么看,分布式系统的一致性都是个老大难问题。与其对抗这种问题,更好的方法是让分布式系统能够实现最终一致性。在最终一致的系统中,虽然不同服务在特定时间点会对数据产生有分歧的视图,但最终会通过汇聚获得一致视图。
如果能对服务进行妥善的建模(例如弱耦合和高内聚),你会发现对大部分用例来说,最终一致性是一种足够好的默认设置。创建最终一致的分布式系统,这种做法在方向上与创建弱耦合系统的目标是一致的。这些方式都趋向于进行异步通信,面对下游服务的故障可以获得固有的保护。
举个例子。在Enchant中有一个工单服务(用于管理客户支持请求)和一个报表服务(用于计算工单的统计信息)。报表服务可以从工单服务获得一个异步的内容更新馈送源(Feed)。这意味着当任何时候工单服务中产生更新后,报表服务即可在几秒后获得相关信息。在这几秒钟时间内,这两个服务会对底层的客户请求产生有差异的视图。对于报表这一具体用例来说,几秒钟的延迟是可接受的。此外这种方式还提供了一个附加的收益,可以保护工单服务不受报表服务故障的影响。
服务的本质
在采用最终一致性的设置后会发现,当请求等待回应而受阻时,并不需要在这过程中将其他所有操作全部完成。任何可以等待(并且是资源或时间密集型)的操作都可以作为作业传递给异步工作进程。
这种方式可以:
理解幂等性
上文提到过作业运行失败后可重试。自动重试失败作业的做法会遇到一个挑战:可能会无法知道失败的作业在失败前是否完成了自己的工作。为确保相关操作足够简单,必须让作业具备幂等性(Idempotent)。在这个语境下,幂等性意味着多次运行同一个作业不会产生任何消极影响。无论作业运行一次或多次,最终结果必须相同。
服务的本质
服务(及其API)和文档一样重要。对于每个服务,一定要提供清晰易用的使用文档。理想情况下,所有服务的使用文档应该放在同一个位置,并确保当需要使用时,服务团队不需要花费大量时间考虑文档到底在哪里。
API有变化后会怎样?
需要将已记录端点的变动情况通知给其他所依赖服务的所有者。通知系统必须了解不同服务目前的所有者是谁,并了解有关团队或所有权的全部改动。这些信息可通过平台进行追踪并提供。
服务的本质
无状态服务的优势之一在于可以同时为服务使用多个实例。由于每个实例都是无状态的,因此可由任何实例处理任何请求。这一特性与负载平衡器非常相符,借此可对服务进行更简单的缩放。同时几乎所有云平台都提供了负载平衡器功能。
传统的负载平衡器位于接收端,此时客户端只知道一个目标的存在。该目标接收请求并将其分散至多个(对外隐藏的)内部服务实例。另一种做法是客户端负载平衡器,例如Netflix使用Ribbon构建了这样的实现。对于客户端负载平衡器,客户端可以知道多个可能的目标,并根据策略选择自己使用的目标,例如可以首选使用同一数据中心内的目标以降低请求延迟。此外还可配合使用这两种做法:首先从传统负载平衡器着手,随后在需要实现高级路由功能时添加客户端负载平衡器。
客户端负载平衡器可包含在客户端库中。
服务的本质
如果需要让数据跨越私有网络边界与外部客户端通信,将有更多额外要求需要考虑。数据需要通过某种方式编码,需要将往返通信的次数降至最低,为确保这些客户端只能访问需要的数据,还需要提高安全措施,诸如此类。
聚合服务将负责收集来自其他服务的数据,处理任何特殊的编码或压缩要求,同时由于客户端只能与这一个服务通信,这样做还有助于简化安全工作的实现。
取决于具体需求,你可能觉得有必要构建多个聚合服务,每个服务针对一个用例(例如公开API、移动客户端、桌面客户端等)。如果需求更简单,通常一个聚合服务就够了。
对一个聚合服务中的业务逻辑量进行限制
由于聚合服务需要处理来自多个服务的数据,很容易无意中泄露其中包含的业务逻辑并降低服务的内聚力。这种情况需要当心!任何与某个服务有关的业务逻辑应该只属于该服务。聚合服务的本意是在外部客户端和内部服务之间充当一个稀薄的粘合层。
如果某个内部服务故障了怎么办?
答案主要取决于具体的上下文。因此首先需要考虑下列几个问题:
聚合服务的本质使得这种服务依赖(并紧密耦合于)一个或多个其他服务,因此这种服务会受到其他任何相关故障服务的影响... 进而导致聚合服务故障。因此必须了解不同的故障场景并制定好应对措施。
服务的本质
需要结合数据所在位置或服务在整个大架构中的角色考虑服务的安全需求。可能需要对传输中或存储后的数据提供安全保护。可能需要在服务边界或私有网络边界实施网络安全机制。很难确保万无一失,但下文提供了一些值得考虑的原则:
微服务架构导致大量小规模但用途更单一的服务相互通信。这就造成了一系列问题:服务相互之间如何查找?是否统一使用相同的协议?如果一个服务无法与其他服务通信会怎样?在讨论服务交互的过程中将探讨这些问题。
服务的交互
随着构建越来越多的服务,在服务间使用标准化的通信方法这一要求愈加重要。由于服务不一定使用相同的语言编写,因此通信协议的选择必须不依赖具体语言和平台。此外还要同时考虑同步和异步的通信。
首先,传输协议
HTTP是同步通信的最佳选择。HTTP客户端几乎已经得到所有语言的支持,很多云平台都内建了HTTP负载平衡器,该协议本身内建了用于缓存、持久连接、压缩、身份验证,以及加密所需的机制。最重要的是,围绕该协议有一个稳健成熟的工具生态体系可供使用:缓存服务器、负载平衡器、优秀的浏览器端调试器,甚至可以对请求进行重播的代理。
HTTP的一大不足在于:这个协议较为繁琐,需要频繁发送纯文本的头字段(Header),并频繁建立和终止连接。虽然可以争辩说考虑到HTTP生态系统已经提供的巨大价值,这些都是合理的取舍,但其实已经有了更好的选择:HTTP/2。通过对头字段进行压缩并通过一个持久连接实现多路复用请求(Multiplexing request),该协议有效地解决了繁琐问题,同时维持了与老版本客户端的向后兼容性。HTTP目前依然实用,未来一样很好用。
话虽如此,如果你已经达到一定规模,通过降低内部传输的开销可以对底线造成较为显著的改善,那么也许更适合实用其他传输方式。
对于异步通信,需要实施发布订阅模式。为此有两个主要方法:
Enterprise Service Bus(ESB)或Messaging Fabric呢?
过于笨重的Messaging Fabric最大的问题在于这种技术鼓励将业务逻辑推送至服务之外直达消息层。这会降低服务的内聚力,并增加了额外的技术层,随着使用时间的延长可能无意间导致复杂度逐渐提高。与一个服务有关的任何业务逻辑都应属于该服务,并由该服务的团队负责管理。强烈建议坚守智能的服务+沉默的管线这样的原则,这样可以确保不同团队维持自己的自治能力。
接着再来谈谈序列化的格式
这方面有两个主要的竞争者:
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的设计更像是一种艺术而非科学。
由于已经选择HTTP作为传输协议,为了释放HTTP的全部潜力,还需要将HTTP与REST配合使用。RESTful API提供了资源丰富的端点,可通过GET、POST以及PATCH等动词操作。我之前写的一篇有关RESTful API设计的文章中详细介绍了对外API的设计,这篇文章中的大部分内容也适用于微服务API的设计。
但是为什么服务API必须是面向资源的?
这样可以让不同服务的API实现一致性并且更简洁。借此可以通过更易于理解的方式检索或搜索内容,无须寻找修改资源某一特定属性所需的方法,可直接针对资源使用PATCH(部分更新)。这样可减少API上的端点数量,有助于进一步降低复杂度。
由于大部分现代化公开API都是RESTful API,因此有丰富的工具可供使用。例如客户端库、测试自动化工具,以及自省代理(Introspecting proxy)。
服务的交互
在一个服务实例变化不定的环境中,用硬编码指定IP地址的方式无疑是行不通的,需要通过某种发现机制让服务能够相互查找。这意味着对于到底有哪些可用服务必须具备“权威的信息来源”。此外还需要通过某种方式借助这个权威来源发现服务实例之间的通信,并对其进行平衡。
服务注册表
这就是信息的权威来源。其中包含了有关可用服务的信息,以及服务的网络位置。考虑到该服务本身的一些关键特质(是一种单一故障点),该服务必须具备极高的容错能力。
可以通过两种方式将服务注册至服务注册表:
在大架构方面,服务注册表也可以充当监控系统或系统可视化工具所用状态信息的来源。
发现和负载平衡
创建一个可用的注册表只解决了问题的一半,还需要实际使用这种注册表才能让服务以动态的方式相互发现!此时主要有两种方法:
使用负载平衡器和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)。发票中不要只列出用到的其他服务,而是列出实际成本的金额。服务的开发和运维成本可以转嫁给服务的用户,而一个服务的实际成本应该包含开发成本、基础结构成本,以及使用其他服务的成本。这样就可以将总成本均摊计算出每个请求的价格,并可以随着请求数量和成本的变化定期(例如每年一次或两次)调整。
如果使用其他服务的成本完全透明,开发者也可以更好地了解怎样做对自己的服务或整个组织是最有益的。
服务的交互
在谈到其他服务时,需要做的工作还有很多。例如:发现、身份认证、断路开关、连接池,以及超时。与其让每个团队完全从零开始自行重写这一套机制,可以考虑将其与合理的默认值一起封装到客户端库中。
客户端库不能包含与任何服务有关的业务逻辑。其范围应该仅限于辅助性的内容,例如连接性、传输、日志,以及监控。另外要提防共享客户端库可能造成的风险。
开发
每个服务都应该有自己的代码库。这样可以确保签出规模尽可能小,源代码控制日志更简洁,并能对访问进行更细化的控制。服务并不是一起部署的,服务的源代码也不应该共置在一起。
此外还要对源代码控制技术实现标准化。这样可以简化团队工作,并让持续集成和持续交付等工作更简单。
开发
开发者需要能在自己的计算机上快速开始工作。为确保针对任何操作系统提供一致的环境,可将开发环境打包为虚拟机。
然而考虑到微服务方法的复杂度以及所涉及的服务数量,让开发者通过一台计算机完成所有开发工作的做法并不现实。此时可将在本地开发和运行的服务与云中运行的隔离环境结合在一起。这样开发者就可以在自己的开发环境中快速迭代,同时配合云中运行的其他服务进行测试。这里需要注意的是,隔离对于这种云环境来说非常关键。在开发者之间共享环境只会由于非预期的变更造成大量的混乱。
开发
需要尽快将开发中的代码与主线分支进行集成。对主线分支的更新可以触发持续集成系统自动进行构建,构建可触发自动化测试以确认该构建是否足够完善。
由于自动化测试是在开发者的计算机上运行的,因此可以在持续集成系统上运行更复杂和耗时的测试。这方面有很多流行的解决方案可以通过计算机群集并行执行多个测试,确保能更快速完成工作。
如果所有测试都成功通过,持续集成系统会将待部署的程序包发布至自动化的部署系统。
这样做能获得哪些收益:
持续集成可以改善团队快速交付高质量软件的能力。
开发
持续交付的目标在于更快速地发布小规模变更。此时无须一次性发布大量变更,而是可以将其拆分为小块,逐个完成并发布。在这个过程中系统依然处于正常运行的状态下。
小规模发布的做法很棒。更容易测试,可简化代码审阅工作,可以让开发者更自信地发布并部署一系列小规模变更。
为了实现持续集成,需要快速完成整个构建、测试,以及开发周期。这意味着需要建立稳固的持续集成和自动化部署管线。
但这样做会不会让最终用户收到尚未完工的功能?
通过使用功能开关(Feature flag),即可在准备好之后将功能发布给特定的用户组。这样便能用小规模的方式部署变更,用户不会收到尚未完工的功能。
开发
使用共享的库,最大的风险在于,对于更新何时部署到使用该更新的服务,你能获得的控制力极为有限。可能需要等待数天甚至数周其他团队才会部署更新后的库。在一个以独立的方式开发和部署不同服务的环境中,任何需要所有服务同步更新的变更都是不切实际的做法。
此时的最佳做法是发布弃用时间表(Deprecation schedule),并与服务团队协调,以确保能及时地应用更新。因此对共享库的任何变更也需要考虑向后兼容的问题。
如果还是不明白的话:共享的库很适合管理诸如连接性、传输、日志,以及监控等辅助内容。与服务有关的业务逻辑也不应该放入共享的库中。
开发
除了核心业务逻辑,服务还要管理一系列其他附加任务。例如:服务注册、监控、客户端负载平衡、限制管理、断路。团队应当能通过这些模板快速实现服务自举(Bootstrap)以处理所有常见任务,并与平台进行恰当的集成。
必须使用模板吗?
模板的使用是为了加快团队工作速度,但并非必须的。然而某些行为可能是必须的,例如实现注册、监控和日志所需的行为。此时更合理的做法是由团队自行决定是否要从零开始进行构建以满足对具体行为的要求,而非必须使用现成的模板。
那么我可以为每个流行的技术堆栈创建一个模板吗?
虽然微服务催生了一种多语言(Polyglot)的架构,但也不能因此而失去理智。仅支持少量技术,这样的做法可以带来多个收益:
因此你应该为每个受支持的堆栈提供模板。
开发
随着所用服务数量逐渐增加,最终将面临架构设计的局限。届时你应该已经对具体需求和服务的使用模式有了更深入了解,可以实现一套可扩展性更完善的解决方案。由于服务都是尽可能简单并专注的,此时服务的替换工作也会变得更容易。
也许你会希望换用更专业的数据库,或者换用其他语言堆栈。只要对已记录的接口(API和事件流)进行妥善的维护,即可在不影响其他服务的前提下彻底更换整个实现。
.. 或者也许你想更换一切,包括API本身!此时你也可以创建全新的服务。将原有服务的用户迁移至新服务,当原有服务不再使用时,将其删除即可。
部署
标准化的部署程序包是自动化部署管线中重要的组成部件。
部署程序包须满足下列特征:
系统映像可以很好地满足这些要求。可以为每个服务创建系统映像,并进行版本控制。每次更新服务都可以创建一个新映像。可以针对物理计算机、虚拟机,或容器创建这样的系统映像。这样便可以对系统所使用的资源(内存、CPU、网络等)进行限制和监控,借此我们可以对不同服务提供一定程度的隔离。这样做的实际效果等同于在每台主机上只运行了一个服务。
铁打的基础结构,流水的映像
当部署程序包是系统映像时,绝对不能对运行中的系统进行就地更新,而是要用通过新映像构建的系统进行替换。这种方法可以提高你的自信和系统的可靠性,因为测试和生产环境的部署使用了完全相同的映像。此外这种做法还能避免配置差异导致对生产环境进行的直接变更。
部署
在将任何服务的任何版本部署到任何环境时,开发者需要通过一种统一的方法触发自动化部署。确保全自动化,尽可能简单的部署,开发者也可以更轻松、更频繁地部署小规模的变更。
我们的目标是零停机更新
如果要让服务离线才能应用更新,那么每次更新无异于向其他所有服务发出了一股震荡波。为了避免这种细微的干扰(有可能对频繁的部署产生阻碍),需要通过某种方式在零停机前提下对服务进行更为优雅的更新。
一种方法是轮流重启动,此时可以对负载平衡器之后的实例挨个进行更新和重启动。虽然听起来较为可行,但如果遇到问题需要回滚,还需要再一次进行全面的轮流重启动。
更稳妥的方法是让运行新版本的实例和老版本实例并行运行,但此时不通过新版实例响应请求。随后将负载平衡器切换至运行新版本的实例,同时继续将老版本实例运行一段时间,以备需要进行快速回滚。这种方法更为强大,也更适合可以临时获得更多资源的云环境。
部署
功能开关(Feature Flag)是指可供你在运行过程中打开或关闭特定功能的代码,借此可有效地实现代码部署和功能部署之间的解耦。通过这种方式可以在一段时间里以增量的方式部署某个功能的代码,随后在一切准备就绪后便可将该功能发布给用户。
服务团队需要使用接口查看并管理平台的功能开关,用于查询开关的代码可包含在共享的库中。
增量式的功能发布
功能开关使得我们可以分阶段将功能发布给一组用户。例如优先将功能发布给10%的用户,或发布给特定地区的用户。借助这种方式可在影响到更大规模的用户前发现可能存在的问题,通过关闭开关还可以实现功能的快速回滚。
开关的寿命应该短一些
功能开关只须在功能成功部署之前使用。长时间使用这样的开关是个糟糕的主意:会让用户支持工作变得更困难(因为不同用户会遇到不同行为),系统测试工作的难度也会加大(因为存在多个代码路径),同时系统调试也会变得更难。当功能全面部署后,应尽快安排删除对应的开关。
只将开关封装在入口点中
功能开关的目的在于实现功能部署和代码部署之间的解耦。因此只需要将这种开关封装在相应功能的入口点中,而不要封装在所有相关的代码路径内。例如对于用户界面上可见的功能,可将开关放入为了在界面上进入该功能而需要点击的链接/按钮中。
部署
可以部署在任何位置的部署程序包中不应该包含与特定环境有关的选项或密信(Secret)。因此需要相互独立的解决方案。团队需要能管理配置,并以安全的方式让服务顺利启动。微服务平台通常针对这种目的提供了内建的解决方案。
交付配置的主要做法包括:
如果使用环境变量需要注意一个问题,环境变量默认情况下非常易于外泄。此时可通过异常处理器(Exception handler)获取环境变量并将其发送至日志平台。子进程也会在启动时获得父进程的环境变量,因此有可能无意中导致密信外泄。为避免这种问题可在读取后将环境变量清空,但这只是个可选的额外操作。
运维
服务的每个实例都会生成日志。在使用系统映像作为部署程序包的情况下,每次部署新版本都会替换这些实例。因此任何日志都不应存储在实例中,这样做会导致下次部署后之前的日志将全部丢失。
此时可通过平台为服务团队提供集中化的日志系统。所有服务可以通过标准化的日志格式,将自己的日志发送至同一个日志系统。这种方法为服务团队带来更大灵活性,可以跨越所有服务、特定的某个服务,或服务的某个实例搜索日志。这一切操作都可以在同一个位置进行。
将日志发送至集中化日志系统所用的代码可包含在共享的库中,或通过服务模板提供。
但要如何跨越多个其他服务追踪某一请求产生的影响?
这时候可以使用关联ID。在与任何服务通信时都提供一个关联ID,并让服务将该ID保存在自己的日志项中。随后在跨越多个服务搜索某一关联ID时,就可以用时间线的方式看到某个原始请求对所有服务造成的副作用。
运维
遭遇故障后,帮助我们快速了解问题影响范围和根源的工具可以为我们带来巨大的价值。集中化的监控应成为平台的核心组件。这种工具可以让团队针对整个平台获得更深入的了解,尤其适合在解决连锁故障时使用。
考虑到高可用性,永远应该在负载平衡器之后为一个服务运行多个实例。因此监控解决方案必须能够将不同实例的衡量值汇总在一起。此外还需要能在聚合后的衡量值中快速向下挖掘,以查看特定组件的详细信息。这一切都有助于帮助我们快速故障是否是服务端造成的,或是否需要对服务的某个实例进行隔离。
要监控哪些度量值?
取决于不同类型的组件,需要监控的度量值也各不相同:
除了与特定服务有关的衡量值之外,其他一切信息都可以通过服务模板或共享库中的代码自动捕获。通过使用自动化的捕获机制,还能为需要监控服务的团队提供有用的初始配置信息。
通过分布式追踪将线索连接在一起
虽然监控解决方案可以很好地帮助我们确定特定服务内外发生了什么事,但依然很难跨越多个服务将不同线索连接在一起针对大环境获得更深入的理解。
分布式追踪系统的请求追踪功能可细分为对服务的每个请求进行追踪。随后所有数据会通过时间线进行可视化,借此即可更深入地了解某一特定请求是如何在不同服务之间流动的,并能快速发现性能瓶颈。
分布式追踪意在监控所记录的关联ID。这两者非常类似,追踪系统用于区分不同请求的ID也可以充当关联ID。
运维
无状态的服务本身是易于缩放的,只要根据需求在负载平衡器之后添加更多实例即可。做出缩放决策所需的信息(CPU/内存用量等)可通过监控平台获取。
很多微服务平台为实例数量的处理提供了声明性接口,这种功能非常易用。只须告知需要的实例数量,其他工作可由平台自行处理。在这样的平台上实现自动缩放,只需要以编程的方式更新“所需实例数量”即可。另外还可以借助这一过程在现有实例故障后增加新的实例。
运维
服务可能还需要与并非自己团队创建的系统通信,例如:数据库、缓存、消息队列、邮件交付系统等。这些系统可以托管式服务的方式由第三方交付给你的团队使用,或在自己组织内部自行托管相关服务。无论哪种方式,考虑到服务数量的庞大以及不同环境可能需要自己专用的系统实例,都需要确保这些系统的供应和管理也能实现自动化。
能不能直接将这些外部系统封装为平台上的服务?
使用持久存储提供数据库系统,并将其与自己的日志和监控系统集成,这种做法绝对可行,然而并非总是实用的。一些系统对基础结构有特殊的要求,尤其是在高可用配置下。一些系统可能无法在故障后自动重启动。因此需要具体情况具体分析。
那么可以让多个服务共享同一个系统吗?
只要确保一个服务无法访问到其他服务的配置或数据,就可以这样做。例如多个服务可以共享一台通用的数据服务器,但每个服务使用自己专用的数据库。服务不会发现同一台数据服务器上还运行了其他数据库。当某个服务需要用比其他服务更快的速度缩放时,还可将其数据库放入一个专用的数据服务器。
然而这种方法的问题在于,共享的资源可能难以单独进行隔离和监控。例如在一台共享的数据服务器上,可能会有一个服务占用了大量资源并无意中影响到其他服务的性能。如果监控机制的粒度不够细化,可能需要花费大量时间才能确定有问题的服务。
人员
服务团队需要拥有、运维、完善自己构建的服务。这些工作需要持续到服务退役那一刻,而非服务发布的那一刻。
通过这种方式,感受到由于架构设计局限所造成痛苦的团队,也将能顺利修复这些问题。在决定如何演化服务以满足未来增长的需求过程中,团队成员针对运维工作的进一步了解也能提供宝贵的意见和价值。在简化运维工作方面所做的全部努力最终都将进一步改善服务的稳定性。
人员
在构建大量小规模服务时,每个团队成员都将成为多个服务的所有者。重点在于拥有这些服务的团队必须具备开发、部署,以及运维这些服务所需的全部技能和工具。他们的日常运维工作必须全面自治,这样才能快速响应不断变化的业务要求。
应对团队成员的流失
时不时会有人离职。遇到这种情况时,需要确保不会有服务成为“孤儿”。就算某个服务可以在很长时间内正常运行不出现任何问题,依然需要在出现问题后有人负责善后。
人员还会在组织内部流动。为整个微服务平台实施一致的开发、部署和运维实践,可以在服务所有权易手后将学习曲线降至最低。
团队能大到怎样的规模?
随着团队规模进一步扩大,交流沟通开始变得不易。团队规模应该足够大,使其能够自行完成相关工作而无须将大量时间浪费在交流沟通的过程中。例如亚马逊就以“两个披萨团队”而广为人知,两个披萨恰好能让他们的整个团队成员吃饱。
从很多已经逐渐拥抱微服务的公司身上,我学到了很多宝贵经验:
作者:Vinay Sahni,阅读英文原文:Best Practices for Building a Microservice Architecture