许多公司的微服务都是基于请求-响应的模式构建的,REST就是这种模式的典型。这很自然,我们自己写程序也总是这么做的:对别的代码模块进行调用,接收到响应后再继续下面的处理流程。这也和每天都发生的实际场景非常相像:用户在浏览网页时,点击一个按钮,然后等待变化发生。
但当我们的真实环境由许多独立的服务构成时,事情就不一样了。随着服务的数量渐渐增加,同步交互的网络也在迅速扩大。之前不明显的可用性问题就会慢慢地引发范围越来越广的不可用故障。
不幸的运维工程师们就要拼命地救火了,他们东奔西跑,一个一个服务地查看,将许许多多的二手信息拼凑起来(什么时候哪个服务对哪个服务发过什么消息),希望可以定位出问题所在。
这是众所周知的问题,相应的对策也很多。对策之一就是保证你的服务的SLA要比整个系统的高出很多。为达到这一点谷歌还提供了一个方案。另一种对策就很简单,把服务之间的同步调用断开就好了。
要这么实现采用异步模式就好了。如果你的项目是和在线零售相关的,你会觉得存在像getImage()或processOrder()这样的同步接口非常正常,它们都期望会立刻有回复。但当用户按下“购买”按钮时,他实际上是触发了一个非常复杂的异步流程,一次真实的购买行为,而且还在现实中要将商品送到客户的家门口,这些远远超出了最初按下按钮那个行为的范围。因此将软件拆散成异步流程就让我们可以将要解决的不同问题拆分开,让我们可以面对一个本来就是异步的世界。
在实践中我们也会很自然地接受这一点。我们会定时查询数据库去获取变更过的数据,或者实现某类定时任务来批量获取数据变更,这些都是变同步为异步的方法。
把这些问题总结起来我们就可以归纳出,在服务之间相互独立运行的环境里,向某个服务发出指令让它完成任务的命令式编程模型并不是非常合适的。
在本文中我们将试试另一种架构,即不是用一连串的命令将多个服务组合起来,而是通过事件流。这种方法本身是正确的,而且这也是在后续文章(本文是作者系列文章中的一篇)中要讨论的许多高级模式的基础,比如将流处理和事件驱动处理的思想结合起来。
命令、事件和查询
在研究具体例子之前我们先要澄清三个基本的概念,即服务之间相互交互的三种机制:命令(Command)、事件(Event)和查询(Query)。如果你以前没有仔细考虑过,现在弄清楚也为时不晚。
事件的强大在于它们既是事实又是触发器。外部的数据可以被系统里面的任何服务重用。但从服务的角度看,事件对系统造成的耦合度要比命令和查询低,这一点非常重要。
服务之间相互交互的三种机制是:
1.命令:是一个动作,是一个要求其它服务完成某些操作的请求,它会改变系统的状态。命令会要求响应。
2.事件:既是事实又是触发器,用通知的方式向外部表明发生了某些事。
3.查询:是一个请求,查看是否发生了什么事。重要的是,查询操作没有副作用,它们不会改变系统的状态。
一个简单的事件驱动流程
下面从一个简单的例子开始:一位顾客下单购买了一件商品。随后发生了两件事:
1.处理相关的支付;
2.系统查看这种商品要不要补充库存;
用请求驱动的方法解决,这将被表述为一个命令链。这里没有查询,系统的交互看起来是这样的:
值得注意的就是“补充库存”这个业务流程是由订单服务触发(或者调用)的。这就把两个服务的责任混淆起来了,理想的情况下我们还是应该进行更好的关注点分离设计(separation
of concerns)。
现在再换个角度看看,相同的流程为什么用事件驱动的方法处理会更合适一些。
1.UI服务发起OrderRequested请求,通知系统有顾客下单了,然后等待该订单被确认(OrderConfirmed)或拒绝,再将结果返回给顾客;
2.订单服务和库存服务都对相同的事件做出响应;
仔细看看这个流程,在UI服务和订单服务之间的交互没什么大变化,只是由直接调用变成了通过事件通信而已。
库存服务很值得注意,现在不再由订单服务告诉它该干什么了。它自己决定要不要参与这次交互的过程。这是这类架构非常重要的特性之一,即由接收者驱动的流程控制(Receiver
Driven Flow Control)。处理逻辑被推送到事件的接收者一端,而不是发送者。责任方调换了。
将控制权交给接收者,这种做法减少了服务之间的耦合,并将架构的可插拔特性提升到了一个新层次,可以非常容易地加入或者剔除某个模块。
架构变得越复杂,这种可插拔的特性就变得越重要。假设我们现在要向系统中加入一个管理实时报价的新服务,根据某件商品的供求状态实时地调整价格。在命令驱动的架构下,我们就该引入一个maybeUpdatePrice()的方法调用,库存服务和订单服务都会调用它。但在事件驱动的架构下,这个服务只不过是订阅了共享数据流的一个新服务而已,它负责在达到一定的触发条件之后,发出更新价格的指令。
将事件和查询放在一起
上面的例子只考虑了命令和事件,没有提到查询。我们在文章开篇就定义了所有的交互操作,即命令、事件和查询。如果系统架构不是非常简单,那查询操作就一定是必不可少的。所以在这里我们让这个例子再复杂一些,在订单服务处理支付之前,先检查一下库存是否充足。
如果用请求驱动的方法来解决这个问题,就会向库存服务发送一条请求,获取当前的库存数量。这就把架构搞得不伦不类了,本来事件流就只是用做通知的,让各个服务都从中获取自己感兴趣的信息,可现在查询却直接去找数据源了。
在比较大型的系统中,服务都是相互之间独立演进的,但远程调用会大大增加服务间的耦合,将服务在运行时捆绑在一起。事实上我们可以采用内部化的办法来避免这类跨服务的查询。用事件流在各个服务中缓存数据集,那么在本地就可以完成查询。
这样在实现增加库存检查的功能时,订单服务可以订阅库存事件流,并保存在本地数据库中,它将查询这个“视图”来验证库存是否充足。
纯粹的事件驱动系统是没有远程查询的概念的,事件会将状态带给各个服务,由它们自己进行本地保存。
这种“通过事件传输状态,再基于本地状态完成查询操作”的方法主要有三个优点:
1.更好的解耦:查询都是在本地完成的,没有跨上下文的调用。与命令驱动的实现方式相比,这样做不会将服务捆绑在一起。
2.更高的自治度:订单服务本地有一份自己管理的库存数据的副本,所以它可以在上面完成任何操作,而不会受限于库存服务提供的查询功能。
3.高效的连接操作:如果我们要对一个订单中的每件货物都查询库存,这实际上是在两个服务之间做一次跨网络的连接操作。在负载增大或者有更多的数据源要参与进来时,这样做的复杂度是不可想像的。“通过事件传输状态,再基于本地状态完成查询操作”的方法解决了这个问题,它让查询(及连接)操作可以在本地完成。
当然这个方法也有它自己的固有缺点。服务会变得自己有状态。它们需要自己不断地跟进和维护这份传输过来的数据副本。状态的复制也让某些问题变得难以定位(怎样实现原子地减少库存的数量?),还要小心数据不一致问题。不过这些问题都相应地有可行的解决方案了,只要多考虑一些就好。与维护更大更复杂的系统所要花费的精力相比,这样做还是相当值得的。
写入者唯一原则
实现这类系统时,一个非常有用的原则就是将产生某种数据的责任交给某个单一的服务,即写入者唯一。这样库存服务就只管理“库存清单”,而订单服务就只维护订单,等等。
这样单一代码路径(但并不一定是单个进程)的方法就解决了一致性、验证和其它许多写入问题。所以在下面的例子中,订单服务负责所有与修改订单状态相关的操作,但整个事件却要涉及订单、支付和物流等多个服务,每个都由相对应的服务进行管理。
必须将事件的传播和责任关联起来,因为事件都不是临时的,不是偷偷摸摸的。事件代表了众所周知的事实,是外部的数据。因此,每个服务都要自己承担起持续地管理共享数据的责任:修复错误、处理模式变化等。
上图中每种颜色都代表了Kafka中的一个Topic,分别属于订单、物流和支付。购物车服务开启整个流程。当一位用户按下“购买”按钮时,它发起“请求订单”事件,一直等到“确认订单”事件之后,才把结果返回给用户。另外三个服务各自管理整个流程中属于自己的状态变化。比如在支付完成后,订单服务就会将订单从“验证通过”状态变为“已确认”。
将模式和集群服务混合起来
上文描述的某些模式与Enterprise Messaging看起来很像,但事实上还是有微妙区别的。Enterprise
Messaging主要管理状态的传递,它有效地将多个数据库跨网络地结合起来了。
事件合作讲的是服务如何通过分发事件、触发服务完成某些动作,最终达到某些业务目标的。因此这是一个关于业务处理的模式,而不是简单的状态移动的机制。
但通常我们总会希望能在自己构建的系统中把这个模式的两面都利用上。事实上,这个模式的优点之一正是在于它适用的场合,既可以处理宏观的事务,又可以处理微观的事务,甚至一起处理。
模式混用的场景是非常常见的。也许我们又希望有远程查询的便利性,又不想辛苦地去维护本地数据集,尤其是在数据量会增长的情况下。这样做就可以更容易地部署简单的功能(在希望构建一些轻量级的、无服务的和事件流时,这一点尤其重要)。原因也可能是我们正处于无状态的容器或浏览器之中。
方法就是约束这些查询接口的范围,最好是通过受限上下文。受限上下文在这里指若干服务的集合,它们可以共享相同的部署或域模型。最好的方案是让系统的架构中可以有许多专门的、有针对性的视图,而不是简单的共享数据库。
要限制远程查询的范围,也可以使用集群上下文模式。在这里事件流是上下文之间唯一的通信模式。但在一个上下文内部的服务在需要时就会同时使用事件驱动处理和请求驱动视图。
下面例子中的系统被分成了三个子系统,不同子系统之间只通过事件通信。在每个子系统内部,我们都会使用细粒度的事件驱动流。有些会包括视图层(查询层)。这就是在解耦和方便之间做出的折衷,让我们可以将细粒度的服务与大型实体,即现有系统的程序及现成的产品混合起来,其中会有许多真实的服务状态。
集群上下文模式
事件驱动服务的五个主要好处是:
1.解耦:打破阻塞式调用的长链,拆分同步工作流。代理节点解耦服务,这样就可以更容易地加入新服务,或改进现有的。
2.离线与异步工作流:当用户按下一个按钮后会发生许多事。有些是同步的,有些是异步的。对后者和前者的设计都垂手可得。
3.状态迁移:事件成了系统内的数据集。流提供了非常有效的方法来实现数据分发,这样就可以在一个受限的上下文内部重组和查询。
4.连接:不同的服务可以更容易地组合、连接和扩大数据集。连接操作都是在本地完成的,速度很快。
5.可追踪性:在有了一个集中的、不可变的清单来记下每一次变更之后,调试分布式系统的“谋杀之迷”问题就很容易了。
总结
在事件驱动的设计中,我们使用的是事件而不是命令。事件触发处理。它们也可以转变成供我们本地查询的视图。在小型系统中,必要时可以回退去做远程同步调用,但在大型系统中我们对这样的操作进行限制,比较理想的是限制到单个受限上下文中。
但这些方法只不过是模式而已,只是把系统组合起来的一些指导原则而已。使用时不能过于教条主义。比如,当某些内容几乎不会改变时,实现一个全局性的查询服务也不失为一个好主意。
方法就是从某个事件的基线开始。事件不会让服务之间耦合起来,而把对流程的控制权交给事件接收者,这样顾虑更少,可插拔性更好。
事件驱动设计的另一个优点是它们不仅适用于小型的、交互性很高的系统,对于大型的、复杂的架构也很适合。事件的模式给了服务自主性,让它们可以自由地演进,不再受限于命令和查询模式的复杂联系。对于运维工程师来说,他们的工作没有太大改进,但他们为分布式问题犯难的次数应该会大大减少,因为现在至少事件有迹可循。
这篇文章中不仅讨论了事件,也涉及了一点分布式日志和流处理。用Kafka实现这种架构时,设计原则稍有不同。Broker的存在让我们有用武之地,让我们可以获得外部的数据,必要时还可以追溯过去的数据。
流平台非常适合这种处理事件和构建视图的模型。视图自然地嵌入在服务之内,完成远程服务查询,或者在持续的流中查询固化下来的数据。
这样就可以进行全局性的优化:利用事件流和事件存储的持久性特性,用流处理工具来对事件进行处理,将多个服务和我们可以查询的固化视图关联起来。这些模式很强大,我们可以用专门为事件流设计的处理工具来重新审视整个业务处理过程。但所有的优化都基于我们在这里讨论的内容,通过一套主流的工具集就可以实现。