微服务设计 学习

第1章 微服务

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

1.1 什么是微服务

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

1.1.1 很小,专注于做好一件事

随着新功能的增加,代码库会越变越大。时间久了代码库会非常庞大,以至于想要知道该在什么地方做修改都很困难。尽管我们想在巨大的代码库中做到清晰地模块化,但事实上这些模块之间的界限很难维护。

微服务将这个理念应用在独立的服务上。根据业务的边界来确定服务的边界,这样就很容易确定某个功能代码应该放在哪里。而且由于该服务专注于某个边界之内,因此可以很好地避免由于代码库过大衍生出的很多相关问题。

1.1.2 自治性

一个微服务就是一个独立的实体。

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

服务会暴露出 API(Application Programming Interface,应用编程接口),然后服务之间通过这些 API 进行通信。

1.2 主要好处
1.2.1 技术异构性

微服务可以帮助我们更快地采用新技术,并且理解这些新技术的好处。

1.2.2 弹性

弹性工程学的一个关键概念是舱壁。

1.2.3 扩展

如果使用较小的多个服务,则可以只对需要扩展的服务进行扩展,这样就可以把那些不需要扩展的服务运行在更小的、性能稍差的硬件上。

1.2.4 简化部署

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

1.2.5 与组织结构相匹配

微服务架构可以很好地将架构与组织结构相匹配,避免出现过大的代码库,从而获得理想的团队大小及生产力。服务的所有权也可以在团队之间迁移,从而避免异地团队的出现。

1.2.6 可组合性

在微服务架构中,系统会开放很多接缝供外部使用。当情况发生改变时,可以使用不同的方式构建应用,而整体化应用程序只能提供一个非常粗粒度的接缝供外部使用。如果想要得到更有用的细化信息,你需要使用榔头撬开它!

1.2.7 对可替代性的优化

使用微服务架构的团队可以在需要时轻易地重写服务,或者删除不再使用的服务。当一个代码库只有几百行时,人们也不会对它有太多感情上的依赖,所以很容易替换它。

1.3 面向服务的架构

SOA(Service-Oriented Architecture,面向服务的架构)是一种设计方法,其中包含多个服务,而服务之间通过配合最终会提供一系列功能。一个服务通常以独立的形式存在于操作系统进程中。服务之间通过网络调用,而非采用进程内调用的方式进行通信。

1.4 其他分解技术

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

1.4.1 共享库

基本上所有的语言都支持将整个代码库分解成为多个库,这是一种非常标准的分解技术。

1.4.2 模块

除了简单的库之外,有些语言提供了自己的模块分解技术。它们允许对模块进行生命周期管理,这样就可以把模块部署到运行的进程中,并且可以在不停止整个进程的前提下对某个模块进行修改。

1.5 没有银弹

我想强调一点:微服务不是免费的午餐,更不是银弹,如果你想要得到一条通用准则,那么微服务是一个错误的选择。你需要面对所有分布式系统需要面对的复杂性。尽管后面用很多的篇幅来讲解如何管理分布式系统,但它仍然是一个很难的问题。如果你过去的经验更多的是关于单块系统,那么为了得到上述那些微服务的好处,你需要在部署、测试和监控等方面做很多的工作。你还需要考虑如何扩展系统,并且保证它们的弹性。如果你发现,还需要处理类似分布式事务或者与 CAP 相关的问题,也不要感到惊讶!

第2章 演化式架构师

2.1 不准确的比较

架构师的一个重要职责是,确保团队有共同的技术愿景,以帮助我们向客户交付他们想要的系统。在某些场景下,架构师只需要和一个团队一起工作,这时他们等同于技术引领者。在其他情况下,他们要对整个项目的技术愿景负责,通常需要协调多个团队之间,甚至是整个组织内的工作。不管处于哪个层次,架构师这个角色都很微妙。在一般的组织中,非常出色的开发人员才能成为架构师,但通常会比其他角色招致更多的批评。相比其他角色而言,架构师对多个方面都有更加直接的影响,比如所构建系统的质量、同事的工作条件、组织应对变化的能力等

2.2 架构师的演化视角

与建造建筑物相比,在软件中我们会面临大量的需求变更,使用的工具和技术也具有多样性。我们创造的东西并不是在某个时间点之后就不再变化了,甚至在发布到生产环境之后,软件还能继续演化。

2.3 分区

前面我们将架构师比作城市规划师,那么在这个比喻里面,区域的概念对应的是什么呢?它们应该是我们的服务边界,或者是一些粗粒度的服务群组。作为架构师,不应该过多关注每个区域内发生的事情,而应该多关注区域之间的事情。这意味着我们应该考虑不同的服务之间如何交互,或者说保证我们能够对整个系统的健康状态进行监控。至于多大程度地介入区域内部事务,在不同的情况下则有所不同。很多组织采用微服务是为了使团队的自治性最大化。

2.4 一个原则性方法

做系统设计方面的决定通常都是在做取舍,而在微服务架构中,你要做很多取舍!当选择一个数据存储技术时,你会选择不太熟悉但能够带来更好可伸缩性的技术吗?在系统中存在两种技术栈是否可接受?那三种呢?做某些决策所需要的信息很容易获取,这些还算是容易的。

2.4.1 战略目标

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

2.4.2 原则

为了和更大的目标保持一致,我们会制定一些具体的规则,并称之为原则,它不是一成不变的.

2.4.3 实践

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

2.4.4 将原则和实践相结合

有些东西对一些人来说是原则,对另一些人来说则可能是实践。比如,你可能会把使用HTTP/REST 作为原则,而不是实践。这也没什么问题,关键是要有一些重要的原则来指导系统的演化,同时也要有一些细节来指导如何实现这些原则。

2.4.5 真实世界的例子

几年间,实践改动得很频繁,而原则基本上没怎么变。可以把这样一个图表打印出来并共享给相关人员,其中每个条目都很简单,所以开发人员应该很容易记住它们。尽管每条实践背后还有很多细节,但仅仅能把它们总结表述出来也是非常有用的。

更好的方式是,创造一些工具来保证我们所做事情的正确性。

2.5 要求的标准
2.5.1 监控

能够清晰地描绘出跨服务系统的健康状态非常关键。这必须在系统级别而非单个服务级别进行考虑。往往在需要诊断一个跨服务的问题或者想要了解更大的趋势时,你才需要知道每个服务的健康状态。简单起见,我建议确保所有的服务使用同样的方式报告健康状态及其与监控相关的数据。

2.5.2 接口

选用少数几种明确的接口技术有助于新消费者的集成。使用一种标准方式很好,两种也不太坏,但是 20 种不同的集成技术就太糟糕了。这里说的不仅仅是关于接口的技术和协议。

2.5.3 架构安全性

一个运行异常的服务可能会毁了整个系统,而这种后果是我们无法承担的,所以,必须保证每个服务都可以应对下游服务的错误请求。没有很好处理下游错误请求的服务越多,我们的系统就会越脆弱。你可以至少让每个下游服务使用它们自己的连接池,进一步让每个服务使用一个断路器

2.6 代码治理
2.6.1 范例

编写文档是有用的。我很清楚这样做的价值,这也正是我写这本书的原因。但是开发人员更喜欢可以查看和运行的代码。如果你有一些很好的实践希望别人采纳,那么给出一系列的代码范例会很有帮助。这样做的一个初衷是:如果在系统中人们有比较好的代码范例可以模仿,那么他们也就不会错得很离谱。

2.6.2 裁剪服务代码模板

如果能够让所有的开发人员很容易地遵守大部分的指导原则,那就太棒了。一种可能的方式是,当开发人员想要实现一个新服务时,所有实现核心属性的那些代码都应该是现成的。

2.7 技术债务

有时候可能无法完全遵守技术愿景,比如为了发布一些紧急的特性,你可能会忽略一些约束。其实这仅仅是另一个需要做的取舍而已。我们的技术愿景有其本身的道理,所以偏离了这个愿景短期可能会带来利益,但是长期来看是要付出代价的。可以使用技术债务的概念来帮助我们理解这个取舍,就像在真实世界中欠的债务需要偿还一样,累积的技术债务也是如此。

2.8 例外管理

每个组织都是不同的。我曾经合作过的某些公司有高度自
治的团队,他们也得到公司足够的信任。对于这种情况,通常原则都是很轻量级的(例外管理可能会完全消失,或者大大减少)。有些组织结构化较强,开发人员拥有较小的自由度。这种情况下,通过例外管理来保证规则的合理性就非常重要了。现实中的情况是多种多样的,但我个人非常支持使用拥有更好自治性的微服务团队,他们有更大的自由度来解决问题。如果你所在的组织对开发人员有非常多的限制,那么微服务可能并不适合你。

2.9 集中治理和领导

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

在 IT 的上下文中有很多事情需要治理,而架构师会承担技术治理这部分的职责。如果说,架构师的一个职责是确保有一个技术愿景,那么治理就是要确保我们构建的系统符合这个愿景,而且在需要的时候还应对愿景进行演化。

2.10 建设团队

对于一个系统技术愿景的主要负责人来说,执行愿景不仅仅等同于做技术决定,和你一起工作的那些人自然会做这些决定。对于技术领导人来说,更重要的事情是帮助你的队友成长,帮助他们理解这个愿景,并保证他们可以积极地参与到愿景的实现和调整中来。

2.11 小结
  • 愿景
    确保在系统级有一个经过充分沟通的技术愿景,这个愿景应该可以帮助你满足客户和组织的需求。
  • 同理心
    理解你所做的决定对客户和同事带来的影响。
  • 适应性
    确保在你的客户和组织需要的时候调整技术愿景。
  • 治理
    确保系统按照技术愿景的要求实现。

第3章 如何建模服务

3.1 MusicCorp简介

让我们把注意力转移到前沿在线零售商 MusicCorp 上来。MusicCorp 最初是实体店经营,但是在唱片生意跌入谷底之后,他们开始把更多的注意力放在了网上。该公司有一个网站,他们认为现在是时候把在线业务的投入翻倍了。毕竟,iPod 只是昙花一现的东西(Zune 明显要好得多),音乐迷们还是很希望有人能够把 CD 送上门。质量比方便更重要,对吧?

3.2 什么的服务是好服务

我希望你专注在两个重要的概念上:松耦合和高内聚。

3.2.1 松耦合

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

3.2.2 高内聚

因为如果你要改变某个行为的话,最好能够只在一个地方进行修改,然后就可以尽快地发布。如果需要在很多不同的地方做这些修改,那么可能就需要同时发布多个微服务才能交付这个功能。在多个不同的地方进行修改会很慢,同时部署多个服务风险也很高,这两者都是我们想要避免的。

3.3 界限上下文

Eric Evans 的《领域驱动设计》一书主要专注如何对现实世界的领域进行建模。该书中有很多非常棒的想法,比如通用语言、仓储、抽象等。其中 Evans 引入的一个很重要的概念是限界上下文(bounded context),刚听到这个概念的时候,我深受启发。他认为任何一个给定的领域都包含多个限界上下文,每个限界上下文中的东西(Eric 更常使用模型这个词,应该比“东西”好得多)分成两部分,一部分不需要与外部通信,另一部分则需要。每个上下文都有明确的接口,该接口决定了它会暴露哪些模型给其他的上下文。

3.3.1 共享的隐藏模型

对于 MusicCorp 来说,财务部门和仓库就可以是两个独立的限界上下文。它们都有明确的对外接口(在存货报告、工资单等方面),也都有着只需要自己知道的一些细节(铲车、计算器)。

为了算出公司的估值,财务部的雇员需要库存信息,所以库存项就变成了两个上下文之间的共享模型。然而,我们不会盲目地把库存项在仓库上下文中的所有内容都暴露出去。比如,尽管在仓库内部有相应的模型来表示库存项,但是我们不会直接把这个模型暴露出去。也就是对该模型来说,存在内部和外部两种表示方式。

3.3.2 模块和服务

明白应该共享特定的模型,而不应该共享内部表示这个道理之后,就可以避免潜在的紧耦合(即我们不希望成为的样子)风险。我们还识别出了领域内的一些边界,边界内部是相关性比较高的业务功能,从而得到高内聚。这些限界上下文可以很好地形成组合边界。

3.3.3 过早划分

过早将一个系统划分成为微服务的代价非常高,尤其是在面对新领域时。很多时候,将一个已有的代码库划分成微服务,要比从头开始构建微服务简单得多。

3.4 业务功能

当你在思考组织内的限界上下文时,不应该从共享数据的角度来考虑,而应该从这些上下文能够提供的功能来考虑。

3.5 逐步划分上下文

一开始你会识别出一些粗粒度的限界上下文,而这些限界上下文可能又包含一些嵌套的限界上下文。举个例子,你可以把仓库分解成为不同的部分:订单处理、库存管理、货物接受等。当考虑微服务的边界时,首先考虑比较大的、粗粒度的那些上下文,然后当发现合适的缝隙后,再进一步划分出那些嵌套的上下文。

3.6 关于业务概念的沟通

修改系统的目的是为了满足业务需求。我们会修改面向客户的功能。如果把系统分解成为限界上下文来表示领域的话,那么对于某个功能所要做的修改,就更倾向于局限在一个单独的微服务边界之内。这样就减小了修改的范围,并能够更快地进行部署。

3.7 技术边界

按照技术接缝对服务边界进行建模也并不总是错误的。比如,我见过当一个组织想要达到某个性能目标时,这种划分方式反而更合理。然而一般来讲,这不应该成为你考虑的首要方式。

第4章 集成

4.1 寻找理想的集成技术

微服务之间通信方式的选择非常多样化,但哪个是正确的呢? SOAP ? XML-RPC ?REST ? Protocol Buffers ?后面会逐一讨论,但是在此之前需要考虑的是,我们到底希望从这些技术中得到什么。

4.1.1 避免破坏性修改

有时候,对某个服务做的一些修改会导致该服务的消费方也随之发生改变。后面会讨论如何处理这种情形,但是我们希望选用的技术可以尽量避免这种情况的发生。比如,如果一个微服务在一个响应中添加了一个字段,那么已有的消费方不应该受到影响。

4.1.2 保证API的技术无关性

保证微服务之间通信方式的技术无关性是非常重要的。这就意味着,不应该选择那种对微服务的具体实现技术有限制的集成方式。

4.1.3 使你的服务易于消费方使用

消费方应该能很容易地使用我们的服务。

4.1.4 隐藏内部实现细节

我们不希望消费方与服务的内部实现细节绑定在一起,因为这会增加耦合。

4.2 为用户创造接口

创建客户这个业务,乍一看似乎就是简单的 CRUD 操作,但对于大多数系统来说并不止这些。添加新客户可能会触发一个新的流程,比如进行付账设置、发送欢迎邮件等。而且修改或者删除客户也可能会触发其他的业务流程。

4.3 共享数据库

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

4.4 同步与异步

如果使用同步通信,发起一个远程服务调用后,调用方会阻塞自己并等待整个操作的完成。如果使用异步通信,调用方不需要等待操作完成就可以返回,甚至可能不需要关心这个操作完成与否。

4.5 编排与协同

编排方式的缺点是,客户服务作为中心控制点承担了太多职责,它会成为网状结构的中心枢纽及很多逻辑的起点。我见过这个方法会导致少量的“上帝”服务,而与其打交道的那些服务通常都会沦为贫血的、基于 CRUD 的服务。

通常来讲,我认为使用协同的方式可以降低系统的耦合度,并且你能更加灵活地对现有系统进行修改。但是,确实需要额外的工作来对业务流程做跨服务的监控。我还发现大多数重量级的编排方案都非常不稳定且修改代价很大。

4.6 远程过程调用

远程过程调用允许你进行一个本地调用,但事实上结果是由某个远程服务器产生的。RPC的种类繁多,其中一些依赖于接口定义(SOAP、Thrift、protocol buffers 等)。不同的技术栈可以通过接口定义轻松地生成客户端和服务端的桩代码。

4.7 REST

REST 是受 Web 启发而产生的一种架构风格。REST 风格包含了很多原则和限制,但是这里我们仅仅专注于,如何在微服务的世界里使用 REST 更好地解决集成问题。REST 是RPC 的一种替代方案。

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

REST 本身并没有提到底层应该使用什么协议。

4.7.1 REST和HTTP

HTTP 本身提供了很多功能,这些功能对于实现 REST 风格非常有用。比如说 HTTP 的动词(如 GET、POST 和 PUT)就能够很好地和资源一起使用。

HTTP 周边也有一个大的生态系统,其中包含很多支撑工具和技术。比如 Varnish 这样的HTTP 缓存代理、mod_proxy 这样的负载均衡器、大量针对 HTTP 的监控工具等。

4.7.2 超媒体作为程序状态的引擎

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

考虑 Amazon.com 这个站点。随着时间的推移,购物车的位置、图像、链接都有可能发生变化,但是人类足够聪明,你还是能够找到它。无论确切的形式和底层使用的控件发生怎样的改变,我们仍然很清楚如果你想要浏览购物车的话,应该去点哪个按钮。

4.7.3 JSON、XML还是其他

由于服务端使用标准文本形式的响应,所以客户端可以很灵活地对资源进行使用,而基于HTTP 的 REST 能够提供多种不同的响应形式。到目前为止我们看到的例子都是 XML 的,但事实上目前 JSON 更加流行。

JSON 无论从形式上还是从使用方法上来说都更简单。

但是 JSON 也有一些缺点。XML 使用链接来进行超媒体控制。JSON 标准中并没有类似的东西,所以出现了很多不同的自定义的方式在 JSON 中进行超媒体控制。

4.7.4 留心过多的约定

由于 REST 越来越流行,帮助我们构建 RESTFul Web 服务的框架也随之流行起来。然而有些工具会为了短期利益而牺牲长期利益,为了让你一开始启动得足够快,它们会使用一些不好的实践。举个例子,有些框架可以很容易地表示数据库对象,并把它们反序列化成进程内的对象,然后直接暴露给外部。我记得在一个会议上看到有人使用 Spring Boot 演示了这种做法,并且宣称这是它们的主要优势。这种方式内在的耦合性所带来的痛苦会远远大于从一开始就消除概念之间的耦合所需要的代价。

在我的团队中一个很有效的模式是先设计外部接口,等到外部接口稳定之后再实现微服务内部的数据持久化。

4.7.5 基于HTTP的REST的缺点

从易用性角度来看,基于 HTTP 的 REST 无法帮助你生成客户端的桩代码,而 RPC 可以。

还有一个小问题:有些 Web 框架无法很好地支持所有的 HTTP 动词。

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

虽然 HTTP 可以用于大流量的通信场景,但对低延迟通信来说并不是最好的选择。相比之下,有一些构建于 TCP(Transmission Control Protocol,传输控制协议)或者其他网络技术之上的协议更加高效。比如 WebSockets。

对于服务和服务之间的通信来说,如果低延迟或者较小的消息尺寸对你来说很重要的话,那么一般来讲 HTTP 不是一个好主意。你可能需要选择一个不同的底层协议,比如 UDP(User Datagram Protocol,用户数据报协议)来满足你的性能要求。

有些 RPC 的实现支持高级的序列化和反序列化机制,然而对于 REST 而言,这部分工作就要自己做了。

尽管有这些缺点,在选择服务之间的交互方式时,基于 HTTP 的 REST 仍然是一个比较合理的默认选择。

4.8 实现基于事件的异步协作方法
4.8.1 技术选择

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

传统上来说,像 RabbitMQ 这样的消息代理能够处理上述两个方面的问题。生产者(producer)使用 API 向代理发布事件,代理也可以向消费者提供订阅服务,并且在事件发生时通知消费者。

另一种方法是使用 HTTP 来传播事件。ATOM 是一个符合 REST 规范的协议,可以通过它提供资源聚合(feed)的发布服务,而且有很多现成的客户端库可以用来消费该聚合。这样当客户服务发生改变时,只需简单地向该聚合发布一个事件即可。消费者会轮询该聚合以查看变化。另一方面,现成的 ATOM 规范和与之相关的库用起来非常方便,而且 HTTP能够很好地处理伸缩性。但正如前面所提到的,HTTP 不擅长处理低延迟的场景,而且使用 ATOM 的话,用户还需要自己追踪消息是否送达及管理轮询等工作。

如果你已经有了一个好的、具有弹性的消息代理的话,就用它来处理事件的订阅和发布吧。但如果没有的话,你可以看一看 ATOM。

4.8.2 异步架构的复杂性

事件驱动的系统看起来耦合非常低,而且伸缩性很好。但是这种编程风格也会带来一定的复杂性,这种复杂性并不仅仅包括对消息的发布订阅操作。

现在来看一个大家可以引以为戒的故事。2006 年,我在一家银行帮客户构建定价系统,系统需要根据市场事件来决定投资组合中的哪些项需要重新定价。一旦确定了需要做的事情之后,就把它们全都放到一个消息队列中。当时我们使用一个网格来创建定价工作者池,这样就可以根据需求来调整定价集群的规模。这些工作者使用消费者竞争模式,每个工作者都不停地处理这些消息,直到没有消息可处理为止。

系统运行起来了,我们感觉很棒。但是在某一次发布之后,我们遇到了一个很令人讨厌的问题。我们的工作者不停地崩溃,不停地崩溃,不停地崩溃。

最终我们发现了问题所在。代码中存在一个 bug,某一种定价请求会导致工作者崩溃。我们当时使用了事务处理队列:当工作者崩溃之后,这个请求上的锁会超时,然后该请求就会被放回到队列中。另一个工作者会重新尝试处理该请求,然后它也会崩溃。这就是 Martin Fowler 提到的灾难性故障转移(catastrophic failover)的一个典型例子(http://martinfowler.com/bliki/CatastrophicFailover.html)。

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

4.9 服务即状态机

不管你选择做一个 REST 忍者,还是坚持使用像 SOAP 这样的基于 RPC 的机制,服务即状态机的概念都很强大。前面提到过(可能已经提的太多了)服务应该根据限界上下文进行划分。我们的客户微服务应该拥有与这个上下文中行为相关的所有逻辑。

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

4.10 响应式扩展

响应式扩展(Reactive extensions,Rx)提供了一种机制,在此之上,你可以把多个调用的结果组装起来并在此基础上执行操作。调用本身可以是阻塞或者非阻塞的.Rx 改变了传统的流程。以往我们会获取一些数据,然后基于此进行操作,现在你可以做的是简单地对操作的结果进行观察,结果会根据相关数据的改变自动更新。

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

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

我的经验是:在微服务内部不要违反 DRY,但在跨服务的情况下可以适当违反 DRY。

4.12 按引用访问

如何传递领域实体的相关信息是一个值得讨论的话题。

想象这样一个场景,你从客户服务获取了一个客户资源,那么就能看到该资源在你发起请求那一刻的样子。但是有可能在你发送了请求之后,其他人对该资源进行了修改,所以你所持有的其实是该客户资源曾经的样子。你持有这个资源的时间越久,其内容失效的可能性就越高。当然,避免不必要的数据请求可以让系统更高效。

原则上来说,应该在不确定数据是否能够保持有效的情况下,谨慎地进行处理。

4.13 版本管理

每次提及微服务的时候,都会有人问我如何做版本管理。大家担心服务的接口难免发生改变,那么如何管理这些改变呢?

4.13.1 尽可能推迟

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

客户端尽可能灵活地消费服务响应这一点符合 Postel 法则(也叫作鲁棒性原则,)。该法则认为,系统中的每个模块都应该“宽进严出”,即对自己发送的东西要严格,对接收的东西则要宽容。这个原则最初的上下文是网络设备之间的交互,因为在这个场景中,所有奇怪的事情都有可能发生。在请求 / 响应的场景下,该原则可以帮助我们在服务发生改变时,减少消费方的修改。

4.13.2 及早发现破坏性修改

及早发现会对消费者产生破坏的修改非常重要,因为即使使用最好的技术,也难以避免破坏性修改的出现。

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

4.13.3 使用语义化的版本管理

如果一个客户端能够仅仅通过查看服务的版本号,就知道它是否能够与之进行集成,那就太棒了。语义化版本管理(http://semver.org/)就是一种能够支持这种方式的规格说明。语义化版本管理的每一个版本号都遵循这样的格式: MAJOR.MINOR.PATCH 。其中 MAJOR 的改变意味着其中包含向后不兼容的修改; MINOR 的改变意味着有新功能的增加,但应该是向后兼容的;最后, PATCH 的改变代表对已有功能的缺陷修复。

4.13.4 不同的接口共存

如果已经做了可以做的所有事情来避免对接口的修改(但还是无法避免),那么下一步的任务就是限制其影响。我们不想强迫客户端跟随服务端一起升级,因为希望微服务可以独立于彼此进行发布。我用过的一种比较成功的方法是,在同一个服务上使新接口和老接口同时存在。所以在发布一个破坏性修改时,可以部署一个同时包含新老接口的版本。

对于使用 HTTP 的系统来说,可以在请求中添加版本信息,也可以将其添加在 URI 中,比如/v1/customer/ 和 /v2/customer/。我也很犹豫采用哪种方法。一方面,我不希望客户端的代码对 URI 模板进行硬编码;但从另一方面来看,这种方法确实非常明确,请求路由也比较容易。

4.13.5 同时使用多个版本的服务

另一种经常被提起的版本管理的方法是,同时运行不同版本的服务,然后把老用户路由到老版本的服务,而新用户可以看到新版本的服务。

4.14 用户界面

到目前为止还没有提及用户界面。有些人可能会向客户提供又冷又硬的试验性 API,但更多的人会尝试创建漂亮的、工作良好的用户界面来满足客户。但最重要的其实是,考虑该界面是否能够很好地支持服务之间的集成。毕竟用户界面是连接各个微服务的工具,而只有把各个服务集成起来才能真正地为客户创造价值。

4.14.1 走向数字化

由于很难预测用户会怎样使用我们的 API,所以很多公司会倾向于把 API 设计得比较细粒度化,比如使用微服务架构所暴露出来的那些 API。

4.14.2 约束

在用户与系统之间,需要考虑不同的交互形式中存在的一些约束。

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

4.14.3 API组合

假设我们的服务彼此之间已经通过 XML 或者 JSON 通信了,那么可以让用户界面直接与这个 API 进行交互。

这种方式有一些问题。首先很难为不同的设备定制不同的响应。

另一个关键的问题是:谁来创建用户界面?

使用 API 入口(gateway)可以很好地缓解这一问题,在这种模式下多个底层的调用会被聚合成为一个调用,当然它也有一定的局限性,后面会做讨论。

4.14.4 UI片段的组合

相比 UI 主动访问所有的 API,然后再将状态同步到 UI 控件,另一种选择是让服务直接暴露出一部分 UI,然后只需要简单地把这些片段组合在一起就可以创建出整体 UI。

这种方式的一个关键优势是,修改服务团队的同时可以维护这些 UI 片段。

原生应用和胖客户无法消费服务端提供的 UI 组件。

有时候服务提供的能力难以嵌入到小部件或者页面中。

4.14.5 为前端服务的后端

对与后端交互比较频繁的界面及需要给不同设备提供不同内容的界面来说,一个常见的解决方案是,使用服务端的聚合接口或 API 入口。

4.14.6 一种混合方式

前面提到的那些选择各自都有其适用的范围。一个组织会选择基于片段组装的方式来构建网站,但对于移动应用来说,BFF 可能是更好的方式。关键是要保持底层服务能力的内聚性。

4.15 与第三方软件集成
4.15.1 缺乏控制

使用类似 CMS 和 SaaS 这样的 COTS 产品会面临的一个挑战是,如何与之进行集成并对其进行扩展,因为大部分技术决策都不受你的控制。

所以更好的方式是,尽量把集成和定制化的工作放在自己能够控制的部分。

4.15.2 定制化

很多企业购买的工具都声称可以为你做深度定制化。一定要小心!这些工具链的定制化往往会比从头做起还要昂贵!

4.15.3 意大利面式的集成

另一个挑战是如何与工具进行集成。

4.15.4 在自己可控的平台进行定制化

核心思想是,任何定制化都只在自己可控的平台上进行,并限制工具的消费者的数量。

4.15.5 绞杀者模式

你通常难以完全控制遗留系统和 COTS 平台,所以当你使用它们时要考虑如果需要移除或者绕过它们的话,应该如何操作。一个有用的模式叫作绞杀者模式(Strangler ApplicationPattern,)。与在CMS 系统前面套一层自己的代码非常类似,绞杀者可以捕获并拦截对老系统的调用。这里你就可以决定,是把这些调用路由到现存的遗留代码中还是导向新写的代码中。

4.16 小结

前面了解了很多不同的集成选择,我也谈了什么样的选择能够最大程度地保证微服务之间的低耦合:

  • 无论如何避免数据库集成
  • 理解 REST 和 RPC 之间的取舍,但总是使用 REST 作为请求 / 响应模式的起点
  • 相比编排,优先选择协同
  • 避免破坏性修改、理解 Postel 法则、使用容错性读取器
  • 将用户界面视为一个组合层

第5章 分解单块系统

单块系统的形成非一日之功。开发人员每天都对系统添加新功能和新代码。一段时间之后,它变成了组织中一个恐怖而巨大的存在,没人想去修改它。但别担心,它并不是无可救药。只要使用了正确的工具,我们就可以手刃这个怪兽。

5.1 关键是接缝

在第 3 章中我们提到了服务应该是高内聚、低耦合的。而在单块系统中,这两点往往都会被破坏。

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

那么什么样的接缝才是好接缝呢?如前面讨论过的,限界上下文就是一个非常好的接缝,因为它的定义就是组织内高内聚和低耦合的边界。

5.2 分解MusicCorp

现在有一个巨大的后台单块服务,其中包含了 MusicCorp 在线音乐系统所需要的所有行为。首先,我们应该识别出组织中的高层限界上下文,这一点在第 3 章中已经讨论过了。然后,尝试理解这个单块系统能够被映射到哪些限界上下文中。假设一开始我们识别出这个单块后台系统包含以下四个上下文。

  • 产品目录
    与正在销售的商品相关的元数据。
  • 财务
    账户、支付、退款等项目的报告。
  • 仓库
    分发客户订单、处理退货、管理库存等。
  • 推荐
    该系统的算法正在申请专利。它是革命性的推荐系统,代码非常复杂。该团队中博士的比例,比一般科学实验室的还要高。

创建包结构来表示这些上下文,然后把已有的代码移动到相应的位置。

在这个过程中,我们还会使用代码来分析这些包之间的依赖。代码应该与组织相匹配,所以表示限界上下文的这些包之间的交互,也应该与组织中不同部分的实际交互方式一致。比如像 Structure 101 这样的工具,就能可视化包之间的依赖。

5.3 分解单块系统的原因
5.3.1 改变的速度

接下来,我们可能会对库存管理方面的代码做大量修改。所以如果现在把仓库接缝抽出来作为一个服务,使其成为一个自治单元,那么后期开发的速度将大大加快。

5.3.2 团队结构

MusicCorp 的交付团队事实上分布在两个不同的地区,一个团队在伦敦,另一个在夏威夷(有些人太舒服了!)。最好能把夏威夷团队维护的大部分代码分离出来,这样它们就能对此全权负责。

5.3.3 安全

MusicCorp 有安全审计的机制,并且决定对敏感信息做更加严密的保护。目前这部分功能由财务相关的代码处理。如果把这个服务分出去,可以对这个独立的服务做监控、传输数据的保护和静态数据的保护等。

5.3.4 技术

维护推荐系统的团队研究出了一种新的算法,这种算法使用了 Clojure 语言中逻辑式编程的库,并且认为这能够大大改善我们的服务。如果能把这部分推荐代码分离到一个单独的服务中,就很容易重新实现一遍,并对其进行测试。

5.4 杂乱的依赖

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

通常经过这样的分析就会发现,数据库是所有杂乱依赖的源头。

5.5 数据库

前面详细讨论了使用数据库作为服务之间集成方式的做法。而且我已经非常明确地表示我不喜欢这么做!这意味着需要找到数据库中的接缝,这样就可以把它们分离干净。然而数据库是一个棘手的怪物。

5.6 找到问题的关键

这些数据库级别的约束可能会有问题,所以需要使用其他的工具来可视化这些数据。SchemaSpy就是一个这样的工具,它可以使用图形的方式展现出表之间的关系。

5.7 例子:打破外键关系

产品目录部分的代码使用通用的行条目表来存储专辑信息,而财务部分的代码使用总账表来跟踪财务事务。

快速的修改方式是,让财务部分的代码通过产品目录服务暴露的 API 来访问数据,而不是直接访问数据库。

5.8 例子:共享静态数据

我见过的把国家代码放在数据库中(如图 5-4 所示)的次数,大约和我在内部 Java 项目中编写 StringUtils 类的次数一样多。

从个人经验来看,大部分场景下,都可以通过把这些数据放入配置文件或者代码中来解决问题,而且它对于大部分场景来说都很容易实现。

5.9 例子:共享数据

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

需要把抽象的客户概念具象化。

5.10 例子:共享表

产品目录需要存储记录的名字和价格,而仓库需要保存仓储的电子记录。最初我们把这两个东西放在了同一个地方,即比较通用的行条目表。当把代码全都放在一起时,事实上很难意识到我们把不同的关注点放在了一起,但现在问题就很明显了。接下来,就可以采取行动把它们存储在不同的表中。

这里的答案是分成两个表。

5.11 重构数据库

在上一个示例中,我们进行了数据库的重构操作,这种操作可以帮助我们分离数据库结构。如果你想更多地了解这个话题,可以看看 Scott J. Ambler 和 Pramod J. Sadalage 编写的Refactoring Databases。

事实上,我会推荐你先分离数据库结构,暂时不对服务进行分离。表结构分离之后,对于原先的某个动作而言,对数据库的访问次数可能会变多。

先分离数据库结构但不分离服务的好处在于,可以随时选择回退这些修改或是继续做,而不影响服务的任何消费者。我们对数据库分离感到满意之后,就可以考虑对整个应用程序的分离了。

5.12 事务边界

使用单块表结构时,所有的创建或者更新操作都可以在一个事务边界内完成。分离数据库之后,这种好处就没有了。

5.12.1 再试一次

对我们来说知道订单被捕获并被处理就足够了,因为可以后面再对仓库的提取表做一次插入操作。我们可以把这部分操作放在一个队列或者日志文件中,之后再尝试对其进行触发。对于某些操作来说这是合理的,但要保证重试能够修复这个问题。

很多地方会把这种形式叫作最终一致性。相对于使用事务来保证系统处于一致的状态,最终一致性可以接受系统在未来的某个时间达到一致。这种方法对于长时间的操作来说尤其管用。

5.12.2 终止整个操作

另一个选择是拒绝整个操作。

5.12.3 分布式事务

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

处理分布式事务(尤其是上面处理客户订单这类的短事务)常用的算法是两阶段提交。在这种方式中,首先是投票阶段。在这个阶段,每个参与者(在这个上下文中叫作 cohort)会告诉事务管理器它是否应该继续。如果事务管理器收到的所有投票都是成功,则会告知它们进行提交操作。只要收到一个否定的投票,事务管理器就会让所有的参与者回退。

分布式事务在某些特定的技术栈上已有现成的实现,比如 Java 的事务 API,该 API 允许你把类似数据库和消息队列这样完全不同的资源,放在一个事务中进行操作。很多算法都很复杂且容易出错,所以我建议你避免自己去创建这套 API。如果你确定这就是你要采取的方式的话,尽量使用现有的实现。

5.12.4 应该怎么办呢

如果现在有一个业务操作发生在跨系统的单个事务中,那么问问自己是否真的需要这么做。是否可以简单地把它们放到不同的本地事务中,然后依赖于最终一致性的概念?

5.13 报告

如我们已经看到的,在对服务进行分离的同时,可能也需要对数据存储进行分离。但是就会在进行一个很常见的操作时出问题,这个操作就是报告。

把架构往微服务的方向进行调整会颠覆很多东西,但这并不意味着我们需要抛弃现有的一切。报告系统的用户和其他用户一样,他们的需求也应该得到满足。修改架构然后让用户去适应,这种做法也未免太过傲慢。我并不是说报告这部分不能进行颠覆(当然是可以的),但是首先需要搞清楚现有流程是如何工作的。有时候你需要选择好战场。

5.14 报告数据库

报告通常需要来自组织内各个部分的数据来生成有用的输出。

5.15 通过服务调用来获取数据

这个模型有很多变体,但它们都依赖 API 调用来获取想要的数据。对于一个非常简单的报告系统(比如展示过去 15 分钟内下的订单数量的系统)来说,这是可行的。为了从两个或者多个系统中获取数据,你需要进行多次调用,然后进行组装。

5.16 数据导出

一种替代方式是,使用一个独立的程序直接访问其他服务使用的那些数据库,把这些数据导出到单独的报告数据库。

5.17 事件数据导出

每个微服务可以在其管理的实体发生状态改变时发送一些事件。比如,我们的客户服务可能会在客户增删改时发送一些事件。对于这些暴露事件聚合(feed)的微服务来说,可以编写自己的事件订阅器把数据导出到报告数据库中。

5.18 数据导出的备份

Netflix 使用过一种方法,该方法利用现有的备份方案解决了他们遇到的与扩展相关的问题。

5.19 走向实时

前面列出了很多把数据从不同的地方汇聚到同一个地方的模式。但是否所有的报告都必须从一个地方出呢?我们有仪表盘、告警、财务报告、用户分析等应用。这些使用场景对于时效性的要求不同,所以需要使用不同的技术。

5.20 修改的代价

贯穿本书,你会看到我一直在强调做小的、增量修改的各种原因,但其中一个关键的好处是,能够理解做出的那些改变会造成什么影响。

这里我采用了一种在设计面向对象系统时的典型技术:CRC(class-responsibility-collaboration,类 - 职责 - 交互)卡片。

5.21 理解根本原因

第一件需要理解的事情是,服务一定会慢慢变大,直至大到需要拆分。我们希望系统的架构随着时间的推移增量地进行变化。关键是要在拆分这件事情变得太过昂贵之前,意识到你需要做这个拆分。

第6章 部署

部署一个单块系统的流程非常简单。然而在众多相互依赖的微服务中,部署却是完全不同的情况。如果部署的方法不合适,那么其带来的复杂程度会让你很痛苦。

6.1 持续集成简介

CI(Continuous Integration,持续集成)已经出现很多年了,但还是值得花点时间来好好复习一下它的基本用法,因为在微服务之间的映射、构建及代码库版本管理等方面,存在很多不同的选择。

CI 的好处有很多。通过它,我们能够得到关于代码质量的某种程度的快速反馈。CI 可以自动化生成二进制文件。用于生成这些构建物的所有代码都在版本的控制之下,所以如果需要的话,可以重新生成这个版本的构建物。通过 CI 我们能够从已部署的构建物回溯到相应的代码,有些 CI 工具,还可以使在这些代码和构建物上运行过的测试可视化。正是因为上述这些好处,CI 才会成为一项如此成功的实践。

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

  • 你是否每天签入代码到主线?
  • 你是否有一组测试来验证修改?
  • 当构建失败后,团队是否把修复CI当作第一优先级的事情来做?
6.2 把持续集成映射到微服务

所以每个微服务都会有自己的代码库和构建流程。我们也会使用 CI 构建流程,全自动化地创建出用于部署的构建物。

6.3 构建流水线和持续交付

在早些年使用持续集成时,我们意识到了把一个构建分成多个阶段是很有价值的。比方说在测试中可能有很多运行很快、涉及范围很小的测试;还有一些比较耗时、涉及范围较大的测试,这些测试通常数量也比较少。如果所有测试一起运行的话,有可能一个快速测试已经失败了,但是因为需要等待那些耗时测试的完成,所以还是无法得到快速反馈。而且如果快速测试失败了,再接着运行剩下的耗时测试也是不合理的!解决这个问题的一个方案是,将构建分解成为多个阶段,从而得到我们熟知的构建流水线。

当一个团队刚开始启动一个新项目时,尤其是什么都没有的情况下,你可能会花很多时间来识别出服务的边界。所以在你识别出稳定的领域之前,可以把初始服务都放在一起。

6.4 平台特定的构建物

大多数技术栈都有相应的构建物类型,同时也有相关的工具来创建和安装这些构建物。

类似于 Puppet 和 Chef 这样的自动化配置管理工具,就可以很好地解决这个问题。

自动化可以对不同构建物的底层部署机制进行屏蔽。Chef、Puppet 及 Ansible 都支持一些通用技术栈的构建物部署。但有一些构建物的部署会非常简单。

6.5 操作系统构建物

有一种方法可以避免多种技术栈下的构建物所带来的问题,那就是使用操作系统支持的构建物。举个例子,对基于 RedHat 或者 CentOS 的系统来说,可以使用 RPM;对 Ubuntu 来说,可以使用 deb 包;对 Windows 来说,可以使用 MSI。

我见过很多团队使用基于 OS 的软件包管理工具,很好地简化了他们的部署流程,并且通常不会产生那种又大又复杂的部署脚本。特别是如果你在 Linux 上工作,而且采用多种技术栈来部署微服务,那么这种方法就很适合你。

6.6 定制化镜像

我用过的所有虚拟化平台,都允许用户构建自己的镜像,而且现在的工具提供的便利程度,也远远超越了多年前的那些工具。使用这种方法之后事情就变得简单一些了。现在你可以把公共的工具安装在镜像上,然后在部署软件时,只需要根据该镜像创建一个实例,之后在其之上安装最新的服务版本即可。

Packer(http://www.packer.io/)可以用来简化这个创建过程。你可以选择自己喜欢的工具(Chef、Ansible、Puppet 或者其他)来从同一套配置中生成不同平台的镜像。该工具产生之初就为 VMWare、AWS、Rackspcace 云、Digital Ocean 和 Vagrant 提供了支持,而且我也见到此方法在 Linux 和 Windows 平台上的成功运用。这意味着,你可以在生产环境使用AWS 来做部署,并使用 Vagrant 镜像做本地开发和测试,它们都源于同一套配置。

6.6.1 将镜像作为构建物

就像使用 OS 特定软件包那样,可以认为这些 VM 镜像是对不同技术栈的一层抽象。我们不需要关心运行在镜像中的服务,所使用的语言是 Ruby 还是 Java,最终的构建物是 gem还是 JAR 包,我们唯一需要关心的就是它是否工作。然后把精力放在镜像创建和部署的自动化上即可。这个简洁的方法有助于我们实现另一个部署概念:不可变服务器。

6.6.2 不可变服务器

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

为了避免这个问题,可以禁止对任何运行的服务器做手动修改。

6.7 环境

不同环境中部署的服务是相同的,但是每个环境的用途却不一样。在我的开发机上,想要快速部署该服务来运行测试或者做一些手工测试,此时相关的依赖很有可能都是假的;而在生产环境中,需要把该服务部署到多台机器上并使用负载均衡来管理,甚至从持久性(durability)的角度考虑,还需要把这些机器放在不同的数据中心去。

从笔记本到 UAT,最终再到生产环境,我们希望前面的那些环境能不断地靠近生产环境,这样就可以更快地捕获到由环境差异导致的问题。你需要持续地做权衡。有时候重建类生产环境所消耗的时间和代价会让人望而却步,所以你必须做出妥协。比如说,把软件部署到 AWS 上需要 25 分钟,而在本地的 Vagrant 实例中部署服务会快得多。

6.8 服务配置

如果存在不同环境之间的配置差异,应该如何在部署流程中对其进行处理呢?一种方法是对每个环境创建不同的构建物,并把配置内建在该构建物中。刚开始看这种方法好像挺有道理。配置已经被内建了,只需要简单的部署,它应该就能够正常工作了,对吧?

首先,创建这些构建物比较耗时。其次,你需要在构建的时候知道存在哪些环境。你要如何处理敏感的配置数据?我可不想把生产环境的数据库密码提交到源代码中,但是如果在创建这些构建物时需要的话,通常这也是难以避免的。

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

6.9 服务与主机之间的映射

很早之前,就有关于“每台机器(machine)应该有多少个服务”的讨论。

6.9.1 单主机多服务

在每个主机上部署多个服务是很有吸引力的。首先,从主机管理的角度来看它更简单。在一个团队管理基础设施,另一个团队管理软件的模式下,管理基础设施团队的工作量通常与所要管理的主机量成正比。如果单个主机包含更多的服务,那么主机管理的工作量不会随着服务数量的增加而增加。其次是关于成本。

但这个模型也有一些挑战。首先,它会使监控变得更加困难。服务的部署也会变得更复杂,因为很难保证对一个服务的部署不会影响其他的服务。这个模型对团队的自治性也不利。

6.9.2 应用程序容器

如果你对基于 IIS 的 .NET 应用程序部署,或者基于 servlet 容器的 Java 应用程序部署比较熟悉的话,那么应该非常了解把不同的服务放在同一个容器中,再把容器放置到单台主机上的模式。

无论你最终是否使用将多个服务放在一个主机中的部署模型,我都会强烈建议你看看自包含的微服务构建物。对于 .NET 来说,可能是类似 Nancy 这样的东西,而 Java很多年前就已经支持了。举个例子,令人敬仰的 Jetty 嵌入式容器中,使用了一个非常轻量级的自包含 HTTP 服务器,而它正是 Dropwizard 技术栈的核心。Google 非常广泛地采用了嵌入式 Jetty 容器来直接服务静态内容,所以这种做法的伸缩性肯定是没有问题的。

6.9.3 每个主机一个服务

每个主机一个服务的模型,这种模型避免了单主机多服务的问题,并简化了监控和错误恢复。这种方式也可以减少潜在的单点故障。一台主机宕机只会影响一个服务,虽然在虚拟化平台上不一定真的是这样。

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

6.9.4 平台即服务

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

好的 PaaS 解决方案已经为你做了很多,它们能够很好地帮你管理数量众多的组件。尽管如此,我还是不确定这些模型是否正确,且自管理主机(self-hosted)的选择又很有限,所以这种方法可能也不适合你。但是在未来的十年,我希望 PaaS 能够成为部署平台的首选,而不是自己管理主机及每个服务的部署。

6.10 自动化

我们提到的很多问题都可以使用自动化来解决。

使用支持自动化的技术非常重要。让我们从管理主机的工具开始考虑这个问题,你能否通过写一行代码来启动或者关闭一个虚拟机?你能否自动化部署写好的软件?你能否不需要手工干预就完成数据库的变更?想要游刃有余地应对复杂的微服务架构,自动化是必经之路。

6.11 从物理机到虚拟机
6.11.1 传统的虚拟化技术

虚拟化技术允许我们把一台物理机分成多台独立的主机,每台主机可以运行不同的东西。所以如果我们想要把每个服务部署在独立的主机上,为什么不把物理设备划分成小块呢?

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

6.11.2 Vagrant

Vagrant 是一个很有用的部署平台,通常在开发和测试环境时使用,而非生产环境。Vagrant 可以在你的笔记本上创建一个虚拟的云。它的底层使用的是标准的虚拟化系统(通常是 VirtualBox,但也可以使用其他平台)。

6.11.3 Linux容器

Linux 用户可以使用另外一种虚拟化的替代方案。相比使用 hypervisor 隔离和控制虚拟主机的方法来说,Linux 容器可以创建一个隔离的进程空间,进而在这个空间中运行其他的进程。

6.11.4 Docker

Docker 是构建在轻量级容器之上的平台。它帮你处理了大多数与容器管理相关的事情。你可以在 Docker 中创建和部署应用,这些基于容器的应用与 VM 世界中的镜像很类似。Docker 也能管理容器的配置,并帮你处理一些网络问题,甚至还提供了自己的 registry 概念,允许你存储 Docker 应用程序的版本。

有好几个公司都在生产环境使用了 Docker。它提供了很多轻量级容器的好处,比如快速启动和配置等,并且使用了一些工具来避免它的缺点。如果你正在寻找不同的部署平台,我强烈建议你看看 Docker。

6.12 一个部署接口

不管用于部署的底层平台和构建物是什么,使用统一接口来部署给定的服务都是一个很关键的实践。

6.13 小结

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

第7章 测试

7.1 测试类型

作为一名顾问,我喜欢使用形式各异的象限来对世界进行分类。起初,我以为这本书不会有这样的象限。幸运的是,Brian Marick 想出了一个非常棒的分类测试体系,恰好就是用象限的方式。Lisa Crispin 和 Janet Gregory 在《敏捷软件测试》一书中,用来将不同测试类型分类的测试象限,这个象限是 Matrick 的演化版本。

鉴于本章的目的,我们将忽略手工测试。手工测试是很有用的,也肯定有它存在的必要。不过,测试微服务架构的系统跟测试独立系统的区别,很大程度上在于各种类型的自动化测试。因此,我们将集中精力在自动化测试上面。

7.2 测试范围

Mike Cohn 在他的《Scrum 敏捷软件开发》一书中介绍了一种叫作“测试金字塔”的模型,其中描述了不同的自动化测试类型。这个金字塔模型不仅可以帮助我们思考不同的测试类型应该覆盖的范围,还能帮助我们思考应该为这些不同的测试类型投入多大的比例Cohn 在他的原始模型中把自动化测试划分为单元测试、服务测试和用户界面测试三层。

7.2.1 单元测试

单元测试通常只测试一个函数和方法调用。通过 TDD(Test-Driven Design,测试驱动开发)写的测试就属于这一类,由基于属性的测试技术所产生的测试也属于这一类。在单元测试中,我们不会启动服务,并且对外部文件和网络连接的使用也很有限。通常情况下你需要大量的单元测试。如果做得合理,它们运行起来会非常非常快,在现代硬件环境上运行上千个这种测试,可能连一分钟都不需要。

7.2.2 服务测试

服务测试是绕开用户界面、直接针对服务的测试。在独立应用程序中,服务测试可能只测试为用户界面提供服务的一些类。对于包含多个服务的系统,一个服务测试只测试其中一个单独服务的功能。

7.2.3 端到端测试

端到端测试会覆盖整个系统。这类测试通常需要打开一个浏览器来操作图形用户界面(GUI),也很容易模仿类似上传文件这样的用户交互。

7.2.4 权衡

在使用这个金字塔时,应该了解到越靠近金字塔的顶端,测试覆盖的范围越大,同时我们对被测试后的功能也越有信心。而缺点是,因为需要更长的时间运行测试,所以反馈周期会变长。并且当测试失败时,比较难定位是哪个功能被破坏。而越靠近金字塔的底部,一般来说测试会越快,所以反馈周期也会变短,测试失败后更容易定位被破坏的功能,持续集成的构建时间也很短。另外,还能避免我们在不知道已经破坏了某个功能的情况下转去做新的任务。这些更小范围的测试失败后,我们更容易定位错误的地方,甚至经常能定位到具体哪行代码。从另一个角度来看,当只测试了一行代码时,我们又很难有充足的信心认为,系统作为一个整体依然能正常工作。

7.2.5 比例

既然所有的测试都有优缺点,那每种类型需要占多大的比例呢?一个好的经验法则是,顺着金字塔向下,下面一层的测试数量要比上面一层多一个数量级。如果当前的权衡确实给你带来了问题,那可以尝试调整不同类型自动化测试的比例,这是非常重要的!

7.3 实现服务测试

服务测试只想要测试一个单独服务的功能,为了隔离其他的相关服务,需要一种方法给所有的外部合作者打桩。

7.3.1 mock还是打桩

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

与打桩相比,mock 还会进一步验证请求本身是否被正确调用。如果与期望请求不匹配,测试便会失败。这种方式的实现,需要我们创建更智能的模拟合作者,但过度使用 mock会让测试变得脆弱。

7.3.2 智能的打桩服务

以前我都是自己创建打桩服务。为了启动测试需要的打桩服务,我尝试过 Apache、Nginx和嵌入式 Jetty 容器,甚至还使用过命令行启动 Python 的 Web 服务器。这样的工作我曾经重复做了很多次。我在 ThoughtWorks 的同事 Brandon Bryars,创建了一个叫作Mountebank(http://www.mbtest.org/)的打桩 /mock 服务器,它帮助了很多人避免像我那样重复工作多次。

7.4 微妙的端到端测试

一个不成熟的方案是,直接在客户服务流水线的最后增加这些测试。

一些更好地支持构建流水线的 CI 工具可以很方便地实现这样的扇入模型。这样,任意一个服务在任何时候只要发生变化,我们都会运行针对这些服务的测试。如果测试通过,便会触发端到端测试。

7.5 端到端测试的缺点

遗憾的是,端到端测试有很多的缺点。

7.6 脆弱的测试

包含在测试中的服务数量越多,测试就会越脆弱,不确定性也就越强。如果测试失败以后每个人都只是想重新运行一遍测试,然后希望有可能通过,那么这种测试是脆弱的。不仅这种涉及多个服务的测试很脆弱,涉及多线程功能的测试通常也会有问题,测试失败有时是因为资源竞争、超时等,有时是功能真的被破坏了。脆弱的测试是我们的敌人,因为这种测试的失败不能告诉我们什么有用的信息。如果所有人都习惯于重新构建 CI,以期望刚失败的测试通过,那么最终结果只能是看到堆积的提交,然后突然间你会发现有一大堆功能早已经被破坏了。

在“Eradicating Non-Determinism in Tests”(http://martinfowler.com/articles/nonDeterminism.html 这篇博文中,Martin Fowler 建议发现脆弱的测试时应该立刻记录下来,当不能立即修复时,需要把它们从测试套件中移除,然后就可以不受打扰地安心修复它们。修复时,首先看看能不能通过重写来避免被测代码运行在多个线程中,再看看是否能让运行的环境更稳定。更好的方法是,看看能否用不易出现问题的小范围测试取代脆弱的端到端测试。有时候,改变被测软件本身以使之更容易测试也是一个正确的方向。

7.6.1 谁来写这些测试

既然这些测试是某服务流水线的一部分,一个比较合理的想法是,拥有这些服务的团队应该写这些测试。

7.6.2 测试多长时间

运行缓慢和脆弱性是很大的问题。

并行运行测试可以改善缓慢的问题。可以使用 Selenium Grid 等工具来达到这个效果。然而这种方法并不能代替去真正了解什么需要被测试,以及哪些不必要的测试可以被删掉。

7.6.3 大量的堆积

部署的变更内容越多,发布的风险就会越高,我们就越有可能破坏一些功能。保障频繁发布软件的关键是基于这样的一个想法:尽可能频繁地发布小范围的改变。

7.6.4 元版本

在端到端测试阶段,人们很容易有这样的想法:我知道所有服务在这些版本下能够一起工作,为什么不一起部署它们呢?这个对话很快会演化成:为什么不给整个系统使用同一个版本号呢?引用 Brandon Bryars(http://martinfowler.com/articles/enterpriseREST.html)的话:“现在 2.1.0 有问题了。”

为应用于多个服务上的修改使用相同的版本,会使得我们很快接受这样的理念:同时修改和部署多个服务是可以接受的。这个成了常态,成了正常的情况。而这样做后,我们就会丢弃微服务的主要优势之一:独立于其他服务单独部署一个服务的能力。

7.7 测试场景,而不是故事

尽管有如上所述的缺点,但对许多用户来说,覆盖一两个服务的端到端测试还是可管理的,也是有意义的。但覆盖 3 个、4 个、10 个或 20 个服务的测试怎么办?不用多长时间,这些测试套件便会变得非常臃肿,而在最坏的情况下,这个测试场景甚至可能会出现笛卡儿积式的爆炸。

7.8 拯救消费者驱动的测试

使用之前所提到的端到端测试,我们试图解决的关键问题是什么?是试图确保部署新的服务到生产环境后,变更不会破坏新服务的消费者。有一种不需要使用真正的消费者也能达到同样目的的方式,它就是 CDC(Consumer-Driven Contract,消费者驱动的契约)。

7.8.1 Pact

Pact(https://github.com/realestate-com-au/pact)是一个消费者驱动的测试工具,最初是在开发 RealEstate.com.au 的过程中创建的,现在已经开源,功能大部分是由 Beth Skurrie 组织开发的。该工具最初是使用 Ruby 语言,现在支持包括 JVM 和 .NET 的版本。

7.8.2 关于沟通

在敏捷中,故事通常被认为是一种促进沟通的方式。CDC 也起到类似的作用。它们可以推动关于如何编写一组服务的 API 的讨论,当其被破坏时也可以触发 API 该如何演进的讨论。

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

本章之前的内容详细地描述了端到端测试的大量缺点,而随着测试覆盖的服务数量的增加,这些缺点会更加凸显。一段时间以来,通过跟实施大规模微服务的人一直保持交流,我意识到随着时间的推移,大部分人更喜欢使用类似 CDC 的工具和更好的监控来代替端到端测试。但这并不意味着端到端测试应该被全部扔掉。他们会在使用一种叫作语义监控semantic monitoring)的技术来监控生产系统时,用到端到端场景测试。

7.10 部署后再测试

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

7.10.1 区分部署和上线

要达到这个目的,一种方式是突破传统的在部署之前运行测试的方法。如果可以部署软件到生产环境,在有真正生产负载(production load)之前运行测试,我们可以发现特定环境中的问题。一个常见的例子是,用来验证部署后的系统是否正常工作的、针对新部署软件的一系列的冒烟测试套件。这些测试帮助我们识别与环境有关的任何问题。如果你能够使用一条命令来部署任何给定的微服务(应该这么做),应该把自动运行冒烟测试也加到这条命令中。

另一个例子是所谓的蓝 / 绿部署。使用蓝 / 绿部署时,我们会部署两份软件,但只有一个接受真正的请求。

7.10.2 金丝雀发布

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

金丝雀发布是一种功能强大的技术,帮助大家用实际的请求来验证软件的新版本,同时可能推出一个糟糕的新版本,提供工具来帮助控制风险。不过,它也确实比蓝 / 绿部署需要更复杂的配置和更多的思考。你可以比蓝 / 绿部署共存多版本服务的时间更长,不过也会比蓝 / 绿部署占用更多,时间更长的硬件资源。你还需要更复杂的请求路由,因为为了对发布工作更有信心,你可能需要增加或减少请求。不过如果你已经实现蓝 / 绿部署,那实现金丝雀发布需要的部分构建块可能已经有了。

7.10.3 平均修复时间胜过平均故障间隔时间

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

7.11 跨功能的测试

本章大部分内容集中在讨论测试特定的功能,以及这种功能测试在微服务系统下有哪些不同。不过,还有一种类型的测试也是非常重要的,值得我们在这讨论一下。非功能性需求,是对系统展现的一些特性的一个总括的术语,这些特性不能像普通的特性那样简单实现。它包括以下方面,比如一个网页可接受的延迟时间,系统能够支持的用户数量,用户界面如何让残疾人也可以访问,或者如何保障客户数据的安全。

7.12 小结
  • 优化快速反馈,并相应地使用不同类型的测试。
  • 尽可能使用消费者驱动的契约测试,来替换端到端测试。
  • 使用消费者驱动的契约测试,提供团队之间的对话要点。
  • 尝试理解投入更多的努力测试与更快地在生产环境发现问题之间的权衡(MTBF 与MTTR 权衡的优化)。

第8章 监控

8.1 单一服务,单一服务器

首先,我们希望监控主机本身。CPU、内存等所有这些主机的数据都有用。

接下来,我们要查看服务器本身的日志。

最后,我们可能还想要监控应用程序本身。

8.2 单一服务,多个服务器

除了要查看所有主机的数据,还要查看单个主机自己的数据。换句话说,我们既想把数据聚合起来,又想深入分析每台主机。Nagios 允许以这样的方式组织我们的主机,到目前为止一切还好。类似的方式也可以满足我们对应用程序的监控。

接下来就是日志。我们的服务运行在多个服务器上,登录到每台服务器查看日志,可能会让我们感到厌倦。如果只有几个主机,我们还可以使用像 ssh-multiplexers 这样的工具,在多个主机上运行相同的命令。用一个大的显示屏,和一个 grep “Error” app.log ,我们就可以定位错误了。

8.3 多个服务,多个服务器

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

8.4 日志,日志,更多的日志

现在,运行服务的主机数量成为一个挑战。现在再使用 SSH multiplexing 检索日志,已经无法缓解这个问题了,况且也没有一个足够大的屏幕显示每台主机的终端。我们希望用专门获取日志的子系统来代替它,让日志能够集中在一起方便使用。这方面的一个例子是logstash(http://logstash.net),它可以解析多种日志文件格式,并将它们发送给下游系统进行进一步调查。

Kibana(https://www.elastic.co/products/kibana)是一个基于 ElasticSearch 查看日志的系统,如图 8-4 所示。你可以使用查询语法来搜索日志,它允许在查询时指定时间和日期范围,或使用正则表达式来查找匹配的字符串。Kibana 甚至可以把你发给它的日志生成图表,只需看一眼就能知道已经发生了多少错误。

8.5 多个服务的指标跟踪

Graphite 就是一个让上述要求变得很容易的系统。

8.6 服务指标

我强烈建议你公开自己服务的基本指标。作为 Web 服务,最低限度应该暴露如响应时间和错误率这样的一些指标。如果你的服务器前面没有一个 Web 服务器来帮忙做的话,这一点就更重要了。

8.7 综合监控

我们可以通过定义正常的 CPU 级别,或者可接受的响应时间,判断一个服务是否健康。如果我们的监控系统监测到实际值超出这些安全水平,就可以触发警告。类似像 Nagios 这样的工具,完全有能力做这个。

8.8 关联标识

最终用户看到的任何功能都由大量的服务配合提供,一个初始调用最终会触发多个下游的服务调用。

在这种情况下,一个非常有用的方法是使用关联标识(ID)。在触发第一个调用时,生成一个 GUID。然后把它传递给所有的后续调用,如图 8-5 所示。类似日志级别和日期,你也可以把关联标识以结构化的方式写入日志。使用合适的日志聚合工具,你能够对事件在系统中触发的所有调用进行跟踪。

传递关联标识时需要保持一致性,这是使用共享的、薄客户端库的一个强烈的信号。团队达到一定规模时,很难保证每个人都以正确的方式调用下游服务以收集正确的数据。只需服务链中的某个服务忘记传递关联标识,你就会丢失重要的信息。如果你决定创建一个内部客户端库来标准化这样的工作,请确保它很薄且不依赖提供的任何特定服务。例如,如果你正在使用 HTTP 作为通信协议,只需包装标准的 HTTP 客户端库,添加代码确保在HTTP 头传递关联标识即可。

8.9 级联

级联故障特别危险。

因此,监控系统之间的集成点非常关键。每个服务的实例都应该追踪和显示其下游服务的健康状态,从数据库到其他合作服务。你也应该将这些信息汇总,以得到一个整合的画面。

8.10 标准化

正如我们前面提过的,一个需要持续做出的平衡,是仅规范单个服务,还是规范整个系统。在我看来,监控这个领域的标准化是至关重要的。服务之间使用多个接口,以很多不同的方式合作为用户提供功能,你需要以整体的视角查看系统。你应该尝试以标准格式的方式记录日志。

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 类似。Suro 明确可以处理两种数据,用户行为的相关指标和更多的运营数据(如应用程序日志)。然后这些数据可以被分发到不同的系统中,像 Storm 的实时分析、离线批处理的 Hadoop 或日志分析的 Kibana。

许多组织正在朝一个完全不同的方向迈进:不再为不同类型的指标提供专门的工具链,而是提供伸缩性很好的更为通用的事件路由系统。这些系统能提供更多的灵活性,同时还能简化我们的架构。

8.13 小结

对每个服务而言

  • 最低限度要跟踪请求响应时间。做好之后,可以开始跟踪错误率及应用程序级的指标。
  • 最低限度要跟踪所有下游服务的健康状态,包括下游调用的响应时间,最好能够跟踪错误率。一些像 Hystrix 这样的库,可以在这方面提供帮助。
  • 标准化如何收集指标以及存储指标。
  • 如果可能的话,以标准的格式将日志记录到一个标准的位置。如果每个服务各自使用不同的方式,聚合会非常痛苦!
  • 监控底层操作系统,这样你就可以跟踪流氓进程和进行容量规划。

对系统而言

  • 聚合 CPU 之类的主机层级的指标及应用程序级指标。
  • 确保你选用的指标存储工具可以在系统和服务级别做聚合,同时也允许你查看单台主机的情况。
  • 确保指标存储工具允许你维护数据足够长的时间,以了解你的系统的趋势。
  • 使用单个可查询工具来对日志进行聚合和存储。
  • 强烈考虑标准化关联标识的使用。
  • 了解什么样的情况需要行动,并根据这些信息构造相应的警报和仪表盘。
  • 调查对各种指标聚合方式做统一化的可能性,像 Suro 或 Riemann 这样的工具可能会对你有用

第9章 安全

9.1 身份验证和授权

当谈到与我们系统交互的人和事时,身份验证和授权是核心概念。在安全领域中,身份验证是确认他是谁的过程。对于一个人,通常通过用户输入的用户名和密码来验证。我们认为只有用户本人才能够知道这些信息,因此输入这些信息的人一定是他。当然,还存在其他更复杂的系统。我的手机可以用指纹来确认我是我本人。通常来说,当我们抽象地讨论进行身份验证的人或事时,我们称之为主体(principal)。

9.1.1 常见的单点登录实现

身份验证和授权的一种常用方法是,使用某种形式的 SSO(Single Sign-On,单点登录)解决方案。在企业级领域中占据统治地位的 SAML 和 OpenID Connect,也提供了这方面的能力。虽然术语略有不同,但它们或多或少使用了相同的核心概念。这里使用的术语来自SAML。

9.1.2 单点登录网关

在微服务系统中,每个服务可以自己处理如何重定向到身份提供者,并与其进行握手。显然,这意味着大量的重复工作。使用共享库可以解决这个问题,但我们必须小心地避免可能来自共享代码的耦合。而且如果有多个不同的技术栈,共享库也很难提供帮助。

你可以使用位于服务和外部世界之间的网关作为代理,而不是让每个服务管理与身份提供者握手。基本想法是,我们可以集中处理重定向用户的行为,并且只在一个地方执行握手。

9.1.3 细粒度的授权

网关可以提供相当有效的粗粒度的身份验证。

9.2 服务间的身份验证和授权
9.2.1 在边界内允许一切

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

9.2.2 HTTP(S)基本身份验证

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

9.2.3 使用SAML或OpenID Connect

如果你已经在使用 SAML 或 OpenID Connect 作为身份验证和授权方案,你可以在服务之间的交互中也使用它们。如果你正在使用一个网关,可以使用同一个网关来路由所有内网通信,但如果每个服务自己处理集成,那么系统应该就自然而然这么工作。这样做的好处在于,你利用现有的基础设施,并把所有服务的访问控制集中在中央目录服务器。如果想要避免中间人的攻击,我们仍然需要通过 HTTPS 来路由通信。

9.2.4 客户端证书

确认客户端身份的另一种方法是,使用 TLS(Transport Layer Security,安全传输层协议),TLS 是 SSL 在客户端证书方面的继任者。

9.2.5 HTTP之上的HMAC

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

9.2.6 API密钥

像 Twitter、谷歌、Flickr 和 AWS 这样的服务商,提供的所有公共 API 都使用 API 密钥。API 密钥允许服务识别出是谁在进行调用,然后对他们能做的进行限制。限制通常不仅限于特定资源的访问,还可以扩展到类似于针对特定的调用者限速,以保护其他人服务调用的质量等。

9.2.7 代理问题

不幸的是,这个问题没有简单的答案,因为它本身就不是一个简单的问题。不过,要知道它的存在。根据所讨论操作的敏感性,你可能需要在隐式信任、验证调用方的身份或要求调用者提供原始主体的凭证这些安全方式里做一个选择。

9.3 静态数据的安全
9.3.1 使用众所周知的加密算法

对于静态数据的加密,除非你有一个很好的理由选择别的,否则选择你的开发平台上的AES-128 或 AES-256 的一个广为人知的实现即可。

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

9.3.2 一切皆与密钥相关

一个解决方案是,使用单独的安全设备来加密和解密数据。另一个方案是,使用单独的密钥库,当你的服务需要密钥的时候可以访问它。密钥的生命周期管理(和更改它们的权限)是非常重要的操作,而这些系统可以帮你处理这个事情。

9.3.3 选择你的目标

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

9.3.4 按需解密

第一次看到数据的时候就对它加密。只在需要时进行解密,并确保解密后的数据不会存储在任何地方。

9.3.5 加密备份

备份是有好处的。我们想要备份重要的数据,那些我们非常担心的需要加密的数据,几乎也自然重要到需要备份!所以它看起来像是显而易见的观点,但是我们需要确保备份也被加密。这意味着,我们需要知道应该用哪个密钥来处理哪个版本的数据,特别是当密钥更改时。清晰的密钥管理变得非常重要。

9.4 深度防御
9.4.1 防火墙

有一个或多个防火墙是一个非常明智的预防措施。有些非常简单,只在特定端口限制特定的通信类型。其他的则要复杂一些。例如,ModSecurity 是一种应用程序防火墙,可以在特定的 IP 范围限制连接数,并检测其他类型的恶意攻击。

多个防火墙是有价值的。你可能决定在本地主机上使用 IPTables,设置允许的入口和出口,以确保这个主机的安全。这些规则可以根据本地运行的服务进行定制,而外围的防火墙则控制一般的访问。

9.4.2 日志

好的日志实践,特别是聚合多个系统的日志的能力,虽然不能起到预防的作用,但可以帮助检测出发生了不好的事情,以便之后进行恢复。

日志可以让你事后看看是否有不好的事情发生过。但是请注意,我们必须小心那些存储在日志里的信息!敏感信息需要被剔除,以确保没有泄露重要的数据到日志里,如果泄露的话,最终可能会成为攻击者的重要目标。

9.4.3 入侵检测(和预防)系统

IDS(Intrusion Detection Systems,入侵检测系统)可以监控网络或主机,当发现可疑行为时报告问题。IPS(Intrusion Prevention Systems,入侵预防系统), 也会监控可疑行为,并进一步阻止它的发生。不同于防火墙主要是对外阻止坏事进来,IDS 和 IPS 是在可信范围内积极寻找可疑行为。当你从零开始时,IDS 可能更有意义。这些系统是基于启发式的(正如很多的应用防火墙),很有可能刚开始的通用规则,对于你的服务行为来说过于宽松或过于严格。

9.4.4 网络隔离

对于单块系统而言,我们在通过构造网络来提供额外的保护方面能做的很有限。

9.4.5 操作系统

我们的系统依赖于大量的不是我们自己编写的软件,即操作系统和其他的支持工具,其中的安全漏洞有可能会暴露我们的应用程序。在这里,基本的建议能让你走得很远。给操作系统的用户尽量少的权限,开始时也许只能运行服务,以确保即使这种账户被盗,造成的伤害也最小。

9.5 一个示例

一个细粒度的架构,在安全实施上给了我们更多的自由。对于那些处理最敏感信息的,或暴露最有价值的功能的部分,我们可以采用最严格的安全措施。但对系统的其他部分,我们可以采用宽松一些的安全措施。

9.6 保持节俭

由于磁盘空间变得更便宜,并且数据库的功能进一步加强,获取和存储大量信息的性能正迅速改善。这些数据是有价值的——不仅仅是对企业本身,他们越来越多地把数据当作一个宝贵资产,同样对看重自己隐私的用户来说数据也很重要。属于个人的数据,或者可以用来获得个人信息的数据,是我们最关心的。

9.7 人的因素

不过,你可能还需要流程和政策,来处理组织中的人为因素。当有人离开组织时,你如何撤销访问凭证?你如何保护自己免受社会工程学的攻击?作为一个好的思维锻炼,你可以考虑一个心怀不满的前雇员,如果他想的话,可能会如何损害你的系统。让自己站在恶意方的角度思考,通常是一个了解你可能需要做什么保护的好方法,而且确实有一些恶意方可能具有跟当前雇员同样多的内部信息。

9.8 黄金法则

如果你只能带走本章的一句话,那便是:不要实现自己的加密算法。不要发明自己的安全协议。除非你是一个有多年经验的密码专家,如果你尝试发明自己的编码或精密的加密算法,你会出错。即使你是一个密码专家,仍然可能会出错。

9.9 内建安全

就像对待自动化功能测试那样,我们不想把安全留给一组不同的人实现,也不想把所有的事情留到最后一分钟才去做。帮助培养开发人员的安全意识很关键,提高每个人对安全问题的普遍意识,有助于从最开始减少这些问题。让人们熟悉 OWASP 十大列表和 OWASP的安全测试框架,是一个很好的起点。

9.10 外部验证

对于安全,我认为进行外部评估的价值很大。由外部方实施的类似渗透测试这样的实验,真的可以模拟现实世界的意图。这样做还可以避开这样的问题:团队并不总能看到自己所犯的错误,因为他们太接近于问题本身了。

9.11 小结

如果你想要一个基于浏览器的应用程序安全的基本概述,优秀的非营利的 OWASP(OpenWeb Application Security Project,开放式 Web 应用程序安全项目,https://www.owasp.org/)是一个很好的起点,其定期更新的十大安全风险文档,应被视为所有开发人员的必备读物。最后,如果你想要获得关于密码学的更全面的讨论,请查阅由 Niels Ferguson、BruceSchneier 和 Tadayoshi Kohno 所著的 Cryptography Engineering。

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

梅尔 • 康威于 1968 年 4 月在 Datamation 杂志上发表了一篇名为“How Do CommitteesInvent”的论文,文中指出:

任何组织在设计一套系统(广义概念上的系统)时,所交付的设计方案在结构上都与该组织的沟通结构保持一致。

10.1 证据
10.1.1 松耦合组织和紧耦合组织

组织的耦合度越低,其创建的系统的模块化就越好,耦合也越低;组织的耦合度越高,其创建的系统的模块化也越差。

10.1.2 Windows Vista

微软对它的一个特定产品 Windows Vista 进行了实证研究(http://research.microsoft.com/pubs/70535/tr-2008-11.pdf),观察其自身组织结构如何影响软件质量。具体而言,研究者通过查看多种因素来确定系统中什么样的组件容易出错。 9 涉及的指标包括代码复杂度等常用的软件质量指标。从统计数据可以看出,与组织结构相关联的指标和软件质量的相关度最高。

10.2 Netflix和Amazon

组织和架构应该一致,信奉这个理念的两个典范是 Amazon 和 Netflix。在早期,Amazon就开始理解了,团队对他们所管理系统的整个生命周期负责的好处。它想要团队共同拥有和运营其创建的系统,并管理整个生命周期。

10.3 我们可以做什么

这些证据、轶事和经验表明,组织结构对系统的性质和质量确实有着深刻的影响。这个理解对我们有什么帮助?让我们看看几种不同的组织情况,了解每种情况对我们的系统设计可能产生的影响。

10.4 适应沟通途径

让我们首先单独考虑一个简单的团队。它负责系统设计与实现的各个方面。团队内可以进行频繁的、细粒度的沟通。想象一下,由这样的团队负责一个单一的服务,比如音乐商店的产品目录服务。服务的内部是大量细粒度的方法或函数调用。正如之前所讨论的,我们希望通过服务拆分,使得服务内变化的频度要远远高于服务间变化的频度。这个有着细粒度沟通能力的团队,能够与服务内部频繁讨论代码这个需求很好地匹配。

10.5 服务所有权

服务所有权是什么意思呢?一般来说,它意味着拥有服务的团队负责对该服务进行更改。只要更改不破坏服务的消费者,团队就可以随时重新组织代码。对于许多团队而言,所有权延伸到服务的方方面面,从应用程序的需求、构建、部署到运维。这种模式在微服务的世界尤为普遍,一个小团队更容易负责一个小服务。

10.6 共享服务的原因

我见过很多团队采用共享服务所有权的模式。不过我发现这种方式效果不佳,原因之前已经讨论过。然而,理解人们为何选用共享服务的原因是很重要的,尤其是当我们能够找到一些令人信服的替代模式,来解决人们潜在的担忧时。

10.6.1 难以分割

很显然,拆分服务的成本太高是多个团队负责单个服务的原因之一,你的组织或许看不到这一点。

10.6.2 特性团队

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

10.6.3 交付瓶颈

共享服务的另一个关键原因是,这样做可以避免交付瓶颈。

不共享服务,我们有几种方式来应对这种情况。第一种方式就是等待。网站和移动应用程序团队转而开发别的功能。取决于特性的重要性和延迟的时长,这可能是个好的主意,但也可能是一个糟糕的想法。

另一种方式是,你可以派人到产品目录团队帮助他们更快地工作。你的系统使用越标准化的技术栈和编程范式,就越容易让其他人更改你的服务。当然,另一方面,如前文所述,标准化会导致团队降低采取正确的解决方案来解决问题的能力,并可能会降低效率。而且,如果该团队在地球的另一边,这也是不可能的。

10.7 内部开源

那么,如果我们已经尽了最大的努力,仍然无法找到方法来避免共享几个服务该怎么办?在这个时候,拥抱内部开源模式可能更合理。

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

10.7.1 守护者的角色

我们仍然希望得到高质量的服务。我们想要体面的代码质量,服务代码的组织方式应该表现出某种一致性。我们也要确保现在的更改不会让未来计划中的更改变得更加困难。这意味着,我们在内部也要采用跟标准开源项目同样的模式。这需要分离出一组受信任的提交者(核心团队)和不受信任的提交者(团队外提交变更的人)。

10.7.2 成熟

服务越不稳定或越不成熟,就越难让核心团队之外的人提交更改。在服务的核心模块到位前,团队可能不知道什么样的代码是好的,因此也很难知道什么是一个好的提交。在这个阶段,服务本身正处于快速变化的状态。

大多数开源项目在完成第一个版本的核心代码前,往往不允许让更广泛的不受信任的提交者们提交代码。

10.7.3 工具

为了更好地支持内部开源模型,你需要一些工具。使用支持 pull 请求(或类似的其他方式)的分布式版本控制工具是很重要的。根据组织的大小,你可能还需要支持讨论和修改提交申请的工具;可能这并不意味着需要一个完整的代码评审系统,但将评论附到提交申请中的能力是非常有用的。最后,你需要让提交者能够很容易地构建和部署软件以供他人使用。这通常需要良好的构建和部署流水线,以及集中构件物仓库。

10.8 限界上下文和团队结构

如前文所述,我们以限界上下文来定义服务的边界。因此,我们希望团队也与限界上下文保持一致。这有很多好处。首先,团队会发现它在限界上下文内更容易掌握领域的概念,因为它们是相互关联的。其次,限界上下文中的服务更有可能发生交互,保持一致可以简化系统设计和发布的协调工作。最后,在交付团队与业务干系人进行交互方面,它有利于团队与此领域内的一两个专家创建良好的合作关系。

10.9 孤儿服务

那么,如何处理不再活跃维护的服务呢?当我们迈向更细粒度的架构时,服务本身变得更小。我们已经讨论过,小服务的目标之一是使它们更简单。功能较少的简单服务,可能在很长一段时间内不需要更改。

如果你的团队结构与组织的限界上下文是一致的,那么即使是修改频率很低的服务也会有实际的所有者。

10.10 案例研究:RealEstate.com.au

REA 的核心业务是房地产,但包含多个不同的方面,每一方面都是一条业务线(Line OfBusiness,LOB)。例如,一条业务线是澳大利亚的住宅房地产销售,而另一条业务线涉及REA 的海外业务。这些业务线有相关联的交付团队(或小组);一些可能只有一个团队,而最大的有四个。对于住宅房地产,有多个团队参与创建网站和产品目录服务,允许人们浏览房源。成员时不时地在这些团队之间轮换,但往往长时间留在这个业务线, 以确保团队成员可以更好地建立业务线的领域知识。反过来,这有助于各种业务干系人和为其实现业务功能的交付团队之间进行沟通。

每条业务线团队,负责自己创建的服务的整个生命周期,包括构建、测试、发布和运维,甚至弃用。一个核心交付服务团队,为这些团队提供建议、指导和工具来帮助他们完成工作。浓厚的自动化文化非常关键,REA 大量使用 AWS,关键原因是想让团队更加自治。

这些组织的系统架构和组织结构对变化都有着很好的适应性,这能够产生巨大的效益,因为这样的组织改进了团队的自治性,并且能够加快新需求和新功能的发布速度。很多组织都意识到,系统架构并非凭空产生的。

10.11 反向的康威定律

当时,我们看到公司的组织结构与系统的三个子系统严格一致。三个 IT 方面的业务线或部门,分别对应输入、核心和输出子系统。这些业务线都有单独的交付团队。我当时并没有意识到,这些组织结构没有早于系统设计,反而是根据系统设计发展成这样的。在印刷业务减少时,伴随着数字业务的增长,系统设计无意中为组织如何发展铺好了道路。

最后,我们意识到,无论系统有什么设计缺陷,我们都不得不通过改变组织结构来推动系统的更改。许多年后,这个过程仍然在进行中!

10.12 人

我们必须承认,在微服务环境中,开发人员很难只在自己的小世界中编写代码。他需要意识到像跨网络边界调用及隐式失败等隐式问题。我们还讨论过微服务的能力,它让尝试新技术更为容易,从数据存储到编程语言。但如果你从一个单块系统的世界走来,那里的大多数开发人员只需要使用一种语言,并且对运维完全没有意识,那么直接把他们扔到微服务的世界,就像是粗鲁地把他们从单纯的世界中叫醒一样。

同样,赋权给开发团队以增加自治也是充满挑战的。过去的人们习惯把工作扔给别人,习惯指责别人,现在让他们对自己的工作完全负责可能会让其感觉不舒服。你甚至会发现,让开发人员携带寻呼机,以防系统需要他们的支持时,都会有合同壁垒!

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

关于这个话题,每个组织都有自己的节奏。了解你的员工能够承受的变化,不要逼他们改变太快!也许在短时间内,你仍然需要一个单独的团队来处理线上支持或生产环境部署,以便给开发人员足够的时间调整到新的实践中。然而,你可能不得不接受,需要组织中不同人的参与才能搞定这个工作。无论方法是什么,你需要跟员工清楚地阐明,在微服务的世界里每个人的责任,以及为何这些责任如此重要。这能够帮助你了解技能差距并思考如何弥补它们。对许多人来说,这将是一个非常可怕的旅程。请记住,如果没有把人们拉到一条船上,你想要的任何变化从一开始就注定会失败。

10.13 小结

康威定律强调了试图让系统设计跟组织结构不匹配所导致的危险。这引导我们试图将服务所有权与同地团队相匹配,而两者本身与组织限界上下文是匹配的。当两者不一致时,我们会得到本章所阐述的那些摩擦点。认识到两者之间的联系,我们要确保正在构建的系统对组织而言是合理的。

第11章 规模化服务

11.1 故障无处不在

我们知道事情可能会出错,硬盘可能会损坏,软件可能会崩溃。任何读过“分布式计算的故障”(https://en.wikipedia.org/wiki/Fallacies_of_Distributed_Computing)的人都会告诉你,网络也是不可靠的。我们可以尽力尝试去限制引起故障的因素,但达到一定规模后,故障难以避免。

即使有些人不必考虑超大规模的情况,但是如果我们能够拥抱故障,那么就能够游刃有余地管理系统。例如,如果我们可以很好地处理服务的故障,那么就可以对服务进行原地升级,因为计划内的停机要比计划外的更容易处理。

让我再陈述一遍:规模化后,即使你买最好的工具,最昂贵的硬件,也无法避免它们会发生故障的事实。因此,你需要假定故障会发生。如果以这种想法来处理你做的每一件事情,为其故障做好准备,那么就会做出不同的权衡。如果你知道一个服务器将会发生故障,系统也可以很好地应对,那么又何必在阻止故障上花很多精力呢?为什么不像谷歌那样,使用裸主板和一些便宜的组件(一些尼龙搭扣),而不必过多地担心单节点的弹性?

11.2 多少是太多

有一个自动扩容系统,能够应对负载增加或单节点的故障,这可能是很棒的,但对于一个月只需运行一两次的报告系统就太夸张了,因为这个系统,即使宕机一两天也没什么大不了的。同样,搞清楚如何做蓝 / 绿部署,使服务在升级时无需停机,对你的在线电子商务系统来说可能会有意义,但对企业内网的知识库来说可能有点过头了。

了解你可以容忍多少故障,或者系统需要多快,取决于系统的用户。反过来,这会帮助你了解哪些技术将对你有意义。也就是说,你的用户不是经常能阐明需求到底是什么,所以你需要通过问问题来提取正确的信息,并帮助他们了解提供不同级别服务的相对成本。

  • 响应时间/延迟
  • 可用性
  • 数据持久性
11.3 功能降级

构建一个弹性系统,尤其是当功能分散在多个不同的、有可能宕掉的微服务上时,重要的是能够安全地降级功能。想象一下,我们电子商务网站上的一个标准的 Web 页面。要把网站的各个功能组合在一起,我们需要几个微服务联合发挥作用。一个微服务可能显示出售专辑的详细信息,另一个显示价格和库存数量。我们还需要展示购物车内容,这可能是另一个微服务。现在,如果这些微服务中的任何一个宕掉,都会导致整个 Web 页面不可用,那么我们可以说,该系统的弹性还不如只使用一个服务的系统。

11.4 架构性安全措施

有一些模式,组合起来被称为架构性安全措施,它们可以确保如果事情真的出错了,不会引起严重的级联影响。

我曾是一个项目的技术负责人,这个项目是构建一个在线的分类广告网站。网站本身需要处理相当高的访问量,并获得了大量的业务收入。如图 11-1 所示,我们的核心应用程序是处理一些分类广告本身的展示,同时代理调用其他服务以提供不同类型的产品。这其实是一个绞杀者应用的例子,新系统拦截了对遗留应用程序的调用,并逐渐替代它们。作为这个项目的一部分,也要逐步把这些遗留应用替换掉。我们刚刚迁移了访问量最多和收益最大的产品,但剩余的大部分广告仍由许多旧的应用程序提供服务。无论是这些应用程序的搜索数量,还是获得的收益,都非常大。

11.5 反脆弱的组织

像 Netflix 完全是基于 AWS 的基础设施一样,Netflix 的经营规模也是众所周知的。这两个因素意味着,它必须很好地应对故障。实际上 Netflix 通过引发故障来确保其系统的容错性。

11.5.1 超时

超时是很容易被忽视的事情,但在使用下游系统时,正确地处理它是很重要的。在考虑下游系统确实已经宕掉之前,我需要等待多长时间?

如果等待太长时间来决定调用失败,整个系统会被拖慢。如果超时太短,你会将一个可能还在正常工作的调用错认为是失败的。如果完全没有超,一个宕掉的下游系统可能会让整个系统挂起。

11.5.2 断路器

在自己家里,断路器会在电流尖峰时保护你的电子设备。如果出现尖峰,断路器会切断电路,保护你昂贵的家用电器。你也可以手动使用断路器切断家里的部分电源,让电器安全地工作。Michael Nygard 在 Release It! 一书中,介绍了使用同样的想法作为软件的保护机制会产生奇妙的效果。

11.5.3 舱壁

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

11.5.4 隔离

一个服务越依赖于另一个,另一个服务的健康将越能影响其正常工作的能力。如果我们使用的集成技术允许下游服务器离线,上游服务便不太可能受到计划内或计划外宕机的影响。

服务间加强隔离还有另一个好处。当服务间彼此隔离时,服务的拥有者之间需要更少的协调。团队间的协调越少,这些团队就更自治,这样他们可以更自由地管理和演化服务。

11.6 幂等

对幂等操作来说,其多次执行所产生的影响,均与一次执行的影响相同。如果操作是幂等的,我们可以对其重复多次调用,而不必担心会有不利影响。当我们不确定操作是否被执行,想要重新处理消息,从而从错误中恢复时,幂等会非常有用。

11.7 扩展

一般来说,我们扩展系统的原因有以下两个。首先,为了帮助处理失败;如果我们担心有些东西会失败,那么多一些这些东西会有帮助,对吗?其次,我们为性能扩展,无论是处理更多的负载、减少延迟或两者兼而有之。

11.7.1 更强大的主机

一些操作可能受益于更强大的主机。一个有着更快的 CPU 和更好的 I/O 的机器,通常可以改善延迟和吞吐量,允许你在更短的时间内处理更多的工作。这种形式的扩展通常被称为垂直扩展,它是非常昂贵的,尤其是当你使用真正的大机器时。

11.7.2 拆分负载

单服务单主机模型肯定要优于多服务单主机模型。然而在最初的时候,很多人决定将多个微服务共存于一台主机,以降低成本或简化主机管理(尽管这个原因有待商榷)。因为微服务是通过网络通信的独立进程,所以把它们切换到使用自己的主机来提高吞吐量和伸缩性,应该是一件很容易的事。这还可以增加系统的弹性,因为单台主机的宕机将影响较少数量的微服务。

11.7.3 分散风险

弹性扩展的一种方式是,确保不要把所有鸡蛋放在一个篮子里。一个简单的例子是,确保你不要把多个服务放到一台主机上,因为主机的宕机会影响多个服务。

另一种常见的减少故障的方法是,确保不要让所有的服务都运行在同一个数据中心的同一个机架上,而是分布在多个数据中心。

11.7.4 负载均衡

当你想让服务具有弹性时,要避免单点故障。对于公开一个同步 HTTP 接口这样典型的微服务来说,要达到这个目的最简单的方法是,在一个负载均衡器后,放置多台主机运行你的微服务实例。对于微服务的消费者来说,你不知道你是在跟一个微服务实例通信,还是一百个。

11.7.5 基于worker的系统

负载均衡不是服务的多个实例分担负载和降低脆弱性的唯一方式。根据操作性质的不同,基于 worker 的系统可能和负载均衡一样有效。在这里,所有的实例工作在一些共享的待办作业列表上。列表里可能是一些 Hadoop 的进程,或者是共享的作业队列上的一大批监听器。这些类型的操作非常适合批量或异步作业。比如像图像缩略图处理、发送电子邮件或生成报告这样的任务。

11.7.6 重新设计

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

11.8 扩展数据库
11.8.1 服务的可用性和数据的持久性

例如,对于所有写入数据库的数据,我可以将一份副本存储到一个弹性文件系统。如果数据库出现故障,数据不会丢失,因为有一个副本,但数据库本身是不可用的,这会使我们的微服务也不可用。一个更常用的模式是使用副本。把写入主数据库的所有数据,都复制到备用副本数据库。如果主数据库出现故障,我的数据是安全的,但如果没有一个机制让主数据库恢复或提升副本为主数据库,即使数据是安全的,数据库依然不可用。

11.8.2 扩展读取

很多服务都是以读取数据为主的。例如保存我们出售物品信息的目录服务。添加新物品记录是相当不规律的,如果说每笔写入的目录数据都有 100 次以上的读取,这个数字不会让你感到惊讶。令人高兴的是,扩展读取要比扩展写入更容易。缓存的数据在这里可以发挥很大的作用,我们稍后会进行更深入的讨论。另一种模式是使用只读副本。

11.8.3 扩展写操作

扩展读取是比较容易的。那么扩展写操作呢?一种方法是使用分片。采用分片方式,会存在多个数据库节点。当你有一块数据要写入时,对数据的关键字应用一个哈希函数,并基于这个函数的结果决定将数据发送到哪个分片。举一个非常简单的(实际上是很糟的)的例子,你可以想象将客户记录 A~M 写到一个数据库实例,而 N~Z 写到另一个数据库实例。你可以在应用程序里管理这部分逻辑,但一些数据库,例如 Mongo,已经帮你处理了很多。

11.8.4 共享数据库基础设施

某些类型的数据库,例如传统的 RDBMS,在概念上区分数据库本身和模式(schema)。这意味着,一个正在运行的数据库可以承载多个独立的模式,每个微服务一个。这可以有效地减少需要运行系统的机器的数量,从这一点来说它很有用,不过我们也引入了一个重要的单点故障。如果该数据库的基础设施出现故障,它会影响多个微服务,这可能导致灾难性故障。如果你正以这样的方式配置数据库,请确保慎重考虑了风险,并且确定该数据库本身具有尽可能高的弹性。

11.8.5 CQRS

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

11.9 缓存

缓存是性能优化常用的一种方法,通过存储之前操作的结果,以便后续请求可以使用这个存储的值,而不需花时间和资源重新计算该值。通常情况下,缓存可以消除不必要的到数据库或其他服务的往返通信,让结果返回得更快。如果使用得当,它可以带来巨大的性能好处。HTTP 在处理大量请求时,伸缩性如此良好的原因就是内置了缓存的概念。

11.9.1 客户端、 代理和服务器端缓存

使用客户端缓存的话,客户端会存储缓存的结果。由客户端决定何时(以及是否)获取最新副本。理想情况下,下游服务将提供相应的提示,以帮助客户端了解如何处理响应,因此客户端知道何时以及是否需要发送一个新的请求。代理服务器缓存,是将一个代理服务器放在客户端和服务器之间。反向代理或 CDN(Content Delivery Network,内容分发网络),是很好的使用代理服务器缓存的例子。服务器端缓存,是由服务器来负责处理缓存,可能会使用像 Redis 或 Memcache 这样的系统,也可能是一个简单的内存缓存。

11.9.2 HTTP缓存

首先,使用 HTTP,我们可以在对客户端的响应中使用 cache-control 指令。这些指令告诉客户他们是否应该缓存资源,以及应该缓存几秒。我们还可以设置 Expires 头部,它不再指定一段内容应该缓存多长时间,而是指定一个日期和时间,资源在该日期和时间后被认为失效,需要再次获取。你共享的资源本质,决定了哪一种方法最为合适。标准的静态网站内容,像 CSS 和图片,通常很适合使用简单的 cache-control TTL(Time To Live,生存时间值)。另一方面,如果你事先知道什么时候会更新一个新版本的资源,设置 Expires头部将更有意义。以上两种方法都非常有用,客户端甚至无需发请求给服务器。

11.9.3 为写使用缓存

你会发现尽管自己经常在读取时使用缓存,但在一些用例中,为写使用缓存也是有意义的。例如,如果你使用后写式(writebehind)缓存,可以先写入本地缓存中,并在之后的某个时刻将缓存中的数据写入下游的、可能更规范化的数据源中。当你有爆发式的写操作,或同样的数据可能会被写入多次时,这是很有用的。后写式缓存是在缓冲可能的批处理写操作时,进一步优化性能的很有用的方法。

11.9.4 为弹性使用缓存

缓存可以在出现故障时实现弹性。使用客户端缓存,如果下游服务不可用,客户端可以先简单地使用缓存中可能失效了的数据。我们还可以使用像反向代理这样的系统提供的失效数据。对一些系统来说,使用失效但可用的数据,比完全不可用的要好,不过这需要你自己做出判断。显然,如果我们没有把请求的数据放在缓存中,那么可做的事情不多,但还是有一些方法的。

11.9.5 隐藏源服务

使用普通的缓存,如果请求缓存失败,请求会继续从数据源获取最新的数据,请求调用会一直等到结果返回。

对于那些提供高度可缓存数据的服务,从设计上来讲,源服务本身就只能处理一小部分的流量,因为大多数请求已经被源服务前面的缓存处理了。如果我们突然得到一个晴天霹雳的消息,由于整个缓存区消失了,源服务就会接收到远大于其处理能力的请求。

11.9.6 保持简单

避免在太多地方使用缓存!在你和数据源之间的缓存越多,数据就越可能失效,就越难确定客户端最终看到的是否是最新的数据。这在一个涉及多个服务的微服务架构调用链中,很有可能产生问题。再强调一次,缓存越多,就越难评估任何数据的新鲜程度。所以如果你认为缓存是一个好主意,请保持简单,先在一处使用缓存,在添加更多的缓存前慎重考虑!

11.9.7 缓存中毒:一个警示

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

11.10 自动伸缩

如果你足够幸运,可以完全自动化地创建虚拟主机以及部署你的微服务实例,那么你已经具备了对微服务进行自动伸缩的基本条件。

11.11 CAP定理

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

11.11.1 牺牲一致性

现实情况是,即使我们没有数据库节点之间的网络故障,数据复制也不是立即发生的。正如前面提到的,系统放弃一致性以保证分区容忍性和可用性的这种做法,被称为最终一致性;也就是说,我们希望在将来的某个时候,所有节点都能看到更新后的数据,但它不会马上发生,所以我们必须清楚用户将看到失效数据的可能性。

11.11.2 牺牲可用性

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

11.11.3 牺牲分区容忍性

我们要挑选 CAP 中的两点,对吗?所以,我们有最终一致的 AP 系统。我们有一致的,但很难实现和扩展的 CP 系统。为什么没有 CA 系统呢?嗯,我们应如何牺牲分区容忍性呢? 如果系统没有分区容忍性,就不能跨网络运行。换句话说,需要在本地运行一个单独的进程。所以,CA 系统在分布式系统中根本是不存在的。

11.11.4 AP还是CP

哪个是正确的,AP 还是 CP ?好吧,现实中要视情况而定。因为我们知道,在人们构建系统的过程中需要权衡。我们知道 AP 系统扩展更容易,而且构建更简单,而 CP 系统由于要支持分布式一致性会遇到更多的挑战,需要更多的工作。但我们可能不了解这种权衡对业务的影响。对于库存系统,如果一个记录过时了 5 分钟,这可接受吗?如果答案是肯定的,那么解决方案可以是一个 AP 系统。但对于银行客户的余额来说呢?能使用过时的数据吗?如果不了解操作的上下文,我们无法知道正确的做法是什么。了解 CAP 定理只是让你知道这些权衡的存在,以及需要问什么问题。

11.11.5 这不是全部或全不

个别服务甚至不必是 CP 或 AP 的。

11.11.6 真实世界

我们必须认识到,无论系统本身如何一致,它们也无法知道所有可能发生的事情,特别是我们保存的是现实世界的记录。这就是在许多情况下,AP 系统都是最终正确选择的原因之一。除了构建 CP 系统的复杂性外,它本身也无法解决我们面临的所有问题。

11.12 服务发现

一旦你已经拥有不少微服务,关注点就会不可避免地转向它们究竟在何处。也许你想知道,在特定环境下有哪些微服务在运行,据此你才能知道哪些应该被监测。也许像了解你的账户服务在哪里一样简单,以便其消费者知道在哪里能找到它。或许你只是想方便组织里的开发人员了解哪些 API 可用,以避免他们重新发明轮子。从广义上来说,上述所有用例都属于服务发现。与微服务世界中的其他问题类似,我们有很多不同的选项来处理它。

DNS

最好先从简单的开始。DNS 让我们将一个名称与一个或多个机器的 IP 地址相关联。例如,我们可以决定,总能在 accounts.musiccopr.com 上发现账户服务。接着会将这个域名关联到运行该服务的主机的 IP 地址上,或者关联到一个负载均衡器,然后给不同的实例分发负载。这意味着,我们不得不把更新这些条目作为部署服务的一部分。

11.13 动态服务注册

作为一种在高度动态的环境发现节点的方法,DNS 存在一些缺点,从而催生了大量的替代系统,其中大部分包括服务注册和一些集中的注册表,注册表进而可以提供查找这些服务的能力。通常,这些系统所做的不仅仅是服务注册和服务发现,这可能也不是一件好事。这是一个拥挤的领域,因此只看其中几个选项,让你大概了解一下有哪些选择可用。

11.13.1 Zookeeper

Zookeeper(http://zookeeper.apache.org/)最初是作为 Hadoop 项目的一部分进行开发的。它被用于令人眼花缭乱的众多使用场景中,包括配置管理、服务间的数据同步、leader 选举、消息队列和命名服务(对我们有用的)。

11.13.2 Consul

和 Zookeeper 一样,Consul(http://www.consul.io/)也支持配置管理和服务发现。但它比Zookeeper 更进一步,为这些关键使用场景提供了更多的支持。例如,它为服务发现提供一个 HTTP 接口。Consul 提供的杀手级特性之一是,它实际上提供了现成的 DNS 服务器。具体来说,对于指定的名字,它能提供一条 SRV 记录,其中包含 IP 和端口。这意味着,如果系统的一部分已经在使用 DNS,并且支持 SRV 记录,你就可以直接开始使用 Consul,而无需对现有系统做任何更改。

11.13.3 Eureka

Netflix 的开源系统 Eureka(https://github.com/Netflix/eureka),追随 Consul 和 Zookeeper等系统的趋势,但它没有尝试成为一个通用配置存储。实际上,它有非常确定的目标使用场景。

Eureka 还提供了基本的负载均衡功能,它可以支持服务实例的基本轮询调度查找。它提供了一个基于 REST 的接口,因此你可以编写自己的客户端,或者使用它自己提供的 Java 客户端。Java 客户端提供了额外的功能,如对实例的健康检查。很显然,如果你绕过 Eureka的客户端,直接使用 REST 接口,就可以自行实现了。

11.13.4 构造你自己的系统

我自己用过并且在其他地方见过的一种方法是,构造你自己的系统。曾经在一个项目上,我们大量使用 AWS,它提供了将标签添加到实例的能力。当启动服务实例时,我使用标签来帮助定义实例是做什么的。

11.13.5 别忘了人

到目前为止,我们看过的系统让服务实例注册自己并查找所需要通信的其他服务变得非常容易。但是我们有时也想要这些信息。无论你选择什么样的系统,要确保有工具能让你在这些注册中心上生成报告和仪表盘,显示给人看,而不仅仅是给电脑看。

11.14 文档服务

通过将系统分解为更细粒度的微服务,我们希望以 API 的形式暴露出很多接缝,人们可以用它来做很多很棒的事情。如果正确地进行了服务发现,就能够知道东西在哪里。但是我们如何知道这些东西的用处,或如何使用它们?一个明显的选择是 API 的文档。当然,文档往往会过时。理想情况下,我们会确保文档总是和最新的微服务 API 同步,并当大家需要知道服务在哪里时,能够很容易地看到这个文档。两种不同的技术,Swagger 和 HAL,试图使这成为现实,这两个都值得一看。

11.14.1 Swagger

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

11.14.2 HAL和HAL浏览器

HAL(Hypertext Application Language, 超 文 本 应 用 程 序 语 言,http://stateless.co/hal_specification.html)本身是一个标准,用来描述我们公开的超媒体控制的标准。正如我们在第 4 章中提过的,超媒体控制是一种方法,它允许客户逐步探索我们的 API 来使用服务,并且其耦合度比其他集成技术都低。如果你决定采用 HAL 的超媒体标准,那么不仅可以利用广泛使用的客户端库消费 API(在撰写本文时,HAL 维基列出了多种不同语言的 50 个支持库),也可以使用 HAL 的浏览器,它提供了一种通过 Web 浏览器探索 API的方式。

11.15 自描述系统

在 SOA 的早期演化过程中,UDDI (Universal Description, Discovery, and Integration,通用描述、发现与集成服务)标准的出现,帮助人们理解了哪些服务正在运行。这些方法都相当重量级,并催生出一些替代技术去试图理解我们的系统。

第12章 总结

12.1 微服务的原则
12.1.1 围绕业务概念建模

经验表明,围绕业务的限界上下文定义的接口,比围绕技术概念定义的接口更加稳定。针对系统如何工作这个领域进行建模,不仅可以帮助我们形成更稳定的接口,也能确保我们能够更好地反映业务流程的变化。使用限界上下文来定义可能的领域边界。

12.1.2 接受自动化文化

微服务引入了很多复杂性,其中的关键部分是,我们不得不管理大量的服务。解决这个问题的一个关键方法是,拥抱自动化文化。前期花费一定的成本,构建支持微服务的工具是很有意义的。自动化测试必不可少,因为相比单块系统,确保我们大量的服务能正常工作是一个更复杂的过程。调用一个统一的命令行,以相同的方式把系统部署到各个环境是一个很有用的实践,这也是采用持续交付对每次提交后的产品质量进行快速反馈的一个关键部分。

请考虑使用环境定义来帮助你明确不同环境间的差异,但同时保持使用统一的方式进行部署的能力。考虑创建自定义镜像来加快部署,并且创建全自动化不可变服务器,这会更容易定位系统本身的问题。

12.1.3 隐藏内部实现细节

为了使一个服务独立于其他服务,最大化独自演化的能力,隐藏实现细节至关重要。限界上下文建模在这方面可以提供帮助,因为它可以帮助我们关注哪些模型应该共享,哪些应该隐藏。服务还应该隐藏它们的数据库,以避免陷入数据库耦合,这在传统的面向服务的架构中也是最常见的一种耦合类型。使用数据泵(data pump)或事件数据泵(event datapump),将跨多个服务的数据整合到一起,以实现报表的功能。

在可能的情况下,尽量选择与技术无关的 API,这能让你自由地选择使用不同的技术栈。请考虑使用 REST,它将内部和外部的实现细节分离方式规范化,即使是使用 RPC,你仍然可以采用这些想法。

12.1.4 让一切都去中心化

为了最大化微服务能带来的自治性,我们需要持续寻找机会,给拥有服务的团队委派决策和控制权。在这个过程初期,只要有可能,就尝试使用资源自助服务,允许人们按需部署软件,使开发和测试尽可能简单,并且避免让独立的团队来做这些事。

在这个旅程中,确保团队保持对服务的所有权是重要的一步,理想情况下,甚至可以让团队自己决定什么时候让那些更改上线。使用内部开源模式,确保人们可以更改其他团队拥有的服务,不过请注意,实现这种模式需要很多的工作量。让团队与组织保持一致,从而使康威定律起作用,并帮助正在构建面向业务服务的团队,让他们成为其构建的业务领域的专家。一些全局的引导是必要的,尝试使用共同治理模型,使团队的每个成员共同对系统技术愿景的演化负责。

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

12.1.5 可独立部署

我们应当始终努力确保微服务可以独立部署。甚至当需要做不兼容更改时,我们也应该同时提供新旧两个版本,允许消费者慢慢迁移到新版本。这能够帮助我们加快新功能的发布速度。拥有这些微服务的团队,也能够越来越具有自治性,因为他们不需要在部署过程中不断地做编配。当使用基于 RPC 的集成时,避免使用像 Java RMI 提供的那种使用生成的桩代码,紧密绑定客户端 / 服务器的技术。

通过采用单服务单主机模式,可以减少部署一个服务引发的副作用,比如影响另一个完全不相干的服务。请考虑使用蓝 / 绿部署或金丝雀部署技术,区分部署和发布,降低发布出错的风险。使用消费者驱动的契约测试,在破坏性的更改发生前捕获它们。

请记住,你可以更改单个服务,然后把它部署到生产环境,无需联动地部署其他任何服务,这应该是常态,而不是例外。你的消费者应该自己决定何时更新,你需要适应他们。

12.1.6 隔离失败

微服务架构能比单块架构更具弹性,前提是我们了解系统的故障模式,并做出相应的计划。如果我们不考虑调用下游可能会失败的事实,系统会遭受灾难性的级联故障,系统也会比以前更加脆弱。

当使用网络调用时,不要像使用本地调用那样处理远程调用,因为这样会隐藏不同的故障模式。所以确保使用的客户端库,没有对远程调用进行过度的抽象。

如果我们心中持有反脆弱的信条,预期在任何地方都会发生故障,这说明我们正走在正确的路上。请确保正确设置你的超时,了解何时及如何使用舱壁和断路器,来限制故障组件的连带影响。如果系统只有一部分行为不正常,要了解其对用户的影响。知道网络分区可能意味着什么,以及在特定情况下牺牲可用性或一致性是否是正确的决定。

12.1.7 高度可观察

我们不能依靠观察单一服务实例,或一台服务器的行为,来看系统是否运行正常。相反,我们需要从整体上看待正在发生的事情。通过注入合成事务到你的系统,模拟真实用户的行为,从而使用语义监控来查看系统是否运行正常。聚合你的日志和数据,这样当你遇到问题时,就可以深入分析原因。而当需要重现令人讨厌的问题,或仅仅查看你的系统在生产环境是如何交互时,关联标识可以帮助你跟踪系统间的调用。

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

我的第一条建议是,你越不了解一个领域,为服务找到合适的限界上下文就越难。

从头开发也很具有挑战性。不仅仅因为其领域可能是新的,还因为对已有东西进行分类,要比对不存在的东西进行分类要容易得多!

当微服务规模化以后,你面临的许多挑战会变得更加严峻。

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值