是时候改变你对微服务的认知了!
微服务是一种架构风格(但没有精确的定义),一个大型复杂软件应用由一个或多个微服务组成。系统中的各个微服务可被独立部署,各个微服务之间是松耦合的。它们也具有一些共同的特性,如围绕业务能力组织服务、自动化部署、智能端点、对语言及数据的“去集中化”控制等等。大部分时候,微服务都是建立在一种基于请求和响应的协议之上。比如,REST等。每个微服务仅关注于完成一件任务并很好地完成该任务。在所有情况下,每个任务代表着一个小的业务能力。
随着service数量急速的增长,同步交互比例也随着service在急速增长。这时候,我们的service就会遇到很多的瓶颈。对于这种问题市面上也有一些解决方案。一种方案就是确保您的个人服务具有比您的系统更高的SLA。另一种方法是简单地分解将服务绑定在一起的同步关系。但是这种解决方法不能够从根本上解决问题。所以我们可以采用异步机制来解决这个问题。
比如,电商网站中你会发现同步接口getImage()或者processOrder(),看上去没有什么奇怪的地方,但当用户点击了“购买”后,触发了一个复杂且异步的处理过程。这个过程涉及到购买、送货上门给用户,这一切都是发生在当初的那一次的按钮点击。所以我们就需要把一个程序处理逻辑切分成多个异步,这就是问题所在。
但是后面你后发现我们其实已经自动异步了。因为我们自己会定时轮询数据库表来更改又或者通过cron定时job来实现一些更新。而这些方法都是一些打破同步的方式,虽然这种做法总让人记得不是很好。所以这次的文章里面我们要讨论的不是把service们通过命令链揉到一块,而是通过事件流(stream of events)来做,这是一种完全不同的架构,也是一个不错的方式。但是在讨论之前,我们需要做一个简单的基础讨论,普及三个简单的概念。
两个service之间有三种交互方式,分别是命令(Commands)、事件(Events)以及查询(Queries)。从service的角度来说,事件要比命令和查询都要解耦。除此以外,服务之间的交互有三种机制:
①Commands :命令是一个操作。希望在另一个服务中执行某些操作的一个请求,这会改变系统状态的东西, 属于请求驱动,命令期待有响应。
②Events :事件既是一个事实也是一个触发器。 发生了一些事情,表示为通知。
③Queries :查询是一个请求,是一个查找一些东西的请求, 属于请求驱动。但是查询不会使得系统状态发生改变。
下面举一个简单的例子,一个简单事件驱动流程。
去购买一个物品那后续动作就是支付以及系统检查是否还有更多的商品需要被订购。
首先要注意的问题是“购买更多”的这个业务流程是随着订单服务(Order Service)一块被初始化的。责任跨了两个service,这就使得责任不独立。如果我们使用事件驱动方式的话,那么事情就会变得好一些。
首先是在返回给用户之前,UI service 发布一个OrderRequested事件,然后等待OrderConfirmed(或者Rejected)。
订单服务(Orders Service)和库存服务(Stock Service) react这个事件。
最后会发现UI service和Orders Service并没有改变很多,而是通过事件来通信。
Order Service告诉Stock service(库存服务)要做什么。然后StockService自己决定是否参与本次交互,这是事件驱动架构非常重要的属性,也就是说接收者驱动流程控制。这种控制反转给接收者,很好的解耦了服务之间的交互,这就为架构提供了可插拔性,组件们可以轻松的被插入和替换掉。
但是随着架构变得越来越复杂,这种可插拔性的因素变得更加重要。比如我们要添加一个实时管理定价的service,根据供需调整产品的价格。在一个命令驱动的世界里,我们就需要引入一个可以由库存服务(Stock Service)和订单服务(Orders Service)调用的类似updatePrice()这样的方法。但是在事件驱动世界更新价格的话,service只需要订阅共享的stream就可以了,当相应的条件符合时,就去执行更新价格的操作。
除了命令和事件之外,还有一个查询,那就通过上面的例子来讲讲查询吧。首先根据上面的例子让订单服务(Orders Service)在支付之前检查是否有足够的库存。
一般来说,在请求驱动(request-driven)的架构中,我们可能会向库存服务(Stock Service)发送一个查询请求然后获取到当前的库存数量。对于服务(service)需要独立发展的较大的生态系统,远程查询要涉及到很多关联,耦合很严重,要把很多服务捆绑在一起。我们可以通过“内部化”来避免这种涉及多个上下文交叉的查询。而事件流可以被用于在每个service中缓存数据集,这样我们就可以在本地来完成查询。
但是这就导致了模型混合,事件流纯粹被用作通知,允许任何的service加入flow,但查询却是通过请求驱动的方式直接访问源。所以,增加库存检查,订单服务(Order Service)可以订阅库存服务(Stock Service)的事件流,库存一有更新,订单服务就会收到通知,然后把更新存储到本地的数据库。这样就可以通过查询本地视图(view)来检查是否有足够的库存。
但是通过事件来传播( “Queryby Event Propagation”)的查询也是有好处的。
首先,这样可以更好的解耦:在本地查询。这样就不涉及跨上下文调用了。这种做法涉及到的服务们远远不及那种”请求驱动”所涉及到的服务数量多。
其次,能更好的自治:订单服务(Order Service)拥有一份库存数据集的copy,所以订单服务可以任意使用这个本地的数据集,而不是说像请求驱动里的那样仅仅只能检查库存限额,只能通过Stock Service所提供的接口。
最后是高效Join:如果我们在每次下订单的时候都要去查询库存,就要求每次都要高效的做join,通过跨网络对两个service进行join。随着更多的数据源需要关联,或者需求的增加,这可能会变得越来越艰巨。所以通过事件传播来查询(Query by Event Propagation)将查询(和join)本地化后就可以解决这个问题。
但是这种做法会让Service从本质上变得有状态,这样就使得他们需要被跟踪和矫正这些数据集,随着时间的推移,也就是你得保证数据同步。状态的重复也可能使一些问题更难理解,所以在解决问题的时候需要多考虑。
在面对事件驱动风格的系统,一个非常有用的原则就是针对指定类型的传播的事件分配责任的时候,考虑单一的写入者(只分配给一个单一的service)。因为Stock Service只应该处理库存这一件事情,而Order Service也只属于订单。
这样的话有助于我们通过单个代码路径(尽管不一定是单个进程)来排除一致性,验证和其他写入路径(writepath)问题。
所以尽管整个事件流跨越了订单(Orders),付款(Payments)和发货(Shipments),但是订单服务(Order Service)控制着对订单进行的每个状态的更改,总的来说每个都由它们各自的服务来管理。
分配事件传播(event propagation)的责任很重要,因为这些不仅仅是短暂的事件。随着时间的推移,服务(services)需要去负责更新和同步这些共享数据集(shared datasets):比如,修复错误,处理schema的变化等情况。他们代表了共同的事实(facts),以及数据在外部(data-on-the-outside)。
当用户点击“购买”时,会引发“Order Requested”,等待“Order Confirmed”事件,然后再回复给用户。 另外三个服务处理与其工作流程部分相关的状态转换。 例如,付款处理完成后,订单服务(Order Service)将订单从“已验证(Validated)”推送到“已确认(Confirmed)”。以上就是单一写入者原则。
其实上面的说到的模型和像企业消息(Enterprise Messaging)相似,但其实是有一些不同的。企业消息在实践中主要关注状态的转换,通过网络有效地将数据库捆绑在一起。而事件协作(Event Collaboration)则更偏重的是协作,既然是协作就不简单的是状态转换,事件协作是关于服务(service)通过一系列事件进行一些业务目标,这些事件将触发service的执行。所以这是业务处理(business processing)的一种模式,而不是简单的转换状态的机制。
我们可能希望提供远程查询的方便灵活性,而不是本地维护数据集的成本,特别是数据集增长时。我们现在很多都是无状态的,比如容器或者浏览器,在这种情况下也许远程查询是一种合适的选择这样的话就会让我们的查询变得更加的简单,我们只需要轻松部署简单的函数就可以了。
通常情况下,建立一个具有多个特定,具体视图的架构,而不是单一的共享数据存储。远程查询设计的诀窍就是限制这些查询接口的范围,理想情况下应该是在有限的上下文中(context)。注意是多个具体的视图,而不是单一的共享数据存储。(一个独立(bounded)的上下文一般是指有那么一组service,它们共享同一个发布流水线或者是同一个领域模型【domain model】)。
我们可以使用一种叫做“集群式上下文模式(clustered context pattern)”,这是为了限制远程查询(remote queries)的边界(scope)。这种情况下,事件就流纯粹是用作上下文之间的通信。但在一个上下文里的具体service们则可以既有事件驱动(event-driven)的处理,同时也有请求驱动(request-driven)的视图(view),具体根据实际情况需要。这就是模式(Patterns)和集群服务(Clustering Services)的混合。
在集群上下文模型(Clustered Context Model)中事件驱动(event-driven)五个关键好处:
①解耦:把一个很长的同步执行链的命令给分解,异步化。分解同步工作流。 Brokers 或topic解耦服务(service),所以更容易插入新的服务(service),具有更强的插拔性。
②离线/异步流:当用户点击按钮时,一些同步,一些异步,很多事情都会发生。 对能力的设计,无论是以前的,还是将来的,都是更自由的。提高了性能以及自由度。
③状态同步更新:事件流对分布式数据集提供了一种有效的机制,数据集可以在一个有界的上下文里被重构(“传播”或“更新”)和查询。
④Joins:从不同的服务(service)组合/join/扩展数据集更容易。本地化的 join更快速。
⑤可追溯性: 当有一个统一化的,中心化的,不可变的,保持性的地方来记录每个互动时,它会及时展现。
总的来说,在事件驱动的方法中我们使用事件(Events)而不是命令(Commands)。事件触发业务处理过程,事件也可以用到更新本地视图上。当然在必要时,我们可以再回到远程同步查询这种方式,特别是在较小的系统中,而且我们还将远程同步查询的范围扩大到更大的范围(理想情况下,还是要仅限于单个独立的上下文,也就是单个领域模型,不能再扩大)。而且所有这些方法都只是模式(pattern)。模式就会有框得太死的问题。模式覆盖不到的地方,我们就要具体情况具体对待了。例如,单点登录服务。
从事件的基准出发去考虑问题。事件让服务之间不再耦合,并且将控制(flow-control)权转移到接收者,这就有了更好的“分离关注(separated concerns)”和更好的可插拔性。
关于事件驱动方法,它们对于大型且复杂的架构同样适用,就像它们对于小型,高度协作的架构一样。事件让service们可以自主的决定自己的所有事情,为服务们提供自由发展所需的自主权。
另外,关于事件和查询混合的场景。在纯事件驱动方法中,查询完全基于本地的数据集,而没有远程查询。本地数据集则是通过事件触发来更新状态。然而,很多时候,基于请求驱动的查询方式在很多时候也是比较方便的,因为本地数据集的方式,状态的同步更新确实是一件更加需要成本的事情。
还有单一写入者让我们数据更新有了统一的入口,有助于我们通过单个代码路径(尽管不一定是单个进程)来排除一致性,验证和其他“写入路径(writepath)”问题。
最后我们讨论了集群上下文模型。每个领域模型组成一个独立的区域,然后再由多个区域共同组成一个领域模型集群,模型之间又通过Kafka来交互。每个领域模型里又可以包含几种模式的混合,比如Events、Views、UI,这些里边可以既有事件驱动模式,又有请求驱动模式。