O’Reilly的电子书《Reactive Microservices Architecture》讲述了微服务/分布式系统的一些设计原则,本文是笔者阅读完此书后的理解。书籍地址:https://info.lightbend.com/COLL-20XX-Reactive-Microservices-Architecture-RES-LP.html。
微服务相比传统的单体应用能够带来快速的响应,以小的系统产生大的影响。而随着网络加速、磁盘成本降低、RAM成本降低、多核技术的发展、云架构技术的爆发,微服务不再受这些客观条件的限制,已经开始大规模的应用。
与SOA架构,微服务和它都具有相同的初衷:解耦、隔离、组合、集成、分散以及自主,但是SOA经常被误解和误用,尤其是使用ESB来支持对多个单体系统的协议(复杂、低效、不稳定)调用,使得系统变得非常复杂。而随着这些年硬件以及软件架构理念的发展,所有的系统基本都已经变成分布式架构,也带来了很多新的挑战。也就需要新的思路和理念来面对这些问题,其中本书所讲述的响应式原则(Reactive principles)即一种解决分布式系统的思路。响应式原则也并非一个新的东西,Erlang中的Actor模型即一种响应式设计。微服务是响应式原则的一个架构设计,其借鉴了SOA架构中好的理念,并使用了现代的基础服务设施(云服务、自动化工具等)。
响应式微服务定义
使用微服务架构最关键的一个原则就是将系统划分成一个个相互隔离、无依赖的子系统,这些子系统通过定义良好的协议进行通信。其中隔离是实现弹性、可伸缩系统的前提,并且需要在服务间建立异步通信边界,因此要在以下两方面进行解耦:
- 时间:允许并发。
- 空间:允许分布式和移动性,即服务能够随时移动。
此外,微服务还需要消除共享状态从而最小化相互协作、联结的成本,要尽量达到“不共享任何东西”。
隔离任何东西
隔离是微服务架构中最重要的特性。不仅仅是微服务带来的很多优势的基础,也是对设计和架构影响最大的方面。如康威定律所说,它还对组织架构有非常大的影响,
1
|
|
失败隔离是一种与“舱壁”(船舱的隔板)相关的设计模式:隔离错误、失败以防止其蔓延至所有服务,导致更大面积的失败。
“舱壁”这种模式已经在轮船上使用了几个世纪:创建一个个密封不漏水的空间以防止船的外壳破损或者其他泄漏。这些空间是完全互相隔离的,这样即使一个隔离区充满了水,也不会蔓延流到其他隔离空间中,从而使得船整体仍然能够运作。
弹性(从失败中恢复的能力)即依赖于这种舱壁和失败隔离的设计,并且需要打破同步通信机制。由此,微服务一般是在边界之间使用异步消息传输,从而使得正常的业务逻辑避免对捕获错误、错误处理的依赖。
进一步的,服务之间的隔离使得“持续交付”变得很容易,能够随时地部署服务而无需担心影响正常的业务。而且隔离的单个服务很容易监控、调试、测试和部署,非常便于扩展。
自主地行动
上面所讲的隔离是自主性的前提。只有当服务之间是完全隔离的,那么才可能实现完全的自主,包括独立的决策、独立的行动以及与其他服务协调合作来解决问题。
一个自主的服务仅仅保证其对外公布的协议/API的正确性即可。如此,不仅能够让我们更好地了解协作的这些系统以及对他们的建模,也能够在面对冲突、失败状况时,只在一个服务内进行排查、修复即可。
使用自主服务能够给服务编排、工作流管理以及服务合作上带来很大灵活性,同时也带来可扩展性、可用性、运行时管理等优势。但其付出的代价就是需要花心思在定义良好的可组合的API设计上,这个是有一定挑战性的。
只做一件事,并且做好
如Unix编程哲学所说:程序应该只做一件事,并且做好它。然后让他们一起工作完成任务。这也类似于面向对象编程语言中软件开发单一职责原则(SRP)的描述。
而在微服务中一个很大的问题就是如何正确地确定服务的大小。比如怎样的粒度才能被认为是“微”(micro)?多少行代码还能被认为是微服务。但这里“micro”其实是和职责范围有关的,就如Unix的SRP原则:只做一件事并且做好。
每一个服务都应该只有一个存在的原因,提供了一组相关的功能,业务和职责不会糅杂在一起。所有服务组织在一起整体上能够便于扩展、具有弹性、易理解和维护。
拥有自己的私有状态
微服务中有一个很关键的部分就是状态(state),很多微服务也都是有状态的实体,包括对状态和行为的封装。而在“无状态”的设计理念下,很多服务都把自己的状态下沉到一个大的共享数据库中,这也是很多传统的Web框架的做法。如此就造成了在扩展性、可用性以及数据集成上很难做好把控。而本质上,一个有着共享数据库的微服务架构本质还是一个单体应用。
合理的方式是一个服务既然具有单一职责,那么就应该拥有自己的状态和持久化机制,建模成一个边界上下文,有自己的域名和语言。这些也都是DDD(Domain-Drivern Design)里面的技术。微服务受DDD影响很大,其中很多微服务的上下文的概念都来自于DDD。
当访问一个服务时,也只能是客气的请求其状态而并不能强制其一定具有状态。如此,每个服务都能够通过事件溯源(Event Sourcing)和CQRS(Command Query Responsibility Segreation)自定义自己的状态表示和实现(RDBMS、NoSQL、Time-Series、EventLog)。
去中心化的数据管理和持久化(多语言持久化)能够带来很多优势。数据的存储媒介可以根据服务自己的需要选择,服务包括其数据都可以看做一个单独的单元。同时并不允许一个服务直接去访问另一个服务的数据库,如果要访问只能通过API(通过指定规范、策略和Code Review来保证)。
Event Log是一种消息的存储方式。我们可以以消息进入服务的形式存储(发送到服务的Commnds),即命令溯源(Command Sourcing)。我们也可以忽略命令,让命令先执行对服务产生一些作用,如果触发了状态变更,那么我们捕获此次变动并用事件溯源(Event Sourcing)将此次Event存储到EventLog中。
消息有序存储,能够提供服务所有的交互历史。同时消息也保存了服务的事务,也就能够对这些事务日志进行查询、审计、重放从而用于弹性伸缩、调试以及冗余等。
命令溯源和事件溯源是不同的语义。重放命令意味着会重放其带来的副作用。而重放事件则是执行状态的改变。需要根据具体场景的不同选择使用哪种溯源技术。
使用EventLog可以避免"对象关系不匹配"的问题(ORM中经常出现)。而由于其自身天然适合异步消息传输,因此绝大多数情况下,Event Log是微服务中最佳的持久化模型。
拥抱异步消息传输
微服务之间的通信的最佳机制就是消息传输。如上文所说,服务之间的异步边界能够在时间和空间两方面进行解耦,能够提升整体系统的性能。
异步非阻塞执行以及IO都是对资源的高效操作,能够最小化访问共享资源时的阻塞消耗(扩展性、低延迟以及高吞吐的最大障碍)。简单的例子:如果要发起对10个服务的访问,其中每一个请求需要耗时100ms,那么如果使用同步模式,则完成所有请求则需要10*100=1000ms。而如果使用异步模式,同时发起10个线程,则一共就需要100ms。
异步消息传输还能够让我们注重网络编程的限制,而不是假装这些限制不存在,尤其是在失败场景下。还能够让我们更关注工作流以及服务间的数据流、协议、交互是怎样进行的。
然而目前微服务的默认通信协议以REST为主,其本质是同步通信机制,比较适用于可控的服务调用或者紧耦合的服务调用上。
此外,使用异步消息传输的另一个需求在于对消息的持续流处理(可能是无界的)。也是我们从“data at rest”到"data in motion"的理念的改变。之前的数据是离线被使用的,而现在的数据是被在线处理的。应用对数据变更的响应需要达到实时级别:当变动发生,需要实时进行持续的查询、聚合并反馈给最终的应用。这个理念的形成经历了三个主要阶段:
“data at rest”: 将大量数据存储在HDFS类似的数据存储媒介中,然后使用离线批处理技术去处理这些数据,一般会有数个小时的延迟。
意识到了“data in motion”正变得越来越重要:在数秒内捕获数据、处理数据并反馈给运行中的系统。Lambda即此时出现的一种架构: 加速层用来做实时在线计算;批处理层用来做复杂的离线处理。加速层实时处理的结果后续被批处理层的结果合并。这个模型解决了某些场景需要数据即时响应的问题,但其架构的复杂使得不容易维护。
“data in motion”: 全面拥抱移动数据的概念。传统的面向批处理的架构都在逐渐向纯流处理的架构转变。这种模型作为通信协议和持久化方案(通过Event Logging)也能够给微服务带来“data in motion”和流处理的能力。
保持移动,但可寻址
如上述所讲,异步消息传输带来了对时间和空间的解耦。其中,对于空间的解耦也被称为“位置透明”:在多核或者多结点上的微服务在运行时无须改变结点即可以动态扩展的能力。这也决定了系统的弹性和移动性。要实现这些需要依赖云计算带来的一些特性和其“按需使用”模型。
而可寻址则是说服务的地址需要是稳定的,从而可以无限地引用此服务,而无论服务目前是否可以被定位到。当服务在运行中、已经停止、被挂起、升级中、已经崩溃等等情形下,地址都应该是可用的,任意客户端能够随时发送消息给一个地址。实际中,这些消息有可能进入队列排队、重提交、代理、日志记录或者进入死信队列。此外,地址需要是虚拟的,可以代表一组实例提供的服务。
- 在无状态的服务间做负载均衡:如果服务是无状态的,那么请求被哪一个服务实例处理都是没任何问题的。也有很多种的路由算法供使用,如:轮训、广播或者基于度量信息。
- 在有状态的服务之间构建Active-Passive的冗余设计:如果一个服务是有状态的,那么可以使用sticky路由算法(同一个客户端的请求都会发送给同一个服务实例)。冗余一个passive实例是为了在主实例挂的时候接管上面的请求。因此,服务的每一个状态变动都需要同步到passive实例上。
- 有状态的服务的重定位:将一个服务实例从一个位置移动到另一个位置可以提高引用的本地性(让数据和计算靠近)和资源利用率。
使用虚拟地址能够让服务消费方无须关心服务目前是如何配置操作的,只要知道地址即可。
微服务系统实现
一个微服务并非真正的“微服务”,一系列微服务通过通信、合作才能够解决问题,才能组成一个完整的业务系统。实现一个服务是相对简单的,困难的是其他基础设施的实现:服务发现、协作、安全、冗余、数据一致性、容错、部署以及与其他系统的集成。
系统需要利用现实
微服务架构带来的一个很大优势就在于它提供了一套工具,能够利用现实,模仿真实的世界来创建系统,包括真实世界的限制和机会。
首先根据“康威定律”,微服务的部署是和现实中工程组织/部门如何工作是相适应的。此外,还需要注意的是现实不是一致的,任何事情都是相对的,即使是时间和“现在”这个概念。
信息的传播速度不可能比光快,甚至大部分是很慢的,这也意味着信息通信是有延迟的。信息都是来自过去的,我们稍微思考一下可以知道信息承载的都是我们观察到的东西。而我们观察/学习到的事实至少都是很短时间之前发生的,也就是说我们总是在看过去,“现在”只是旁观者的视角。
每一个微服务都可以看做一个安全的小岛,提供了确定性和强一致性,上面的时间和“目前”都是绝对的。但是当离开一个微服务的边界时,就进入了一片充满非确定性的大海-分布式系统的世界。如很多人所说,构建分布式系统是困难的。但现实世界同时也提供了如何解决诸如弹性、可伸缩、隔离性等分布式问题的解决思路。因此,即使构建分布式系统是困难的,但是我们也不应该退化为单体应用,而是学习如何使用一系列的设计原则、抽象概念和工具来管理它。
正如Pat Helland在《Data on the Outside versus Data on the Inside.”》对"data on the inside"和“data on the outside”的对比所说:内部的数据就是我们本地的“目前”,而外部数据-事件即是来自过去的信息,服务之间的命令则是“对未来的希望”。
服务发现
服务发现要解决的问题就是如何定位一系列的服务从而可以使用地址去调用。其中最简单的手段就是将地址和端口信息硬编码在所有服务中或者外置在服务的配置文件中。这种方式的问题在于其是一种静态部署模型,与微服务的初衷是相矛盾的。
服务需要保持解耦和移动,而系统需要是弹性和动态的。因此可以通过使用控制反转(Inversion of Control)模式引入一个间接层来解决此问题。也就是说每一个服务都上报自己的信息(位置、如何访问)给一个统一的平台。这个平台被称作“服务发现”,是微服务平台的一个基础部分。这样,一旦服务的信息被存储了,服务就可以使用“服务注册中心”来查找调用服务的信息,这种模式被称作“Client-Side服务发现”。另一种策略是将信息存储、维护在一个负载均衡器(AWS的ELB)或者直接维护在服务提供方的地址中-“Server-Side服务发现”。
可以选择CP特性的数据库作为服务信息的存储,能够保证信息的一致性。但是这种数据库是牺牲了一定程度的可用性来达到强一致性的,并且依赖一些额外的基础设施,而很多时候强一致性并非那么需要。因此,更好的选择是使用AP特性的点对点的技术来存储,比如使用CRDTs(Conflict-Free Replicated Data Types )与Epidemic Gossip可以实现信息的最终一致性传播,能够有更好的弹性,也不需要额外的基础设施。
API管理
API管理解决的问题在于如何将服务的协议和API统一管理起来,以方便服务的调用。包括协议和数据版本的升级和后退等。解决此问题可以通过引入一个负责序列化编码、协议维护以及数据传输的层,甚至直接将服务版本化。这在DDD中被称作"Anti-Corruption"层,可以加入到服务本身或者在API网关中实现。
假如一个客户端需要调用10个服务(每一个都有不同的API)来完成一个任务,那么对于这个客户端来说是非常繁琐的。相比起让客户端直接去调用服务,更好的方式是让客户端通过API网关服务来调用。API网关负责接受客户端的请求,然后路由请求到相应的服务(如果有必要需要转换协议),组装响应并将其返回给客户端。这样,做为客户端和服务之间的一层其就能够简化client-to-service协议。但这里如果是中心化的则很难达到高可用和可扩展性,所以使用去中心化技术(比如服务发现)实现API网关则是更好的选择。
但需要注意的是API网关,包括所有的核心出服务并不是一定要自建的,理想地它应该是底层平台的一部分。
管理通信模式
在一个由数个微服务组成的系统中,使用点对点的通信就能完成服务间的通信工作。但是当服务数目越来越多,如果还是让他们之间直接调用,那么很快整个系统会变得混乱不堪。解决此问题需要一个机制能够解耦发送者和接受者,并且能够按照某种定义好的原则路由数据。
发布订阅机制是一种解决方案:发布者发布信息到某个topic中,订阅者监听此topic以监听消息。可以使用可扩展消息系统(Apache Kafka、Amazon Kinesis)或者NoSQL数据库(AP特性数据库,如Cassendra和Riak)来实现。
在SOA架构中,ESB承担的即这种角色。微服务中我们肯定不会使用它来桥接单体应用,但是可以将它做为一个发布系统用来广播任务和数据或者做为系统间的通信总线(通过Spark Streaming收集数据到Spark中)。
发布订阅协议有时候也是有不足的。比如无法提供允许程序员自定义路由规则的高级路由特性或者数据的转化、丰富、分隔以及合并等功能(可以使用Akka Streams或者Apache Camel)。
集成
系统与外界或者系统之间的通信都是必需的。当与一个外部系统通信时,尤其当外部系统无法把控时,那么就会有很大的失败风险(系统超载、业务失败)。因此即使协议协商得再好,也不能信赖外部服务,需要做好各种预防措施以保证自身服务的安全。
首先要达成一个良好的协议从而可以最小化一个系统突发超载造成服务不可用的风险,比如要避免发起的请求超过服务提供方的承载能力。也要尽量避免使用同步通信机制,否则就把自身服务的可用性放在了依赖的第三方服务的控制中。
避免级联失败需要服务足够解耦和隔离。使用异步通信机制是一个最佳的方案。此外,还需要通过背压(back-pressure,接收方根据自己的接受状况调节接受速率,通过反向的响应来控制发送方的发送速率)来达成数据流速度的一致性,以防止响应快速的系统压垮其中较慢的部分。而越来越多的工具和库都在开始拥抱“响应式流”(Reactive Streams)规范(Akka Stream、RxJava、Spark Streaming、Cassandra drivers),这些技术使用异步背压实时流来桥接系统,从而在总体上提高系统的可靠性、性能以及互操作性。
如何管理调用服务时候的失败也是微服务中一个关键的问题。捕获到错误后,先重试,而如果错误一直发生,那么就隔离服务一段时间直到服务恢复-“断路器”模式(Netflix和Akka中都有实现)。
面对可扩展性、高吞吐以及可用性的要求,系统集成的实现从传统的依赖于中心化服务如RDBMS/ESB逐渐变为现在采用去中心化策略(HTTP REST、ZeroMQ)或者订阅发布系统(Kakka、Amazon Kinesis)。而最近事件流平台(Event Streaming Platforms)正成为系统集成选型的趋势,其理念来自于Fast Data和实时数据管理。
如上文所述,服务之间使用异步通信机制能够得到很多的好处。但是如果是客户端(浏览器、APP)与服务之间的通信,使用REST经常是更好的选择。但是并非所有的地方都非得使用同步通信机制,需要根据不同的场景做不同的评估。很多情况下,开发者出于习惯都会倾向于使用同步方案,而不是根据真正的需要作出能够简化操作、提升操作性的选择。这里给出几个通常会使用同步方案建模但其本质是异步行为的事例:
- 查询一个商品是否有货,如果此商品比较热门被卖光了,用户要得到通知。
- 如果一个餐馆的特价菜单改动了,用户要立刻知道。
- 用户对于一个网站的评论需要实时对话。
- 广告系统根据用户在页面上的行为输出不同的响应。
对于上述实例,我们需要分别进行分析去理解怎样才是符合客户端和服务通信的最自然的方式。同时也经常需要根据数据的完整性约束来寻找可以弱化一致性保证(有序)的可能,目的就是找到最少的协调约束条件给用户以直观的语义:找到利用现实的最佳策略。
安全管理
安全管理主要是对服务的认证授权管理,限制某些service只允许某些服务访问。
- TLS Client Certificates也被称为相互验证、双路验证。它给每一个service都分配一个单独的私钥和证书,从而能够很好地保证服务间的认证访问。不仅仅服务要验证客户端的身份,客户端也要验证服务的身份。因此,其不仅能防止数据被窃听,而且即使在不安全的网络中也能防止对数据的拦截和转发。基于SSL之上的通信不仅安全,其也是一个公开、易于理解的标准。但是其非常复杂,无法得到底层平台的足够支持。同样的,HTTPS Basic Authentication也是双路验证,但其对SSL证书的管理也很复杂,请求也不能被反向代理缓存。
- Asymmetric Request Signing:每一个服务都需要使用自己的私钥给自己发送的请求进行签名,同时每一个服务的公钥都要上报给“服务发现”服务。此方案的缺点在于一旦网络不可靠,那么则很难防止数据窃听或者请求重放攻击。
- Hash Message Authentication Code (HMAC) : 基于共享密钥来对请求进行一定规则的签名。这个方案比较简单,但是由于每一对需要通信的服务都需要唯一的一个共享密钥,整个系统则需要所有服务排列数目的共享密钥数量,实现起来比较麻烦。
最小化数据耦合
微服务架构中,完成一个任务需要多个服务的协同,因此最小化服务之间的状态协作成本,有助于提升微服务系统的整体性能。
需要做的是要从业务的视角去分析数据以理解数据间的关系、担保和完整性约束。然后对数据进行反范式设计并在系统内定义一致性边界。如此,可以在边界内部实现强一致性。接着,需要使用这些边界来驱动微服务的设计和范围。如果设计的服务之间有很多数据依赖和关系,那么就需要去减少甚至是消除这些数据的耦合,从而避免对服务状态的协同。
最小化协作成本
如上节所述,已经最小化数据耦合了,但仍然还是会有业务场景需要多个服务协作完成。这个的确是无法避免的,但到了目前这一步,需要做的是可以根据需要逐渐的添加协作,而不是一开始各种耦合再逐渐去消除(比较麻烦和困难)。
这里提供几种可扩展、弹性伸缩的方式来协同数据改变,以达到Composability(对数据的变动无须停止数据所在的服务,也无须等待某些条件)。
- Apology-Oriented Programming: 基于请求原谅比请求权限容易的想法。如果你不能响应协作,那么就做出一个合理的猜测,赌一个条件已经满足,后续如果错了,那么就道歉并做补偿。这种做法和现实是非常相似的。比如航班的超售,如果起飞的时候没有座位那么就去做一些补偿措施。
- 事件驱动架构(Event-Driven Architecture ):基于异步消息传输和事件溯源。需要区分命令和事件,命令表示一个将要产生副作用的操作意图(对未来的希望),而事件表示已经发生的事实。使用CQRS模式进行查询,将写入方(持久化事件日志)与读取方(将数据存入RDBMS或者NoSQL数据库中)分离。这里使用事件日志做状态管理和持久化具有很多好处:简化审计、调试、冗余、容错,并且允许重放过去任意时间点的事件流。
- ACID2.0:由Pat Helland创造,定义了一组原则,目的是实现可扩展、弹性的协议以及API设计。A是Associative,表示分组消息不会产生影响,可以批量处理。C是Commutative,表示消息的顺序不重要。I是Idempotent,表示消息重复不会产生影响。D是Distributed,没有实质的意义,猜测是为了凑ACID这个首字母缩写。
CRDTs是一个囊括了上面这些东西的工具,可以实现最终一致性、据有丰富的数据结构(counters、sets、maps、graphs),并且不需要协作就可以收敛聚合。其更新操作的顺序前后也并不影响最终的合并结果,能够自动安全的进行合并。虽然CRDTs最近才出现在业界视野中,但其实它已经在生产环境使用了很多年。已经有一些生产级别的库可以直接使用(Akka、Riak)。
然而,很多业务场景并不允许最终一致性。这时可以使用因果一致性(causal consistency)。因果关系很容易被大家理解。而且因果一致性模型能够实现可扩展性和可用性。其一般使用逻辑时间,在很多NoSQL数据库、事件日志以及分布式事件流产品中都可用。
分布式事务是一个经常使用的方式用来协调分布式系统的变动,但其本质需要约束并发执行,保证同一时间只有一个用户在操作。因此其成本非常昂贵,会使得系统变慢、无法扩展。Saga模式是分布式事务之外的一个能够实现可扩展、弹性伸缩的选择。它的理论基础在于一个长时间运行的业务事务大多时候都是由多个事务步骤组成的,而事务步骤的总体一致性能够通过将这些步骤分组成一个总体的分布式事务来实现。该技术将每一个阶段的事务与一个可补偿回滚的事务配对,如果一个阶段的事务失败了,那么总体的分布式事务就可以回滚(反向顺序)。
总结
当设计一个响应式微服务时,需要坚持隔离、单一职责、自主、独占状态、异步消息传输和移动等特质。微服务需要协作才能形成一个系统去发挥作用。一个能够提供基础服务和响应式原则模式的复杂微服务平台是有必要的。