《微服务设计》 - 书摘

前言

微服务是一种分布式系统解决方案,推动细粒度服务的使用,这些服务协同工作,且每个服务都有自己的生命周期。

微服务也整合了过去十年来的新概念和技术,因此得以避开许多面向服务的架构中的陷阱。

细粒度的微服务架构包含了很多方面的内容,所以本书的范围很广,适用于对系统的设计、开发、部署、测试和运维感兴趣的人们。

我意识到,虽然基础设施自动化、测试和持续交付等技术很有用,但如果系统本身的设计不支持快速变化,那所能做的事情将会受到很大限制。

我自己的经历,以及我在ThoughtWorks和其他公司的同事的经历,都强化了这样的事实:使用大量的独立生命周期的服务,会引发很多令人头痛的问题。

因为其变化的节奏很快,所以这本书更加关注理念,而不是特定技术,因为实现细节变化的速度总是比它们背后的理念要快得多。而且,我完全相信几年后我们会对微服务适用的场景了解更多,也会知道如何更好地使用它。

我建议大家看看第2章,其中涉及的话题很广,并提供了一些框架,来帮助你更加深入地学习后面的主题。

第1章 微服务

在类似Amazon和Google这样成功的大型组织中,有很多小团队,他们各自对某个服务的全生命周期负责。

随着领域驱动设计、持续交付、按需虚拟化、基础设施自动化、小型自治团队、大型集群系统这些实践的流行,微服务也应运而生。它并不是被发明出来的,而是从现实世界中总结出来的一种趋势或模式。

1.1 什么是微服务

微服务就是一些协同工作的小而自治的服务。

内聚性是指将相关代码放在一起,在考虑使用微服务的时候,内聚性这一概念很重要。Robert C. Martin有一个对单一职责原则(SingleResponsibility Principle, http://programmer.97things.oreilly.com/wiki/index.php/ The Single Responsibility Principle)的论述:“把因相同原因而变化的东西聚合到一起,而把因不同原因而变化的东西分离开来。”该论述很好地强调了内聚性这一概念。

澳大利亚RealEstate.com.au的Jon Eaves认为,一个微服务应该可以在两周内完全重写,这个经验法则在他所处的特定上下文中是有效的。

当考虑多小才足够小的时候,我会考虑这些因素:服务越小,微服务架构的优点和缺点也就越明显。使用的服务越小,独立性带来的好处就越多。但是管理大量服务也会越复杂,本书的剩余部分会详细讨论这一复杂性。如果你能够更好地处理这一复杂性,那么就可以尽情地使用较小的服务了。

服务之间均通过网络调用进行通信,从而加强了服务之间的隔离性,避免紧耦合。

API的实现技术应该避免与消费方耦合,这就意味着应该选择与具体技术不相关的API实现方式,以保证技术的选择不被限制。本书后面会讨论选择好的解耦性API的重要性。

如果系统没有很好地解耦,那么一旦出现问题,所有的功能都将不可用。有一个黄金法则是:你是否能够修改一个服务并对其进行部署,而不影响其他任何服务?如果答案是否定的,那么本书剩余部分讨论的那些好处对你来说就没什么意义了。

1.2 主要好处

微服务有很多不同的好处,其中很多好处也适用于任何一个分布式系统。但相对于分布式系统或者面向服务的架构而言,微服务要更胜一筹,它会把这些好处推向极致。

微服务可以帮助我们更快地采用新技术,并且理解这些新技术的好处。尝试新技术通常伴随着风险,这使得很多人望而却步。尤其是对于单块系统而言,采用一个新的语言、数据库或者框架都会对整个系统产生巨大的影响。

贯穿本书的一个问题是,微服务如何寻找平衡。

庞大的单块服务只能作为一个整体进行扩展。即使系统中只有一小部分存在性能问题,也需要对整个服务进行扩展。如果使用较小的多个服务,则可以只对需要扩展的服务进行扩展,这样就可以把那些不需要扩展的服务运行在更小的、性能稍差的硬件上

在有几百万代码行的单块应用程序中,即使只修改了一行代码,也需要重新部署整个应用程序才能够发布该变更。这种部署的影响很大、风险很高,因此相关干系人不敢轻易做部署。于是在实际操作中,部署的频率就会变得很低。

在微服务架构中,各个服务的部署是独立的,这样就可以更快地对特定部分的代码进行部署。如果真的出了问题,也只会影响一个服务,并且容易快速回滚,这也意味着客户可以更快地使用我们开发的新功能。

分布式系统和面向服务架构声称的主要好处是易于重用已有功能。而在微服务架构中,根据不同的目的,人们可以通过不同的方式使用同一个功能,在考虑客户如何使用该软件时这一点尤其重要。单纯考虑桌面网站或者移动应用程序的时代已经过去了。

更糟糕的是,这些程序是使用某种奇怪的Fortran变体编写的,并且只能运行在25年前就应该被淘汰的硬件上。为什么这些系统直到现在还没有被取代?其实你很清楚答案:工作量很大,而且风险很高。

当使用多个小规模服务时,重新实现某一个服务或者是直接删除该服务都是相对可操作的。

1.3 面向服务的架构

在现实世界中,由于我们对项目的系统和架构有着更好的理解,所以能够更好地实施SOA,而这事实上就是微服务架构。就像认为XP或者Scrum是敏捷软件开发的一种特定方法一样,你也可以认为微服务架构是SOA的一种特定方法。

1.4 其他分解技术

当你开始使用微服务时会发现,很多基于微服务的架构主要有两个优势:首先它具有较小的粒度,其次它能够在解决问题的方法上给予你更多的选择。

但是你还是需要很小心,如果使用共享代码来做服务之间的通信的话,那么它会成为一个耦合点。

服务之间可以并且应该大量使用第三方库来重用公共代码,但有时候效果不太好。

Java本身并没有真正的模块概念,至少要到Java 9才能看到这个特性加入到语言中。OSGI最初是Eclipse Java IDE使用的一种安装插件的方式,而现在很多项目都在使用库来对Java程序进行模块化。

Erlang采用了不同的方式,模块的概念内嵌在Erlang语言的运行时中,因此这种模块化分解的方式是很成熟的。你可以对Erlang的模块进行停止、重启或者升级等操作,且不会引起任何问题。Erlang甚至支持同时运行同一个模块的多个版本,从而可以支持更加优雅的模块升级。

1.5 没有银弹

在本章结束之前,我想强调一点:微服务不是免费的午餐,更不是银弹,如果你想要得到一条通用准则,那么微服务是一个错误的选择。你需要面对所有分布式系统需要面对的复杂性。尽管后面用很多的篇幅来讲解如何管理分布式系统,但它仍然是一个很难的问题。如果你过去的经验更多的是关于单块系统,那么为了得到上述那些微服务的好处,你需要在部署、测试和监控等方面做很多的工作。

2.1 不准确的比较

“你总提及的那个词,它的含义与你想表达的意思并不一样。”——Inigo Montoya,电影《公主新娘》中的人物

架构师的一个重要职责是,确保团队有共同的技术愿景,以帮助我们向客户交付他们想要的系统。

软件并没有类似这种真正的工程师和建筑师在物理规则方面的约束,事实上,我们要创造的东西从设计上来说就是要足够灵活,有很好的适应性,并且能够根据用户的需求进行演化。

2.2 架构师的演化视角

对于我们创造的大多数产品来说,交付到客户手里之后,还是要响应客户的变更需求,而不是简单地交给客户一个一成不变的软件包。因此架构师必须改变那种从一开始就要设计出完美产品的想法,相反我们应该设计出一个合理的框架,在这个框架下可以慢慢演化出正确的系统,并且一旦我们学到了更多知识,应该可以很容易地应用到系统中。

这个想法是Erik Doernenburg告诉我的,他认为更好的类比是城市规划师,而不是建筑师。如果你玩过SimCity,那么你应该很熟悉城市规划师这个角色。城市规划师的职责是优化城镇布局,使其更易于现有居民生活,同时也会考虑一些未来的因素。为了达到这个目的,他需要收集各种各样的信息。

城市规划师应该尽量去预期可能发生的变化,但是也需要明白一个事实:尝试直接对各个方面进行控制往往不会奏效。

当用户对软件提出变更需求时,我们需要对其进行响应并做出相应的改变。未来的变化很难预见,所以与其对所有变化的可能性进行预测,不如做一个允许变化的计划。

借鉴Frank Buschmann的一个说法:架构师的职责之一就是保证该系统适合开发人员在其上工作。

尽管他会引入较少的规范,并尽量少地对发展的方向进行纠正,但是如果有人决定要在住宅区建造一个污水池,他应该能制止。

他们需要保证系统不但能够满足当前的需求,还能够应对将来的变化。而且他们还应该保证在这个系统上工作的开发人员要和使用这个系统的用户一样开心。

2.3 分区

前面我们将架构师比作城市规划师,那么在这个比喻里面,区域的概念对应的是什么呢?它们应该是我们的服务边界,或者是一些粗粒度的服务群组。作为架构师,不应该过多关注每个区域内发生的事情,而应该多关注区域之间的事情。

如果你和四个团队在一起工作,那么每四周和每个团队都工作半天,可以帮助你有效地和团队进行沟通,并了解他们都在做什么。

2.4 一个原则性的方法

“规则对于智者来说是指导,对于愚蠢者来说是遵从。”——一般认为出自Douglas Bader

做系统设计方面的决定通常都是在做取舍,而在微服务架构中,你要做很多取舍!

做一名架构师已经很困难了,但幸运的是,通常我们不需要定义战略目标!战略目标关心的是公司的走向以及如何才能让自己的客户满意。这些战略目标的层次一般都很高,但通常不会涉及技术这个层面,一般只在公司或者部门层面制定。这些目标可以是“开拓东南亚的新市场”或者“让用户尽量使用自助服务”。

而且原则越多,它们发生重叠和冲突的可能性就越大。

通常这些实践是技术相关的,而且是比较底层的,所以任何一个开发人员都能够理解。这些实践包括代码规范、日志数据集中捕获或者HTTP/REST作为标准集成风格等。由于实践比较偏技术层面,所以其改变的频率会高于原则。

比如一个.NET团队可能有一套实践,一个Java团队有另一套实践,但背后的原则是相同的。

上面提到的一些项可以使用文档来支撑,但大多数情况下我喜欢给出一些示例代码供人阅读、研究和运行,从而传递上面涉及的那些信息。更好的方式是,创造一些工具来保证我们所做事情的正确性。

2.5 要求的标准

简单起见,我建议确保所有的服务使用同样的方式报告健康状态及其与监控相关的数据。

每个服务内的技术应该对外不透明,并且不要为了服务的具体实现而改变监控系统。日志功能和监控情况类似:也需要集中式管理。

一个运行异常的服务可能会毁了整个系统,而这种后果是我们无法承担的,所以,必须保证每个服务都可以应对下游服务的错误请求。没有很好处理下游错误请求的服务越多,我们的系统就会越脆弱。

2.6 代码治理

我也见过一个团队的士气和生产力是如何被强制使用的框架给毁掉的。基于代码重用的目的,越来越多的功能被加到一个中心化的框架中,直至把这个框架变成一个不堪重负的怪兽。如果你决定要使用一个裁剪的服务代码模板,一定要想清楚它的职责是什么。

还有一些我接触过的团队,把服务代码模板简单地做成了一个共享的库依赖,这时他们就要非常小心地防止对DRY(Don't RepeatYourself,避免重复代码)的追求导致系统过度耦合!

2.7 技术债务

可以使用技术债务的概念来帮助我们理解这个取舍,就像在真实世界中欠的债务需要偿还一样,累积的技术债务也是如此。

2.8 例外管理

。现实中的情况是多种多样的,但我个人非常支持使用拥有更好自治性的微服务团队,他们有更大的自由度来解决问题。如果你所在的组织对开发人员有非常多的限制,那么微服务可能并不适合你。

2.9 集中治理和领导

架构师的部分职责是治理。

治理通过评估干系人的需求、当前情况及下一步的可能性来确保企业目标的达成,通过排优先级和做决策来设定方向。对于已经达成一致的方向和目标进行监督。——COBIT 5

类比一下教小孩儿骑自行车的过程。你没法替代他们去骑车。你会看着他们摇摇晃晃地前行,但是,如果每次你看到他们要跌倒就上去扶一把,他们永远都学不会。而且无论如何,他们真正跌倒的次数会比你想象的要少!

2.10 建设团队

我坚定地相信,伟大的软件来自于伟大的人。所以如果你只担心技术问题,那么恐怕你看到的问题远远不及一半。

2.11 小结

演进式架构师应该理解,成功要靠不断地取舍来实现。总会存在一些原因需要你改变工作的方式,但是具体做哪些改变就只能依赖于自己的经验了。而僵化地固守自己的想法无疑是最糟糕的做法。

第3章 如何建模服务

“对手的论证让我想到了异教徒。当别人问异教徒世界由什么支撑时,他说:‘一只乌龟。’别人再问他那乌龟又由什么支撑呢?他回答:‘另一只乌龟。'”——Joseph Barker(1854)

3.2 什么样的服务是好服务

如果做到了服务之间的松耦合,那么修改一个服务就不需要修改另一个服务。使用微服务最重要的一点是,能够独立修改及部署单个服务而不需要修改系统的其他部分,这真的非常重要。

一个松耦合的服务应该尽可能少地知道与之协作的那些服务的信息。这也意味着,应该限制两个服务之间不同调用形式的数量,因为除了潜在的性能问题之外,过度的通信可能会导致紧耦合。

所以,找到问题域的边界就可以确保相关的行为能放在同一个地方,并且它们会和其他边界以尽量松耦合的形式进行通信。

3.3 限界上下文

他认为任何一个给定的领域都包含多个限界上下文,每个限界上下文中的东西(Eric更常使用模型这个词,应该比“东西”好得多)分成两部分,一部分不需要与外部通信,另一部分则需要。每个上下文都有明确的接口,该接口决定了它会暴露哪些模型给其他的上下文。

在这本书中,Evans使用细胞作为比喻:“细胞之所以会存在,是因为细胞膜定义了什么在细胞内,什么在细胞外,并且确定了什么物质可以通过细胞膜。”

财务部门不需要知道仓库的内部细节。但它确实也需要知道一些事情,比如,需要知道库存水平以便于更新账户。图3-1展示了一个上下文图表示例。可以看到其中包含了仓库的内部概念,比如订单提取员、货架等。类似地,公司的总账是财务部必备的一部分,但是不会对外共享。

有时候,同一个名字在不同的上下文中有着完全不同的含义。比如,退货表示的是客户退回的一些东西。在客户的上下文中,退货意味着打印运送标签、寄送包裹,然后等待退款。在仓库的上下文中,退货表示的是一个即将到来的包裹,而且这个包裹会重新入库。退货这个概念会与将要执行的任务相关,比如我们可能会发起一个重新入库的请求。

这些模块边界就可以成为绝佳的微服务候选。一般来讲,微服务应该清晰地和限界上下文保持一致。熟练之后,就可以省掉在单块系统中先使用模块的这个步骤,而直接使用单独的服务。然而对于一个新系统而言,可以先使用一段时间的单块系统,因为如果服务之间的边界搞错了,后面修复的代价会很大。所以最好能够等到系统稳定下来之后,再确定把哪些东西作为一个服务划分出去。

一年之后,团队识别了出非常稳定的边界,并据此将这个单块系统拆分成多个微服务。当然这并不是我见过的唯一一个过早划分的例子。过早将一个系统划分成为微服务的代价非常高,尤其是在面对新领域时。很多时候,将一个已有的代码库划分成微服务,要比从头开始构建微服务简单得多。

3.4 业务功能

所以首先要问自己“这个上下文是做什么用的”,然后再考虑“它需要什么样的数据”。

3.5 逐步划分上下文

当考虑微服务的边界时,首先考虑比较大的、粗粒度的那些上下文,然后当发现合适的缝隙后,再进一步划分出那些嵌套的上下文。

如果订单处理、库存管理及货物接收是由不同的团队维护的,那么他们大概会希望这些服务都是顶层微服务。另一方面,如果它们都是由一个团队管理的,那么嵌套式结构会更合理。其原因在于,组织结构和软件架构会互相影响,第10章会对此做详细讨论。

3.7 技术边界

我把这种架构称为洋葱架构,因为它有很多层,而且当纵切这些层次时,我只想哭。

3.8 小结

Eric Evans在《领域驱动设计》中提到的概念对于寻找明显的服务边界来说非常有用。在本章中我只提到了其中的一小部分。我推荐你看一看Vaughn Vernon的《实现领域驱动设计》,它

4.1 寻找理想的集成技术

如果你从事IT业已经超过15分钟,不用我说也应该知道,你工作的领域在不断地变化,而唯一不变的就是变化。

我很喜欢保持开放的心态,这也正是我喜欢微服务的原因。

4.3 共享数据库

目前为止,我和同事在业界所见到的最常见的集成形式就是数据库集成。使用这种方式时,如果其他服务想要从一个服务获取信息,可以直接访问数据库。如果想要修改,也可以直接在数据库中修改。这种方式看起来非常简单,而且可能是最快的集成方式,这也正是它这么流行的原因。

如果我决定为了更好地表示数据或者增加可维护性而修改表结构的话,我的消费方就无法进行工作。数据库是一个很大的共享API,但同时也非常不稳定。如果我想改变与之相关的逻辑,比如说帮助台如何管理客户,这就需要修改数据库。为了不影响其他服务,我必须非常小心地避免修改与其他服务相关的表结构。

4.4 同步与异步

这两种不同的通信模式有着各自的协作风格,即请求/响应或者基于事件。对于请求/响应来说,客户端发起一个请求,然后等待响应。这种模式能够与同步通信模式很好地匹配,但异步通信也可以使用这种模式。我可以发起一个请求,然后注册一个回调,当服务端操作结束之后,会调用该回调。

整个系统都很聪明,也就是说,业务逻辑并非集中存在于某个核心大脑,而是平均地分布在不同的协作者中。基于事件的协作方式耦合性很低。客户端发布一个事件,但并不需要知道谁或者什么会对此做出响应,这也意味着,你可以在不影响客户端的情况下对该事件添加新的订阅者。

4.5 编排与协同

如果使用协同,可以仅仅从客户服务中使用异步的方式触发一个事件,该事件名可以叫作“客户创建”。电子邮件服务、邮政服务及积分账户可以简单地订阅这些事件并且做相应处理,如图4-4所示。这种方法能够显著地消除耦合。

我还发现大多数重量级的编排方案都非常不稳定且修改代价很大。基于这些事实,我倾向于使用协同方式,在这种方式下每个服务都足够聪明,并且能够很好地完成自己的任务。

RPC(Remote Procedure Call,远程过程调用)和REST(REpresentational State Transfer,表述性状态转移)。

4.6 远程过程调用

远程过程调用允许你进行一个本地调用,但事实上结果是由某个远程服务器产生的。

举个例子,我可以让一个Java服务暴露一个SOAP接口,然后使用WSDL(Web Service Definition Language, Web服务描述语言)定义的接口生成.NET客户端的代码。其他的技术,比如Java RMI,会导致服务端和客户端之间更紧的耦合,这种方式要求双方都要使用相同的技术栈,但是不需要额外的共享接口定义。然而所有的这些技术都有一个核心特点,那就是使用本地调用的方式和远程进行交互。

那些RPC的实现会帮你生成服务端和客户端的桩代码,从而让你快速开始编码。基本不用花时间,我就可以在服务之间进行内容交互了。这通常也是RPC的主要卖点之一:易于使用。

使用本地调用不会引起性能问题,但是RPC会花大量的时间对负荷进行封装和解封装,更别提网络通信所需要的时间。

你还需要考虑网络本身。分布式计算中一个非常著名的错误观点就是“网络是可靠的”

你应该做出一个假设:有一些恶意的攻击者随时有可能对网络进行破坏,因此网络的出错模式也不止一种。服务端可能会返回一个错误信息,或者是请求本身就是有问题的。你能够区分出不同的故障模式吗?如果可以,分别如何处理?

这里存在一个问题,因为对规格说明进行了修改,所以所有的客户端都需要重新生成桩,无论该客户端是否需要这个新方法。

如果最后发现Customer对象中的年龄字段完全没有任何消费者使用,你可能想要去掉这个字段。但如果单单从服务端的实现中删除年龄,而客户端没有做相应修改的话,那么即使它们从来没有用过这个字段,客户端中和Customer对象反序列化相关的代码还是会出问题。所以为了应用这些修改,需要同时对服务端和客户端进行部署。这就是任何一个使用二进制桩生成机制的RPC所要面临的挑战:客户端和服务器的部署无法分离。如果使用这种技术,离lock-step发布就不远了。
类似地,不删除字段而是调整Customer的结构也会遇到类似的问题。一个可能的例子是,把名(firstName)和姓(surname)封装到一个新的类型中来简化代码。有一个办法可以避免这种问题,即使用一个字典类型作为参数进行传递。但如果真这么做的话,就会失去自动生成桩的好处,因为你还是要手动去匹配和提取这些字段。

如果你决定要选用RPC这种方式的话,需要注意一些问题:不要对远程调用过度抽象,以至于网络因素完全被隐藏起来;确保你可以独立地升级服务端的接口而不用强迫客户端升级,所以在编写客户端代码时要注意这方面的平衡;在客户端中一定不要隐藏我们是在做网络调用这个事实;在RPC的方式中经常会在客户端使用库,但是这些库如果在结构上组织得不够好,也可能会带来一些问题,后面会对此做更详细的讨论。

4.7 REST

REST是受Web启发而产生的一种架构风格

REST是RPC的一种替代方案。

REST风格包含的内容很多,上面仅仅给出了简单的介绍。我强烈建议你看一看Richardson的成熟度模型(http://martinfowler.com/articles/richardsonMaturityModel.html),其中有对REST不同风格的比较。

GET使用幂等的方式获取资源,POST创建一个新资源。这就意味着,我们可以避免很多不同版本的createCustomer及editCustomer方法。相反,简单地POST一个Customer的表示到服务端,然后服务端就可以创建一个新的资源,接下来可以发起一个GET请求来获取资源的表示。

从概念上来说,对于一个Customer资源,访问接口只有一个,但是可以通过HTTP协议的不同动词对其进行不同的操作。

很多时候,似乎那些已有的并且很好理解的标准和技术会被忽略,然后新推出的标准又只能使用全新的技术来实现,而这些新技术的提供者也就是制定那些新标准的公司!

REST引入的用来避免客户端和服务端之间产生耦合的另一个原则是“HATEOAS”(Hypermedia As The Engine Of Application State,超媒体作为程序状态的引擎。

超媒体的概念是:有一块内容,该内容包含了指向其他内容的链接,而这些内容的格式可以不同(如文本、图像、声音等)

HATEOAS背后的想法是,客户端应该与服务端通过那些指向其他资源的链接进行交互,而这些交互有可能造成状态转移。它不需要知道Customer在服务端的URI,相反客户端根据链接导航到它想要的东西。

这种方式的一个缺点是,客户端和服务端之间的通信次数会比较多,因为客户端需要不断地发现链接、请求、再发现链接,直到找到自己想要进行的那个操作,所以这里终究还是需要做一些取舍。我建议,你一开始先让客户端去自行遍历和发现它想要的链接,然后如果有必要的话再想办法优化。别忘了前面我们提到了很多跟HTTP相关的工具可以帮助我们。很多文献都讨论过过早优化的坏处,所以这里我就不花时间讨论这个话题了。

JSON无论从形式上还是从使用方法上来说都更简单。有些支持者认为,相比XML, JSON的内容更加紧凑,这为选用JSON增加了砝码,虽然真实世界中这并不是太重要的问题

对于有些接口来说,HTML既可以做UI,也可以做API,当然这么做是很容易出错的,因为与人类之间的交互,和与计算机之间的交互的差异是很大的。

不过我个人来讲还是很喜欢XML的,因为在工具上有很好的支持。举个例子,如果我只想提取负载(在4.13节讨论“版本化”时会再讨论该技术)中某个特定部分的话,可以使用XPATH,而支持XPATH标准的工具相当多。CSS选择器也可以,并且用起来还更简单。使用JSON的话,也有JSONPATH可用,但是目前支持该标准的工具很有限。我感觉很奇怪的是,很多人选择JSON是因为它很轻量,但是又想方设法把超媒体控制之类的概念添加进去,而这些概念是在XML中早已存在的

不过我承认我可能是少数派,大多数人还是喜欢使用JSON。

我们很容易把存储的数据直接暴露给消费者,那么如何避免这个问题呢?在我的团队中一个很有效的模式是先设计外部接口,等到外部接口稳定之后再实现微服务内部的数据持久化。

但是我发现使用库会增加复杂度,因为人们会不自觉地回到基于HTTP的RPC的思路上去,然后构建出一些共享库。在客户端和服务器之间共享代码是非常危险的,4.11节会就此做更多讨论。

还有一个小问题:有些Web框架无法很好地支持所有的HTTP动词。这就意味着你很容易处理GET和POST请求,但是PUT和DELETE就很麻烦了。

性能上也可能会遇到问题。基于HTTP的REST支持不同的格式,比如JSON或者二进制,所以负载相对SOAP来说更加紧凑,当然和像Thrift这样的二进制协议是没法比的。在要求低延迟的场景下,每个HTTP请求的封装开销可能是个问题。

比如WebSockets,虽然名字中有Web,但其实它基本上跟Web没什么关系。在初始的HTTP握手之后,客户端和服务端之间就仅仅通过TCP连接了。

尽管有这些缺点,在选择服务之间的交互方式时,基于HTTP的REST仍然是一个比较合理的默认选择。如果想了解更多,我建议你阅读《REST实战》这本书,该书对REST做了非常详细的介绍。

4.8 实现基于事件的异步协作方式

主要有两个部分需要考虑:微服务发布事件机制和消费者接收事件机制。

它会增加开发流程的复杂度,因为你需要一个额外的系统(即消息代理)才能开发及测试服务。你也需要额外的机器和专业知识来保持这些基础设施的正常运行。但一旦做好了,它会是实现松耦合、事件驱动架构的一种非常有效的方法。

不过需要注意的是,消息代理仅仅是中间件世界中的一小部分而已。队列本身是很合理、很有用的东西。但是中间件厂商通常倾向于把很多的软件打包进去,比如像企业级服务总线这样的东西。谨记这个原则:尽量让中间件保持简单,而把业务逻辑放在自己的服务中。

但要注意沉没成本的陷阱,比如当你发现有越来越多的消息代理可以满足需求时,就应该在某个时间点做出相应的调整。

举个例子,考虑一个非常耗时的异步请求/响应,需要考虑响应返回时需要怎么处理。该响应是否回到发送请求的那个节点?如果是的话,节点服务停止了怎么办?如果不是,是否需要把信息事先存储到某个其他地方,以便于做相应处理?如果API设计得好的话,短生命周期的异步操作还是比较容易管理的,但尽管如此,对于习惯了进程间同步调用的程序员来说,使用异步模式也需要思维上的转换。

这就是Martin Fowler提到的灾难性故障转移

强烈推荐你读一读《企业集成模式》这本书,其中详细讨论了很多不同的编程模式。

4.9 服务即状态机

不管你选择做一个REST忍者,还是坚持使用像SOAP这样的基于RPC的机制,服务即状态机的概念都很强大。

把关键领域的生命周期显式建模出来非常有用。我们不但可以在唯一的一个地方处理状态冲突(比如,尝试更新已经被移除的用户),而且可以在这些状态变化的基础上封装一些行为。

我仍然认为基于HTTP的REST相比其他集成技术更合理,但不管你选的是什么,都要记住上面的原则。

4.11 微服务世界中的DRY和代码重用的危险

开发人员对DRY这个缩写非常熟悉,即Don't Repeat Yourself。虽然从字面上看DRY仅仅是避免重复代码,但其更精确的含义是避免系统行为和知识的重复。

如果你想要修改的行为在系统的很多部分都有重复实现的话,那么就很容易漏掉某些部分的修改,从而导致bug,所以强制性地使用DRY一般来讲是合理的。

我们想要避免微服务和消费者之间的过度耦合,否则对微服务任何小的改动都会引起消费方的改动。而共享代码就有可能会导致这种耦合。比如,客户端可以通过库共享其中表示系统核心实体的公共领域对象,而所有的服务也会使用这个库。所以当任何部分需要对库做修改时,都会引起其他部分的重新部署。如果你的系统通过消息队列进行通信,那么你需要过滤(由不同步的部署导致的)失效的内容,忘记这么做会引起严重的问题。

跨服务共用代码很有可能会引入耦合。但使用像日志库这样的公共代码就没什么问题,因为它们对外是不可见的。Realestate.com.au使用了很多深度定制化的服务模板来快速创建新服务。他们不会在服务之间共用代码,而是把这些代码复制到每个新的服务中,以防止耦合的发生。

我的经验是:在微服务内部不要违反DRY,但在跨服务的情况下可以适当违反DRY。服务之间引入大量的耦合会比重复代码带来更糟糕的问题。但这的确是一个值得进一步探索的问题。

如果你想要使用客户端库,一定要保证其中只包含处理底层传输协议的代码,比如服务发现和故障处理等。千万不要把与目标服务相关的逻辑放到客户端库中。想清楚你是否要坚持使用客户端库,或者你是否允许别人使用不同的技术栈来对底层API进行调用

4.12 按引用访问

更合理的方式应该是,仅仅发送表示客户资源和订单资源的URI,然后等邮件服务器就绪时再回过头来查询这些信息。

4.13 版本管理

减小破坏性修改影响的最好办法就是尽量不要做这样的修改。

另一个延迟破坏性修改的关键是鼓励客户端的正确行为,避免过早地将客户端和服务端紧密绑定起来

客户端尽可能灵活地消费服务响应这一点符合Postel法则(也叫作鲁棒性原则,https://tools.ietf.org/html/rfc761)。该法则认为,系统中的每个模块都应该“宽进严出”,即对自己发送的东西要严格,对接收的东西则要宽容。

及早发现会对消费者产生破坏的修改非常重要,因为即使使用最好的技术,也难以避免破坏性修改的出现。我强烈建议使用消费者驱动的契约来及早定位这些问题,第7章会对该技术做详细的讲解。

一旦意识到,你可能会对某一个消费者造成破坏,那么可以选择要么尽量避免该破坏性修改,要么接受它,并跟维护这些服务的人员好好聊一聊。

语义化版本管理的每一个版本号都遵循这样的格式:MAJOR.MINOR.PATCH。其中MAJOR的改变意味着其中包含向后不兼容的修改;MINOR的改变意味着有新功能的增加,但应该是向后兼容的;最后,PATCH的改变代表对已有功能的缺陷修复。

我用过的一种比较成功的方法是,在同一个服务上使新接口和老接口同时存在。所以在发布一个破坏性修改时,可以部署一个同时包含新老接口的版本。

为了使其更可控,我们在内部把所有对V1的请求进行转换处理,然后去访问V2,继而V2再去访问V3。使用这种方式后,以后应该删除哪些代码也就比较清楚了。

这其实就是一个扩展/收缩模式的实例,它允许我们对破坏性修改进行平滑的过度。首先扩张服务的能力,对新老两种方式都进行支持。然后等到老的消费者都采用了新的方式,再通过收缩API去掉旧的功能。

首先,如果我需要修复一个服务内部的bug,需要修复两个版本,并做两次部署。而且我很可能也需要在代码库中拉分支,这无疑会引入很多问题。其次,把用户路由到正确的服务中去也是一件比较复杂的事情。想要实现这一点,要么寻求中间件的帮助,要么自己写很多的nginx脚本,但这样做的话系统会难以理解和管理。最后,考虑服务中可能需要管理的持久化状态。不同版本的服务创建的用户,都需要被存储在同一个数据库中,并且它们对于不同的服务均可见,这可能会引入更多的复杂性。

我对于共存两个服务的这种做法,是否适用于一般的项目保持怀疑态度。

4.14 用户界面

然后Web时代到来了。我们开始考虑是否应该让UI变得比较薄,而把更多的逻辑放在服务端。刚开始,服务端程序会渲染好整个页面,然后一次性发送回客户端的浏览器,所以浏览器端要做的事情就很有限。

所以,尽管我们的核心服务可能是一样的,但仍需要应对不同应用场景的约束。在考虑不同风格的用户界面组合时,需要保证它们做到了这一点。

这种方式有一个工作得很好的变种,即将一系列粗粒度的UI部分组装起来。也就是说不再创建小部件,而是对胖客户端应用的所有内容或一个网站的所有页面进行组装。

这样做会得到一个聚合所有服务的巨大的层。由于所有的东西都被放在了一起,也就失去了不同用户界面之间的隔离性,从而限制了独立于彼此进行发布的能力。我个人比较喜欢的模式是,保证一个这样的后端只为一个应用或者用户界面服务,如图4-10所示。

这种模式有时也叫作BFF(Backends For Frontends,为前端服务的后端)。

4.15 与第三方软件集成

对于一般规模的组织来说,如果某个软件非常特殊,并且它是你的战略性资产的话,那就自己构建;如果不是这么特别的话,那就购买。

举个例子,一般规模的组织不会把工资系统当作它的战略性资产,因为全世界的人领工资的方式都大同小异。类似地,大部分组织倾向于购买现成的CMS(Content Management System,内容管理系统),因为这一类工具对它们的业务来说并不是那么关键。

很多企业购买的工具都声称可以为你做深度定制化。一定要小心!这些工具链的定制化往往会比从头做起还要昂贵!如果你决定购买一个产品,但是它提供的能力不完全适合你,也许改变组织的工作方式会比对软件进行定制化更加合理。

核心思想是,任何定制化都只在自己可控的平台上进行,并限制工具的消费者的数量。为了更好地理解这个概念,接下来看两个例子。

这种工具的使用范围往往一开始会比较小,但随着时间的发展它会在你的组织中变得越来越重要,以至于后续的方向和选择都会围绕它来做。但这么重要的系统竟然不是自己做的,而是第三方厂家提供的,这是个很严重的问题。

与在CMS系统前面套一层自己的代码非常类似,绞杀者可以捕获并拦截对老系统的调用。这里你就可以决定,是把这些调用路由到现存的遗留代码中还是导向新写的代码中。这种方式可以帮助我们逐步对老系统进行替换,从而避免影响过大的重写。

5.1 关键是接缝

类似地,松耦合也不复存在:修改一行代码很容易,但是无法保证这一行修改不会对单块系统中的其他部分造成影响。除此之外,为了发布这个功能,我们还需要把整个系统重新部署一次。

在《修改代码的艺术》这本书中,Michael Feathers定义了接缝的概念,从接缝处可以抽取出相对独立的一部分代码,对这部分代码进行修改不会影响系统的其他部分。识别出接缝不仅仅能够清理代码库,更重要的是,这些被识别出的接缝可以成为服务的边界。

Java中包(package)的概念是一个非常弱的例子,但能够满足大部分的使用场景。

5.2 分解MusicCorp

比如像Structure 101这样的工具,就能可视化包之间的依赖。举个例子,如果发现仓库包依赖于财务包中的代码,而真实的组织中并不存在这样的依赖,那么就需要看看到底是什么问题,并想办法解决它。

5.3 分解单块系统的原因

把单块系统想象成为一块大理石,我们可以把整块石头炸开,但这样做的结果通常不好。增量开凿的方式更合理。

5.4 杂乱的依赖

如果你识别出来的几个接缝之间可以形成一个有向无环图(前面提到的包建模工具可以对此提供帮助),就能够看出来哪些接缝会比较难处理。

5.7 例子:打破外键关系

首先要去除财务部分的代码对行条目表的访问,因为这张表属于产品目录相关的代码,所以当产品目录服务分离出去以后,财务和产品目录两部分代码就会不可避免地使用数据库进行集成。快速的修改方式是,让财务部分的代码通过产品目录服务暴露的API来访问数据,而不是直接访问数据库。这个API调用会成为微服务化的第一步,如图5-3所示。

现在你会发现一个事实:你需要做两次数据库调用来生成报告。没错,做成两个独立的服务之后也会是这样。这时很多人就会对性能表示担忧。我对这些担忧给出的答案很简单:你的系统需要多快?系统现在是多快?如果能够对当前性能做一个测试,并且还知道你的期望是什么,那就可以放心地做这些修改。有时候让系统的一部分变慢会带来更大的好处,尤其是当这个“慢”事实上还在可接受的范围内时。

这也就意味着,我们可能需要实现跨服务的一致性检查,或者周期性触发清理数据的任务。

事实上,首先应该知道系统的期望行为是什么,然后再根据期望行为做决定。

5.8 例子:共享静态数据

这似乎暗示着,系统中所支持国家的改变频率比部署新代码的频率还要高,但不管真正的原因是什么,这些将共享静态数据存在数据库中的例子非常多。

第三个方法有些极端,即把这些静态数据放入一个单独的服务中。在我以前遇到过的一些场景中,数据量和复杂性及相关的规则值得我们这样做,但如果仅仅是国家代码的话就不必了。

5.9 例子:共享数据

所以,无论是财务相关的代码还是仓库相关的代码,都会向同一个表写入数据,有时还会从中读取数据。在这种情况下应如何做分离?其实这种情况很常见:领域概念不是在代码中进行建模,相反是在数据库中隐式地进行建模。这里缺失的领域概念是客户。

5.11 重构数据库

表结构分离之后,对于原先的某个动作而言,对数据库的访问次数可能会变多。因为以前简单地用一个SELECT语句就能得到所有的数据,现在则需要分别从不同的地方拿到数据,然后在内存中进行连接。

5.12 事务边界

我们可以把这部分操作放在一个队列或者日志文件中,之后再尝试对其进行触发。对于某些操作来说这是合理的,但要保证重试能够修复这个问题。很多地方会把这种形式叫作最终一致性。

提取表的处理比较简单,因为插入失败会导致事务的回退。但是订单表已经提交了的事务该怎么处理呢?解决方案是,再发起一个补偿事务来抵消之前的操作。对于我们来说,可能就是简单的一个DELETE操作来把订单从数据库中删除。然后还需要向用户报告该操作失败了。

手动编配补偿事务非常难以操作,一种替代方案是使用分布式事务。分布式事务会横跨多个事务,然后使用一个叫作事务管理器的工具来统一编配其他底层系统中运行的事务。就像普通的事务一样,一个分布式的事务会保证整个系统处于一致的状态。唯一不同的是,这里的事务会运行在不同系统的不同进程中,通常它们之间使用网络进行通信。

两阶段提交

只要收到一个否定的投票,事务管理器就会让所有的参与者回退。

该算法隐式地认为上述这些情况不会发生,即如果一个cohort在投票阶段投了赞成票,则它一定能提交成功。

如果你遇到的场景确实需要保持一致性,那么尽量避免把它们放在不同的地方,一定要尽量这样做。如果实在不行,那么要避免仅仅从纯技术(比如数据库事务)的角度考虑,而是显式地创建一个概念来表示这个事务。你可以把这个概念当作一个句柄或者钩子,在此之上,能够相对容易地进行类似补偿事务这样的操作,这也是在系统中监控这些复杂概念的一种方式。举个例子,你可以创建一个叫作“处理中的订单”的概念,围绕这个概念可以把所有与订单相关的端到端操作(及相应的异常)管理起来。

5.16 数据导出

这时你会说:“但是Sam,你说过使用数据库集成是不好的!”很高兴你这么问,不枉我前面多次强调这个问题!但如果实现得好的话,这个场景可以是一个例外,因为它使得报表这件事情变得足够简单,从而可以抵消耦合带来的缺点。

在曾经做过的另一个项目中,我们把一系列数据以JSON格式导出到AWS S3,有效地把S3变成了一个巨大的数据集市!

5.18 数据导出的备份

Netflix已经决定把Cassandra作为在众多服务中进行数据备份的标准方式。他们投入了大量的时间来构建相应的工具来提高Cassandra的易用性,其中大部分工作已经通过开源项目的方式共享给了全世界。

在进行报表时,Netflix需要对所有这些数据进行处理,但由于要考虑扩展性问题,所以是一个非凡的挑战。它使用了Hadoop,将SSTable的备份数据作为任务的数据源。慢慢地,Netflix实现了一个能够处理大量数据的流水线,并且开源了出来,它就是Aegisthus项目(https://github.com/Netflix/aegisthus)。

5.19 走向实时

把数据按需路由到多个不同地方的通用事件系统

5.20 修改的代价

这里我采用了一种在设计面向对象系统时的典型技术:CRC(class-responsibility-collaboration,类-职责-交互)卡片。你可以在一张卡片写上类的名字、它的职责及与谁进行交互。当我进行设计时,会把每个服务的职责列出来,写清楚它提供了什么能力,和哪些服务之间有协作关系。

第6章 部署

我会从持续集成和持续交付说起。这些概念与我们下面要讨论的主题并不相同,但又有所关联,了解它们可以帮助我们在考虑构建什么、如何构建以及如何部署时,做出更好的决定。

6.1 持续集成简介

我猜你很有可能正在组织内使用持续集成。如果没有的话,你应该开始这么做,因为这个关键实践允许我们更快速、更容易地修改代码。如果没有持续集成,向微服务架构进行转型就会非常痛苦。

他们认为使用了CI工具就算是采用了CI这个实践,事实上,只有工具是远远不够的。

我很喜欢Jez Humble用来测试别人是否真正理解CI的三个问题。

你是否每天签入代码到主线?

你是否有一组测试来验证修改?

当构建失败后,团队是否把修复CI当作第一优先级的事情来做?

6.2 把持续集成映射到微服务

这会影响CI的周期时间,也会影响单个修改从开发到上线的速度。更糟糕的是,我不知道哪些构建物应该被重新部署,哪些不应该。我是否需要部署所有的服务来保证所有的修改都能生效?这就很难说清楚了。而且通过提交消息来猜测哪个服务真正被修改了,也是一件很困难的事情。使用这种方式的组织,往往都会退回到同时部署所有代码的模式,而这也正是我们非常不想看到的。

6.3 构建流水线和持续交付

所有好的规则都需要考虑例外。“每个微服务一个构建”的方法,基本上在大多数情况下都是合理的,那么是否有例外呢?当一个团队刚开始启动一个新项目时,尤其是什么都没有的情况下,你可能会花很多时间来识别出服务的边界。所以在你识别出稳定的领域之前,可以把初始服务都放在一起。

6.5 操作系统构建物

这个方向肯定是正确的,但是Windows惯用的风格是部署在IIS,这意味着,这种方法可能对一些Windows团队没有吸引力。

尽量减少需要维护的操作系统的数量,最好只维护一种。它可以大大减少不同机器之间可能存在的不同之处,并减小部署和维护的工作量。

6.6 定制化镜像

同时,我们也想避免一台机器运行的时间过长,因为这会引起配置漂移(后面会详细解释)。

类似于蓝/绿部署(第7章会详细讲解)的模式,可以帮助你缓解这个问题,因为它允许我们在老版本服务不下线的同时,去部署新版本的服务。

当你创建VMWare镜像时,这会是一个很大的问题。想象一下,在网络上传送一个20GB的镜像文件是怎样一个场景。后面会介绍一种容器技术:Docker,它可以避免上述的一些问题。

不可变服务器

通过把配置都存到版本控制中,我们可以自动化重建服务,甚至重建整个环境。但是如果部署完成后,有人登录到机器上修改了一些东西呢?这就会导致机器上的实际配置和源代码管理中的配置不再一致,这个问题叫作配置漂移。

6.7 环境

管理单块系统的环境很具有挑战性,尤其是当你对那些很容易自动化的系统没有访问权的时候。当你需要对每个微服务考虑多个环境时,事情会更加艰巨。后面会讲一些能够简化这些工作的部署平台。

6.8 服务配置

一个更好的方法是只创建一个构建物,并将配置单独管理。从形式上来说,这针对的可能是每个环境的一个属性文件,或者是传入到安装过程中的一些参数。还有一个在应对大量微服务时比较流行的方法是,使用专用系统来提供配置,第11章会详细讨论这个话题。

6.9 服务与主机之间的映射

前虚拟化时代

我见过的最糟糕的情况是,把多个服务绑定在一起进行部署,即部署全部的服务,这些工作都是为了简化单主机多服务的部署模型。在我看来,这个小小的改进其实是放弃了微服务的一个关键好处:独立部署不同的服务。如果你采用了单主机多服务模型,请确保每个服务都可以独立进行部署。

把所有东西放在一台主机上意味着,即使每个服务的需求是不一样的,我们也不得不对它们一视同仁。我的同事Neal Ford提到过,很多关于部署和主机管理的工作实践都是为了优化稀缺资源的利用。

单主机单服务的模型会大大简化问题的排查。如果你还没有使用这个模型,我也不会说微服务就一定不适合你。但我会建议你,将其看作减小微服务复杂性的一个方法。

但主机数量的增加也可能是个问题。管理更多的服务器,运行更多不同的主机也会引入很多的隐式代价。尽管存在这些问题,但我仍然认为在使用微服务架构时这是比较好的模型。

当使用PaaS(Platform-as-a-Service,平台即服务)时,你工作的抽象层次要比在单个主机上工作时的高。

在编写本书时,就已经出现了很多好用的PaaS平台。Heroku就是一个黄金级的PaaS。

以我的经验来看,PaaS平台想要做得越聪明,通常也就可能错得越离谱。我用过的好几个PaaS,都尝试根据应用程序的使用情况来自动伸缩,但都做得不好。因为平台一般都会尽量去满足一些比较通用的需求,而非特定用户的特殊需求,所以你的应用程序越不标准,就越难一起和PaaS进行工作。

6.10 自动化

但即使我们控制了主机的数量,还是会有很多服务。这就意味着有更多的部署要处理、更多的服务要监控、更多的日志要收集,所以自动化很关键。自动化还能够帮助开发人员保持工作效率。

自动化,尤其是给开发人员使用的自动化工具,成为Gilt能够大量采用微服务的关键驱动力。一年后,Gilt大约有10个微服务上线;2012年,超过100个;2014年,超过450个。也就是说,在Gilt平均每个开发人员拥有三个微服务。

6.11 从物理机到虚拟机

但是把机器划分成大量的VM并不是免费的。把物理机想象成一个装袜子的抽屉,如果你在抽屉里放置了很多木隔板,那么可存放袜子的总量是多还是少了?答案很明显是少了,因为隔板本身也占空间!管理抽屉是比较简单的,不仅仅是放袜子,你也可以把T恤放在某个隔间里面,但是更多的隔板意味着更少的总空间。

这里的问题是,hypervisor本身也需要一定的资源来完成自己的工作。它们会占用CPU、I/O和内存等。hypervisor管理的主机越多,占用的资源就越多。在某个点上,这些额外的开销就会变成继续切分物理机的限制。在实际中,这意味着当你把物理机切分得越来越小时,能够得到的收益也就越有限,因为hypervisor占用了很多资源。

在Linux上,进程必须由用户来运行,并且根据权限的不同拥有不同的能力。进程可以创建其他进程。举个例子,如果我在终端启动了一个进程,你可以认为它是终端程序的子进程。Linux内核的任务就是维护这个进程树。

这个通用的方法有很多具体的形式,比如Solaris Zones和OpenVZ,但最流行的还是LXC。基本上所有的现代Linux内核都提供了LXC。

我们得到的好处不仅仅是避免了hypervisor的使用,还可以加快反馈的速度,因为相比完整的虚拟机,Linux容器可以启动得非常快。对于一台虚拟机来说,花几分钟时间来启动是很正常的,但是Linux容器通常只要几秒钟就能完成启动。

Docker是构建在轻量级容器之上的平台。它帮你处理了大多数与容器管理相关的事情。你可以在Docker中创建和部署应用,这些基于容器的应用与VM世界中的镜像很类似。

我们可以在Vagrant中启动单个VM,然后在其中运行多个Docker实例,每个实例中包含一个服务,而非原来的一个Vagrant虚拟机中包含一个服务。接下来,就可以使用Vagrant来创建和销毁Docker平台本身,并使用Docker来快速配置每个服务了。

很多与Docker相关的技术,能够帮助我们更好地使用它。CoreOS是一个专门为Docker设计的操作系统。它是一个经过裁剪的Linux OS,仅提供了有限的功能以保证Docker的运行。这意味着,它比其他操作系统消耗的资源更少,从而可以把更多的资源留给容器。

Docker本身并不能解决所有的问题,它只是一个在单机上运行的简单的PaaS。

调度层的一个关键需求是,当你向其请求一个容器时会帮你找到相应的容器并运行它。在这个领域,Google最近的开源工具Kubernetes和CoreOS集群技术能够提供一定的帮助,而且似乎每个月都有新的竞争者出现。另一个基于Docker的有趣的工具是Deis(http://deis.io/),它试图在Docker之上,提供一个类似于Heroku那样的PaaS。

在很多情况下,这种Docker加上一个合适的调度层的解决方案介于IaaS和PaaS之间,很多地方使用CaaS(Container-as-a-Service,容器即服务)来描述它。

如果你正在寻找不同的部署平台,我强烈建议你看看Docker。

6.12 一个部署接口

在Windows上使用PowerShell,可以达到同样的效果。

Terraform是来自Hashicorp的一个很新的工具,它就可以帮你做上述事情。一般我不太会在书里提这么新的工具,因为与其说它是个工具,还不如说只是一个想法而已,但它正在朝着上述那个方向发展。

6.13 小结

但要记住的一点是,无论你采用什么技术,自动化的文化对一切管理来说都非常重要。自动化一切,如果你采用的技术不支持的话,就去选用一个新的技术吧!使用类似AWS这样的平台,能够在你进行自动化时提供大量的便利。

最后,如果你想要深入了解这些话题,我强烈推荐你读一读JezHumble和David Farley的《持续交付》,这本书对流水线设计和构建物管理有更深入的讨论。

第7章 测试

它可以帮助我们实现尽早交付软件与保持软件高质量之间的平衡,因为有时鱼和熊掌是不可兼得的。

7.1 测试类型

作为一名顾问,我喜欢使用形式各异的象限来对世界进行分类。起初,我以为这本书不会有这样的象限。

放弃大规模的手工测试,尽可能多地使用自动化是近年来业界的一种趋势,对此我深表赞同。如果当前你正在使用大量的手工测试,我建议在深入微服务的道路之前,先解决这个问题,否则很难获得微服务架构带来的好处,因为你无法快速有效地验证软件。

7.2 测试范围

只测试一行代码是单元测试吗?我会说是。那测试多个函数或者多个类仍然是单元测试吗?我会说不是,不过很多人并不同意!从现在开始,尽管单元测试和服务测试这两个名称有歧义,我还是继续使用它们。不过对于用户界面测试,接下来我们改称它为端到端测试。

这些测试的主要目的是,能够对于功能是否正常快速给出反馈。单元测试对于代码重构非常重要,因为我们知道,如果不小心犯了错误,这些小范围的测试能很快做出提醒,这样我们就可以放心地随时调整代码。

服务测试比简单的单元测试覆盖的范围更大,因此当运行失败时,也比单元测试更难定位问题。不过,相比更大范围的测试,服务测试中包含的组件已经少多了,因此也没大范围的测试那么脆弱。

当范围更大的测试(比如服务测试或者端到端测试)失败以后,我们会尝试写一个单元测试来重现问题,以便将来能够以更快的速度捕获同样的错误。我们通过这种方式来持续地缩短反馈周期。

举个例子,我曾在一个单块系统上工作过,这个系统有4000个单元测试、1000个服务测试和60个端到端测试。我们发现测试的反馈周期很长,其原因在于有太多的服务测试和端到端测试(后者是反馈周期变长的罪魁祸首),之后我们便尽量使用小范围的测试来替换这些大范围的测试。

一种常见的测试反模式,通常被称为测试甜筒或倒金字塔。在这种反模式中,有一些甚至没有小范围的测试,只有大范围的测试。这些项目的测试运行起来往往极度缓慢,反馈周期很长。

7.3 实现服务测试

打桩,是指为被测服务的请求创建一些有着预设响应的打桩服务。

在我看来,打桩和mock之间的区别很明显。不过据我了解,很多人会感到困惑,特别是当再引入其他诸如fakes、spies和dummies这些术语时。Martin Fowler把包括打桩和mock在内的所有这些术语统称为测试替身(Test Double, http://www.martinfowler.com/bliki/TestDouble.html)。

我在ThoughtWorks的同事Brandon Bryars,创建了一个叫作Mountebank(http://www.mbtest.org/)的打桩/mock服务器,它帮助了很多人避免像我那样重复工作多次。

7.4 微妙的端到端测试

解决这两个问题的一种优雅的方法是,让多个流水线扇入(fan in)到一个独立的端到端测试的阶段(stage)。

7.6 脆弱的测试

包含在测试中的服务数量越多,测试就会越脆弱,不确定性也就越强。

Martin Fowler建议发现脆弱的测试时应该立刻记录下来,当不能立即修复时,需要把它们从测试套件中移除,然后就可以不受打扰地安心修复它们。

有些组织的答案是由一个专门的团队来写这些测试。这可能是灾难性的。开发软件的人渐渐远离测试代码,周期时间(cycle time)会变长,因为服务的拥有者实现功能需要等待测试团队来写端到端测试。

很不幸,这是一个非常常见的组织模式,只要团队没有在第一时间测试自己所写的代码,就会出现很大的问题。

这是一个困难的风险/回报权衡。当你删除一个测试时,会有人感谢你吗?也许吧。不过,如果因为你删除的测试而漏掉一个缺陷,你肯定会被指责。

希望它发生与真正让它发生是不一样的。

这可能会导致大量的堆积。在修复失败的端到端测试的同时,上游团队一直在提交更多的变更。结果是,除了使修复构建更加困难外,要部署的变更内容也多了。解决这个问题的一个方法是,端到端测试失败后禁止提交代码,但考虑到测试套件的运行时间过长,这个要求通常是不切实际的。试想一下这样的命令:“你们30个开发人员在这个耗时7小时的构建修复之前不准提交代码!”

保障频繁发布软件的关键是基于这样的一个想法:尽可能频繁地发布小范围的改变。

7.7 测试场景,而不是故事

解决这个问题的最佳方法是,把测试整个系统的重心放到少量核心的场景上来。把任何在这些核心场景之外的功能放在相互隔离的服务测试中覆盖。团队之间需要就这些核心场景达成一致,并共同拥有。

7.8 拯救我们的消费者驱动的测试

有一种不需要使用真正的消费者也能达到同样目的的方式,它就是CDC(Consumer-Driven Contract,消费者驱动的契约)。

所以通过CDC,无需使用时间可能很长的端到端测试,我们就可以在进入生产环境之前发现破坏性变化。

Pact(https://github.com/realestate-com-au/pact)是一个消费者驱动的测试工具,最初是在开发RealEstate.com.au的过程中创建的,现在已经开源,功能大部分是由Beth Skurrie组织开发的。

重要的是,CDC需要消费者和生产服务之间具有良好的沟通和信任。如果双方都在同一个团队(或就是同一个人!),那么这应该不难。然而,如果你消费的服务由第三方提供,那么CDC可能不适用,因为你们可能缺乏充分的沟通及信任。在这种情况下,对有可能出错的组件不得不使用有限的大范围的端到端测试。

7.9 还应该使用端到端测试吗

你要知道,再怎么测试也不可能消除所有的缺陷,所以生产环境中有效的监控和修复还是有必要的。理解了这一点,你就能够理解从生产环境中学习是一个明智的决定。

当然,对你所在组织的风险,你理解得要比我多很多,但在这里我想促使大家多思考一下,有多少端到端测试是我们真正需要的。

7.10 部署后再测试

但如果我们的模型并不完美,那么系统在面对愤怒的使用者时就会出现问题。缺陷会溜进生产环境,新的失效模式会出现,用户也会以我们意想不到的方式来使用系统。

一个常见的例子是,用来验证部署后的系统是否正常工作的、针对新部署软件的一系列的冒烟测试套件。

另一个例子是所谓的蓝/绿部署。

还有一种方式值得我们详细讨论一下,它有时会与蓝/绿部署相混淆,因为它会使用一些类似的实现技术。这种方式被称为金丝雀发布(canary releasing)。

金丝雀发布是指通过将部分生产流量引流到新部署的系统,来验证系统是否按预期执行。

金丝雀发布与蓝/绿发布的不同之处在于,新旧版本共存的时间更长,而且经常会调整流量。

有些团队选择先复制一份生产请求,然后引导复制的请求到金丝雀。使用这种方法,现运行的生产版本和金丝雀版本可以有相同的请求,只是生产环境的请求结果是外部可见的。这方便大家对新旧版本做比较,同时又避免假如金丝雀失败,影响到客户的请求。不过,复制生产请求的工作可能会很复杂,尤其是在事件/请求不是幂等的情况下。

有时花费相同的努力让发布变更变得更好,比添加更多的自动化功能测试更加有益。在Web操作的世界,这通常被称为平均故障间隔时间(Mean Time Between Failures, MTBF)和平均修复时间(Mean Time To Repair, MTTR)之间的权衡优化。

7.11 跨功能的测试

非功能性需求,是对系统展现的一些特性的一个总括的术语,这些特性不能像普通的特性那样简单实现。它包括以下方面,比如一个网页可接受的延迟时间,系统能够支持的用户数量,用户界面如何让残疾人也可以访问,或者如何保障客户数据的安全。

8.1 单一服务,单一服务器

首先,我们希望监控主机本身。CPU、内存等所有这些主机的数据都有用。我们想知道,系统健康的时候它们应该是什么样子的,这样当它们超出边界值时,就可以发出警告。

我们甚至可以更进一步,使用logrotate帮助我们移除旧的日志,避免日志占满了磁盘空间。

8.3 多个服务,多个服务器

答案是,从日志到应用程序指标,集中收集和聚合尽可能多的数据到我们的手上。

8.5 多个服务的指标跟踪

Graphite就是一个让上述要求变得很容易的系统。它提供一个非常简单的API,允许你实时发送指标数据给它。然后你可以通过查看这些指标生成的图表和其他展示方式来了解当前的情况。

另外,要确保你可以获得原始的数据,以便在需要之时生成自己的报告或仪表盘。

了解趋势另一个重要的好处是帮助我们做容量规划。我们的系统到达极限了吗?多久之后需要更多的主机?

8.6 服务指标

首先,有一句老话,80%的软件功能从未使用过。

8.8 关联标识

通常我们需要在初始调用更大的上下文中看待这个错误;换句话说,就像查看栈跟踪那样,我们也想查看调用链的上游。

在这种情况下,一个非常有用的方法是使用关联标识(ID)。在触发第一个调用时,生成一个GUID。然后把它传递给所有的后续调用,如图8-5所示。类似日志级别和日期,你也可以把关联标识以结构化的方式写入日志。

8.9 级联

使用合成监控(例如,模拟客户搜索一首歌)会将问题暴露出来。但为了确定问题的原因,我们需要报告这一事实是一个服务无法访问另一个服务。

因此,监控系统之间的集成点非常关键。每个服务的实例都应该追踪和显示其下游服务的健康状态,从数据库到其他合作服务。你也应该将这些信息汇总,以得到一个整合的画面。你会想了解下游服务调用的响应时间,并检测是否有错误。

8.10 标准化

你应该尝试以标准格式的方式记录日志。你一定想把所有的指标放在一个地方,你可能需要为度量提供一个标准名称的列表;如果一个服务指标叫作ResponseTime,另一个叫作RspTimeSecs,而它们的意思是一样的,这会非常令人讨厌。

8.11 考虑受众

· 他们现在需要知道什么· 他们之后想要什么· 他们如何消费数据

讨论定量信息的图形化显示所涉及的所有细微差别已经超出了本书的范围,一个不错的起点是Stephen Few的优秀图书:Information Dashboard Design:Displaying Data for Ata-Glance Monitoring。

8.12 未来

Riemann(http://riemann.io/)是一个事件服务器,允许高级的聚合和事件路由,所以该工具可以作为上述解决方案的一部分。Suro(https://github.com/Netflix/suro)是Netflix的数据流水线,其解决的问题与Riemann类似。

8.13 小结

我还试图描绘了系统监控发展的方向:从专门只做一件事的系统转向通用事件处理系统,从而可以全面地审视你的系统。这是一个令人激动的新兴空间,虽然全面讲解已经超出了本书的范围,但希望我介绍的已足够你起步。

第9章 安全

那到底需要多安全呢?我们如何知道什么是足够安全呢?

我们还需要考虑人的因素。谁在使用我们的系统,他又会做些什么?而这又与我们的服务器如何交互有什么关系?

9.1 身份验证和授权

不过,在分布式系统这个领域,我们需要考虑更高级的方案。我们不希望每个人使用不同的用户名和密码来登录不同的系统。我们的目的是要有一个单一的标识且只需进行一次验证。

当主体试图访问一个资源(比如基于Web的接口)时,他会被定向到一个身份提供者那里进行身份验证。这个身份提供者会要求他提供用户名和密码,或使用更先进的双重身份验证。一旦身份提供者确认主体已通过身份验证,它会发消息给服务提供者,让服务提供者来决定是否允许他访问资源。

因此,尽管我认为OpenID Connect是未来的方向,但很有可能需要一段时间,它才能被广泛地应用。

不过,一定要小心。网关层承担越来越多的功能后,最终本身会是一个庞大的耦合点。而且功能越多,受攻击面就越大。

9.2 服务间的身份验证和授权

我们的第一个选项是,在边界内对服务的任何调用都是默认可信的。

对于大多数使用这种模式的组织来说,我担心隐式信任模型并不是一个明智的决定,而更糟糕的是,很多时候人们没有在一开始意识到它的风险。

当使用HTTPS时,客户端获得强有力的保证,它所通信的服务端就是客户端想要通信的服务端。它给予我们额外的保护,避免人们窃听客户端和服务端之间的通信,或篡改有效负载。

SSL之上的流量不能被反向代理服务器(比如Varnish或Squid)所缓存,这是使用HTTPS的另一个缺点。这意味着,如果你需要缓存信息,就不得不在服务端或客户端内部实现。你可以在负载均衡中把Https的请求转成Http的请求,然后在负载均衡之后就可以使用缓存了。

确认客户端身份的另一种方法是,使用TLS(Transport LayerSecurity,安全传输层协议), TLS是SSL在客户端证书方面的继任者。在这里,每个客户端都安装了一个X.509证书,用于客户端和服务器端之间建立通信链路。服务器可以验证客户端证书的真实性,为客户端的有效性提供强有力的保证。

另一种方法使用HMAC(Hash-based Message AuthenticationCode,基于哈希的消息码)对请求进行签名,它是OAuth规范的一部分,并被广泛应用于亚马逊AWS的S3 API。

使用HMAC,请求主体和私有密钥一起被哈希处理,生成的哈希值随请求一起发送。然后,服务器使用请求主体和自己的私钥副本重建哈希值。如果匹配,它便接受请求。这样做的好处是,如果一个中间人更改了请求,那么哈希值会不匹配,服务器便知道该请求已被篡改过。

JWT(JSON WebTokens, JSON Web令牌,http://self-issued.info/docs/draft-ietf-oauth-json-web-token.html)也值得一看,它们使用类似的方式实现,并且似乎正在吸引更多的关注。但是要注意,正确实现这个方式的难度。我的同事曾经与一个团队实施自己的JWT方案,仅仅因为忽略了一个布尔检查,就导致整个身份验证码都失效!希望随着时间的推移,我们可以看到更多可重用库的实现。

9.3 静态数据的安全

搞砸数据加密最简单的方法是,尝试实现你自己的加密算法,或甚至试图实现别人的。无论使用哪种编程语言,都有被广泛认可的加密算法可供使用,它们都是经过同行评审,并定期打补丁的。

关于密码,你应该考虑使用一种叫作加盐密码哈希(saltedpassword hashing, https://crackstation.net/hashing-security.htm#properhashing)的技术。

实现得不好的加密比没有加密更糟糕,因为虚假的安全感会让你的视线从球上面移开(双关语)。

之前已经讨论过,加密的过程依赖一个数据加密算法和一个密钥,然后使用二者对数据进行加密。那么,你的密钥存储在哪里?如果加密数据是因为担心有人窃取整个数据库,那么把密钥存储在同一个数据库中,并不会真正消除这种担心!因此,我们需要把密钥存储到其他地方。但存到哪里呢?

有些数据库甚至包含内置的加密支持,比如SQL Server的透明数据加密(Transparent Data Encryption),旨在以一种透明的方式处理这个问题。

再说一次,加密很复杂。避免实现自己的方案,花些时间在已有的方案研究上!

通过把系统划分为更细粒度的服务,你可能发现加密整个数据存储是可行的,但即使可行也不要这么做。限制加密到一组指定的表是明智的做法。

9.4 深度防御

当你从零开始时,IDS可能更有意义。这些系统是基于启发式的(正如很多的应用防火墙),很有可能刚开始的通用规则,对于你的服务行为来说过于宽松或过于严格。

这真的是最基本的东西,但令人惊讶的是,我经常看到很多重要的软件运行在未安装补丁的、陈旧的操作系统上。你可能拥有世界上最好的受保护的应用程序级安全,但如果有一个旧版本的Web服务器作为root用户运行在你的机器上,而机器没有应用缓冲区溢出的漏洞补丁,那么你的系统仍然是极其脆弱的。

9.6 保持节俭

这样做的好处是多方面的。首先,如果你不存储它,就没有人能偷走它。第二,如果你不存储它,就没有人(例如,政府机构)可以要它!

9.8 黄金法则

重新发明轮子在很多情况下通常只是浪费时间,但在安全领域,它会带来直接的危害。

第10章 康威定律和系统设计

我们的行业还很年轻,它似乎在不断地重塑自己。不过,一些关键定律还是经受住了时间的考验。例如摩尔定律,它表示集成电路上可容纳的晶体管数目每两年会增加一倍。该定律已经被证明准确得惊人(尽管有人预测,这种趋势已经放缓)。

梅尔 ·康威于1968年4月在Datamation杂志上发表了一篇名为“HowDo Committees Invent”的论文,文中指出:任何组织在设计一套系统(广义概念上的系统)时,所交付的设计方案在结构上都与该组织的沟通结构保持一致。

埃里克 · S.雷蒙德在《新黑客字典》中总结这一现象时指出:“如果你有四个小组开发一个编译器,那你会得到一个四步编译器。”

10.1 证据

在研究中,通过匹配不同类型组织中比较相似的产品,他们发现,组织的耦合度越低,其创建的系统的模块化就越好,耦合也越低;组织的耦合度越高,其创建的系统的模块化也越差。

微软对它的一个特定产品Windows Vista进行了实证研究(http://research.microsoft.com/pubs/70535/tr-2008-11.pdf),观察其自身组织结构如何影响软件质量。

10.2 Netflix和Amazon

于是产生了著名的“两个比萨团队”,即没有一个团队应该大到两个比萨不够吃。

10.4 适应沟通途径

一个拥有许多服务的单个团队对其管理的服务会倾向于更紧密地集成,而这种方式在分布式组织中是很难维护的。

10.5 服务所有权

团队需要自己负责部署和维护应用程序,这会激励团队创建出易于部署的服务;也就是说,当没有人能够接受你扔出去的东西时,也就不用担心人们会犯“把东西扔出墙”这种错误了!

当然我很喜欢这种模式。它把决定权交给最合适的人,赋予团队更多的权力和自治,也使其对工作更负责。我见过太多太多的开发人员,把系统移交给测试或部署阶段后,就认为他们的工作已经完成了。

10.6 共享服务的原因

特性团队(即基于特性开发的团队)的想法,是一个小团队负责开发一系列特性需要的所有功能,即使这些功能需要跨越组件(甚至服务)的边界。特性团队的目标很合理。这种结构促使团队保持关注在最终的结果上,并确保工作是集成起来的,避免了跨多个不同的团队试图协调变化的挑战。

但是,让我们再考虑一下什么是微服务:服务会根据业务领域,而不是技术进行建模。如果负责某个微服务的团队与业务领域相匹配,则它更容易保持对客户的关注,也更容易进行以特性为导向的开发,因为它对服务相关的所有技术有一个全面的了解并且拥有所有权。

另一种方式是,你可以派人到产品目录团队帮助他们更快地工作。你的系统使用越标准化的技术栈和编程范式,就越容易让其他人更改你的服务。

10.7 内部开源

标准的开源项目中,一小部分人被认为是核心提交者,他们是代码的守护者。如果你想修改一个开源项目,要么让一个提交者帮你修改,要么你自己修改,然后提交给他们一个pull请求。核心的提交者对代码库负责,他们是代码库的所有者。

最后,你需要让提交者能够很容易地构建和部署软件以供他人使用。这通常需要良好的构建和部署流水线,以及集中构件物仓库。

10.10 案例研究:RealEstate.com.au

浓厚的自动化文化非常关键,REA大量使用AWS,关键原因是想让团队更加自治。

拥抱变化的能力成功地帮助公司从本地市场扩张到海外市场。而且,更振奋人心的是,与项目组成员交流后留给我的印象是,现在的架构和组织结构只是最新的版本,而不是最终的目的地。我敢说五年后,REA将再次迥然不同。

10.12 人

“不管一开始看起来什么样,它永远是人的问题。”——杰拉尔德·温伯格,咨询第二定律

尽管这本书主要是关于技术的,但是人的问题也绝不只是一个次要问题;他们是你现在拥有系统的构建者,并将继续构建系统的未来。不考虑当前员工的感受,或不考虑他们现有的能力来提出一个该如何做事的设想,有可能会导致一个糟糕的结果。

关于这个话题,每个组织都有自己的节奏。了解你的员工能够承受的变化,不要逼他们改变太快!也许在短时间内,你仍然需要一个单独的团队来处理线上支持或生产环境部署,以便给开发人员足够的时间调整到新的实践中。

无论方法是什么,你需要跟员工清楚地阐明,在微服务的世界里每个人的责任,以及为何这些责任如此重要。这能够帮助你了解技能差距并思考如何弥补它们。对许多人来说,这将是一个非常可怕的旅程。请记住,如果没有把人们拉到一条船上,你想要的任何变化从一开始就注定会失败。

第11章 规模化微服务

当你处理书中的小例子时,一切似乎都很简单,但现实世界要复杂得多。当我们的微服务架构从刚开始的简单变得复杂后,会发生什么呢?当我们不得不处理发生故障的多个独立服务,或管理数以百计的服务时,该怎么办呢?

11.1 故障无处不在

例如,如果我们可以很好地处理服务的故障,那么就可以对服务进行原地升级,因为计划内的停机要比计划外的更容易处理。

如果你知道一个服务器将会发生故障,系统也可以很好地应对,那么又何必在阻止故障上花很多精力呢?为什么不像谷歌那样,使用裸主板和一些便宜的组件(一些尼龙搭扣),而不必过多地担心单节点的弹性?

11.2 多少是太多

跨功能需求就是,要考虑数据的持久性、服务的可用性、吞吐量和服务可接受的延迟等这些方面。

11.3 功能降级

现在,如果这些微服务中的任何一个宕掉,都会导致整个Web页面不可用,那么我们可以说,该系统的弹性还不如只使用一个服务的系统。

11.4 架构性安全措施

架构性安全措施

深入到细节中,我们发现,处理系统缓慢要比处理系统快速失败困难得多。在分布式系统中,延迟是致命的。

为了避免这种情况再次发生,我们最终修复了以下三个问题:正确地设置超时,实现舱壁隔离不同的连接池,并实现一个断路器,以便在第一时间避免给一个不健康的系统发送调用。

11.5 反脆弱的组织

一些公司喜欢组织游戏日,在那天系统会被关掉以模拟故障发生,然后不同团队演练如何应对这种情况。我在谷歌工作期间,在各种不同的系统中都能遇到这种活动,并且我认为经常组织这类演练对于很多公司来说都是有益的。

这些项目中最著名的是混乱猴子(Chaos Monkey),在一天的特定时段随机停掉服务器或机器。知道这可能会发生在生产环境,意味着开发人员构建系统时不得不为它做好准备。

你的系统正分布在多台机器上(它们会发生故障),通过网络(它也是不可靠的)通信,这些都会使你的系统更脆弱,而不是更健壮。所以,无论你是否打算提供像谷歌或Netflix那样规模化的服务,在分布式架构下,准备好如何应对各种故障的发生是非常重要的。

如何实现断路器依赖于请求失败的定义,但当使用HTTP连接实现它们时,我会把超时或5XX的HTTP返回码作为失败的请求。通过这种方式,当一个下游资源宕掉,或超时,或返回错误码时,达到一定阈值后,我们会自动停止向它发送通信,并启动快速失败。当它恢复健康后,我们会自动重新发送请求。

Nygard在Release It!中,介绍了另一种模式:舱壁(bulkhead),是把自己从故障中隔离开的一种方式。在航运领域,舱壁是船的一部分,合上舱口后可以保护船的其他部分。所以如果船板穿透之后,你可以关闭舱壁门。如果失去了船的一部分,但其余的部分仍完好无损。

关注点分离也是实现舱壁的一种方式。通过把功能分离成独立的微服务,减少了因为一个功能的宕机而影响另一个的可能性。

当然,不需要重新创造你自己的断路器。Netflix的Hystrix库(https://github.com/Netflix/Hystrix)是一个基于JVM的断路器,附带强大的监控。还有其他的基于不同技术栈的断路器实现,比如.NET的Polly(https://github.com/App-vNext/Polly),或Ruby的circuit breakermixin(https://github.com/wsargent/circuit breaker)。

11.6 幂等

对幂等操作来说,其多次执行所产生的影响,均与一次执行的影响相同。

记住,仅仅因为你使用HTTP作为底层协议,并不意味着就可以免费得到它提供的一切好处。

11.7 扩展

大机器通常给我们更多的CPU内核,但如果写的软件没有充分利用它们也是不够的。另一个问题是,这种形式的扩展无法改善我们服务器的弹性!尽管如此,这可能是一个可以快速见效的很好的方式,特别是当你正在使用虚拟化供应商的服务,并且它允许你轻松地调整机器的大小时。

弹性扩展的一种方式是,确保不要把所有鸡蛋放在一个篮子里。

当然,值得注意的是,供应商给你的SLA保证肯定会减轻他们的责任!如果供应商错失担保目标,给他们的客户也就是你带来大量金钱上的损失,你会发现即使翻遍整个合同,也很难找到可以从他们那里追回任何损失的条款。因此,我强烈建议你,了解供应商如果没有履行义务的影响,并看看你是否需要准备一个B(或C)计划。例如,我的很多客户都将一个灾难恢复托管平台放到一个不同的供应商那里,以确保他们不至于脆弱得因为一家公司出错而受影响。

当你想让服务具有弹性时,要避免单点故障。

不过,无论采用哪种方式,当考虑负载均衡器的配置时,要像对待服务的配置一样对待它:确保它存放在版本控制系统中,并且可以被自动化地应用。

如果我们使用已有的软件来做这件事情,好处是可以享用很多前人所做的努力。然而,我们仍然需要知道,如何配置和维护这些系统,使得它们具有弹性。

正如Jeff Dean在他的演 讲“Challenges in Building Large-ScaleInformation Retrieval Systems”(2009年WSDM会议)中所说的,你的设计应该“考虑10倍容量的增长,但超过100倍容量时就要重写了”。在某些时刻,你需要做一些相当激进的事情,以支持负载容量增加到下一个级别。

如果在前期为准备大量的负载而构建系统,将在前期做大量的工作,以准备应对也许永远不会到来的负载,同时耗费了本可以花在更重要的事情上的精力,例如,理解是否真有人会使用我们的产品。Eric Ries[插图]讲述了一个故事,他花了六个月的时间构建了一个产品,却压根没有人下载。他反思说,他本可以在网页上放一个链接,当有人点击时返回404,以此来检验是否真的有这样的需求。与此同时他可以在海滩上度过六个月,并且这种方式跟花六个月构建产品学到的知识是一样多的!

11.8 扩展数据库

更直接地说,重要的是你要区分服务的可用性和数据的持久性这两个概念。你需要明白这是不同的两件事情,因此会有不同的解决方案。

几年前,使用只读副本进行扩展风靡一时,不过现在我建议你首先看看缓存,因为它可以提供更显著的性能改善,而且工作量往往更少。

扩展读取是比较容易的。那么扩展写操作呢?一种方法是使用分片。采用分片方式,会存在多个数据库节点。当你有一块数据要写入时,对数据的关键字应用一个哈希函数,并基于这个函数的结果决定将数据发送到哪个分片。

分片写操作的复杂性来自于查询处理。查找单个记录是很容易的,因为可以应用哈希函数找到数据应该在哪个实例上,然后从正确的分片获取它。

跨分片查询往往采用异步机制,将查询的结果放进缓存。例如,Mongo使用map/reduce作业来执行这些查询。

最近,越来越多的系统支持在不停机的情况下添加额外的分片,而重新分配数据会放在后台执行;例如,Cassandra在这方面就处理得很好。不过,添加一个分片到现有的集群依然是有风险的,因此你需要确保对它进行了充分的测试。

正如你可能已经推断出的,从上面这些简单的概述中我们发现,扩展数据库写操作非常棘手,而各种数据库在这方面的能力开始真正分化。

CQRS(Command-Query Responsibility Segregation,命令查询职责分离)模式,是一个存储和查询信息的替代模型。传统的管理系统中,数据的修改和查询使用的是同一个系统。使用CQRS后,系统的一部分负责获取修改状态的请求命令并处理它,而另一部分则负责处理查询。

这种形式的分离允许不同类型的扩展。我们系统的命令和查询部分可能是在不同的服务或在不同的硬件上,完全可以使用不同类型的数据存储。这解锁了处理扩展的大量方法。

但要提醒大家一句:相对于单一数据存储处理所有的CRUD操作的模式,这种模式是一个相当大的转变。我见过不止一个经验丰富的开发团队在纠结如何正确地使用这一模式!

11.9 缓存

代理服务器缓存,是将一个代理服务器放在客户端和服务器之间。反向代理或CDN(Content DeliveryNetwork,内容分发网络),是很好的使用代理服务器缓存的例子。服务器端缓存,是由服务器来负责处理缓存,可能会使用像Redis或Memcache这样的系统,也可能是一个简单的内存缓存。

哪种缓存最合理取决于你正在试图优化什么。

使用服务器缓存,一切对客户端都是不透明的,它们不需要关心任何事情。缓存在服务器外围或服务器限界内时,很容易了解一些类似数据是否失效这样的事情,还可以跟踪和优化缓存命中率。在你有多种类型客户端的情况下,服务器缓存可能是提高性能的最快方式。

我工作过的每一个面向公众的网站,最终都是混合使用这三种方法。不过对于几个分布式系统,我没有使用任何缓存。所有这些都取决于你需要处理多少负载,对数据及时性有多少要求,以及你的系统现在能做什么。知道你有几个不同的工具,这只是一个开始而已。

在这样广泛使用的规范里内置了这些控制手段,这意味着可以利用大量已存在的软件,来帮助我们处理缓存。

像AWS的CloudFront或Akamai这样的CDN,可以把请求路由到调用附近的缓存,以确保通信不会跨越半个地球。简单地说,HTTP客户端库和客户端缓存可以帮我们做大量的工作。

关于各种方式的优点的深入讨论,可以看一下《REST实战》,或阅读HTTP 1.1规范的第13章(http://www.w3.org/Protocols/rfc2616/rfc2616-sec13.html#sec13.3.3),它们描述了客户端和服务器应该如何实现这些不同的控制手段。

使用后写式缓存,如果对写操作的缓冲做了适当的持久化,那么即使下游服务不可用,我们也可以将写操作放到队列里,然后当下游服务可用时再将它们发送过去。

我曾经在《卫报》中见过一种技术,随后在其他地方也见过,就是定期去爬(crawl)现有的工作的网站,生成一个可以在意外停机时使用的静态网站。虽然这个爬下来的版本不比工作系统的缓存内容新,但在必要时,它可以确保至少有一个版本的网站可以显示。

如果我们突然得到一个晴天霹雳的消息,由于整个缓存区消失了,源服务就会接收到远大于其处理能力的请求。在这种情况下,保护源服务的一种方式是,在第一时间就不要对源服务发起请求。

再强调一次,缓存越多,就越难评估任何数据的新鲜程度。所以如果你认为缓存是一个好主意,请保持简单,先在一处使用缓存,在添加更多的缓存前慎重考虑!

缓存可以很强大,但是你需要了解数据从数据源到终点的完整缓存路径,从而真正理解它的复杂性以及使它出错的原因。

11.10 自动伸缩

关键是要知道一旦发现有上升的趋势,你能够多快完成扩展。如果你只能在负载增加的前几分钟得到消息,但是扩展至少需要10分钟,那么你需要保持额外的容量来弥合这个差距。良好的负载测试套件在这里是必不可少的。你可以使用它们来测试自动伸缩规则。如果没有测试能够重现触发伸缩的不同负载,那么你只能在生产环境上发现规则的错误,但这时的后果不堪设想!

另一方面,一个大新闻可能会导致意外的高峰,在短时间内需要更多的容量。

AWS允许你指定这样的规则:“这个组里至少应该有5个实例”,所以如果一个实例宕掉后,一个新的实例会自动启动。当有人忘记关掉这个规则时,就会导致一个有趣的打鼹鼠游戏(whack-a-mole),即当试图停掉一个实例进行维护时,它却自动启动起来了!

11.11 CAP定理

其核心是告诉我们,在分布式系统中有三方面需要彼此权衡:一致性(consistency)、可用性(availability)和分区容忍性(partition tolerance)。具体地说,这个定理告诉我们最多只能保证三个中的两个。

一致性是当访问多个节点时能得到同样的值。可用性意味着每个请求都能获得响应。分区容忍性是指集群中的某些节点在无法联系后,集群整体还能继续进行服务的能力。

如果我们需要保证一致性,相反想要放弃其他方面,会发生什么呢?好吧,为了保证一致性,每个数据库节点需要知道,它所拥有的数据副本和其他数据库节点中的数据完全相同。现在在分区情况下,如果数据库节点不能彼此通信,则它们无法协调以保证一致性。由于无法保证一致性,所以我们唯一的选择就是拒绝响应请求。换句话说,我们牺牲了可用性。

即使是在单个进程的系统中,锁都很容易出错,在分布式系统中当然就更难做好了。

还记得我们在第5章讨论的分布式事务吗?它们很具有挑战性,核心原因是需要确保多个节点的一致性问题。

让多节点实现正确的一致性太难了,我强烈建议如果你需要它,不要试图自己发明使用的方式。相反,选择一个提供这些特性的数据存储或锁服务。例如Consul(我们很快就会讨论到),设计实现了一个强一致性的键/值存储,在多个节点之间共享配置。

如果你认为需要实现自己的CP数据存储,首先请阅读完所有相关的论文,然后再拿一个博士学位,最后准备几年的时间来试错。与此同时,我会使用一些合适的、现成的工具,或者放弃一致性,去努力构建一个最终一致性的AP系统。

我们的系统作为一个整体,不需要全部是AP或CP的。目录服务可能是AP的,因为我们不太介意过时的记录。但库存服务可能需要是CP的,因为我们不想卖给客户一些没有的东西,然后不得不道歉。

你会经常看到关于有人打破CAP定理的文章。其实他们并没有,他们所做的其实是创建一个系统,其中有些功能是CP的,有些是AP的。CAP定理背后有相应的数学证明。尽管在学校尝试过多次,但最终我不得不承认数学规律是无法打破的。

我们讨论过的大部分,是电子世界内存中存储的比特和字节。我们以近乎小孩子的方式谈论一致性,想象在所构建系统的范围内,可以使世界停止,让一切都有意义。然而,我们所构建的只是现实世界的一个映射,有些也是我们无法控制的,对吗?

让我们重新考虑一下库存系统,它会映射到真实世界的实体物品。我们在系统里记录了专辑的数量,在一天开始时,有100张The Brakes的Give Blood专辑。卖了一张后,剩99张。很简单,对吧?但如果订单在派送的过程中,有人不小心把一张专辑掉到地上并且被踩坏了,现在该怎么办?我们的系统说99张,但货架上是98张。

11.12 服务发现

首先,它们提供了一些机制,让一个实例注册并告诉所有人:“我在这里!”其次,它们提供了一种方法,一旦服务被注册就可以找到它。然后,当考虑在一个不断销毁和部署新实例的环境中,服务发现会变得更复杂。理想情况下,我们希望无论选择哪种解决方案,它都应该可以解决这些问题。

处理不同环境的更先进的方式是,在不同的环境中使用不同的域名服务器。所以我可以假定,总是可以通过accounts.musiccorp.com找到账户服务,但根据其所处环境的不同,可能会解析到不同的主机上。如果你已经将环境放进不同的网段,并且可以很容易地管理DNS服务器和条目,这可能是相当简洁的解决方式,但如果你不能从这种设置中获取更多其他的好处,相对来说这个投入就太大了。

如前所述,DNS是广为人知并被广泛支持的。但它确实有一两个缺点。我建议在采用更复杂的方案之前,调查一下它是否适合你。当你只有单个节点时,使用DNS直接引用主机就可以了。但对于那些有多个主机实例的情况,应该将DNS条目解析到负载均衡器,它可以正确地把单个主机移入和移出服务。

11.13 动态服务注册

通常,这些系统所做的不仅仅是服务注册和服务发现,这可能也不是一件好事。这是一个拥挤的领域,因此只看其中几个选项,让你大概了解一下有哪些选择可用。

在众多选项中,Zookeeper可以说是比较老的,而且对比新的替代品,在服务发现方面没有提供很多现成的功能。即便如此,它还是被充分使用和测试过的,并得到了广泛使用。Zookeeper底层算法的正确实现相当困难。

我知道一个数据库供应商,只使用Zookeeper作为leader选举,以确保在出现故障的情况下,能够正确提升主节点。这个客户认为Zookeeper太重量级了,然后自己实现了PAXOS算法来替换Zookeeper,结果花费了大量时间来修复其中的缺陷。人们常说,你不应该实现自己的加密算法库。我想延伸这个说法,你也不应该实现自己的分布式协调系统。使用已有的可工作的选择是非常明智的。

例如,它为服务发现提供一个HTTP接口。Consul提供的杀手级特性之一是,它实际上提供了现成的DNS服务器。具体来说,对于指定的名字,它能提供一条SRV记录,其中包含IP和端口。这意味着,如果系统的一部分已经在使用DNS,并且支持SRV记录,你就可以直接开始使用Consul,而无需对现有系统做任何更改。

Consul很新,鉴于它使用算法的复杂性,我通常犹豫是否要推荐用它来完成这种重要的工作。

无论你选择什么样的系统,要确保有工具能让你在这些注册中心上生成报告和仪表盘,显示给人看,而不仅仅是给电脑看。

11.14 文档服务

Swagger让你描述API,产生一个很友好的Web用户界面,使你可以查看文档并通过Web浏览器与API交互。能够直接执行请求是一个非常棒的特性。例如,你可以定义POST模板,明确微服务期望的内容是什么样的。

HAL还描述了一些超媒体标准,并有相应的客户端支持库,这是一个额外的好处,也许这就是在已使用超媒体控件的人中,使用HAL作为API文档比使用Swagger更多的原因。如果你在使用超媒体,我更推荐使用HAL而不是Swagger。但是如果你没有使用超媒体,也不能判断将来是否切换,我肯定会建议使用Swagger。

11.16 小结

作为一种设计方法,微服务还相当年轻,所以虽然我们有一些很好的经验可以借鉴,但我相信未来几年,会产生更多有用的模式来处理规模化。尽管如此,我希望本章列出的一些步骤,可供你在规模化微服务的旅途中借鉴,并打下良好的基础。

除了本章所涵盖的内容,我推荐Nygard的优秀图书Release It!。在书里他分享了一系列关于系统故障的故事,以及一些处理它们的模式。这本书很值得一读(事实上,我甚至认为它应该成为构建任何规模化系统的必读书籍)。

第12章 总结

在前面的章节我们已经讨论了相当多的内容,从微服务的定义到如何划分它的边界,从集成技术到安全和监控。我们甚至还探讨了微服务架构下,架构师的角色应该是什么样子的。

12.1 微服务的原则

你可以选择全部采用这些原则,或者定制采用一些在自己的组织中有意义的部分。但请注意,组合使用这些原则的价值:整体使用的价值要大于部分使用之和。所以,如果决定要舍弃其中一个原则,请确保你明白其带来的损失。

经验表明,围绕业务的限界上下文定义的接口,比围绕技术概念定义的接口更加稳定。

像企业服务总线或服务编配系统这样的方案,会导致业务逻辑的中心化和哑服务,应该避免使用它们。使用协同来代替编排或哑中间件,使用智能端点(smartendpoint)确保相关的逻辑和数据,在服务限界内能保持服务的内聚性。

我们应当始终努力确保微服务可以独立部署。甚至当需要做不兼容更改时,我们也应该同时提供新旧两个版本,允许消费者慢慢迁移到新版本。这能够帮助我们加快新功能的发布速度。拥有这些微服务的团队,也能够越来越具有自治性,因为他们不需要在部署过程中不断地做编配。

通过采用单服务单主机模式,可以减少部署一个服务引发的副作用,比如影响另一个完全不相干的服务。请考虑使用蓝/绿部署或金丝雀部署技术,区分部署和发布,降低发布出错的风险。

我们不能依靠观察单一服务实例,或一台服务器的行为,来看系统是否运行正常。相反,我们需要从整体上看待正在发生的事情。通过注入合成事务到你的系统,模拟真实用户的行为,从而使用语义监控来查看系统是否运行正常。聚合你的日志和数据,这样当你遇到问题时,就可以深入分析原因。

12.2 什么时候你不应该使用微服务

这个问题我被问过很多次了。我的第一条建议是,你越不了解一个领域,为服务找到合适的限界上下文就越难。正如我们前面所讨论的,服务的界限划分错误,可能会导致不得不频繁地更改服务间的协作,而这种更改成本很高。所以,如果你不了解一个单块系统领域的话,在划分服务之前,第一件事情是花一些时间了解系统是做什么的,然后尝试识别出清晰的模块边界。

因此,请再次考虑首先构建单块系统,当稳定以后再进行拆分。

我希望这本书的建议,可以帮你预见其中的一些问题,并且了解一些如何解决这些问题的具体技巧。

12.3 临别赠言

微服务架构会给你带来更多的选择,也需要你做更多的决策。相比简单的单块系统,在微服务的世界里,做决策是一个更为常见的活动。我可以保证,你总会在一些决策上出错。既然知道了我们难免要做一些错事,那该怎么办呢?嗯,我会建议你,尽量缩小每个决策的影响范围。这样一来,如果做错了,只会影响系统的一小部分。学会拥抱演进式架构的概念,在这种概念下,系统会在你学到一些新东西之后扩展和变化。不要去想大爆炸式的重写,取而代之的是随着时间的推移,逐步对系统进行一系列更改,这样做可以保持系统的灵活性。

如果微服务适合你,我希望你把它看作一个旅程,而不是终点。逐步前行。一块块地拆分你的系统,逐步学习。习惯这一点:从很多方面来说,持续地改变和演进系统,这条规则比我在本书中分享给你的任何一个知识都要重要。变化是无法避免的,所以,拥抱它吧!

Sam Newman是ThoughtWorks的一名技术专家。目前,他一部分时间用在客户的项目上,一部分时间用于ThoughtWorks的内部系统架构上。他曾与全球多个领域的多家公司合作过,常常同时涉及开发和运维。如果你问他是做什么的,他会说:“我和人们一起构建更好的软件系统。”

本书封面上的动物是蜜蜂。在20000种已知的蜂类中,只有7种被认为是蜜蜂。蜜蜂之所以不同,是因为它们采食花粉和花蜜酿造蜂蜜,并用蜂蜡建造蜂巢。人类养蜂采蜜已传承数千年之久。

它们有三个社群阶级:蜂后、雄蜂和工蜂。每个蜂巢有一个蜂后,在一次飞行交配后能保持3~5年的产卵期,每日产卵可达2000个。雄蜂在蜂群中的作用是与蜂后交配(交配后它们的带刺生殖器会被撕离身体,不久便会死亡)。工蜂是繁殖器官发育不完善的雌性蜜蜂。工蜂最为忙碌,它们在一生中承任了很多职责,例如保育、筑巢、储存花蜜和花粉、放哨、清洁和采蜜。采蜜蜂会以特别的舞蹈方式告知同伴采蜜的讯息。

O'Reilly封面上的许多动物都濒临灭绝,它们对这个世界都是非常重要的。

posted @ 2021-10-31 23:12  zh89233  阅读(217)  评论(0编辑  收藏  举报