《微服务设计》书摘
之前读《微服务设计》时候摘录的笔记,总内容不是一般的多。分享出来大家一同进步,也方便自己查漏补缺。
目前只是摘录的内容堆砌,未做提炼与点评,后续有时间可能会加以完善。
书籍简介
- 名称:微服务设计
- 作者:Sam Newman
- 内容:本书全面介绍了微服务的建模、集成、测试、部署和监控,通过一个虚构的公司讲解了如何建立微服务架构。主要内容包括认识微服务在保证系统设计与组织目标统一上的重要性,学会把服务集成到已有系统中,采用递增手段拆分单块大型应用,通过持续集成部署微服务,等等。
如果你在路上遇到岔路口,走小路(岔路)
前言
因为其变化的节奏很快,所以这本书更加关注理念,而不是特定技术,因为实现细节变化的速度总是比它们背后的理念要快得多。
Safari Books Onlin是应运而生的数字图书馆。它同时以图书和视频的形式出版世界顶级技术和商务作家的专业作品。
第1章 微服务
保持每次提交均可发布的重要性
随着领域驱动设计、持续交付、按需虚拟化、基础设施自动化、小型自治团队、大型集群系统这些实践的流行,微服务也应运而生
1.1 什么是微服务
微服务就是一些协同工作的小而自治的服务。
尽管我们想在巨大的代码库中做到清晰地模块化,但事实上这些模块之间的界限很难维护。相似的功能代码开始在代码库中随处可见,使得修复bug或实现更加困难
把因相同原因而变化的东西聚合到一起,而把因不同原因而变化的东西分离开来
根据业务的边界来确定服务的边界,这样就很容易确定某个功能代码应该放在哪里。而且由于该服务专注于某个边界之内,因此可以很好地避免由于代码库过大衍生出的很多相关问题
有些语言的表达力更好,能够使用很少的代码完成相同的功能
澳大利亚RealEstate.com.au的Jon Eaves认为,一个微服务应该可以在两周内完全重写,这个经验法则在他所处的特定上下文中是有效的。
该服务是否能够很好地与团队结构相匹配。如果代码库过大,一个小团队无法正常维护,那么很显然应该将其拆成小的。在后面关于组织匹配度的部分会对该话题做更多讨论
PAAS Platform As A Service,平台即服务
我们要尽量避免把多个服务部署到同一台机器上,尽管现如今机器的概念已经非常模糊了
这些服务应该可以彼此间独立进行修改,并且某一个服务的部署不应该引起该服务消费方的变动
如果暴露得过多,那么服务消费方会与该服务的内部实现产生耦合。这会使得服务和消费方之间产生额外的协调工作,从而降低服务的自治性
着应该选择与具体技术不相关的API实现方式,以保证技术的选择不被限制
有一个黄金法则是:你是否能够修改一个服务并对其进行部署,而不影响其他任何服务?
1.2 主要好处
可以在不同的服务中使用最适合该服务的技术。尝试使用一种适合所有场景的标准化技术,会使得所有的场景都无法得到很好的支持
有些组织会限制语言的选择,比如Netflix和Twitter选用的技术大多基于JVM
贯穿本书的一个问题是,微服务如何寻找平衡
弹性工程学的一个关键概念是舱壁。如果系统中的一个组件不可用了,但并没有导致级联故障,那么系统的其他部分还可以正常运行。服务边界就是一个很显然的舱壁。
微服务系统本身就能够很好地处理服务不可用和功能降级问题
如今Gilt有450多个微服务,每一个服务都分别运行在多台机器上
在有几百万代码行的单块应用程序中,即使只修改了一行代码,也需要重新部署整个应用程序才能够发布该变更。这种部署的影响很大、风险很高,因此相关干系人不敢轻易做部署。于是在实际操作中,部署的频率就会变得很低。这意味着在两次发布之间我们对软件做了很多功能增强,但直到最后一刻才把这些大量的变更一次性发布到生产环境中。这时,另外一个问题就显现出来了:两次发布之间的差异越大,出错的可能性就更大!
在微服务架构中,各个服务的部署是独立的,这样就可以更快地对特定部分的代码进行部署。如果真的出了问题,也只会影响一个服务,并且容易快速回滚,这也意味着客户可以更快地使用我们开发的新功能
在微服务架构中,系统会开放很多接口供外部使用。当情况发生改变时,可以使用不同的方式构建应用,而整体化应用程序只能提供一个非常粗粒度的接口供外部使用。如果想要得到更有用的细化信息,你需要使用榔头撬开它
如果你在一个大中型组织工作,很可能接触过一些庞大而丑陋的遗留系统。这些系统无人敢碰,却对公司业务的运营至关重要。更糟糕的是,这些程序是使用某种奇怪的Fortran变体编写的,并且只能运行在25年前就应该被淘汰的硬件上。为什么这些系统直到现在还没有被取代?其实你很清楚答案:工作量很大,而且风险很高。
1.3 面向服务的架构
SOA(Service-Oriented Architecture,面向服务的架构
服务之间通过网络调用,而非采用进程内调用的方式进行通信
它的目标是在不影响其他任何人的情况下透明地替换一个服务,只要替换之后的服务的外部接口没有太大的变化即可
就像认为XP或者Scrum是敏捷软件开发的一种特定方法一样,你也可以认为微服务架构是SOA的一种特定方法。
1.4 其他分解技术
团队可以围绕库来进行组织,而库本身可以被重用。但是这种方式存在一些缺点。首先,你无法选择异构的技术。一般来讲,这些库只能在同一种语言中,或者至少在同一个平台上使用。其次,你会失去独立地对系统某一部分进行扩展的能力。再次,除非你使用的是动态链接库,否则每次当库有更新的时候,都需要重新部署整个进程,以至于无法独立地部署变更。而最糟糕的影响可能是你会缺乏一个比较明显的接缝来建立架构的安全性保护措施,从而无法确保系统的弹性
如果使用共享代码来做服务之间的通信的话,那么它会成为一个耦合点
Erlang采用了不同的方式,模块的概念内嵌在Erlang语言的运行时中,因此这种模块化分解的方式是很成熟的。你可以对Erlang的模块进行停止、重启或者升级等操作,且不会引起任何问题。Erlang甚至支持同时运行同一个模块的多个版本,从而可以支持更加优雅的模块升级
尽管在一个单块进程中创建隔离性很好的模块是可能的,但是我很少见到真正有人能做到。这些模块会迅速和其他代码耦合在一起,从而失去意义
第2章 演化式架构师
2.1 不准确的比较
架构师的一个重要职责是,确保团队有共同的技术愿景,以帮助我们向客户交付他们想要的系统。
。在一般的组织中,非常出色的开发人员才能成为架构师,但通常会比其他角色招致更多的批评。
很多时候人们似乎忘了,我们的行业还很年轻。我们编写的程序在计算机上运行,而计算机从出现到现在也只有70年左右而已,因此我们需要在现存的行业中不断地寻找,帮助别人理解我们到底是做什么的。我们不是医生或者医学工程师,也不是水管工或者电工。我们处于这些行业的中间地带,因此社会很难理解我们,我们也不清楚自己到底处于什么位置。
我的一个朋友在成为认证建筑师的前一天说:“如果明天我在酒吧对于如何盖房子给了你错误的建议,那么我要为此负责。从法律的角度来说,因为我是一个认证建筑师,所以做错了很可能会被起诉。”他们在社会中有非常重要的作用,因此需要经过专门认证才能工作。
这也就是为什么我觉得很多形式的IT证书都没价值,因为我们对什么是好的知之甚少。
软件并没有类似这种真正的工程师和建筑师在物理规则方面的约束,事实上,我们要创造的东西从设计上来说就是要足够灵活,有很好的适应性,并且能够根据用户的需求进行演化
2.2 架构师的演化视角
与建造建筑物相比,在软件中我们会面临大量的需求变更,使用的工具和技术也具有多样性
此架构师必须改变那种从一开始就要设计出完美产品的想法,相反我们应该设计出一个合理的框架,在这个框架下可以慢慢演化出正确的系统,并且一旦我们学到了更多知识,应该可以很容易地应用到系统中
他认为更好的类比是城市规划师,而不是建筑师
城市规划师更多考虑的是人和公共设施如何从一个区域移到另一个区域,而不是具体在每个区域中发生的事情
城市规划师应该尽量去预期可能发生的变化,但是也需要明白一个事实:尝试直接对各个方面进行控制往往不会奏效
未来的变化很难预见,所以与其对所有变化的可能性进行预测,不如做一个允许变化的计划。为此,应该避免对所有事情做出过于详尽的设计
所以我们的架构师应该像城市规划师那样专注在大方向上,只在很有限的情况下参与到非常具体的细节实现中来。他们需要保证系统不但能够满足当前的需求,还能够应对将来的变化。而且他们还应该保证在这个系统上工作的开发人员要和使用这个系统的用户一样开心
2.3 分区
作为架构师,不应该过多关注每个区域内发生的事情,而应该多关注区域之间的事情。这意味着我们应该考虑不同的服务之间如何交互,或者说保证我们能够对整个系统的健康状态进行监控。
很多组织采用微服务是为了使团队的自治性最大化
最低的要求是:架构师需要花时间和团队在一起工作,理想情况下他们应该一起进行编码。
理想情况下,你应该参与普通的工作,这样才能真正理解普通的工作是什么样子。
2.4 一个原则性的方法
规则对于智者来说是指导,对于愚蠢者来说是遵从。
如果你是制定公司技术愿景的人,那么你可能需要花费更多的时间和组织内非技术的部分(通常他们被叫作业务部门)进行交互。那么业务部门的愿景是什么?它又会如何发生改变呢?
一般来讲,原则最好不要超过10个,或者能够写在一张海报上,不然大家会很难记住。而且原则越多,它们发生重叠和冲突的可能性就越大
你应该显式地指出哪些是原则,哪些是约束,这样用户就会很清楚哪些是不能变的
比如前面我们提过一个原则是开发团队应该可以对软件开发全流程有控制权,相应的实践就是所有的服务都部署在不同的AWS账户中,从而可以提供资源的自助管理和与其他团队的隔离
关键是要有一些重要的原则来指导系统的演化,同时也要有一些细节来指导如何实现这些原则
2.5 要求的标准
清楚地定义出一个好服务应有的属性
能够清晰地描绘出跨服务系统的健康状态非常关键。这必须在系统级别而非单个服务级别进行考虑
我建议确保所有的服务使用同样的方式报告健康状态及其与监控相关的数据
日志功能和监控情况类似:也需要集中式管理。
必须保证每个服务都可以应对下游服务的错误请求。没有很好处理下游错误请求的服务越多,我们的系统就会越脆弱
2.6 代码治理
我见过的比较奏效的两种方式是,提供范例和服务代码模板
理想情况下,你提供的优秀范例应该来自真实项目,而不是专门实现的一个完美的例子。因为如果你的范例来自真正运行的代码,那么就可以保证其中所体现的那些原则都是合理的
记录数据指标
你想要把所有的指标数据都发送到中心Graphite服务器,那么就可以使用像Dropwizard's Metric这样的开源库,只需要在此基础上做一些配置,响应时间和错误率等信息就会自动被推送到某个已知的服务器上
创建服务代码模板不是某个中心化工具的职责,也不是指导(即使是通过代码)我们应怎样工作的架构团队的职责。应该通过合作的方式定义出这些实践,所以你的团队也需要负责更新这个模板(内部开源的方式能够很好地完成这项工作)
基于代码重用的目的,越来越多的功能被加到一个中心化的框架中,直至把这个框架变成一个不堪重负的怪兽
如果你强制团队使用它,一定要确保它能够简化开发人员的工作,而不是使其复杂化
有一个我接触过的组织非常担心这个问题,所以他们会手动把服务代码模板复制到各个服务中。这样做的问题是,如果核心服务代码模板升级了,那么需要花很长时间把这些升级应用到整个系统中。但相对于耦合的危险而言,这个问题倒没那么严重
2.7 技术债务
对于某些组织来说,架构师应该能够提供一些温和的指导,然后让团队自行决定如何偿还这些技术债务
其他的组织就需要更加结构化的方式,比如维护一个债务列表,并且定期回顾
2.8 例外管理
有时候我们会决定针对某个规则破一次例,然后把它记录下来。如果这样的例外出现了很多次,就可以通过修改原则和实践的方式把我们的理解固化下来。
如果你所在的组织对开发人员有非常多的限制,那么微服务可能并不适合你
2.9 集中治理和领导
架构师的一个职责是确保有一个技术愿景,那么治理就是要确保我们构建的系统符合这个愿景,而且在需要的时候还应对愿景进行演化
我很喜欢的一种模式是,由架构师领导这个小组,但是每个交付团队都有人参加。架构师负责确保该组织的正常运作,整个小组都要对治理负责。这样职责就得到了分担,并且保证有来自高层的支持。这也可以保证信息从开发团队顺畅地流入这个小组,从而保证小组做出更合理的决定。
如果你给一个小组权力去做决定,但在最后又忽略了这个决定,那这个小组就毫无意义可言了
类比一下教小孩儿骑自行车的过程。你没法替代他们去骑车。你会看着他们摇摇晃晃地前行,但是,如果每次你看到他们要跌倒就上去扶一把,他们永远都学不会。而且无论如何,他们真正跌倒的次数会比你想象的要少!但是,如果他们马上就要驶入车流繁忙的大马路,或者附近的鸭子池塘,你就必须站出来了。作为一名架构师,你必须要在团队驶向类似鸭子池塘这样的地方时抓紧他们
2.10 建设团队
对于技术领导人来说,更重要的事情是帮助你的队友成长,帮助他们理解这个愿景,并保证他们可以积极地参与到愿景的实现和调整中来
在单块系统中,人们为某些事情负责的机会非常有限,而在微服务架构中存在多个自治的代码库,每个代码库都有着自己独立的生命周期,这就给更多人提供了对单个服务负责的机会,而当这些人在单个服务上面得到足够锻炼之后,就可以给他们更多的责任,从而帮助他们逐步达成自己的职业目标,同时通过分担职责也可以防止某一个人的负担过重。
以人为本
我坚定地相信,伟大的软件来自于伟大的人。所以如果你只担心技术问题,那么恐怕你看到的问题远远不及一半。
2.11 小结
理解你所做的决定对客户和同事带来的影响。
第3章 如何建模服务
“对手的论证让我想到了异教徒。当别人问异教徒世界由什么支撑时,他说:‘一只乌龟。’别人再问他那乌龟又由什么支撑呢?他回答:‘另一只乌龟。'”——Joseph Barker(1854)
3.2 什么样的服务是好服务
松耦合和高内聚,如果这两点做不到,那么微服务也就没什么价值了
使用微服务最重要的一点是,能够独立修改及部署单个服务而不需要修改系统的其他部分,这真的非常重要。
什么会导致紧耦合呢?一个典型的错误是,使用紧耦合的方式做服务之间的集成,从而使得一个服务的修改会致使其消费者的修改
一个松耦合的服务应该尽可能少地知道与之协作的那些服务的信息。这也意味着,应该限制两个服务之间不同调用形式的数量,因为除了潜在的性能问题之外,过度的通信可能会导致紧耦合
3.3 限界上下文
他认为任何一个给定的领域都包含多个限界上下文,每个限界上下文中的东西(Eric更常使用模型这个词,应该比“东西”好得多)分成两部分,一部分不需要与外部通信,另一部分则需要。每个上下文都有明确的接口,该接口决定了它会暴露哪些模型给其他的上下文
细胞之所以会存在,是因为细胞膜定义了什么在细胞内,什么在细胞外,并且确定了什么物质可以通过细胞膜
是对该模型来说,存在内部和外部两种表示方式
同一个名字在不同的上下文中有着完全不同的含义
退货表示的是客户退回的一些东西。在客户的上下文中,退货意味着打印运送标签、寄送包裹,然后等待退款。在仓库的上下文中,退货表示的是一个即将到来的包裹,而且这个包裹会重新入库。退货这个概念会与将要执行的任务相关,比如我们可能会发起一个重新入库的请求。这个退货的共享模型会在多个不同的进程中使用,并且在每个限界上下文中都会存在相应的实体,不过,这些实体仅仅是在每个上下文的内部表示而已。
然而对于一个新系统而言,可以先使用一段时间的单块系统,因为如果服务之间的边界搞错了,后面修复的代价会很大。所以最好能够等到系统稳定下来之后,再确定把哪些东西作为一个服务划分出去过早将一个系统划分成为微服务的代价非常高,尤其是在面对新领域时
团队逐渐又把这些服务合并成了一个单块系统,从而给所有人时间去理解服务边界到底应该在哪。一年之后,团队识别了出非常稳定的边界,并据此将这个单块系统拆分成多个微服务
3.5 逐步划分上下文
另一个倾向于嵌套式方法的原因是,它可以使得架构更成块儿从而更好地测试
3.7 技术边界
RPC(Remote Procedure Call,远程过程调用)来提供服务
洋葱架构,因为它有很多层,而且当纵切这些层次时,我只想哭
3.8 小结
Eric Evans在《领域驱动设计》中提到的概念对于寻找明显的服务边界来说非常有用
第4章 集成
4.1 寻找理想的集成技术
如果你从事IT业已经超过15分钟,不用我说也应该知道,你工作的领域在不断地变化,而唯一不变的就是变化。
保证微服务之间通信方式的技术无关性是非常重要的。这就意味着,不应该选择那种对微服务的具体实现技术有限制的集成方式
提供一个客户端库也可以简化消费方的使用。但是通常这种库与其他我们想要得到的东西不可兼得。举个例子,使用客户端库对于消费方来说很方便,但是会造成耦合的增加
不希望消费方与服务的内部实现细节绑定在一起,因为这会增加耦合。与细节绑定意味着,如果想要改变服务内部的一些实现,消费方就需要跟着做出修改。这会增加修改的成本,而这恰恰是我们想要避免的。这也会导致为了避免消费方的修改而尽量少地对服务本身进行修改,而这会导致服务内部技术债的增加。所以,所有倾向于暴露内部实现细节的技术都不应该被采用。
4.3 共享数据库
数据库是一个很大的共享API,但同时也非常不稳定
为了不影响其他服务,我必须非常小心地避免修改与其他服务相关的表结构。这种情况下,通常需要做大量的回归测试来保证功能的正确性
隐藏实现细节非常重要,因为它让我们的服务拥有一定的自治性,从而可以轻易地修改其内部实现
4.4 同步与异步
如果使用同步通信,发起一个远程服务调用后,调用方会阻塞自己并等待整个操作的完成。如果使用异步通信,调用方不需要等待操作完成就可以返回,甚至可能不需要关心这个操作完成与否
异步通信也可以使用这种模式。我可以发起一个请求,然后注册一个回调,当服务端操作结束之后,会调用该回调
基于事件的协作方式耦合性很低。客户端发布一个事件,但并不需要知道谁或者什么会对此做出响应,这也意味着,你可以在不影响客户端的情况下对该事件添加新的订阅者。
4.5 编排与协同
考虑具体实现时,有两种架构风格可以采用。使用编排(orchestration)的话,我们会依赖于某个中心大脑来指导并驱动整个流程,就像管弦乐队中的指挥一样。使用协同(choreography)的话,我们仅仅会告知系统中各个部分各自的职责,而把具体怎么做的细节留给它们自己,就像芭蕾舞中每个舞者都有自己的方式,同时也会响应周围其他人
编排方式的缺点是,客户服务作为中心控制点承担了太多职责,它会成为网状结构的中心枢纽及很多逻辑的起点。我见过这个方法会导致少量的“上帝”服务,而与其打交道的那些服务通常都会沦为贫血的、基于CRUD的服务。
使用协同,可以仅仅从客户服务中使用异步的方式触发一个事件,该事件名可以叫作“客户创建”。电子邮件服务、邮政服务及积分账户可以简单地订阅这些事件并且做相应处理
我认为使用协同的方式可以降低系统的耦合度,并且你能更加灵活地对现有系统进行修改。但是,确实需要额外的工作来对业务流程做跨服务的监控。我还发现大多数重量级的编排方案都非常不稳定且修改代价很大。基于这些事实,我倾向于使用协同方式,在这种方式下每个服务都足够聪明,并且能够很好地完成自己的任务。
如果想要请求/响应风格的语义,又想避免其在耗时业务上的困境,可以采用异步请求加回调的方式
针对请求/响应方式,可以考虑两种技术:RPC(Remote Procedure Call,远程过程调用)和REST(REpresentational State Transfer,表述性状态转移)
4.6 远程过程调用
WSDL(Web Service Definition Language, Web服务描述语言)
SOAP使用XML作为消息格式
比如TCP能够保证送达,UDP虽然不能保证送达但协议开销较小,所以你可以根据自己的使用场景来选择不同的网络技术
那些RPC的实现会帮你生成服务端和客户端的桩代码,从而让你快速开始编码。基本不用花时间,我就可以在服务之间进行内容交互了。这通常也是RPC的主要卖点之一:易于使用。
有时候RPC带来的代价要远远大于一开始快速启动的好处。
是RPC会花大量的时间对负荷进行封装和解封装
分布式计算中一个非常著名的错误观点就是“网络是可靠的”(https://blogs.oracle.com/jag/resource/Fallacies.html),事实上网络并不可靠。即使客户端和服务端都正常运行,整个调用也有可能会出错。这些错误有可能会很快发生,也有可能会过一段时间才会显现出来,它们甚至有可能会损坏你的报文
因为对规格说明进行了修改,所以所有的客户端都需要重新生成桩,无论该客户端是否需要这个新方法。
lock-step
在客户端中一定不要隐藏我们是在做网络调用这个事实
4.7 REST
一个资源的对外显示方式和内部存储方式之间没有什么耦合
孩子王的外线和内部存储方式耦合严重,字段名基本一致,需要改变
REST本身并没有提到底层应该使用什么协议,尽管事实上最常用HTTP。我以前也见过使用其他协议来实现REST的例子,比如串口或者USB,当然这会引入大量的工作。HTTP的一些特性,比如动词,使得在HTTP之上实现REST要简单得多,而如果使用其他协议的话,就需要自己实现这些特性
HTTP的动词(如GET、POST和PUT)就能够很好地和资源一起使用
REST架构风格声明了一组对所有资源的标准方法,而HTTP恰好也定义了一组方法可供使用
GET使用幂等的方式获取资源,POST创建一个新资源。这就意味着,我们可以避免很多不同版本的createCustomer及editCustomer方法。相反,简单地POST一个Customer的表示到服务端,然后服务端就可以创建一个新的资源,接下来可以发起一个GET请求来获取资源的表示
从概念上来说,对于一个Customer资源,访问接口只有一个,但是可以通过HTTP协议的不同动词对其进行不同的操作
很多时候,似乎那些已有的并且很好理解的标准和技术会被忽略,然后新推出的标准又只能使用全新的技术来实现,而这些新技术的提供者也就是制定那些新标准的公司!
“HATEOAS”(Hypermedia As The Engine Of Application State,超媒体作为程序状态的引擎。天哪,它真的需要一个缩写吗?)
HATEOAS背后的想法是,客户端应该与服务端通过那些指向其他资源的链接进行交互,而这些交互有可能造成状态转移。它不需要知道Customer在服务端的URI,相反客户端根据链接导航到它想要的东西。
考虑Amazon.com这个站点。随着时间的推移,购物车的位置、图像、链接都有可能发生变化,但是人类足够聪明,你还是能够找到它。无论确切的形式和底层使用的控件发生怎样的改变,我们仍然很清楚如果你想要浏览购物车的话,应该去点哪个按钮。这就是为什么在网页上可以做出一些增量的修改,只要这些客户和站点之间的隐式约定仍然满足,这些修改就不会破坏站点的功能
你一开始先让客户端去自行遍历和发现它想要的链接,然后如果有必要的话再想办法优化,这种方式的一个缺点是,客户端和服务端之间的通信次数会比较多,因为客户端需要不断地发现链接、请求、再发现链接,直到找到自己想要进行的那个操作
HAL(Hypertext Application Language,[超文本应用语言](http://stateless.co/hal specification.html)试图为JSON(也包括XML,虽然大家普遍认为XML不需要它的帮助)定义通用的超文本标准格式
XPATH
XPath即为XML路径语言,它是一种用来确定XML(标准通用标记语言的子集)文档中某部分位置的语言。XPath基于XML的树状结构,有不同类型的节点,包括元素节点,属性节点和文本节点,提供在数据结构树中找寻节点的能力。 [1] 起初 XPath 的提出的初衷是将其作为一个通用的、介于XPointer与XSLT间的语法模型。但是 XPath 很快的被开发者采用来当作小型查询语言。
我感觉很奇怪的是,很多人选择JSON是因为它很轻量,但是又想方设法把超媒体控制之类的概念添加进去,而这些概念是在XML中早已存在的
在我的团队中一个很有效的模式是先设计外部接口,等到外部接口稳定之后再实现微服务内部的数据持久化。在此期间,简单地将实体持久化到本地磁盘的文件上,当然这并非长久之计。这样做可以保证服务的接口是由消费者的需求驱动出来的,从而避免数据存储方式对外部接口的影响。其缺点是推迟了数据存储部分的集成。我认为对于新的服务来说,这个取舍是可接受的。
有些Web框架无法很好地支持所有的HTTP动词。这就意味着你很容易处理GET和POST请求,但是PUT和DELETE就很麻烦了
对于服务和服务之间的通信来说,如果低延迟或者较小的消息尺寸对你来说很重要的话,那么一般来讲HTTP不是一个好主意。你可能需要选择一个不同的底层协议,比如UDP(User Datagram Protocol,用户数据报协议)来满足你的性能要求。很多RPC框架都可以很好地运行在除了TCP之外的其他网络协议上。
4.8 实现基于事件的异步协作方式
像RabbitMQ这样的消息代理能够处理上述两个方面的问题。生产者(producer)使用API向代理发布事件,代理也可以向消费者提供订阅服务,并且在事件发生时通知消费者。这种代理甚至可以跟踪消费者的状态,比如标记哪些消息是该消费者已经消费过的。这种系统通常具有较好的可伸缩性和弹性,但这么做也是有代价的。它会增加开发流程的复杂度,因为你需要一个额外的系统(即消息代理)才能开发及测试服务。你也需要额外的机器和专业知识来保持这些基础设施的正常运行。但一旦做好了,它会是实现松耦合、事件驱动架构的一种非常有效的方法。通常来说我很喜欢这种方式
尽量让中间件保持简单,而把业务逻辑放在自己的服务中
但是有一种场景需要避免,即多个工作者处理了同一条消息,从而造成浪费。如果使用消息代理,一个标准的队列就可以很好地处理这种场景。
我们当时使用了事务处理队列:当工作者崩溃之后,这个请求上的锁会超时,然后该请求就会被放回到队列中。另一个工作者会重新尝试处理该请求,然后它也会崩溃。这就是Martin Fowler提到的灾难性故障转移(catastrophic failover)的一个典型例子
除了代码中的bug外,我们还忘了设置一个作业最大重试次数。所以后面不但修复了bug本身,还设置了这个最大重试次数。但是我们也意识到需要有一种方式来查看甚至是重发这些有问题的消息。所以最后实现了一个消息医院(或者叫死信队列),所有失败的消息都会被发送到这里。我们还创建了一个界面来显示这些消息,如果需要的话还可以触发一个重试。如果你只熟悉点到点的同步通信,就很难快速发现这个问题。
企业集成模式
4.10 响应式扩展
响应式扩展(Reactive extensions, Rx)提供了一种机制,在此之上,你可以把多个调用的结果组装起来并在此基础上执行操作
4.11 微服务世界中的DRY和代码重用的危险
开发人员对DRY这个缩写非常熟悉,即Don't Repeat Yourself,从字面上看DRY仅仅是避免重复代码,但其更精确的含义是避免系统行为和知识的重复
我们想要避免微服务和消费者之间的过度耦合,否则对微服务任何小的改动都会引起消费方的改动。而共享代码就有可能会导致这种耦合。比如,客户端可以通过库共享其中表示系统核心实体的公共领域对象,而所有的服务也会使用这个库。所以当任何部分需要对库做修改时,都会引起其他部分的重新部署。如果你的系统通过消息队列进行通信,那么你需要过滤(由不同步的部署导致的)失效的内容,忘记这么做会引起严重的问题。
Realestate.com.au使用了很多深度定制化的服务模板来快速创建新服务。他们不会在服务之间共用代码,而是把这些代码复制到每个新的服务中,以防止耦合的发生。
在微服务内部不要违反DRY,但在跨服务的情况下可以适当违反DRY
这么做的问题在于,如果开发服务端API和客户端API的是同一批人,那么服务端的逻辑就有可能泄露到客户端中。我对此很清楚,因为我以前就这么做过。潜入客户端库的逻辑越多,内聚性就越差,然后你必须在修复一个服务端问题的同时,也需对多个客户端进行修改。这样做也会限制技术的选择,尤其是当你强制消费方使用该客户端库时
Netflix使用客户端库的另一个同等重要的(如果不是更重要的)原因是,保证系统的可靠性和可伸缩性。Netflix的客户端库会处理类似服务发现、故障模式、日志等方面的工作,可以看到这些方面与服务本身的职责并没有什么关系。如果不使用这些共享客户端,Netflix就很难保证客户端和服务器之间的通信能够在规模化的情况下正常工作
如果你想要使用客户端库,一定要保证其中只包含处理底层传输协议的代码,比如服务发现和故障处理等。千万不要把与目标服务相关的逻辑放到客户端库中
最后,确保由客户端来负责何时进行客户端库的升级,这样才能保证每个服务可以独立于其他服务进行发布!
4.12 按引用访问
有时候使用本地副本没什么问题,但在其他场景下你需要知道该副本是否已经失效。所以当你持有一个本地副本时,请确保同时持有一个指向原始资源的引用,这样在你需要的时候就可以对本地副本进行更新。。同时为了能够在处理事件时得到资源的最新状态,也应该拥有该实体的引用以便于查询。
如果在获取资源的同时,可以得到资源的有效性时限(即该资源在什么时间之前是有效的)信息的话,就可以进行相应的缓存,从而减小服务的负载。
我们做的时候,其实就把记录的ID放在redis里,然后发送的时候实时查询信息,再发出去
发货之后需要请求邮件服务来发送一封邮件。一种做法是,把客户的邮件地址、姓名、订单详情等信息发送到邮件服务。但是邮件服务有可能会将这个请求放入队列,然后在将来的某个时间再从队列中取出来,在这个时间差中,客户和订单的信息有可能就会发生变化。更合理的方式应该是,仅仅发送表示客户资源和订单资源的URI,然后等邮件服务器就绪时再回过头来查询这些信息。
4.13 版本管理
然而REST就好得多,因为对于内部实现的修改不太容易引起服务接口的变化。
鲁棒性原则
客户端尽可能灵活地消费服务响应这一点符合Postel法则(也叫作鲁棒性原则)。该法则认为,系统中的每个模块都应该“宽进严出”,即对自己发送的东西要严格,对接收的东西则要宽容。这个原则最初的上下文是网络设备之间的交互,因为在这个场景中,所有奇怪的事情都有可能发生。在请求/响应的场景下,该原则可以帮助我们在服务发生改变时,减少消费方的修改。
语义化版本管理
语义化版本管理的每一个版本号都遵循这样的格式:MAJOR.MINOR.PATCH。其中MAJOR的改变意味着其中包含向后不兼容的修改;MINOR的改变意味着有新功能的增加,但应该是向后兼容的;最后,PATCH的改变代表对已有功能的缺陷修复。
为了更好地理解语义化版本管理,让我们来看一个简单的用例。帮助台应用能够与1.2.0版本的客户服务一起使用。如果新功能的增加引起了客户服务的版本变成了1.3.0,那么帮助台应用不应该看到任何行为的变化,并且自身也不需要做任何改动。由于当前的客户端可能会依赖于在1.2.0版本中新加入的功能,所以不能保证,现在的版本可以和1.1.0版本的客户服务一起工作。当客户服务升级到2.0.0版本时,本地应用程序应该也需要做相应的修改。
我用过的一种比较成功的方法是,在同一个服务上使新接口和老接口同时存在。所以在发布一个破坏性修改时,可以部署一个同时包含新老接口的版本。
为了使其更可控,我们在内部把所有对V1的请求进行转换处理,然后去访问V2,继而V2再去访问V3。使用这种方式后,以后应该删除哪些代码也就比较清楚了。
对于使用HTTP的系统来说,可以在请求中添加版本信息,也可以将其添加在URI中,比如/v1/customer/和/v2/customer/。
升级消费者到新版本的时间越长,就越应该考虑在同一个微服务中暴露两套API的做法
4.14 用户界面
相比UI主动访问所有的API,然后再将状态同步到UI控件,另一种选择是让服务直接暴露出一部分UI,然后只需要简单地把这些片段组合在一起就可以创建出整体UI,
活样式指导(living style guides),即将HTML组件、CSS及图片等资源进行共享,从而使其具有一定程度的一致性
交互越多就越难把一个服务做成控件的形式,也许最终只能通过API调用来解决问题
4.15 与第三方软件集成
SaaS(Software as a Service,软件即服务平台
一般来讲,我和同事的建议是,对于一般规模的组织来说,如果某个软件非常特殊,并且它是你的战略性资产的话,那就自己构建;如果不是这么特别的话,那就购买。
很多企业购买的工具都声称可以为你做深度定制化。一定要小心!这些工具链的定制化往往会比从头做起还要昂贵!如果你决定购买一个产品,但是它提供的能力不完全适合你,也许改变组织的工作方式会比对软件进行定制化更加合理。
核心思想是,任何定制化都只在自己可控的平台上进行,并限制工具的消费者的数量。
在CMS选定之前,用一个假的静态内容服务来替代它。后来甚至在CMS确定之前,直接在生产环境使用了该静态内容服务。等到CMS终于选好了之后,没有做任何修改就顺利地把原来的服务给替换掉了。
我见过的很多CRM工具的实现都是粘性(内聚性的反方向)服务的典范。
这种工具的使用范围往往一开始会比较小,但随着时间的发展它会在你的组织中变得越来越重要,以至于后续的方向和选择都会围绕它来做。但这么重要的系统竟然不是自己做的,而是第三方厂家提供的,这是个很严重的问题。
我所服务的组织意识到虽然很多事情都使用CRM在管理,但是这个平台并没有带来与其代价相对应的收益。
5.3 分解单块系统的原因
最好考虑一下把哪部分代码抽取出去得到的收益最大,而不是为了抽取而抽取。
5.4 杂乱的依赖
我们想要拉取出来的接缝应该尽量少地被其他组件所依赖。
通常经过这样的分析就会发现,数据库是所有杂乱依赖的源头
5.6 找到问题的关键
把数据库映射相关的代码和功能代码放在同一个上下文中,可以帮助我们理解哪些代码用到了数据库中的哪些部分。
5.7 例子:打破外键关系
快速的修改方式是,让财务部分的代码通过产品目录服务暴露的API来访问数据,而不是直接访问数据库
你需要做两次数据库调用来生成报告。没错,做成两个独立的服务之后也会是这样。这时很多人就会对性能表示担忧。我对这些担忧给出的答案很简单:你的系统需要多快?系统现在是多快?如果能够对当前性能做一个测试,并且还知道你的期望是什么,那就可以放心地做这些修改。
首先应该知道系统的期望行为是什么,然后再根据期望行为做决定
5.8 例子:共享静态数据
我见过的把国家代码放在数据库中(如图5-4所示)的次数,大约和我在内部Java项目中编写StringUtils类的次数一样多。这似乎暗示着,系统中所支持国家的改变频率比部署新代码的频率还要高,但不管真正的原因是什么,这些将共享静态数据存在数据库中的例子非常多。
第一个是为每个包复制一份该表的内容,也就是说,未来每个服务也都会保存这样一份副本。当然这会导致一个潜在的一致性问题。比如说,当澳大利亚东海岸新成立了一个国家叫作Newmantopia,你有可能会漏修改掉一些服务中的表。第二个方法是,把这些共享的静态数据放入代码,比如放在属性文件中,或者简单地放在一个枚举中。数据一致性的问题仍然存在,虽然从经验上看,修改配置文件比修改在线数据库要简单得多。通常这是比较合理的办法。第三个方法有些极端,即把这些静态数据放入一个单独的服务中。在我以前遇到过的一些场景中,数据量和复杂性及相关的规则值得我们这样做,但如果仅仅是国家代码的话就不必了。从个人经验来看,大部分场景下,都可以通过把这些数据放入配置文件或者代码中来解决问题,而且它对于大部分场景来说都很容易实现
5.9 例子:共享数据
领域概念不是在代码中进行建模,相反是在数据库中隐式地进行建模。
5.11 重构数据库
你想要在一次发布中把单块服务直接变成两个服务,并且每个服务有各自的数据库结构吗?事实上,我会推荐你先分离数据库结构,暂时不对服务进行分离
先分离数据库结构但不分离服务的好处在于,可以随时选择回退这些修改或是继续做,而不影响服务的任何消费者。我们对数据库分离感到满意之后,就可以考虑对整个应用程序的分离了。
5.12 事务边界
最终一致性
其实,对我们来说知道订单被捕获并被处理就足够了,因为可以后面再对仓库的提取表做一次插入操作。我们可以把这部分操作放在一个队列或者日志文件中,之后再尝试对其进行触发。对于某些操作来说这是合理的,但要保证重试能够修复这个问题。很多地方会把这种形式叫作最终一致性
提取表的处理比较简单,因为插入失败会导致事务的回退。但是订单表已经提交了的事务该怎么处理呢?解决方案是,再发起一个补偿事务来抵消之前的操作。
那如果补偿事务失败了该怎么办呢?这显然是有可能的,这时在订单表中就会有一条记录在提取表中没有对应的记录。在这种情况下,你要么重试补偿事务,要么使用一些后台任务来清除这些不一致的状态。可以给后台的维护人员提供一个界面来进行该操作,或者将其自动化。
手动编配补偿事务非常难以操作,一种替代方案是使用分布式事务。分布式事务会横跨多个事务,然后使用一个叫作事务管理器的工具来统一编配其他底层系统中运行的事务。就像普通的事务一样,一个分布式的事务会保证整个系统处于一致的状态。唯一不同的是,这里的事务会运行在不同系统的不同进程中,通常它们之间使用网络进行通信
两阶段提交
处理分布式事务(尤其是上面处理客户订单这类的短事务)常用的算法是两阶段提交。在这种方式中,首先是投票阶段。在这个阶段,每个参与者(在这个上下文中叫作cohort)会告诉事务管理器它是否应该继续。如果事务管理器收到的所有投票都是成功,则会告知它们进行提交操作。只要收到一个否定的投票,事务管理器就会让所有的参与者回退。
这种方式会使得所有的参与者暂停并等待中央协调进程的指令,从而很容易导致系统的中断。如果事务管理器宕机了,处于等待状态的事务就永远无法完成。如果一个cohort在投票阶段发送消息失败,则所有其他参与者都会被阻塞,投票结束之后的提交也有可能会失败。该算法隐式地认为上述这些情况不会发生,即如果一个cohort在投票阶段投了赞成票,则它一定能提交成功。cohort需要一种机制来保证这件事情的发生。这意味着此算法并不是万无一失的,而只是尝试捕获大部分的失败场景。
如果你遇到的场景确实需要保持一致性,那么尽量避免把它们放在不同的地方,一定要尽量这样做
如果实在不行,那么要避免仅仅从纯技术(比如数据库事务)的角度考虑,而是显式地创建一个概念来表示这个事务。你可以把这个概念当作一个句柄或者钩子,在此之上,能够相对容易地进行类似补偿事务这样的操作,这也是在系统中监控这些复杂概念的一种方式。举个例子,你可以创建一个叫作“处理中的订单”的概念,围绕这个概念可以把所有与订单相关的端到端操作(及相应的异常)管理起来。
5.14 报表数据库
通常为了防止对主系统性能产生影响,报表系统会从副本数据库中读取数据,如图5-12所示。[插图]图5-12:标准只读副本这种方式有一个很大的好处,即所有的数据存储在同一个地方,因此可以使用非常简单的工具来做查询。但也存在一些缺点。首先,数据库结构成了单块服务和报表系统之间的共享API,所以对表结构的修改需要非常小心。事实上,这也会阻碍所有人去做类似的修改。
一些数据库允许我们在只读的备份库上做一些优化,以加快读取速度,从而更高效地生成报表
但由于产品数据库的限制,报表数据库的表结构是无法随意优化的。所以通常来讲,这个表结构要么非常适用于其中一种场景,但对其他的来说不好用,或者取二者的最小公约数,也就是两种场景都不够好用。
对于报表系统来说,可以尝试Cassandra这种基于列的数据库,因为它对大数据量处理得很好
5.15 通过服务调用来获取数据
这个模型有很多变体,但它们都依赖API调用来获取想要的数据。对于一个非常简单的报表系统(比如展示过去15分钟内下的订单数量的系统)来说,这是可行的。为了从两个或者多个系统中获取数据,你需要进行多次调用,然后进行组装。
但是报表天然就允许用户访问不同时期的历史数据,这意味着,如果用户访问的资源是别人没有访问过的(或者在很长一段时间内没有人访问),则缓存无法命中
导出报表的方法
发起调用的系统可以POST一个BatchRequest,其中携带一个位置信息,服务器可以将所有数据写入该文件。客户服务会返回HTTP 202响应码来表示请求已经接受了,但还没有处理。调用系统接下来轮询这个资源,直到得到一个201 Created状态,这表示请求已经被满足了,然后发起调用的系统就可以获取这个数据。通过这种方式可以将大数据文件导出,而不需要HTTP之上的开销,只是简单地把一个CSV文件存储到共享的位置而已。
5.16 数据导出
这时你会说:“但是Sam,你说过使用数据库集成是不好的!”很高兴你这么问,不枉我前面多次强调这个问题!但如果实现得好的话,这个场景可以是一个例外,因为它使得报表这件事情变得足够简单,从而可以抵消耦合带来的缺点。
通过让同一个团队来维护服务本身和数据导出,可以缓解二者之间的耦合
5.17 事件数据导出
对于这些暴露事件聚合(feed)的微服务,我们可以在客户端编写自己的事件订阅器把数据导出到报表数据库中
因为可以在事件发生时就给报表系统发送数据,而不是靠原有的周期性数据导出,所以数据就能更快地流入报表系统。
因为事件数据导出的方式与服务的内部实现耦合很小,所以可以把这部分工作交给另一个独立的团队(而非持有数据的那个团队)来维护。只要事件流的设计没有造成订阅者和服务之间的耦合,则这个事件映射器就可以独立于它所订阅的服务来进行演化。
所有需要的信息都必须以事件的形式广播出去,所以在数据量比较大时,不容易像数据导出方式那样直接在数据库级别进行扩展
5.20 修改的代价
我们可以,也一定会犯错误,需要接受这个事实。但是另外一件我们应该做的事情是,理解如何降低这些错误所造成的影响。
巨大的修改代价意味着风险的增大。如何才能控制这些风险?我的方式是在影响最小的地方犯错误。
这里我采用了一种在设计面向对象系统时的典型技术:CRC(class-responsibility-collaboration,类-职责-交互)卡片。你可以在一张卡片写上类的名字、它的职责及与谁进行交互。当我进行设计时,会把每个服务的职责列出来,写清楚它提供了什么能力,和哪些服务之间有协作关系。遍历的用例越多,你就越能知道这些组件是否以正确的方式在一起工作
5.21 理解根本原因
对库和轻量级服务框架的投资能够减小创建新服务的代价
第6章 部署
6.1 持续集成简介
构建物
作为这个流程的一部分,我们经常会生成一些构建物(artifact)以供后续验证使用,比如启动一个服务并对其运行测试。理想情况下,这些构建物应该只生成一次,然后在本次提交所对应的所有部署环节中使用。这不仅可以避免多次重复做一件事情,还可以保证部署上线的构建物与测试通过的那个是同一个。为了重用构建物,需要把它们放在某个仓储中。CI本身会提供这样的仓储,你也可以使用一个独立系统来做这件事情
即使你只使用生命周期很短的分支来管理这些修改,也要尽可能频繁地把代码检入到单个主线分支中。
如果你允许别人在构建失败时提交更多的修改,用于修复构建的时间就会大大增加。
6.2 把持续集成映射到微服务
那么还有其他方法吗?我比较喜欢的方法是,每个微服务都有自己的CI,这样就可以在将该微服务部署到生产环境之前做一个快速的验证,如图6-3所示。这里的每个微服务都有自己的代码库,分别与相应的CI绑定。当对代码库进行修改时,可以只运行相关的构建以及其中的测试。我只会得到一个需要部署的构建物,代码库与团队所有权的匹配程度也更高了。如果你对一个服务负责,就应该同时对相关的代码库和构建负责。在这样的世界中,跨微服务做修改会更加困难,但是我认为,相比单块代码库和单块构建流程所带来的问题而言,这个问题更容易解决。
每个与微服务相关的测试也应该和其本身的代码放在一起,这样就很容易知道对于某个服务来说应该运行哪些测试。
6.3 构建流水线和持续交付
构建流水线可以很好地跟踪软件构建进度:每完成一个阶段,就离终点更近一步。流水线也能够可视化本次构建物的软件质量。构建物会在整个构建的第一个环节生成,然后它会被用在整个流水线中。随着构建物通过不同的阶段,我们越来越能确定该软件能够在生产环境下正常工作。
6.4 平台特定的构建物
人员想要测试一些功能,或者做一次生产环境的部署。现在想象一下,所要部署的服务使用了三种完全不同的部署机制,比如Ruby的Gem、JAR包和Node.js的NPM包,你会有什么感觉?自动化可以对不同构建物的底层部署机制进行屏蔽。Chef、Puppet及Ansible都支持一些通用技术栈的构建物部署
6.5 操作系统构建物
RPM [1] 是RPM Package Manager(RPM软件包管理器)的缩写,这一文件格式名称虽然打上了RedHat的标志,但是其原始设计理念是开放式的,现在包括OpenLinux、S.u.S.E.以及Turbo Linux等Linux的分发版本都有采用,可以算是公认的行业标准了。
对基于RedHat或者CentOS的系统来说,可以使用RPM;对Ubuntu来说,可以使用deb包;对Windows来说,可以使用MSI
但如果软件是部署在你可控的机器上,那么我建议,尽量减少需要维护的操作系统的数量,最好只维护一种。它可以大大减少不同机器之间可能存在的不同之处,并减小部署和维护的工作量。
6.6 定制化镜像
可能需要使用collectd来收集操作系统的状态,使用logstash来做日志的聚合,还可能需要安装nagios来做监控
一种减少启动时间的方法是创建一个虚拟机镜像,其中包含一些常用的依赖,如图6-5所示。我用过的所有虚拟化平台,都允许用户构建自己的镜像,而且现在的工具提供的便利程度,也远远超越了多年前的那些工具。使用这种方法之后事情就变得简单一些了。现在你可以把公共的工具安装在镜像上,然后在部署软件时,只需要根据该镜像创建一个实例,之后在其之上安装最新的服务版本即可。
当你创建VMWare镜像时,这会是一个很大的问题。想象一下,在网络上传送一个20GB的镜像文件是怎样一个场景
现在已经做到了使用包含依赖的虚拟机镜像来加速反馈,那么为什么要止步于此呢?我们可以更进一步,把服务本身也包含在镜像中,这样就把镜像变成了构建物。现在当你启动镜像时,服务就已经就绪了。Netflix就是因为这个快速启动的好处,把自己的服务内建在了AWS AMI中。
配置漂移
通过把配置都存到版本控制中,我们可以自动化重建服务,甚至重建整个环境。但是如果部署完成后,有人登录到机器上修改了一些东西呢?这就会导致机器上的实际配置和源代码管理中的配置不再一致,这个问题叫作配置漂移。
为了避免这个问题,可以禁止对任何运行的服务器做手动修改。相反,无论修改多么小,都需要经过构建流水线来创建新的机器。事实上,即使不使用镜像,你也可以实现类似的模式,但它是把镜像作为构建物的一个非常合理的扩展。你甚至可以在镜像的创建过程中禁止SSH,以确保没有人能够登录到机器上做任何修改。
前面我已经说过,任何能够简化工作的措施都值得尝试
6.7 环境
不同环境中部署的服务是相同的,但是每个环境的用途却不一样。在我的开发机上,想要快速部署该服务来运行测试或者做一些手工测试,此时相关的依赖很有可能都是假的;而在生产环境中,需要把该服务部署到多台机器上并使用负载均衡来管理,甚至从持久性(durability)的角度考虑,还需要把这些机器放在不同的数据中心去。
6.8 服务配置
一个更好的方法是只创建一个构建物,并将配置单独管理。从形式上来说,这针对的可能是每个环境的一个属性文件,或者是传入到安装过程中的一些参数。还有一个在应对大量微服务时比较流行的方法是,使用专用系统来提供配置
6.9 服务与主机之间的映射
但这个模型也有一些挑战。首先,它会使监控变得更加困难。举个例子,当监控CPU使用率时,应该监控每个单独的服务还是整个机器呢?服务之间的相互影响也是不可避免的
这个模型对团队的自治性也不利。如果不同团队所维护的服务安装在了同一台主机上,那么谁来配置这些服务所在的主机呢?很有可能最后有一个专门的团队来做这些事情,这就意味着,需要和更多人协调才能完成服务的部署。
把所有东西放在一台主机上意味着,即使每个服务的需求是不一样的,我们也不得不对它们一视同仁
我的同事Neal Ford提到过,很多关于部署和主机管理的工作实践都是为了优化稀缺资源的利用
第一个缺点是,它会不可避免地限制技术栈的选择。你只能使用一种技术栈
它们中的很多实现,都在兜售通过集群管理来支持内存中的共享会话状态的能力,而这无论如何都是应该避免的方式,因为它会影响服务的可伸缩性
更重要的是,这样做之后我们才有可能采用一些不同的部署技术,比如前面提到的基于镜像的部署或者不可变服务器模式。
PaaS(Platform-as-a-Service,平台即服务)
所以这也是你要做的取舍。以我的经验来看,PaaS平台想要做得越聪明,通常也就可能错得越离谱。我用过的好几个PaaS,都尝试根据应用程序的使用情况来自动伸缩,但都做得不好。因为平台一般都会尽量去满足一些比较通用的需求,而非特定用户的特殊需求,所以你的应用程序越不标准,就越难一起和PaaS进行工作。
6.10 自动化
当机器数量比较少时,手动管理所有的事情是有可能的。我以前就这么做过。记得当时我管理了少量的生产环境机器,登录到机器上进行日志收集、软件部署、进程查看等工作。我的生产力似乎仅受能够打开的终端窗口的数量的限制,所以当我开始使用了第二个显示器时,生产力得到了很大的提高。
理想情况下,开发人员使用的工具链应该和部署生产环境时使用的完全一样,这样就可以及早发现问题。
使用支持自动化的技术非常重要。让我们从管理主机的工具开始考虑这个问题,你能否通过写一行代码来启动或者关闭一个虚拟机?你能否自动化部署写好的软件?你能否不需要手工干预就完成数据库的变更?想要游刃有余地应对复杂的微服务架构,自动化是必经之路。
在刚开始的三个月,REA仅仅成功地把两个新的微服务部署上线,开发团队对所有的构建、部署及线上支持负责。在接下来的三个月,大概有10~15个类似的服务部署上线。18个月后,REA已经有了60~70个服务。
一年后,Gilt大约有10个微服务上线;2012年,超过100个;2014年,超过450个。也就是说,在Gilt平均每个开发人员拥有三个微服务。
6.11 从物理机到虚拟机
但是把机器划分成大量的VM并不是免费的。把物理机想象成一个装袜子的抽屉,如果你在抽屉里放置了很多木隔板,那么可存放袜子的总量是多还是少了?答案很明显是少了,因为隔板本身也占空间!管理抽屉是比较简单的,不仅仅是放袜子,你也可以把T恤放在某个隔间里面,但是更多的隔板意味着更少的总空间
Hypervisor是一种运行在物理服务器和操作系统之间的中间软件层,可允许多个操作系统和应用共享一套基础物理硬件,因此也可以看作是虚拟环境中的“元”操作系统,它可以协调访问服务器上的所有物理设备和虚拟机,也叫虚拟机监视器(Virtual Machine Monitor)。Hypervisor是所有虚拟化技术的核心。非中断地支持多工作负载迁移的能力是Hypervisor的基本功能。当服务器启动并执行Hypervisor时,它会给每一台虚拟机分配适量的内存、CPU、网络和磁盘,并加载所有虚拟机的客户操作系统。
这里的问题是,hypervisor本身也需要一定的资源来完成自己的工作。它们会占用CPU、I/O和内存等。hypervisor管理的主机越多,占用的资源就越多。在某个点上,这些额外的开销就会变成继续切分物理机的限制。在实际中,这意味着当你把物理机切分得越来越小时,能够得到的收益也就越有限,因为hypervisor占用了很多资源。
这些工具能够帮助你在本地机器上轻松地创建出类生产环境。你可以同时创建多个VM,通过关掉其中的几台来测试故障模式,并且可以把本地目录映射到虚拟机中,这样就可以在修改完代码之后立即看到效果
在Linux上,进程必须由用户来运行,并且根据权限的不同拥有不同的能力。进程可以创建其他进程。举个例子,如果我在终端启动了一个进程,你可以认为它是终端程序的子进程。Linux内核的任务就是维护这个进程树。
Linux容器扩展了这个想法。每个容器就是整个系统进程树的一棵子树。
一个运行LXC的主机,如果仔细看你会发现一些不同之处。首先,不需要hypervisor;其次,尽管每个容器可以运行不同的操作系统发行版,但必须共享相同的内核(因为进程树存在于内核中)。这意味着,我们的主机操作系统可以运行Ubuntu,而在容器中可以运行CentOS,只要它们的内核相同即可。
对于一台虚拟机来说,花几分钟时间来启动是很正常的,但是Linux容器通常只要几秒钟就能完成启动。
有许多文档和已知的方法介绍了某些容器中的进程,有可能会跳出该容器与其他容器中的进程,或者与底层主机发生干扰。这些问题有些是故意这样设计的,有些是bug
如果你不信任你的代码,那么就别指望它能够在容器中安全地运行。如果你想要的是那种隔离,那么需要考虑使用虚拟机
CoreOS是一个专门为Docker设计的操作系统。它是一个经过裁剪的Linux OS,仅提供了有限的功能以保证Docker的运行。这意味着,它比其他操作系统消耗的资源更少,从而可以把更多的资源留给容器。它甚至没有类似debs或RPM这样的包管理器,所有的软件都被装在一个独立的Docker应用程序中,并仅在各自的容器中运行。
调度层的一个关键需求是,当你向其请求一个容器时会帮你找到相应的容器并运行它。在这个领域,Google最近的开源工具Kubernetes和CoreOS集群技术能够提供一定的帮助,而且似乎每个月都有新的竞争者出现。另一个基于Docker的有趣的工具是Deis,它试图在Docker之上,提供一个类似于Heroku那样的PaaS。
6.12 一个部署接口
从本地开发测试到生产环境部署。这些不同环境的部署机制应该尽量相似,我可不想因为部署流程不一致,导致一些只能在生产环境才能发现的问题
参数化的命令行调用是触发任何部署的最合理的方式
能够在不同环境中设置不同凭证(credential)的能力很关键。敏感环境的凭证信息会被存储在不同的代码库中,这些代码库只有少数人可以访问。
6.13 小结
首先,专注于保持服务能够独立于其他服务进行部署的能力,无论采用什么技术,请确保它能够提供这个能力。我倾向于一个服务一个代码库,对于每个微服务一个CI这件事情,我不仅仅是倾向,并且非常坚持,因为只有这样才能实现独立部署。
最后,如果你想要深入了解这些话题,我强烈推荐你读一读Jez Humble和David Farley的《持续交付》,这本书对流水线设计和构建物管理有更深入的讨论。
第7章 测试
7.1 测试类型
作为一名顾问,我喜欢使用形式各异的象限来对世界进行分类
放弃大规模的手工测试,尽可能多地使用自动化是近年来业界的一种趋势,对此我深表赞同。如果当前你正在使用大量的手工测试,我建议在深入微服务的道路之前,先解决这个问题,否则很难获得微服务架构带来的好处,因为你无法快速有效地验证软件。
7.2 测试范围
只测试一行代码是单元测试吗?我会说是。那测试多个函数或者多个类仍然是单元测试吗?我会说不是,不过很多人并不同意!
对于用户界面测试,接下来我们改称它为端到端测试
单元测试通常只测试一个函数和方法调用。
在使用这个金字塔时,应该了解到越靠近金字塔的顶端,测试覆盖的范围越大,同时我们对被测试后的功能也越有信心。而缺点是,因为需要更长的时间运行测试,所以反馈周期会变长。并且当测试失败时,比较难定位是哪个功能被破坏。而越靠近金字塔的底部,一般来说测试会越快,所以反馈周期也会变短,测试失败后更容易定位被破坏的功能,持续集成的构建时间也很短。
既然所有的测试都有优缺点,那每种类型需要占多大的比例呢?一个好的经验法则是,顺着金字塔向下,下面一层的测试数量要比上面一层多一个数量级。如果当前的权衡确实给你带来了问题,那可以尝试调整不同类型自动化测试的比例,这是非常重要的!
一种常见的测试反模式,通常被称为测试甜筒或倒金字塔。在这种反模式中,有一些甚至没有小范围的测试,只有大范围的测试。这些项目的测试运行起来往往极度缓慢,反馈周期很长。如果把这些缓慢的测试作为持续集成的一部分,那就很难做到多次构建。而长时间的构建也意味着当提交有错误时,需要很长一段时间才能发现这个问题。
7.3 实现服务测试
打桩
打桩,是指为被测服务的请求创建一些有着预设响应的打桩服务。比如我可能会设置积分账户,当有请求询问客户123的余额时,它应该返回15000。这时候的测试不关心这个打桩服务被访问了0次、1次还是100次。
与打桩相比,mock还会进一步验证请求本身是否被正确调用。如果与期望请求不匹配,测试便会失败。这种方式的实现,需要我们创建更智能的模拟合作者,但过度使用mock会让测试变得脆弱。相比之下,前面提到的打桩并不在乎请求发生了0次、1次还是很多次。
Martin Fowler把包括打桩和mock在内的所有这些术语统称为测试替身
被打桩的服务之所以“写死”,是因为要隔离无关项。你既然测试的是A函数,就不应该让B参与进来,不然说不清是谁的错,只会让测试复杂化。而且用桩函数代替B,还可以在B未完成时单独测试A,不必等到B、C...都完成在做测试,这样往往会复杂化。
可以把Mountebank看作一个通过HTTP可编程的小应用软件。虽然它是用Node.js编写的,但对调用它的服务来说这完全是透明的。当启动后,你可以发送命令告诉它需要打桩什么端口、使用哪种协议(目前支持TCP、HTTP和HTTPS,未来会支持更多)以及当收到请求时该响应什么内容。当你想把它当mock来使用时,它还支持对预期行为的设置。你可以在Montebank的一个实例上,很方便地添加或删除打桩接口,这样就可以使用一个实例来打桩多个下游的合作服务。
7.4 微妙的端到端测试
运行端到端测试需要部署多个服务。显然,这种测试可以覆盖更大的范围,也让我们对系统的正常工作更有信心。另一方面,这种测试运行起来比较慢,定位失败也更加困难
如果说客户服务的测试需要部署多个服务,然后运行端到端测试来覆盖,那么其他服务的端到端测试该怎么办?如果它们也测试同样的功能,就会发现这些测试有很多的重叠,而且需要在运行测试前花费大量的成本来重复部署这些服务
7.6 脆弱的测试
包含在测试中的服务数量越多,测试就会越脆弱,不确定性也就越强。
当发现脆弱的测试时,我们应该竭尽全力去解决这个问题。否则,人们就会开始对测试套件失去信心,因为它们“总是这样失败”。一个包含脆弱测试的测试套件往往会成为Diane Vaughn所说的异常正常化(the normalization of deviance)的受害者,也就是说,随着时间的推移,我们对事情出错变得习以为常,并开始接受它们是正常的。因为人类有这种倾向,所以在开始接受失败测试是正常的之前,应该尽快找到这些脆弱的测试并消除它们。
发现脆弱的测试时应该立刻记录下来,当不能立即修复时,需要把它们从测试套件中移除,然后就可以不受打扰地安心修复它们。修复时,首先看看能不能通过重写来避免被测代码运行在多个线程中,再看看是否能让运行的环境更稳定。更好的方法是,看看能否用不易出现问题的小范围测试取代脆弱的端到端测试。有时候,改变被测软件本身以使之更容易测试也是一个正确的方向
我曾经见过很多反模式。一种情况是,这些测试对所有人开放,所有团队成员都可以在无须对测试套件质量有任何理解的情况下随意添加测试。这往往会导致测试用例爆炸,有时甚至会导致我们前面谈到的测试甜筒。我还曾经看到过这样的情况,因为测试没有真正的拥有者,所以它们的结果会被忽略。当测试失败后,每个人都认为是别人的问题,大家根本不在乎测试是否通过。
开发软件的人渐渐远离测试代码,周期时间(cycle time)会变长,因为服务的拥有者实现功能需要等待测试团队来写端到端测试。因为这些测试由别的团队编写,实现服务的团队很少参与,所以很难了解如何运行和修复这些测试。很不幸,这是一个非常常见的组织模式,只要团队没有在第一时间测试自己所写的代码,就会出现很大的问题。
我发现最好的平衡是共享端到端测试套件的代码权,但同时对测试套件联合负责。团队可以随意提交测试到这个套件,但实现服务的团队必须全都负责维护套件的健康
实际上,我很少看到团队精细地管理端到端测试套件、减少重复覆盖的测试或花足够的时间让它们变快。
删除测试往往令人担忧,我怀疑这与想要移除机场的某些安保措施有共通点。无论安保措施多么无效,当你想要移除它们时,人们都会下意识地认为这是无视人们的安全,或想要帮助恐怖分子。很难在增加的价值和承受的负担之间寻求平衡。这是一个困难的风险/回报权衡。当你删除一个测试时,会有人感谢你吗?也许吧。不过,如果因为你删除的测试而漏掉一个缺陷,你肯定会被指责。
端到端测试失败后禁止提交代码,但考虑到测试套件的运行时间过长,这个要求通常是不切实际的。试想一下这样的命令:“你们30个开发人员在这个耗时7小时的构建修复之前不准提交代码!
保障频繁发布软件的关键是基于这样的一个想法:尽可能频繁地发布小范围的改变。
7.7 测试场景,而不是故事
解决这个问题的最佳方法是,把测试整个系统的重心放到少量核心的场景上来。把任何在这些核心场景之外的功能放在相互隔离的服务测试中覆盖。团队之间需要就这些核心场景达成一致,并共同拥有。对于音乐商店来说,我们可能会专注于像购买CD、退货或创建一个客户等高价值的交互,它们的数量应该很少。
“少量”的意思是即使对于一个复杂系统来说,也应该是非常低的两位数
7.8 拯救我们的消费者驱动的测试
CDC(Consumer-Driven Contract,消费者驱动的契约)
Pac是一个消费者驱动的测试工具,最初是在开发RealEstate.com.au的过程中创建的,现在已经开源,功能大部分是由Beth Skurrie组织开发的。该工具最初是使用Ruby语言,现在支持包括JVM和.NET的版本
7.10 部署后再测试
不要相信用户的输入
但如果我们的模型并不完美,那么系统在面对愤怒的使用者时就会出现问题。缺陷会溜进生产环境,新的失效模式会出现,用户也会以我们意想不到的方式来使用系统。
冒烟测试
冒烟测试是自由测试的一种。冒烟测试(smoketest)在测试中发现问题,找到了一个Bug,然后开发人员会来修复这个Bug。这时想知道这次修复是否真的解决了程序的Bug,或者是否会对其它模块造成影响,就需要针对此问题进行专门测试,这个过程就被称为SmokeTest。在很多情况下,做SmokeTest是开发人员在试图解决一个问题的时候,造成了其它功能模块一系列的连锁反应,原因可能是只集中考虑了一开始的那个问题,而忽略其它的问题,这就可能引起了新的Bug。SmokeTest优点是节省测试时间,防止build失败。缺点是覆盖率还是比较低。
-
蓝绿部署是不停老版本,部署新版本然后进行测试,确认OK,将流量切到新版本,然后老版本同时也升级到新版本。
-
灰度是选择部分部署新版本,将部分流量引入到新版本,新老版本同时提供服务。等待灰度的版本OK,可全量覆盖老版本。
灰度是不同版本共存,蓝绿是新旧版本切换
蓝/绿部署
零宕机部署
保持旧版本运行,除了给予我们在切换生产流量前可以测试服务这个好处外,还可以大幅度地减少发布软件所需要的停机时间。使用某些生产流量重定向的机制时,我们甚至可以做到在客户无感知的情况下进行版本切换,达到零宕机部署。
就是灰度发布
金丝雀发布
7.11 跨功能的测试
非功能性需求,是对系统展现的一些特性的一个总括的术语,这些特性不能像普通的特性那样简单实现。它包括以下方面,比如一个网页可接受的延迟时间,系统能够支持的用户数量,用户界面如何让残疾人也可以访问,或者如何保障客户数据的安全。
跨功能需求
考虑CFR时常太迟了。我强烈建议尽早去看CFR,并定期审查。
当有多个同步的调用链时,链的任何部分变得缓慢,整个链都会受影响,最终会对整体速度有明显的影响。这使得用一些方法对微服务系统进行性能测试,比对单块系统更重要
通常性能测试会一直拖延,如果不是直到上线都没有发生的话,也通常只在上线前才会发生!不要掉入这个拖延的陷阱。
越长时间没有运行性能测试,就越难追踪最初引起性能问题的原因。性能问题很难解决,因此,如果新引入的问题可以通过查看少量的提交来发现,我们的生活将会更加轻松。
测试运行完后一定要确保看结果!我一直感到很惊讶,遇到的很多团队花费很大工作量实现性能测试,但在运行它们后却从不查看结果。这个原因通常是,人们不知道一个好的结果应该是什么样的。性能测试需要有目标。有了目标以后,可以基于运行结果让构建变红或变绿,变红(失败)的构建是需要行动的一个清晰信号。
7.12 小结
契约测试 ,又称之为 消费者驱动的契约测试(Consumer-Driven Contracts,简称CDC),根据 消费者驱动契约 ,我们可以将服务分为消费者端和生产者端,而消费者驱动的契约测试的核心思想在于是从消费者业务实现的角度出发,由消费者自己会定义需要的数据格式以及交互细节,并驱动生成一份契约文件。然后生产者根据契约文件来实现自己的逻辑,并在持续集成环境中持续验证。
消费者驱动的契约测试
第8章 监控
监控小的服务,然后聚合起来看整体。
8.2 单一服务,多个服务器
grep "Error" app.log
8.4 日志,日志,更多的日志
日志监控神器
Kibana是一个基于ElasticSearch查看日志的系统,如图8-4所示。你可以使用查询语法来搜索日志,它允许在查询时指定时间和日期范围,或使用正则表达式来查找匹配的字符串。Kibana甚至可以把你发给它的日志生成图表,只需看一眼就能知道已经发生了多少错误。
8.5 多个服务的指标跟踪
要想知道什么时候该紧张,什么时候该放松,秘诀是收集系统指标足够长的时间,直到有清晰的模式浮现。
在过去,当我们还在使用物理主机时,通常一年才会考虑一次这个问题。在供应商提供按需计算的IaaS(Infrastructure as a Service,基础设施即服务)的新时代,我们可以在几分钟内(如果不是秒级的话)实现扩容和缩容
8.6 服务指标
我强烈建议你公开自己服务的基本指标。作为Web服务,最低限度应该暴露如响应时间和错误率这样的一些指标。如果你的服务器前面没有一个Web服务器来帮忙做的话,这一点就更重要了。但是你真的应该做得更多。例如,账户服务会想要暴露客户查看过往订单的次数,而网络商店可能希望知道过去的一天赚了多少钱。
有一句老话,80%的软件功能从未使用过。我无法评论这个数字是否准确,但是作为一个已在软件行业工作近20年的程序员,我知道自己花了很多时间在一些从未被真正使用的功能上。如果能知道这些未被使用的功能是什么,不是很好的事情吗?
所以我倾向于暴露一切数据,然后依靠指标系统对它们进行处理。
8.7 综合监控
我们可以通过定义正常的CPU级别,或者可接受的响应时间,判断一个服务是否健康。如果我们的监控系统监测到实际值超出这些安全水平,就可以触发警告。类似像Nagios这样的工具,完全有能力做这个。
我们创建的这个假事件就是一个合成事务的例子。使用此合成事务来确保系统行为在语义上的正确性,这也是这种技术通常被称为语义监控的原因。
同样,我们必须确保不会触发意料之外的副作用。一个朋友告诉我,有一个电子商务公司不小心在其订单的生产系统上跑测试,直到大量的洗衣机送达总部,它才意识到这个错误。
8.8 关联标识
我们如何才能重建请求流,以重现和解决这个问题呢?在这种情况下,一个非常有用的方法是使用关联标识(ID)。在触发第一个调用时,生成一个GUID。然后把它传递给所有的后续调用,如图8-5所示。类似日志级别和日期,你也可以把关联标识以结构化的方式写入日志。使用合适的日志聚合工具,你能够对事件在系统中触发的所有调用进行跟踪
Zipkin可以提供非常详细的服务间调用的追踪信息,还有一个界面帮助显示数据
8.9 级联
级联故障特别危险。想象这样一个情况,我们的音乐商店网站和产品目录服务之间的网络连接瘫痪了,服务本身是健康的,但它们之间无法交互。如果只查看某个服务的健康状态,我们不会知道已经出问题了。
因此,监控系统之间的集成点非常关键。每个服务的实例都应该追踪和显示其下游服务的健康状态,从数据库到其他合作服务。你也应该将这些信息汇总,以得到一个整合的画面。你会想了解下游服务调用的响应时间,并检测是否有错误。
8.10 标准化
你应该尝试以标准格式的方式记录日志。你一定想把所有的指标放在一个地方,你可能需要为度量提供一个标准名称的列表;如果一个服务指标叫作ResponseTime,另一个叫作RspTimeSecs,而它们的意思是一样的,这会非常令人讨厌
8.11 考虑受众
我们在做容量规划的时候可能才会对其感兴趣。同样,你的老板可能想立即知道,上次发布后收入下降了25%,但可能不需要知道,“Justin Bieber”搜索在最近一小时上涨了5%
8.13 小结
如果可能的话,以标准的格式将日志记录到一个标准的位置。如果每个服务各自使用不同的方式,聚合会非常痛苦!
第9章 监控
9.1 身份验证和授权
当谈到与我们系统交互的人和事时,身份验证和授权是核心概念。
在分布式系统这个领域,我们需要考虑更高级的方案。我们不希望每个人使用不同的用户名和密码来登录不同的系统。我们的目的是要有一个单一的标识且只需进行一次验证
身份验证和授权的一种常用方法是,使用某种形式的SSO(Single Sign-On,单点登录)解决方案。
当主体试图访问一个资源(比如基于Web的接口)时,他会被定向到一个身份提供者那里进行身份验证。这个身份提供者会要求他提供用户名和密码,或使用更先进的双重身份验证。一旦身份提供者确认主体已通过身份验证,它会发消息给服务提供者,让服务提供者来决定是否允许他访问资源。
OpenID Connect 是一套基于 OAuth 2.0 协议的轻量认证级规范,提供通过 API 进行身份交互的框架。较 OAuth 而言, OpenID Connect 方式除了认证请求之外,还标明请求的用户身份。
OpenID Connect已经成为了OAuth 2.0具体实现中的一个标准
然而,我们仍然需要解决下游服务如何接受主体信息的问题,例如用户名和角色。如果你使用HTTP,可以把这些信息放到HTTP头上。在这方面,Shibboleth这样的工具可以帮助你。我见过人们把它和Apache一起使用,这种方式能够很好地处理与基于SAML的身份提供者的集成。
如果选择使用网关路由,请确保你的开发人员不需要太多的工作,就可以启动一个网关及其背后的服务。
这种方法的最后一个问题是,它会带给你一种虚假的安全感。我喜欢深度防御的理念,从网络边界,到子网,到防火墙,到主机,到操作系统,再到底层硬件。你需要在所有这些方面都实现安全措施的能力,我们将很快提到其中的一些。我见过有些人把所有的鸡蛋都放在一个篮子里,依靠网关来处理每一步的安全措施。我们都知道当这个点发生故障后,会发生什么……
相反,你应该倾向于使用粗粒度的角色,围绕组织的工作方式建模
9.2 服务间的身份验证和授权
如果一个攻击者入侵你的网络,你将对典型的中间人攻击基本没有任何防备。如果攻击者决定拦截并读取你正在发送的数据,在你不知情时更改数据,甚至在某些情况下假装是你正在通信的对象,你将不得而知
HTTP基本身份验证,允许客户端在标准的HTTP头中发送用户名和密码。服务端可以验证这些信息,并确认客户端是否有权访问服务。这样做的好处在于,这是一种非常容易理解且得到广泛支持的协议。问题在于,通过HTTP有很高的风险,因为用户名和密码并没有以安全的方式发送。任何中间方都可以看到HTTP头的信息并读取里面的数据。因此,HTTP基本身份验证通常应该通过HTTPS进行通信
SSL之上的流量不能被反向代理服务器(比如Varnish或Squid)所缓存,这是使用HTTPS的另一个缺点。这意味着,如果你需要缓存信息,就不得不在服务端或客户端内部实现。你可以在负载均衡中把Https的请求转成Http的请求,然后在负载均衡之后就可以使用缓存了
考虑每个微服务都要有自己的一组凭证。如果凭证被泄露,你只需撤销有限的受影响的凭证即可,这使得撤销/更改访问更简单
确认客户端身份的另一种方法是,使用TLS(Transport Layer Security,安全传输层协议), TLS是SSL在客户端证书方面的继任者。在这里,每个客户端都安装了一个X.509证书,用于客户端和服务器端之间建立通信链路。服务器可以验证客户端证书的真实性,为客户端的有效性提供强有力的保证。
因此,你应该在通过互联网发送非常重要的数据时,才使用安全通信
使用HMAC,请求主体和私有密钥一起被哈希处理,生成的哈希值随请求一起发送。然后,服务器使用请求主体和自己的私钥副本重建哈希值。如果匹配,它便接受请求。这样做的好处是,如果一个中间人更改了请求,那么哈希值会不匹配,服务器便知道该请求已被篡改过。并且,私钥永远不会随请求发送,因此不存在传输中被泄露的问题。额外的好处是,这个通信更容易被缓存,而且生成哈希的开销要低于处理HTTPS通信的开销
像Twitter、谷歌、Flickr和AWS这样的服务商,提供的所有公共API都使用API密钥。API密钥允许服务识别出是谁在进行调用,然后对他们能做的进行限制。限制通常不仅限于特定资源的访问,还可以扩展到类似于针对特定的调用者限速,以保护其他人服务调用的质量等。
其受欢迎的原因一部分源于这样一个事实,API密钥重点关注的是对程序来说的易用性
作为一个用户,当我登录到在线购物系统时,可以查看我的账户详情。但如果我使用登录后的凭证,欺骗在线购物用户界面去请求别人的信息,那该怎么办
9.3 静态数据的安全
在许多有名的安全漏洞中,都发生了静态数据被攻击者获取的情况,且其中的内容对攻击者来说是可读的。这要么是因为数据以未加密的形式存储,要么是因为保护数据的机制有根本性的缺陷。
使用那些加密算法!并且订阅选择的算法的邮件列表/公告列表,以确保你知道他们新发现的漏洞,这样就可以给算法打补丁或更新了。
实现得不好的加密比没有加密更糟糕,因为虚假的安全感会让你的视线从球上面移开(双关语)
再说一次,加密很复杂。避免实现自己的方案,花些时间在已有的方案研究上!
通过把系统划分为更细粒度的服务,你可能发现加密整个数据存储是可行的,但即使可行也不要这么做。限制加密到一组指定的表是明智的做法。
第一次看到数据的时候就对它加密。只在需要时进行解密,并确保解密后的数据不会存储在任何地方。
备份是有好处的。我们想要备份重要的数据,那些我们非常担心的需要加密的数据,几乎也自然重要到需要备份!所以它看起来像是显而易见的观点,但是我们需要确保备份也被加密。
9.4 深度防御
日志也会泄密
日志可以让你事后看看是否有不好的事情发生过。但是请注意,我们必须小心那些存储在日志里的信息!敏感信息需要被剔除,以确保没有泄露重要的数据到日志里,如果泄露的话,最终可能会成为攻击者的重要目标。
AWS提供自动创建VPN(Virtual Private Cloud,虚拟私有云)的能力,它允许主机处在不同的子网中。然后你可以通过定义对等互连规则(peering rules),指定哪个VPC可以跟对方通信,甚至可以通过网关把流量路由到代理中,实际上,它提供了多个网络范围,在其中可以实施额外的安全措施。
在这里,基本的建议能让你走得很远。给操作系统的用户尽量少的权限,开始时也许只能运行服务,以确保即使这种账户被盗,造成的伤害也最小。
红帽的Spacewalk
我经常看到很多重要的软件运行在未安装补丁的、陈旧的操作系统上
9.5 一个示例
对于那些处理最敏感信息的,或暴露最有价值的功能的部分,我们可以采用最严格的安全措施。但对系统的其他部分,我们可以采用宽松一些的安全措施。
对于浏览器,我们会为无需安全保护的内容使用标准HTTP,以便其能被缓存。对于有安全需要的、登录后才可访问的页面,所有的内容都通过HTTPS传输,这样,如果我们的客户使用像公共WiFi那样的网络,能够给他们提供额外的保护。
但是我们很担心客户的数据。在这里,我们决定加密客户服务中的数据,并需要在读取时解密。如果攻击者真的潜入我们的网络,他们仍然可以发送请求给客户服务的API,但当前的实现并不允许批量检索客户数据。如果这个情况真的发生,我们可能需要考虑使用客户端证书来保护这些信息。即使攻击者攻破数据库所在的机器,下载了全部内容,他们也将需要访问用于加密和解密数据的密钥才能使用这些数据
9.7 人的因素
当有人离开组织时,你如何撤销访问凭证?你如何保护自己免受社会工程学的攻击?作为一个好的思维锻炼,你可以考虑一个心怀不满的前雇员,如果他想的话,可能会如何损害你的系统。
9.8 黄金法则
如果你只能带走本章的一句话,那便是:不要实现自己的加密算法。不要发明自己的安全协议。除非你是一个有多年经验的密码专家,如果你尝试发明自己的编码或精密的加密算法,你会出错。即使你是一个密码专家,仍然可能会出错。
重新发明轮子在很多情况下通常只是浪费时间,但在安全领域,它会带来直接的危害。
9.9 内建安全
ZAP(Zed Attack Proxy)就是一个很好的例子。它由OWASP出品,尝试重现对网站的恶意攻击。
第10章 康威定律和系统设计
梅尔 ·康威于1968年4月在Datamation杂志上发表了一篇名为“How Do Committees Invent”的论文,文中指出:任何组织在设计一套系统(广义概念上的系统)时,所交付的设计方案在结构上都与该组织的沟通结构保持一致。这句话被称为康威定律
埃里克 · S.雷蒙德在《新黑客字典》中总结这一现象时指出:“如果你有四个小组开发一个编译器,那你会得到一个四步编译器。”
10.1 证据
从统计数据可以看出,与组织结构相关联的指标和软件质量的相关度最高
10.2 Netflix和Amazon
Amazon也相信,小团队会比大团队的工作更有效。于是产生了著名的“两个比萨团队”,即没有一个团队应该大到两个比萨不够吃
Netflix从这个例子中学到了很多,因此从一开始,它就确保其本身是由多个小而独立的团队组成,以保证他们创建的服务也能独立于彼此。这确保了系统的架构可以快速地优化。实际上,Netflix为了想要的系统架构,才设计了这样的组织结构
10.4 适应沟通途径
我们希望通过服务拆分,使得服务内变化的频度要远远高于服务间变化的频度
当协调变化的成本增加后,有一件事情会发生:人们要么想方设法降低协调/沟通成本,要么停止更改。而后者正是导致我们最终产生庞大的、难以维护的代码库的原因
一般来说,你应该分配单个服务的所有权给可以保持低成本变化的团队
一个拥有许多服务的单个团队对其管理的服务会倾向于更紧密地集成,而这种方式在分布式组织中是很难维护的
10.5 服务所有权
所有权程度的增加会提高自治和交付速度
当然我很喜欢这种模式。它把决定权交给最合适的人,赋予团队更多的权力和自治,也使其对工作更负责。我见过太多太多的开发人员,把系统移交给测试或部署阶段后,就认为他们的工作已经完成了。
10.6 共享服务的原因
特性团队(即基于特性开发的团队)的想法,是一个小团队负责开发一系列特性需要的所有功能,即使这些功能需要跨越组件(甚至服务)的边界。特性团队的目标很合理。这种结构促使团队保持关注在最终的结果上,并确保工作是集成起来的,避免了跨多个不同的团队试图协调变化的挑战。
特性团队是对传统的IT组织中,团队结构围绕技术边界进行组织的一种修正。例如,你可能有一个团队专门负责用户界面,另一个团队负责应用程序逻辑,第三个团队负责处理数据库。这种环境下,特性团队迈出了一大步,它跨越所有层提供完整的功能
不幸的是,采用这种模式后我很少看到守护者,这会导致我们前面讨论的种种问题
让我们再考虑一下什么是微服务:服务会根据业务领域,而不是技术进行建模。如果负责某个微服务的团队与业务领域相匹配,则它更容易保持对客户的关注,也更容易进行以特性为导向的开发,因为它对服务相关的所有技术有一个全面的了解并且拥有所有权
如前文所述,标准化会导致团队降低采取正确的解决方案来解决问题的能力,并可能会降低效率
另一个选择是,把产品目录拆分成一般音乐目录和铃声目录两个服务。如果支持铃声的工作量非常小,而我们未来在这一领域工作的可能性也很低,这个选择可能是不成熟的。另一方面,如果铃声相关的功能累积有10周的工作量,拆分出服务,并且让移动团队拥有所有权,可能还是有意义的。
10.7 内部开源
标准的开源项目中,一小部分人被认为是核心提交者,他们是代码的守护者。如果你想修改一个开源项目,要么让一个提交者帮你修改,要么你自己修改,然后提交给他们一个pull请求。核心的提交者对代码库负责,他们是代码库的所有者
这需要分离出一组受信任的提交者(核心团队)和不受信任的提交者(团队外提交变更的人)。
好的守护者会花费大量的精力与提交者进行清晰的沟通,并对他们的工作方式进行引导。糟糕的守护者会以此为借口,向别人发号施令,或施加类似宗教战争般固执的技术决策。
当考虑允许不受信赖的提交者提交更改到你的代码库时,你必须做出决定,专门设置一个守护者的开销是否值得:核心团队是否可以把花费在审批更改上的时间,用在更有意义的事情上
服务越不稳定或越不成熟,就越难让核心团队之外的人提交更改。在服务的核心模块到位前,团队可能不知道什么样的代码是好的,因此也很难知道什么是一个好的提交。在这个阶段,服务本身正处于快速变化的状态。
10.9 孤儿服务
微服务的好处之一是,当团队需要更改该服务以添加新的功能但很难修改时,重写这个服务也不会花太长的时间
10.10 案例研究:RealEstate.com.au
一个业务线内,服务间可以不受任何限制地以任何方式来通信,只要团队确定的服务守护者认为合适即可。但是在业务线之间,所有通信都必须是异步批处理,这是非常小的架构团队的几个严格的规则之一
坚持异步批处理,每条业务线在自身的行为和管理上有很大的自由度。它可以随时停止其服务,只要能满足其他业务线的批量集成,以及自己业务干系人的需求,那么没有人会在意
10.11 反向的康威定律
最后,我们意识到,无论系统有什么设计缺陷,我们都不得不通过改变组织结构来推动系统的更改。许多年后,这个过程仍然在进行中!
10.12 人
但如果你从一个单块系统的世界走来,那里的大多数开发人员只需要使用一种语言,并且对运维完全没有意识,那么直接把他们扔到微服务的世界,就像是粗鲁地把他们从单纯的世界中叫醒一样。
尽管这本书主要是关于技术的,但是人的问题也绝不只是一个次要问题;他们是你现在拥有系统的构建者,并将继续构建系统的未来。不考虑当前员工的感受,或不考虑他们现有的能力来提出一个该如何做事的设想,有可能会导致一个糟糕的结果。
关于这个话题,每个组织都有自己的节奏。了解你的员工能够承受的变化,不要逼他们改变太快!
请记住,如果没有把人们拉到一条船上,你想要的任何变化从一开始就注定会失败。
第11章 规模化微服务
11.1 故障无处不在
计划内的停机要比计划外的更容易处理
许多年前,我在谷歌园区待过一段时间,当时看到过一个拥抱故障想法的例子。在山景城一栋建筑的接待区里,放着一些机架很老的机器,好像做展览一样。我注意到两件事情。首先,这些服务器没有放在服务器机箱里,它们只是机架上安插的几个裸主板。不过,更加引起我注意的事情是,硬盘竟然是被尼龙搭扣给扣上的。我问一个谷歌员工为什么要这么做,他说:“哦,硬盘总是坏,我们不想被它们搞砸。这样做的话,只需要把它们拉出来再扔进垃圾桶,然后用尼龙搭扣扣上一个新的。”
如果你知道一个服务器将会发生故障,系统也可以很好地应对,那么又何必在阻止故障上花很多精力呢?为什么不像谷歌那样,使用裸主板和一些便宜的组件(一些尼龙搭扣),而不必过多地担心单节点的弹性?
11.2 多少是太多
有一个自动扩容系统,能够应对负载增加或单节点的故障,这可能是很棒的,但对于一个月只需运行一两次的报告系统就太夸张了,因为这个系统,即使宕机一两天也没什么大不了的。同样,搞清楚如何做蓝/绿部署,使服务在升级时无需停机,对你的在线电子商务系统来说可能会有意义,但对企业内网的知识库来说可能有点过头了。
我期望这个网站,当每秒处理200个并发连接时,90%的响应时间在2秒以内。
当测量可用性时,有些人喜欢查看可接受的停机时间,但这个对调用服务的人又有什么用呢?对于你的服务,我只能选择信赖或者不信赖。测量停机时间,只有从历史报告的角度才有用
11.3 功能降级
对简单的单块应用程序来说,我们不需要做很多决定。系统不是好的,就是坏的。但对于微服务架构,我们需要考虑更多微妙的情况。很多情况下,需要做的往往不是技术决策。从技术方面我们可能知道,当购物车宕掉了有哪些处理方式,但除非理解业务的上下文,否则我们不知道该采取什么行动。比如,也许关闭整个网站,也许仍然允许人们浏览物品目录,也许把用户界面上的购物车控件变成一个可下订单的电话号码。对于每个使用多个微服务的面向用户的界面,或每个依赖多个下游合作者的微服务来说,你都需要问自己:“如果这个微服务宕掉会发生什么?”然后你就知道该做什么了。
11.4 架构性安全措施
通常的代理服务器,只用于代理内部网络对Internet的连接请求,客户机必须指定代理服务器,并将本来要直接发送到Web服务器上的http请求发送到代理服务器中。由于外部网络上的主机并不会配置并使用这个代理服务器,普通代理服务器也被设计为在Internet上搜寻多个不确定的服务器,而不是针对Internet上多个客户机的请求访问某一个固定的服务器,因此普通的Web代理服务器不支持外部对内部网络的访问请求。当一个代理服务器能够代理外部网络上的主机,访问内部网络时,这种代理服务的方式称为反向代理服务。此时代理服务器对外就表现为一个Web服务器,外部网络就可以简单把它当作一个标准的Web服务器而不需要特定的配置。不同之处在于,这个服务器没有保存任何网页的真实数据,所有的静态网页或者CGI程序,都保存在内部的Web服务器上。因此对反向代理服务器的攻击并不会使得网页信息遭到破坏,这样就增强了Web服务器的安全性。
很久以前,老王去饭店吃饭,需要先到饭店,七荤八素点好菜,坐等饭菜上桌,然后大快朵颐,不亦乐乎。
有了第三方订餐外卖平台(代理),老王懒得动身前往饭店,老王打个电话或用APP,先选好某个饭店,再点好菜,外卖小哥会送上门来。
由于某个品牌的饭店口碑特别好,食客络绎不绝涌入,第三方订餐电话也不绝于耳,但是限于饭店接待能力有限,无法提供及时服务,很多食客等得不耐烦了,纷纷铩羽而归,饭店老总看着煮熟的鸭子飞走了,心疼不已。
痛定思痛,老总又成立了几个连锁饭店,形成一个集群,对外提供统一标准的菜品服务,电话订餐电话400-xxx-7777,当食客涌入饭店总台,总台将食客用大巴运到各个连锁店,这样食客既不需要排队,各连锁店都能高速运转起来,一举两得,老总乐开了花,并为此种运作模式起名为“反向代理”(Reverse Proxy)。
反向代理
在计算机世界里,由于单个服务器的处理客户端(用户)请求能力有一个极限,当用户的接入请求蜂拥而入时,会造成服务器忙不过来的局面,可以使用多个服务器来共同分担成千上万的用户请求,这些服务器提供相同的服务,对于用户来说,根本感觉不到任何差别。
反向代理的实现
- 1)需要有一个负载均衡设备来分发用户请求,将用户请求分发到空闲的服务器上
- 2)服务器返回自己的服务到负载均衡设备
- 3)负载均衡将服务器的服务返回用户
以上的潜台词是:用户和负载均衡设备直接通信,也意味着用户做服务器域名解析时,解析得到的IP其实是负载均衡的IP,而不是服务器的IP,这样有一个好处是,当新加入/移走服务器时,仅仅需要修改负载均衡的服务器列表,而不会影响现有的服务。
谈完反向代理服务,再来谈谈终端用户常用的代理服务。
代理
- 1)用户希望代理服务器帮助自己,和要访问服务器通信,为了实现此目标,需要以下工作:
- a) 用户IP报文的目的IP = 代理服务器IP
- b) 用户报文端口号 = 代理服务器监听端口号
- c) HTTP 消息里的URL要提供服务器的链接
- 2)代理服务器可以根据c)里的链接与服务器直接通信
- 3)服务器返回网页
- 4)代理服务器打包步骤3中的网页,返回用户。
代理服务器应用场景
- 场景一
如果不采用代理,用户的IP、端口号直接暴露在Internet(尽管地址转换NAT),外部主机依然可以根据IP、端口号来开采主机安全漏洞,所以在企业网,一般都是采用代理服务器访问互联网。
那有同学会有疑问,那代理服务器就没有安全漏洞吗?
相比千千万万的用户主机,代理服务器数量有限,修补安全漏洞更方便快捷。
- 场景二
在一个家庭局域网,家长觉得外部的世界是洪水猛兽,为了不让小盆友们学坏,决定不让小盆友们访问一些网站,可小盆友们有强烈的逆反心理,侬越是不让我看,我越是想看,于是小盆友们使用了代理服务器,这些代理服务器将禁止访问的网页打包好,然后再转交给小盆友,仅此而已。
当然关键的关键是代理服务器不在禁止名单当中!
如果一个系统宕掉了,你很快就会发现。但当它只是很慢的时候,你需要等待一段时间,然后再放弃。
我们发现了几个问题。程序使用HTTP连接池来处理下游连接。连接池本身的线程,已经设置了当用HTTP调用下游服务时会等待的时间。设置这样的超时本身很好,问题是因为缓慢的下游系统,所有的worker都等了一段时间后再超时。当它们在等待时,更多的请求发送到连接池要求worker线程。因为没有可用的worker,这些请求也被挂起。我们正在使用的连接池,原来确实有一个worker等待的超时设置,不过默认是禁用的!这导致了一个超长的阻塞线程队列。我们的应用程序任何时候通常只有40个并发连接。上述情况造成在五分钟内连接数量达到大约800个,这最终导致系统宕掉
处理系统缓慢要比处理系统快速失败困难得多
即使其他的服务很健康,一个缓慢的服务就可能耗尽所有可用的worker
正确地设置超时,实现舱壁隔离不同的连接池,并实现一个断路器,以便在第一时间避免给一个不健康的系统发送调用
11.5 反脆弱的组织
在《反脆弱》一书中,作者Nassim Taleb认为事物实际上受益于失败和混乱。
实际上Netflix通过引发故障来确保其系统的容错性。
一些公司喜欢组织游戏日,在那天系统会被关掉以模拟故障发生,然后不同团队演练如何应对这种情况。我在谷歌工作期间,在各种不同的系统中都能遇到这种活动,并且我认为经常组织这类演练对于很多公司来说都是有益的
Netflix也采用了更积极的方式,每天都在生产环境中通过编写程序引发故障。
混乱猴子(Chaos Monkey),在一天的特定时段随机停掉服务器或机器。知道这可能会发生在生产环境,意味着开发人员构建系统时不得不为它做好准备。混乱猴子只是Netflix的故障机器人猴子军队(Simian Army)的一部分。混乱大猩猩(Chaos Gorilla)用于随机关闭整个可用区(AWS中对数据中心的叫法),而延迟猴子(Latency Monkey)在系统之间注入网络延迟。Netflix已使用开源代码许可证开源了这些工具。对许多人来说,你的系统是否真的健壮的终极验证是,在你的生产环境上释放自己的猴子军队
它还知道当失败发生后从失败中学习的重要性,并在错误真正发生时采用不指责文化。作为这种学习和演化过程的一部分,开发人员被进一步授权,他们每个人都需要负责管理他的生产服务。
如果等待太长时间来决定调用失败,整个系统会被拖慢。如果超时太短,你会将一个可能还在正常工作的调用错认为是失败的。如果完全没有超,一个宕掉的下游系统可能会让整个系统挂起。
给所有的跨进程调用设置超时,并选择一个默认的超时时间。当超时发生后,记录到日志里看看发生了什么,并相应地调整它们
使用断路器时,当对下游资源的请求发生一定数量的失败后,断路器会打开。接下来,所有的请求在断路器打开的状态下,会快速地失败。一段时间后,客户端发送一些请求查看下游服务是否已经恢复,如果它得到了正常的响应,将重置断路器。
如果我们有这种机制(如家里的断路器),就可以手动使用它们,以使所做的工作更加安全。例如,如果作为日常维护的一部分,我们想要停用一个微服务,可以手动启动依赖它的所有系统的断路器,使它们在这个微服务失效的情况下快速失败。一旦微服务恢复,我们可以重置断路器,让一切都恢复正常。
结合我自己的经历,实际上我错过了使用舱壁的机会。我们应该为每个下游服务的连接使用不同的连接池。这样的话,正如我们在图11-3看到的,如果一个连接池被用尽,其余连接并不受影响。这可以确保,如果下游服务将来运行缓慢,只有那一个连接池会受影响,其他调用仍可以正常进行。
有时拒绝请求是避免重要系统变得不堪重负或成为多个上游服务瓶颈的最佳方法。
一个服务越依赖于另一个,另一个服务的健康将越能影响其正常工作的能力。如果我们使用的集成技术允许下游服务器离线,上游服务便不太可能受到计划内或计划外宕机的影响。
当服务间彼此隔离时,服务的拥有者之间需要更少的协调。团队间的协调越少,这些团队就更自治,这样他们可以更自由地管理和演化服务。
11.6 幂等
有些HTTP动词,例如GET和PUT,在HTTP规范里被定义成幂等的,但要让这成为事实,依赖于你的服务在处理这些调用时是否使用了幂等方式。如果使用了这些动词,但操作不是幂等的,然而调用者认为它们可以安全地重复执行,你可能会让自己陷入困境
11.7 扩展
有时一个大服务器的成本要比两个稍小服务器的成本高,虽然两者联合起来的总性能与大服务器相同。不过,有时我们的软件本身,当有更多额外的可用硬件资源时并不能做得更好。大机器通常给我们更多的CPU内核,但如果写的软件没有充分利用它们也是不够的
通过把这两个功能拆分到单独的服务,减少了关键账户服务上的负载,并且引入一个用以查询的新的账户报表服务(也许使用我们在第4章中描述的一些技术),但作为一个非关键系统,并不需要像核心账户服务那样以富有弹性的方式部署。
弹性扩展的一种方式是,确保不要把所有鸡蛋放在一个篮子里。一个简单的例子是,确保你不要把多个服务放到一台主机上,因为主机的宕机会影响多个服务
如果所有的服务都在不同的主机上,但这些主机实际上都是运行在一台物理机上的虚拟主机呢?如果物理机宕掉,同样也会失去多个服务。一些虚拟化平台能够确保你的主机分布在多个不同的物理机上,以减小发生上述情况的可能性
另一种常见的减少故障的方法是,确保不要让所有的服务都运行在同一个数据中心的同一个机架上,而是分布在多个数据中心。如果你使用基础服务供应商,知道SLA(Service-Level Agreement,服务等级协议)是否提供和具备相应的计划是非常重要的。如果需要确保你的服务在每季度不超过四小时的宕机时间,但是主机供应商只能保证每个季度不超过八小时的宕机时间,你必须改变SAL或选取一个替代解决方案
值得注意的是,供应商给你的SLA保证肯定会减轻他们的责任!如果供应商错失担保目标,给他们的客户也就是你带来大量金钱上的损失,你会发现即使翻遍整个合同,也很难找到可以从他们那里追回任何损失的条款。因此,我强烈建议你,了解供应商如果没有履行义务的影响,并看看你是否需要准备一个B(或C)计划
我的很多客户都将一个灾难恢复托管平台放到一个不同的供应商那里,以确保他们不至于脆弱得因为一家公司出错而受影响
负载均衡器各种各样,从大型昂贵的硬件设备,到像mod proxy这样基于软件的负载均衡器。它们都有一些共同的关键功能。它们都是基于一些算法,将调用分发到一个或多个实例中,当实例不再健康时移除它们,并当它们恢复健康后再添加进来。
使用HTTPS的原因,正如我们在第9章讨论的,是确保请求不容易受到中间人的攻击
我自己倾向于在硬件负载均衡器后使用软件负载均衡器,这样允许团队自由地按需重新配置它们
如果多个微服务实例运行在多台机器上,但只有一台主机在运行数据库实例,那么数据库依然是一个单点故障源。我们很快会讨论应对这个问题的模式。
例如,在半夜你可能不需要很多机器运行电子商务系统,因此可以暂时使用它们来运行生成报告任务的worker。
你的设计应该“考虑10倍容量的增长,但超过100倍容量时就要重写了”
1.7 扩展
当达到特定伸缩阈值时,必须重新设计架构。有人以此为理由,主张从一开始就构建大规模系统。这是很危险的,甚至可能是灾难性的。在开始一个新项目时,我们往往不知道真正想要构建的是什么,也不知道它是否会成功。我们需要快速实验,并以此了解需要构建哪些功能。如果在前期为准备大量的负载而构建系统,将在前期做大量的工作,以准备应对也许永远不会到来的负载,同时耗费了本可以花在更重要的事情上的精力
Eric Ries讲述了一个故事,他花了六个月的时间构建了一个产品,却压根没有人下载。他反思说,他本可以在网页上放一个链接,当有人点击时返回404,以此来检验是否真的有这样的需求。与此同时他可以在海滩上度过六个月,并且这种方式跟花六个月构建产品学到的知识是一样多的
需要更改我们的系统来应对规模化,这不是失败的标志,而是成功的标志
1.8 扩展数据库
更直接地说,重要的是你要区分服务的可用性和数据的持久性这两个概念
服务可以在单个主节点上进行所有的写操作,但是读取被分发到一个或多个只读副本。从主数据库复制到副本,是在写入后的某个时刻完成的,这意味着使用这种技术读取,有时候看到的可能是失效的数据,但是最终能够读取到一致的数据,这样的方式被称为最终一致性。如果你能够处理暂时的不一致,这是一个相当简单和常见的用来扩展系统的方式
分片写操作的复杂性来自于查询处理。查找单个记录是很容易的,因为可以应用哈希函数找到数据应该在哪个实例上,然后从正确的分片获取它。但如果查询跨越了多个节点呢?例如,查找所有年满18岁的顾客。如果你要查询所有的分片,要么需要查询每个分片,然后在内存里进行拼接,要么有一个替代的读数据库包含所有的数据集
最近,越来越多的系统支持在不停机的情况下添加额外的分片,而重新分配数据会放在后台执行;例如,Cassandra在这方面就处理得很好。不过,添加一个分片到现有的集群依然是有风险的,因此你需要确保对它进行了充分的测试。
我经常看到,当人们无法轻松地扩展现有的写容量时,才改变数据库技术
你可能需要看看像Cassandra、Mongo或者Riak这样的数据库系统,它们不同的扩展模型能否给你提供一个长期的解决方案
某些类型的数据库,例如传统的RDBMS,在概念上区分数据库本身和模式(schema)。这意味着,一个正在运行的数据库可以承载多个独立的模式,每个微服务一个。这可以有效地减少需要运行系统的机器的数量,从这一点来说它很有用,不过我们也引入了一个重要的单点故障。如果该数据库的基础设施出现故障,它会影响多个微服务,这可能导致灾难性故障。如果你正以这样的方式配置数据库,请确保慎重考虑了风险,并且确定该数据库本身具有尽可能高的弹性
CQRS最早来自于Betrand Meyer(Eiffel语言之父,开-闭原则OCP提出者)在 Object-Oriented Software Construction 这本书中提到的一种 命令查询分离 (Command Query Separation,CQS) 的概念。其基本思想在于,任何一个对象的方法可以分为两大类:
1、命令(Command):不返回任何结果(void),但会改变对象的状态。
2、查询(Query):返回结果,但是不会改变对象的状态,对系统没有副作用。
根据CQS的思想,任何一个方法都可以拆分为命令和查询两部分
CQRS(Command-Query Responsibility Segregation,命令查询职责分离)模式
11.9 缓存
HTTP在处理大量请求时,伸缩性如此良好的原因就是内置了缓存的概念
客户端缓存可以大大减少网络调用的次数,并且是减少下游服务负载的最快方法之一。但是使用由客户端负责缓存这种方式,如果你想改变缓存的方式,让大批的消费者全都变化是很困难的。让过时的数据失效也比较棘手
一个常见的例子是,反向代理Squid或Varnish,它们可以缓存任何HTTP通信。
请求流程
Etag由服务器端生成,客户端通过If-Match或者说If-None-Match这个条件判断请求来验证资源是否修改。常见的是使用If-None-Match.请求一个文件的流程可能如下:
-
第一次请求
1.客户端发起 HTTP GET 请求一个文件;
2.服务器处理请求,返回文件内容和一堆Header,当然包括Etag(例如"2e681a-6-5d044840")(假设服务器支持Etag生成和已经开启了Etag).状态码200 -
第二次请求
1.客户端发起 HTTP GET 请求一个文件,注意这个时候客户端同时发送一个If-None-Match头,这个头的内容就是第一次请求时服务器返回的Etag:2e681a-6-5d044840
2.服务器判断发送过来的Etag和计算出来的Etag匹配,因此If-None-Match为False,不返回200,返回304,客户端继续使用本地缓存;
流程很简单,问题是,如果服务器又设置了Cache-Control:max-age和Expires呢,怎么办?
答案是同时使用,也就是说在完全匹配If-Modified-Since和If-None-Match即检查完修改时间和Etag之后,服务器才能返回304.(不要陷入到底使用谁的问题怪圈)
实体标签(Entity Tags)或称为Etag
有一种非常强大的请求方式叫作条件GET。当发送一个GET请求时,我们可以指定附加的头告诉服务器,只有满足某些条件时才会返回资源。
假如我们想要获取一个客户的记录,其返回的ETag是o5t6fkd2sa。稍后,也许因为cache-control指令告诉我们这个资源可能已经失效,所以我们想确保得到最新的版本。当发出后续的GET请求,我们可以发送一个If-None-Match:o5t6fkd2sa。这个条件判断请求告诉服务器,如果ETag值不匹配则返回特定URI的资源。如果我们的已经是最新版本,服务器会直接返回响应304(未修改),告诉客户端缓存的已经是最新版本。如果有可用的新版本,我们会得到响应200 OK、更新后的资源以及新的ETag。
后写式缓存
如果你使用后写式(writebehind)缓存,可以先写入本地缓存中,并在之后的某个时刻将缓存中的数据写入下游的、可能更规范化的数据源中。当你有爆发式的写操作,或同样的数据可能会被写入多次时,这是很有用的。后写式缓存是在缓冲可能的批处理写操作时,进一步优化性能的很有用的方法。
对一些系统来说,使用失效但可用的数据,比完全不可用的要好,不过这需要你自己做出判断
我曾经在《卫报》中见过一种技术,随后在其他地方也见过,就是定期去爬(crawl)现有的工作的网站,生成一个可以在意外停机时使用的静态网站。虽然这个爬下来的版本不比工作系统的缓存内容新,但在必要时,它可以确保至少有一个版本的网站可以显示。
在需要时源服务本身会异步地填充缓存。如果缓存请求失败,会触发一个给源服务的事件,提醒它需要重新填充缓存。所以如果整个分片消失了,我们可以在后台重建缓存。可以阻塞请求直到区域被重新填充,但这可能会使缓存本身的争用,从而导致一些问题。更合适的是,如果想优先保持系统的稳定,我们可以让原始请求失败,但要快速地失败。
让请求快速失败,确保不占用资源或增加延迟,我们避免了级联下游服务导致的缓存故障,并给自己一个恢复的机会。
缓存越多,就越难评估任何数据的新鲜程度
即使你可以,还有一个缓存是你无法控制的:用户浏览器中的缓存。这些使用Expires: Never的页面,停留在很多用户的缓存里,永远不会失效,直到缓存已满或者用户手动清理它们。显然,我们无法让上述任何事情发生。我们唯一的选择就是,改变这些网页的URL,以便能够重新获取它们。
可以给刚刚发生改变的信息增加一个fresh字段,一旦有fresh就返回一个新的fresh_id放在url里面
11.10 自动伸缩
众所周知的趋势有可能会触发伸缩的发生。可能系统的负载高峰是从上午9点到下午5点,因此你可以在早上8:45启动额外的实例,然后在下午5:15关掉这些你不再需要的实例,以节省开支。你需要数据来了解负载是如何随着时间的推移而变化的,这些数据统计需要跨好几天甚至是好几周的时间周期。
良好的负载测试套件在这里是必不可少的。你可以使用它们来测试自动伸缩规则。如果没有测试能够重现触发伸缩的不同负载,那么你只能在生产环境上发现规则的错误,但这时的后果不堪设想!
事实上相比响应负载,自动伸缩被更多应用于响应故障。AWS允许你指定这样的规则:“这个组里至少应该有5个实例”,所以如果一个实例宕掉后,一个新的实例会自动启动。当有人忘记关掉这个规则时,就会导致一个有趣的打鼹鼠游戏(whack-a-mole),即当试图停掉一个实例进行维护时,它却自动启动起来了!
一旦你想要为负载伸缩,一定要谨慎不要太仓促缩容。在大多数情况下,手头有多余的计算能力,比没有足够的计算能力要好得多!
11.11 CAP定理
CAP定理
听说过CAP定理,尤其是在讨论各种不同类型的数据存储的优缺点时。其核心是告诉我们,在分布式系统中有三方面需要彼此权衡:一致性(consistency)、可用性(availability)和分区容忍性(partition tolerance)。具体地说,这个定理告诉我们最多只能保证三个中的两个。
- 一致性是当访问多个节点时能得到同样的值。
- 可用性 意味着每个请求都能获得响应。
- 分区容忍性是指集群中的某些节点在无法联系后,集群整体还能继续进行服务的能力。
正如前面提到的,系统放弃一致性以保证分区容忍性和可用性的这种做法,被称为最终一致性;也就是说,我们希望在将来的某个时候,所有节点都能看到更新后的数据,但它不会马上发生,所以我们必须清楚用户将看到失效数据的可能性。
现在在分区情况下,如果数据库节点不能彼此通信,则它们无法协调以保证一致性。由于无法保证一致性,所以我们唯一的选择就是拒绝响应请求。换句话说,我们牺牲了可用性。
在这种模式下,我们的服务必须考虑如何做功能降级,直到分区恢复以及数据库节点之间可以重新同步
CA系统在分布式系统中根本是不存在的
我们要挑选CAP中的两点,对吗?所以,我们有最终一致的AP系统。我们有一致的,但很难实现和扩展的CP系统。为什么没有CA系统呢?嗯,我们应如何牺牲分区容忍性呢?如果系统没有分区容忍性,就不能跨网络运行。换句话说,需要在本地运行一个单独的进程。所以,CA系统在分布式系统中根本是不存在的。
对于库存系统,如果一个记录过时了5分钟,这可接受吗?如果答案是肯定的,那么解决方案可以是一个AP系统。但对于银行客户的余额来说呢?能使用过时的数据吗?如果不了解操作的上下文,我们无法知道正确的做法是什么。了解CAP定理只是让你知道这些权衡的存在,以及需要问什么问题
我们的系统作为一个整体,不需要全部是AP或CP的。目录服务可能是AP的,因为我们不太介意过时的记录。但库存服务可能需要是CP的,因为我们不想卖给客户一些没有的东西,然后不得不道歉。
CAP定理背后有相应的数学证明。尽管在学校尝试过多次,但最终我不得不承认数学规律是无法打破的
让我们重新考虑一下库存系统,它会映射到真实世界的实体物品。我们在系统里记录了专辑的数量,在一天开始时,有100张The Brakes的Give Blood专辑。卖了一张后,剩99张。很简单,对吧?但如果订单在派送的过程中,有人不小心把一张专辑掉到地上并且被踩坏了,现在该怎么办?我们的系统说99张,但货架上是98张。
11.12 服务发现
当处理不同环境中的服务实例时,我见过的一个很好的方式是使用域名模板。例如,我们可以使用一个形如“<服务名>-<环境>.musiccorp.com”的模板,然后基于此模板生成accounts-uat.musiccorp.com或accounts-dev.musiccorp.com这样的域名项。
域名的DNS条目有一个TTL。客户端可以认为在这个时间内该条目是有效的。当我们想要更改域名所指向的主机时,需要更新该条目,但不得不假定客户至少在TTL所指示的时间内持有旧的IP。DNS可以在多个地方缓存条目(甚至JVM也会缓存DNS条目,除非你告诉它不要这么做),它们被缓存的地方越多,条目就越可能会过时。
11.13 动态服务注册
Zookeeper依赖于在集群中运行大量的节点,以提供各种保障。这意味着,你至少应该运行三个Zookeeper节点
人们常说,你不应该实现自己的加密算法库。我想延伸这个说法,你也不应该实现自己的分布式协调系统。使用已有的可工作的选择是非常明智的
和Zookeeper一样,Consul也支持配置管理和服务发现。但它比Zookeeper更进一步,为这些关键使用场景提供了更多的支持。例如,它为服务发现提供一个HTTP接口。
如果你希望当下游服务的位置发生变化时,上游服务能得到提醒,就需要自己构建系统
11.14 文档服务
Swagger让你描述API,产生一个很友好的Web用户界面,使你可以查看文档并通过Web浏览器与API交互。能够直接执行请求是一个非常棒的特性。
第12章 总结
12.1 微服务的原则
但请注意,组合使用这些原则的价值:整体使用的价值要大于部分使用之和。所以,如果决定要舍弃其中一个原则,请确保你明白其带来的损失。
服务还应该隐藏它们的数据库,以避免陷入数据库耦合,这在传统的面向服务的架构中也是最常见的一种耦合类型
你的消费者应该自己决定何时更新,你需要适应他们
如果我们不考虑调用下游可能会失败的事实,系统会遭受灾难性的级联故障,系统也会比以前更加脆弱
12.2 什么时候你不应该使用微服务
你越不了解一个领域,为服务找到合适的限界上下文就越难。
如果你不了解一个单块系统领域的话,在划分服务之前,第一件事情是花一些时间了解系统是做什么的,然后尝试识别出清晰的模块边界
12.3 临别赠言
我会建议你,尽量缩小每个决策的影响范围
不要去想大爆炸式的重写,取而代之的是随着时间的推移,逐步对系统进行一系列更改,这样做可以保持系统的灵活性。
变化是无法避免的,所以,拥抱它吧!
蜂。每个蜂巢有一个蜂后,在一次飞行交配后能保持3~5年的产卵期,每日产卵可达2000个。
O'Reilly封面上的许多动物都濒临灭绝,它们对这个世界都是非常重要的。想要了解更多关于如何帮助它们的信息,请访问animals.oreilly.com