本文基于[凤凰架构](https://icyfenix.cn/)复制缩减网站。
服务结构的演变史
原始分布式时代
???在 20 世纪 70 年代末期到 80 20世纪初,计算机科学刚刚经历了从大型机器到微型机器的转变。计算机逐渐从研究机构和实验室的科研设备转变为商业企业的生产设备,甚至是家庭和个人用户的娱乐设备。 ????当时计算机硬件局促的计算处理能力直接阻碍了单台计算机上信息系统软件的最大规模。为了突破硬件计算能力的限制,高校、研究机构和软硬件制造商开始探索支持同一软件系统运行的可行解决方案。这一阶段是对分布式架构最原始的探索。从结果来看,历史局限性决定了它不可能一蹴而就地解决分布式问题,但仅从过程来看,这一阶段的探索可以说是取得了巨大的成就。研究过程的许多中间成果对当今计算机科学的许多领域产生了深远的影响,直接导致了后续软件架构的演变。譬如它是服务认证和访问控制的基本协议,是分布式服务安全的重要支持,仍用于实现包括 Windows 和 MacOS 登录、认证功能等。
单体系统时代
???顾名思义,它是一个代表所有系统的程序。单体不仅易于开发、测试和部署,而且由于系统中各种功能、模块和方法的调用过程在过程中被调用,因此不会发生
SOA时代
????**服务架构(SOA)**它是一个组件模型,通过定义良好的接口和协议,将应用程序的不同功能单元(称为服务)分开。界面以中立的方式定义,应独立于硬件平台、操作系统和实现服务的编程语言。这使得构建在各种系统中的服务能够统一、通用地交互。 ???为了拆分大型单体系统,独立部署、运行和更新每个子系统,开发人员尝试了多种解决方案。以下三种具有代表性的架构模式如下。
烟囱式架构(Information Silo Architecture) :信息烟囱又称信息孤岛(Information Island),该系统也被称为孤岛信息系统或烟囱信息系统。指与其他相关信息系统完全不相互操作或协调的设计模式。其实这样的系统没有架构设计。然后,在上一节中,企业和部门的例子中,如果两个部门根本没有互动,就没有理由强迫他们在一栋楼里工作;两个不互动的信息系统允许他们使用独立的数据库和服务器进行拆分,唯一的问题也是致命的问题是,企业中是否真的有根本没有互动的部门?对于两个信息系统,即使没有业务关系,系统的人员、组织、权限等主要数据是否完全独立,没有重叠?企业显然不可能看到这样一个独立拆分和老死不相往来的系统。微内核架构(Microkernel Architecture) :微内核架构也被称为插件式架构(Plug-in Architecture)。由于在烟囱架构中,没有业务关系的系统也可能需要共享人员、组织、权限和其他公共主要数据,不妨将这些主要数据与其他公共服务、数据和资源一起,可能被各子系统使用,成为所有业务系统共同依赖的核心(Kernel,也称为 Core System),具体的业务系统使用插件模块(Plug-in Modules)也可以提供可扩展、灵活、自然隔离的功能特性,即微内核架构。 ????这种模式非常适合桌面应用程序 Web 在应用程序中使用。任何计算机系统都是通过各种软件相互配合来实现的。本节列出的不同架构实现的软件可视为整个系统的插件。对于平台应用,如果我们想及时向系统添加新的特性或功能,微内核架构将是一个很好的解决方案。微内核架构也可以嵌入到其他架构模式中,通过插件提供新功能的定制开发能力。如果备实现一个可以支持二次开发的软件系统,微内核也将是一个很好的选择。 ????不过,微内核架构也有它的局限和使用前提,它假设系统中各个插件模块之间是互不认识,不可预知系统将安装哪些模块,因此这些插件可以访问内核中一些公共的资源,但不会直接交互。然而,无论是企业信息系统还是互联网应用程序,这个前提假设在许多场景中都没有建立起来。我们必须找到一种方法,不仅可以拆分独立的系统,还可以使拆分后的子系统顺利地相互呼叫通信。事件驱动架构(Event-Driven Architecture) :为了使子系统相互通信,一个可行的方案是在子系统之间建立一套事件队列管道(Event Queues),来自系统的外部信息将以事件的形式发送到管道中。每个子系统都可以从管道中获取自己感兴趣的事件信息,也可以添加或修改事件的附加信息,甚至可以向管道队列发布一些新的事件。每个消息处理器都是独立的,高度解耦的,但可以通过事件管道与其他处理器(如果有这个消息处理器)互动。
???来到软件架构
- 领导制定技术标准的组织
Open CSA ; - 软件设计有明确的指导原则,如服务包装、自治、松耦合、可重用、可组合、无状态等; SOAP 依靠远程调用协议作为远程调用协议 **SOAP 协议族(WSDL、UDDI 和一大票 WS-*协议)**完成服务的发布、发现和治理;
- 使用一条被称为企业服务总线(Enterprise Service Bus,
ESB )新闻管道实现各子系统之间的通信交互,使各服务室在 ESB 在调度下,不需要相互依赖,但可以相互沟通,这不仅带来了服务松耦合的好处,而且可以进一步实施业务流程(Business Process Management,BPM)提供基础; - 使用服务数据对象(Service Data Object,
SDO )访问和表示数据 - 使用服务组件架构(Service Component Architecture,
SCA )定义服务包装的形式和服务操作容器。
???如果只从技术可行性的角度来判断,在这一整套可以相互精确合作的技术组件的支持下,SOA 分布式环境决了分布式环境中的主要技术问题。 ???SOA 最终目标是总结一套
微服务时代
???微服务是一种通过多个小服务组合构建单个应用程序的架构风格,围绕业务能力而不是特定的技术标准构建。不同的编程语言和数据存储技术可以在不同的过程中运行。服务采用轻量级通信机制和自动化部署机制,实现通信和运维。 ???提出微服务概念后,近十年来没有受到太多追捧。如果只是现有的话 SOA 架构的修复真的很难激发广大技术人员的激情。 ???微服务的真正崛起是 2014 年,相信阅读此文的大多数读者,也是从 Martin Fowler 与 James Lewis 合写的文章《Microservices: A Definition of This New Architectural Term》中首次了解到微服务的。文中列举了微服务的九个核心的业务与技术特征,下面将其一一列出并解读。
围绕业务能力构建(Organized around Business Capability) 。这里再次强调了康威定律的重要性,有怎样结构、规模、能力的团队,就会产生出对应结构、规模、能力的产品。这个结论不是某个团队、某个公司遇到的巧合,而是必然的演化结果。如果本应该归属同一个产品内的功能被划分在不同团队中,必然会产生大量的跨团队沟通协作,跨越团队边界无论在管理、沟通、工作安排上都有更高昂的成本,高效的团队自然会针对其进行改进,当团队、产品磨合调节稳定之后,团队与产品就会拥有一致的结构。分散治理(Decentralized Governance) 。这是要表达“谁家孩子谁来管”的意思,服务对应的开发团队有直接对服务运行质量负责的责任,也应该有着不受外界干预地掌控服务各个方面的权力,譬如选择与其他服务异构的技术来实现自己的服务。这一点在真正实践时多少存有宽松的处理余地,大多数公司都不会在某一个服务使用 Java,另一个用 Python,下一个用 Golang,而是通常会有统一的主流语言,乃至统一的技术栈或专有的技术平台。微服务不提倡也并不反对这种“统一”,只要负责提供和维护基础技术栈的团队,有被各方依赖的觉悟,要有“经常被凌晨 3 点的闹钟吵醒”的心理准备就好。微服务更加强调的是确实有必要技术异构时,应能够有选择“不统一”的权利,譬如不应该强迫 Node.js 去开发报表页面,要做人工智能训练模型时,应该可以选择 Python,等等。通过服务来实现独立自治的组件(Componentization via Services) 。之所以强调通过“服务”(Service)而不是“类库”(Library)来构建组件,是因为类库在编译期静态链接到程序中,通过本地调用来提供功能,而服务是进程外组件,通过远程调用来提供功能。前面的文章里我们已经分析过,尽管远程服务有更高昂的调用成本,但这是为组件带来隔离与自治能力的必要代价。产品化思维(Products not Projects) 。避免把软件研发视作要去完成某种功能,而是视作一种持续改进、提升的过程。譬如,不应该把运维只看作运维团队的事,把开发只看作开发团队的事,团队应该为软件产品的整个生命周期负责,开发者不仅应该知道软件如何开发,还应该知道它如何运作,用户如何反馈,乃至售后支持工作是怎样进行的。注意,这里服务的用户不一定是最终用户,也可能是消费这个服务的另外一个服务。以前在单体架构下,程序的规模决定了无法让全部人员都关注完整的产品,组织中会有开发、运维、支持等细致的分工的成员,各人只关注于自己的一块工作,但在微服务下,要求开发团队中每个人都具有产品化思维,关心整个产品的全部方面是具有可行性的。数据去中心化(Decentralized Data Management) 。微服务明确地提倡数据应该按领域分散管理、更新、维护、存储,在单体服务中,一个系统的各个功能模块通常会使用同一个数据库,诚然中心化的存储天生就更容易避免一致性问题,但是,同一个数据实体在不同服务的视角里,它的抽象形态往往也是不同的。譬如,Bookstore 应用中的书本,在销售领域中关注的是价格,在仓储领域中关注的库存数量,在商品展示领域中关注的是书籍的介绍信息,如果作为中心化的存储,所有领域都必须修改和映射到同一个实体之中,这便使得不同的服务很可能会互相产生影响而丧失掉独立性。尽管在分布式中要处理好一致性的问题也相当困难,很多时候都没法使用传统的事务处理来保证,但是两害相权取其轻,有一些必要的代价仍是值得付出的。强终端弱管道(Smart Endpoint and Dumb Pipe )。弱管道(Dumb Pipe)几乎算是直接指名道姓地反对 SOAP 和 ESB 的那一堆复杂的通信机制。ESB 可以处理消息的编码加工、业务规则转换等;BPM 可以集中编排企业业务服务;SOAP 有几十个 WS-*协议族在处理事务、一致性、认证授权等一系列工作,这些构筑在通信管道上的功能也许对某个系统中的某一部分服务是有必要的,但对于另外更多的服务则是强加进来的负担。如果服务需要上面的额外通信能力,就应该在服务自己的 Endpoint 上解决,而不是在通信管道上一揽子处理。微服务提倡类似于经典 UNIX 过滤器那样简单直接的通信方式,RESTful 风格的通信在微服务中会是更加合适的选择。容错性设计(Design for Failure) 。不再虚幻地追求服务永远稳定,而是接受服务总会出错的现实,要求在微服务的设计中,有自动的机制对其依赖的服务能够进行快速故障检测,在持续出错的时候进行隔离,在服务恢复的时候重新联通。所以“断路器”这类设施,对实际生产环境的微服务来说并不是可选的外围组件,而是一个必须的支撑点,如果没有容错性的设计,系统很容易就会被因为一两个服务的崩溃所带来的雪崩效应淹没。可靠系统完全可能由会出错的服务组成,这是微服务最大的价值所在,也是这部开源文档标题“凤凰架构”的含义。演进式设计(Evolutionary Design) 。容错性设计承认服务会出错,演进式设计则是承认服务会被报废淘汰。一个设计良好的服务,应该是能够报废的,而不是期望得到长存永生。假如系统中出现不可更改、无可替代的服务,这并不能说明这个服务是多么的优秀、多么的重要,反而是一种系统设计上脆弱的表现,微服务所追求的独立、自治,也是反对这种脆弱性的表现。基础设施自动化(Infrastructure Automation) 。基础设施自动化,如 CI/CD 的长足发展,显著减少了构建、发布、运维工作的复杂性。由于微服务下运维的对象比起单体架构要有数量级的增长,使用微服务的团队更加依赖于基础设施的自动化,人工是很难支撑成百上千乃至成千上万级别的服务的。
微服务所带来的自由是一把双刃开锋的宝剑,当软件架构者拿起这把宝剑,一刃指向 SOA 定下的复杂技术标准,将选择的权力夺回的同一时刻,另外一刃也正朝向着自己映出冷冷的寒光。微服务时代中,软件研发本身的复杂度应该说是有所降低。一个简单服务,并不见得就会同时面临分布式中所有的问题,也就没有必要背上 SOA 那百宝袋般沉重的技术包袱。需要解决什么问题,就引入什么工具;团队熟悉什么技术,就使用什么框架。此外,像 Spring Cloud 这样的胶水式的全家桶工具集,通过一致的接口、声明和配置,进一步屏蔽了源自于具体工具、框架的复杂性,降低了在不同工具、框架之间切换的成本,所以,作为一个普通的服务开发者,作为一个“螺丝钉”式的程序员,微服务架构是友善的。可是,微服务对架构者是满满的恶意,对架构能力要求已提升到史无前例的程度,笔者在这部文档的多处反复强调过,技术架构者的第一职责就是做决策权衡,有利有弊才需要决策,有取有舍才需要权衡,如果架构者本身的知识面不足以覆盖所需要决策的内容,不清楚其中利弊,恐怕也就无可避免地陷入选择困难症的困境之中。
后微服务时代
从软件层面独力应对微服务架构问题,发展到软、硬一体,合力应对架构问题的时代,此即为“后微服务时代”。 2017 年是容器生态发展历史中具有里程碑意义的一年。在这一年,各个关键厂家都提出使用Kubernetes ,Kubernetes 登基加冕是容器发展中一个时代的终章,也将是软件架构发展下一个纪元的开端。 Kubernetes 成为容器战争胜利者标志着后微服务时代的开端,但 Kubernetes 仍然没有能够完美解决全部的分布式问题——“不完美”的意思是,仅从功能上看,单纯的 Kubernetes 反而不如之前的 Spring Cloud 方案。这是因为有一些问题处于应用系统与基础设施的边缘,使得完全在基础设施层面中确实很难精细化地处理。 为了解决这一类问题,虚拟化的基础设施很快完成了第二次进化,引入了今天被称为
未来 Kubernetes 将会成为服务器端标准的运行环境,如同现在 Linux 系统;服务网格将会成为微服务之间通信交互的主流模式,把“选择什么通信协议”、“怎样调度流量”、“如何认证授权”之类的技术问题隔离于程序代码之外,取代今天 Spring Cloud 全家桶中大部分组件的功能,微服务只需要考虑业务本身的逻辑,这才是最理想的Smart Endpoints解决方案。
无服务时代
无服务的愿景是让开发者只需要纯粹地关注业务,不需要考虑技术组件,后端的技术组件是现成的,可以直接取用,没有采购、版权和选型的烦恼;不需要考虑如何部署,部署过程完全是托管到云端的,工作由云端自动完成;不需要考虑算力,有整个数据中心支撑,算力可以认为是无限的;也不需要操心运维,维护系统持续平稳运行是云计算服务商的责任而不再是开发者的责任。在 UC Berkeley 的论文中,把无服务架构下开发者不再关心这些技术层面的细节,类比成当年软件开发从汇编语言踏进高级语言的发展过程,开发者可以不去关注寄存器、信号、中断等与机器底层相关的细节,从而令生产力得到极大地解放。 无服务架构的远期前景看起来是很美好的,但笔者自己对无服务中短期内的发展并没有那么乐观。与单体架构、微服务架构不同,无服务架构有一些天生的特点决定了它现在不是,以后如果没有重大变革的话,估计也很难成为一种普适性的架构模式。无服务架构对一些适合的应用确实能够降低开发和运维环节的成本,譬如不需要交互的离线大规模计算,又譬如多数 Web 资讯类网站、小程序、公共 API 服务、移动应用服务端等都契合于无服务架构所擅长的短链接、无状态、适合事件驱动的交互形式;但另一方面,对于那些信息管理系统、网络游戏等应用,又或者说所有具有业务逻辑复杂,依赖服务端状态,响应速度要求较高,需要长链接等这些特征的应用,至少目前是相对并不适合的。这是因为无服务天生“无限算力”的假设决定了它必须要按使用量(函数运算的时间和占用的内存)计费以控制消耗算力的规模,因而函数不会一直以活动状态常驻服务器,请求到了才会开始运行,这导致了函数不便依赖服务端状态,也导致了函数会有冷启动时间,响应的性能不可能太好(目前无服务的冷启动过程大概是在数十到百毫秒级别,对于 Java 这类启动性能差的应用,甚至能到接近秒的级别)。
架构中的微服务
服务发现
所有的远程服务调用都是使用
服务的注册 (Service Registration):当服务启动的时候,它应该通过某些形式(如调用 API、产生事件消息、在 ZooKeeper/Etcd 的指定位置记录、存入数据库,等等)将自己的坐标信息通知到服务注册中心,这个过程可能由应用程序本身来完成,称为自注册模式,譬如 Spring Cloud 的@EnableEurekaClient 注解;也可能有容器编排框架或第三方注册工具来完成,称为第三方注册模式,譬如 Kubernetes 和 Registrator。服务的维护 (Service Maintaining):尽管服务发现框架通常都有提供下线机制,但并没有什么办法保证每次服务都能优雅地下线(Graceful Shutdown)而不是由于宕机、断网等原因突然失联。所以服务发现框架必须要自己去保证所维护的服务列表的正确性,以避免告知消费者服务的坐标后,得到的服务却不能使用的尴尬情况。现在的服务发现框架,往往都能支持多种协议(HTTP、TCP 等)、多种方式(长连接、心跳、探针、进程状态等)去监控服务是否健康存活,将不健康的服务自动从服务注册表中剔除。服务的发现 (Service Discovery):这里的发现是特指狭义上消费者从服务发现框架中,把一个符号(譬如 Eureka 中的 ServiceID、Nacos 中的服务名、或者通用的 FQDN)转换为服务实际坐标的过程,这个过程现在一般是通过 HTTP API 请求或者通过 DNS Lookup 操作来完成,也还有一些相对少用的方式,譬如 Kubernetes 也支持注入环境变量来做服务发现。
以上三点只是列举了服务发现必须提供的功能,从
同时,也请注意到上图中各服务注册中心节点之间的“Replicate”字样,作为用户,我们当然期望服务注册中心一直可用永远健康的同时,也能够在访问每一个节点中都能取到可靠一致的数据,而不是从注册中心拿到的服务地址可能已经下线,这两个需求就构成了 CAP 矛盾,不可能同时满足。以最有代表性的 Netflix Eureka 和 Hashicorp Consul 为例: Eureka 的选择是优先保证高可用性,相对牺牲系统中服务状态的一致性。Eureka 的各个节点间采用异步复制来交换服务注册信息,当有新服务注册进来时,并不需要等待信息在其他节点复制完成,而是马上在该服务发现节点宣告服务可见,只是不保证在其他节点上多长时间后才会可见。同时,当有旧的服务发生变动,譬如下线或者断网,只会由超时机制来控制何时从哪一个服务注册表中移除,变动信息不会实时的同步给所有服务端与客户端。这样的设计使得不论是 Eureka 的服务端还是客户端,都能够持有自己的服务注册表缓存,并以 TTL(Time to Live)机制来进行更新,哪怕服务注册中心完全崩溃,客户端在仍然可以维持最低限度的可用。Eureka 的服务发现模型对节点关系相对固定,服务一般不会频繁上下线的系统是很合适的,以较小的同步代价换取了最高的可用性;Eureka 能够选择这种模型的底气在于万一客户端拿到了已经发生变动的错误地址,也能够通过 Ribbon 和 Hystrix 模块配合来兜底,实现故障转移(Failover)或者快速失败(Failfast)。 Consul 的选择是优先保证高可靠性,相对牺牲系统服务发现的可用性。Consul 采用
服务治理
服务容错
容错性设计不能妥协源于分布式系统的本质是不可靠的,一个大的服务集群中,程序可能崩溃、节点可能宕机、网络可能中断,这些“意外情况”其实全部都在“意料之中”。原本信息系统设计成分布式架构的主要动力之一就是为了提升系统的可用性,最低限度也必须保证将原有系统重构为分布式架构之后,可用性不出现倒退下降才行。如果服务集群中出现任何一点差错都能让系统面临“千里之堤溃于蚁穴”的风险,那分布式恐怕就根本没有机会成为一种可用的系统架构形式。 要落实容错性设计这条原则,除了思想观念上转变过来,正视程序必然是会出错的,对它进行有计划的防御之外,还必须了解一些常用的
故障转移 (Failover):高可用的服务集群中,多数的服务——尤其是那些经常被其他服务所依赖的关键路径上的服务,均会部署多有个副本。这些副本可能部署在不同的节点(避免节点宕机)、不同的网络交换机(避免网络分区)甚至是不同的可用区(避免整个地区发生灾害或电力、骨干网故障)中。故障转移是指如果调用的服务器出现故障,系统不会立即向调用者返回失败结果,而是自动切换到其他服务副本,尝试其他副本能否返回成功调用的结果,从而保证了整体的高可用性。 故障转移的容错策略应该有一定的调用次数限制,譬如允许最多重试三个服务,如果都发生报错,那还是会返回调用失败。原因不仅是因为重试是有执行成本的,更是因为过度的重试反而可能让系统处于更加不利的状况。譬如有以下调用链:Service A → Service B → Service C 。 假设 A 的超时阈值为 100 毫秒,而 B 调用 C 花费 60 毫秒,然后不幸失败了,这时候做故障转移其实已经没有太大意义了,因为即时下一次调用能够返回正确结果,也很可能同样需要耗费 60 毫秒时间,时间总和就已经触及 A 服务的超时阈值,所以在这种情况下故障转移反而对系统是不利的。
快速失败 (Failfast):还有另外一些业务场景是不允许做故障转移的,故障转移策略能够实施的前提是要求服务具备幂等性,对于非幂等的服务,重复调用就可能产生脏数据,引起的麻烦远大于单纯的某次服务调用失败,此时就应该以快速失败作为首选的容错策略。譬如,在支付场景中,需要调用银行的扣款接口,如果该接口返回的结果是网络异常,程序是很难判断到底是扣款指令发送给银行时出现的网络异常,还是银行扣款后返回结果给服务时出现的网络异常的。为了避免重复扣款,此时最恰当可行的方案就是尽快让服务报错,坚决避免重试,尽快抛出异常,由调用者自行处理。安全失败 (Failsafe):在一个调用链路中的服务通常也有主路和旁路之分,并不见得其中每个服务都是不可或缺的,有部分服务失败了也不影响核心业务的正确性。譬如开发基于 Spring 管理的应用程序时,通过扩展点、事件或者 AOP 注入的逻辑往往就属于旁路逻辑,典型的有审计、日志、调试信息,等等。属于旁路逻辑的另一个显著特征是后续处理不会依赖其返回值,或者它的返回值是什么都不会影响后续处理的结果,譬如只是将返回值记录到数据库,并不使用它参与最终结果的运算。对这类逻辑,一种理想的容错策略是即使旁路逻辑调用实际失败了,也当作正确来返回,如果需要返回值的话,系统就自动返回一个符合要求的数据类型的对应零值,然后自动记录一条服务调用出错的日志备查即可,这种策略被称为安全失败。沉默失败 (Failsilent):如果大量的请求需要等到超时(或者长时间处理后)才宣告失败,很容易由于某个远程服务的请求堆积而消耗大量的线程、内存、网络等资源,进而影响到整个系统的稳定。面对这种情况,一种合理的失败策略是当请求失败后,就默认服务提供者一定时间内无法再对外提供服务,不再向它分配请求流量,将错误隔离开来,避免对系统其他部分产生影响,此即为沉默失败策略。故障恢复 (Failback):故障恢复一般不单独存在,而是作为其他容错策略的补充措施,一般在微服务管理框架中,如果设置容错策略为故障恢复的话,通常默认会采用快速失败加上故障恢复的策略组合。它是指当服务调用出错了以后,将该次调用失败的信息存入一个消息队列中,然后由系统自动开始异步重试调用。 故障恢复策略一方面是尽力促使失败的调用最终能够被正常执行,另一方面也可以为服务注册中心和负载均衡器及时提供服务恢复的通知信息。故障恢复显然也是要求服务必须具备幂等性的,由于它的重试是后台异步进行,即使最后调用成功了,原来的请求也早已经响应完毕,所以故障恢复策略一般用于对实时性要求不高的主路逻辑,同时也适合处理那些不需要返回值的旁路逻辑。为了避免在内存中异步调用任务堆积,故障恢复与故障转移一样,应该有最大重试次数的限制。并行调用 (Forking):上面五种以“Fail”开头的策略是针对调用失败时如何进行弥补的,以下这两种策略则是在调用之前就开始考虑如何获得最大的成功概率。并行调用策略很符合人们日常对一些重要环节进行的“双重保险”或者“多重保险”的处理思路,它是指一开始就同时向多个服务副本发起调用,只要有其中任何一个返回成功,那调用便宣告成功,这是一种在关键场景中使用更高的执行成本换取执行时间和成功概率的策略。广播调用 (Broadcast):广播调用与并行调用是相对应的,都是同时发起多个调用,但并行调用是任何一个调用结果返回成功便宣告成功,广播调用则是要求所有的请求全部都成功,这次调用才算是成功,任何一个服务提供者出现异常都算调用失败,广播调用通常会被用于实现“刷新分布式缓存”这类的操作。
容错策略并非计算机科学独有的,在交通、能源、航天等很多领域都有容错性设计,也会使用到上面这些策略,并在自己的行业领域中进行解读与延伸。这里介绍到的容错策略并非全部,只是最常见的几种,笔者将它们各自的优缺点、应用场景总结为表 ,供大家使用时参考:
为了实现各种各样的容错策略,开发人员总结出了一些被实践证明是有效的服务容错设计模式,譬如微服务中常见的
断路器模式
断路器模式是微服务架构中最基础的容错设计模式,以至于像 Hystrix 这种服务治理工具往往被人们忽略了它的服务隔离、请求合并、请求缓存等其他服务治理职能,直接将它称之为微服务断路器或者熔断器。断路器的基本思路是很简单的,就是通过代理(断路器对象)来一对一地(一个远程服务对应一个断路器对象)地接管服务调用者的远程请求。断路器会持续监控并统计服务返回的成功、失败、超时、拒绝等各种结果,当出现故障(失败、超时、拒绝)的次数达到断路器的阈值时,它状态就自动变为“OPEN”,后续此断路器代理的远程访问都将直接返回调用失败,而不会发出真正的远程服务请求。通过断路器对远程服务的熔断,避免因持续的失败或拒绝而消耗资源,因持续的超时而堆积请求,最终的目的就是避免雪崩效应的出现。由此可见,断路器本质是一种快速失败策略的实现方式,它的工作过程可以通过下面图来表示:
从调用序列来看,断路器就是一种有限状态机,断路器模式就是根据自身状态变化自动调整代理请求策略的过程。一般要设置以下三种断路器的状态:
CLOSED :表示断路器关闭,此时的远程请求会真正发送给服务提供者。断路器刚刚建立时默认处于这种状态,此后将持续监视远程请求的数量和执行结果,决定是否要进入 OPEN 状态。OPEN :表示断路器开启,此时不会进行远程请求,直接给服务调用者返回调用失败的信息,以实现快速失败策略。HALF OPEN :这是一种中间状态。断路器必须带有自动的故障恢复能力,当进入 OPEN 状态一段时间以后,将“自动”(一般是由下一次请求而不是计时器触发的,所以这里自动带引号)切换到 HALF OPEN 状态。该状态下,会放行一次远程调用,然后根据这次调用的结果成功与否,转换为 CLOSED 或者 OPEN 状态,以实现断路器的弹性恢复。
这些状态的转换逻辑与条件如图所示:
OPEN 和 CLOSED 状态的含义是十分清晰的,与我们日常生活中电路的断路器并没有什么差别,值得讨论的是这两者的转换条件是什么?最简单直接的方案是只要遇到一次调用失败,那就默认以后所有的调用都会接着失败,断路器直接进入 OPEN 状态,但这样做的效果是很差的,虽然避免了故障扩散和请求堆积,却使得外部看来系统将表现极其不稳定。现实中,比较可行的办法是在以下两个条件同时满足时,断路器状态转变为 OPEN:
- 一段时间(譬如 10 秒以内)内请求数量达到一定阈值(譬如 20 个请求)。这个条件的意思是如果请求本身就很少,那就用不着断路器介入。
- 一段时间(譬如 10 秒以内)内请求的故障率(发生失败、超时、拒绝的统计比例)到达一定阈值(譬如 50%)。这个条件的意思是如果请求本身都能正确返回,也用不着断路器介入。
以上两个条件
舱壁隔离模式
介绍过服务熔断和服务降级,我们再来看看另一个微服务治理中常听见的概念:服务隔离。舱壁隔离模式是常用的实现服务隔离的设计模式,舱壁这个词是来自造船业的舶来品,它原本的意思是设计舰船时,要在每个区域设计独立的水密舱室,一旦某个舱室进水,也只是影响这个舱室中的货物,而不至于让整艘舰艇沉没。这种思想就很符合容错策略中失败静默策略。 我们来看一个具体的场景,当分布式系统所依赖的某个服务,譬如下图中的“服务 I”发生了超时,那在高流量的访问下——或者更具体点,假设平均 1 秒钟内对该服务的调用会发生 50 次,这就意味着该服务如果长时间不结束的话,每秒会有 50 条用户线程被阻塞。如果这样的访问量一直持续,我们按 Tomcat 默认的 HTTP 超时时间 20 秒来计算,20 秒内将会阻塞掉 1000 条用户线程,此后才陆续会有用户线程因超时被释放出来,回归 Tomcat 的全局线程池中。一般 Java 应用的线程池最大只会设置到 200 至 400 之间,这意味着此时系统在外部将表现为所有服务的全面瘫痪,而不仅仅是只有涉及到“服务 I”的功能不可用,因为 Tomcat 已经没有任何空余的线程来为其他请求提供服务了。
对于这类情况,一种可行的解决办法是为每个服务单独设立线程池,这些线程池默认不预置活动线程,只用来控制单个服务的最大连接数。譬如,对出问题的“服务 I”设置了一个最大线程数为 5 的线程池,这时候它的超时故障就只会最多阻塞 5 条用户线程,而不至于影响全局。此时,其他不依赖“服务 I”的用户线程依然能够正常对外提供服务,如图所示。
使用局部的线程池来控制服务的最大连接数有许多好处,当服务出问题时能够隔离影响,当服务恢复后,还可以通过清理掉局部线程池,瞬间恢复该服务的调用,而如果是 Tomcat 的全局线程池被占满,再恢复就会十分麻烦。但是,局部线程池有一个显著的弱点,它额外增加了 CPU 的开销,每个独立的线程池都要进行排队、调度和下文切换工作。根据 Netflix 官方给出的数据,一旦启用 Hystrix 线程池来进行服务隔离,大概会为每次服务调用增加约 3 毫秒至 10 毫秒的延时,如果调用链中有 20 次远程服务调用,那每次请求就要多付出 60 毫秒至 200 毫秒的代价来换取服务隔离的安全保障。 为应对这种情况,还有一种更轻量的可以用来控制服务最大连接数的办法:
重试模式
故障转移和故障恢复策略都需要对服务进行重复调用,差别是这些重复调用有可能是同步的,也可能是后台异步进行;有可能会重复调用同一个服务,也可能会调用到服务的其他副本。无论具体是通过怎样的方式调用、调用的服务实例是否相同,都可以归结为重试设计模式的应用范畴。重试模式适合解决系统中的瞬时故障,简单的说就是有可能自己恢复(Resilient,称为自愈,也叫做回弹性)的临时性失灵,网络抖动、服务的临时过载(典型的如返回了 503 Bad Gateway 错误)这些都属于瞬时故障。重试模式实现并不困难,即使完全不考虑框架的支持,靠程序员自己编写十几行代码也能够完成。在实践中,重试模式面临的风险反而大多来源于太过简单而导致的滥用。我们判断是否应该且是否能够对一个服务进行重试时,应
- 仅在主路逻辑的关键服务上进行同步的重试,不是关键的服务,一般不把重试作为首选容错方案,尤其不该进行同步重试。
- 仅对由瞬时故障导致的失败进行重试。尽管一个故障是否属于可自愈的瞬时故障并不容易精确判定,但从 HTTP 的状态码上至少可以获得一些初步的结论,譬如,当发出的请求收到了 401 Unauthorized 响应,说明服务本身是可用的,只是你没有权限调用,这时候再去重试就没有什么意义。功能完善的服务治理工具会提供具体的重试策略配置(如 Envoy 的Retry Policy),可以根据包括 HTTP 响应码在内的各种具体条件来设置不同的重试参数。
- 仅对具备幂等性的服务进行重试。如果服务调用者和提供者不属于同一个团队,那服务是否幂等其实也是一个难以精确判断的问题,但仍可以找到一些总体上通用的原则。譬如,RESTful 服务中的 POST 请求是非幂等的,而 GET、HEAD、OPTIONS、TRACE 由于不会改变资源状态,这些请求应该被设计成幂等的;PUT 请求一般也是幂等的,因为 n 个 PUT 请求会覆盖相同的资源 n-1 次;DELETE 也可看作是幂等的,同一个资源首次删除会得到 200 OK 响应,此后应该得到 204 No Content 响应。这些都是 HTTP 协议中定义的通用的指导原则,虽然对于具体服务如何实现并无强制约束力,但我们自己建设系统时,遵循业界惯例本身就是一种良好的习惯。
- 重试必须有明确的终止条件,常用的终止条件有两种:
- 超时终止:并不限于重试,所有调用远程服务都应该要有超时机制避免无限期的等待。这里只是强调重试模式更加应该配合上超时机制来使用,否则重试对系统很可能反而是有害的,笔者已经在前面介绍故障转移策略时举过具体的例子,这里就不重复了。
- 次数终止:重试必须要有一定限度,不能无限制地做下去,通常最多就只重试 2 至 5 次。重试不仅会给调用者带来负担,对于服务提供者也是同样是负担。所以应避免将重试次数设的太大。此外,如果服务提供者返回的响应头中带有Retry-After的话,尽管它没有强制约束力,我们也应该充分尊重服务端的要求,做个“有礼貌”的调用者。、、
由于重试模式可以在网络链路的多个环节中去实现,譬如客户端发起调用时自动重试,网关中自动重试、负载均衡器中自动重试,等等,而且现在的微服务框架都足够便捷,只需设置一两个开关参数就可以开启对某个服务甚至全部服务的重试机制。所以,对于没有太多经验的程序员,有可能根本意识不到其中会带来多大的负担。这里笔者举个具体例子:一套基于 Netflix OSS 建设的微服务系统,如果同时在 Zuul、Feign 和 Ribbon 上都打开了重试功能,且不考虑重试被超时终止的话,那总重试次数就相当于它们的重试次数的乘积。假设按它们都重试 4 次,且 Ribbon 可以转移 4 个服务副本来计算,理论上最多会产生高达 4×4×4×4=256 次调用请求。
流量控制
任何一个系统的运算、存储、网络资源都不是无限的,当系统资源不足以支撑外部超过预期的突发流量时,便应该要有取舍,建立面对超额流量自我保护的机制,这个机制就是微服务中常说的“限流”。 要做流量控制,首先要弄清楚到底哪些指标能反映系统的流量压力大小。相较而言,容错的统计指标是明确的,容错的触发条件基本上只取决于请求的故障率,发生失败、拒绝与超时都算作故障;但限流的统计指标就不那么明确了,限流中的“流”到底指什么呢?要解答这个问题,我们先来理清经常用于衡量服务流量压力,但又较容易混淆的三个指标的定义:
每秒事务数 (Transactions per Second,TPS):TPS 是衡量信息系统吞吐量的最终标准。“事务”可以理解为一个逻辑上具备原子性的业务操作。譬如你在 Fenix’s Bookstore 买了一本书,将要进行支付,“支付”就是一笔业务操作,支付无论成功还是不成功,这个操作在逻辑上是原子的,即逻辑上不可能让你买本书还成功支付了前面 200 页,又失败了后面 300 页。每秒请求数 (Hits per Second,HPS):HPS 是指每秒从客户端发向服务端的请求数(请将 Hits 理解为 Requests 而不是 Clicks,国内某些翻译把它理解为“每秒点击数”多少有点望文生义的嫌疑)。如果只要一个请求就能完成一笔业务,那 HPS 与 TPS 是等价的,但在一些场景(尤其常见于网页中)里,一笔业务可能需要多次请求才能完成。譬如你在 Fenix’s Bookstore 买了一本书要进行支付,尽管逻辑上它是原子的,但技术实现上,除非你是直接在银行开的商城中购物能够直接扣款,否则这个操作就很难在一次请求里完成,总要经过显示支付二维码、扫码付款、校验支付是否成功等过程,中间不可避免地会发生多次请求。每秒查询数 (Queries per Second,QPS):QPS 是指一台服务器能够响应的查询次数。如果只有一台服务器来应答请求,那 QPS 和 HPS 是等价的,但在分布式系统中,一个请求的响应往往要由后台多个服务节点共同协作来完成。譬如你在 Fenix’s Bookstore 买了一本书要进行支付,尽管扫描支付二维码时尽管客户端只发送了一个请求,但这背后服务端很可能需要向仓储服务确认库存信息避免超卖、向支付服务发送指令划转货款、向用户服务修改用户的购物积分,等等,这里面每次内部访问都要消耗掉一次或多次查询数。
与容错模式类似,对于具体如何进行限流,也有一些常见常用的设计模式可以参考使用,本节将介绍
流量计数器模式
做限流最容易想到的一种方法就是设置一个计算器,根据当前时刻的流量计数结果是否超过阈值来决定是否限流。譬如前面场景应用题中,我们计算得出了该系统能承受的最大持续流量是 80 TPS,那就控制任何一秒内,发现超过 80 次业务请求就直接拒绝掉超额部分。这种做法很直观,也确实有些简单的限流就是这么实现的,但它并不严谨,以下两个结论就很可能出乎对限流算法没有了解的同学意料之外:
- 即使每一秒的统计流量都没有超过 80 TPS,也不能说明系统没有遇到过大于 80 TPS 的流量压力。 你可以想像如下场景,如果系统连续两秒都收到 60 TPS 的访问请求,但这两个 60 TPS 请求分别是前 1 秒里面的后 0.5 秒,以及后 1 秒中的前面 0.5 秒所发生的。这样虽然每个周期的流量都不超过 80 TPS 请求的阈值,但是系统确实曾经在 1 秒内实在在发生了超过阈值的 120 TPS 请求。
- 即使连续若干秒的统计流量都超过了 80 TPS,也不能说明流量压力就一定超过了系统的承受能力。 你可以想像如下场景,如果 10 秒的时间片段中,前 3 秒 TPS 平均值到了 100,而后 7 秒的平均值是 30 左右,此时系统是否能够处理完这些请求而不产生超时失败?答案是可以的,因为条件中给出的超时时间是 10 秒,而最慢的请求也能在 8 秒左右处理完毕。如果只基于固定时间周期来控制请求阈值为 80 TPS,反而会误杀一部分请求,造成部分请求出现原本不必要的失败。
流量计数器的缺陷根源在于它只是针对时间点进行离散的统计,为了弥补该缺陷,一种名为“滑动时间窗”的限流模式被设计出来,它可以实现平滑的基于时间片段统计。
滑动时间窗模式
滑动窗口算法(Sliding Window Algorithm)在计算机科学的很多领域中都有成功的应用,譬如编译原理中的窥孔优化(Peephole Optimization)、TCP 协议的流量控制(Flow Control)等都使用到滑动窗口算法。对分布式系统来说,无论是服务容错中对服务响应结果的统计,还是流量控制中对服务请求数量的统计,都经常要用到滑动窗口算法。关于这个算法的运作过程,建议你能发挥想象力,在脑海中构造如下场景:在不断向前流淌的时间轴上,漂浮着一个固定大小的窗口,窗口与时间一起平滑地向前滚动。任何时刻静态地通过窗口内观察到的信息,都等价于一段长度与窗口大小相等、动态流动中时间片段的信息。由于窗口观察的目标都是时间轴,所以它被称为形象地称为“滑动时间窗模式”。 举个更具体的例子,假如我们准备观察时间片段为 10 秒,并以 1 秒为统计精度的话,那可以设定一个长度为 10 的数组(设计通常是以双头队列去实现,这里简化一下)和一个每秒触发 1 次的定时器。假如我们准备通过统计结果进行限流和容错,并定下限流阈值是最近 10 秒内收到的外部请求不要超过 500 个,服务熔断的阈值是最近 10 秒内故障率不超过 50%,那每个数组元素(图中称为 Buckets)中就应该存储请求的总数(实际是通过明细相加得到)及其中成功、失败、超时、拒绝的明细数,具体如下图所示:
当频率固定每秒一次的定时器被唤醒时,它应该完成以下几项工作,这也就是滑动时间窗的工作过程:
- 将数组最后一位的元素丢弃掉,并把所有元素都后移一位,然后在数组第一个插入一个新的空元素。这个步骤即为“滑动窗口”。
- 将计数器中所有统计信息写入到第一位的空元素中。
- 对数组中所有元素进行统计,并复位清空计数器数据供下一个统计周期使用。
滑动时间窗口模式的限流完全解决了流量计数器的缺陷,可以保证任意时间片段内,只需经过简单的调用计数比较,就能控制住请求次数一定不会超过限流的阈值,在单机限流或者分布式服务单点网关中的限流中很常用。不过,这种限流也有其缺点,它通常只适用于否决式限流,超过阈值的流量就必须强制失败或降级,很难进行阻塞等待处理,也就很难在细粒度上对流量曲线进行整形,起不到削峰填谷的作用。下面笔者继续介绍两种适用于阻塞式限流的限流模式。
漏桶模式
在计算机网络中,专门有一个术语流量整形(Traffic Shaping)用来描述如何限制网络设备的流量突变,使得网络报文以比较均匀的速度向外发送。 流量整形通常都需要用到缓冲区来实现,当报文的发送速度过快时,首先在缓冲区中暂存,然后再在控制算法的调节下均匀地发送这些被缓冲的报文。常用的控制算法有漏桶算法(Leaky Bucket Algorithm)和令牌桶算法(Token Bucket Algorithm)两种,这两种算法的思路截然相反,但达到的效果又是相似的。 所谓漏桶,就是大家小学做应用题时一定遇到过的“一个水池,每秒以 X 升速度注水,同时又以 Y 升速度出水,问水池啥时候装满”的那个奇怪的水池。你把请求当作水,水来了都先放进池子里,水池同时又以额定的速度出水,让请求进入系统中。这样,如果一段时间内注水过快的话,水池还能充当缓冲区,让出水口的速度不至于过快。不过,由于请求总是有超时时间的,所以缓冲区大小也必须是有限度的,当注水速度持续超过出水速度一段时间以后,水池终究会被灌满,此时,从网络的流量整形的角度看是体现为部分数据包被丢弃,而在信息系统的角度看就体现为有部分请求会遭遇失败和降级。 漏桶在代码实现上非常简单,它其实就是一个以请求对象作为元素的先入先出队列(FIFO Queue),队列长度就相当于漏桶的大小,当队列已满时便拒绝新的请求进入。漏桶实现起来很容易,困难在于如何确定漏桶的两个参数:桶的大小和水的流出速率。如果桶设置得太大,那服务依然可能遭遇到流量过大的冲击,不能完全发挥限流的作用;如果设置得太小,那很可能就会误杀掉一部分正常的请求,这种情况与流量计数器模式中举过的例子是一样的。流出速率在漏桶算法中一般是个固定值,对本节开头场景应用题中那样固定拓扑结构的服务是很合适的,但同时你也应该明白那是经过最大限度简化的场景,现实中系统的处理速度往往受到其内部拓扑结构变化和动态伸缩的影响,所以能够支持变动请求处理速率的令牌桶算法往往可能会是更受程序员青睐的选择。
令牌桶模式
如果说漏桶是小学应用题中的奇怪水池,那令牌桶就是你去银行办事时摆在门口的那台排队机。它与漏桶一样都是基于缓冲区的限流算法,只是方向刚好相反,漏桶是从水池里往系统出水,令牌桶则是系统往排队机中放入令牌。 假设我们要限制系统在 X 秒内最大请求次数不超过 Y,那就每间隔 X/Y 时间就往桶中放一个令牌,当有请求进来时,首先要从桶中取得一个准入的令牌,然后才能进入系统处理。任何时候,一旦请求进入桶中却发现没有令牌可取了,就应