微服务架构变得越来越流行了。它是模块化的一种方法。它把一整块应用拆分成一个个服务。它让团队在开发大型复杂的应用时更快地交付出高质量的软件。团队成员们可以轻松地接受到新技术,因为他们可以使用最新且推荐的技术栈来实现各自的服务。微服务架构也通过让每个服务都被部署在最佳状态的硬件上而改善了应用的扩展性。
但微服务不是万能的。特别是在 领域模型、事务以及查询这几个地方,似乎总是不能适应拆分。或者说这几块也是微服务需要专门处理的地方,相对于过去的单体架构。
在这篇文章中,我会描述一种开发微服务的方法,这个方法可以解决这些问题。主要是通过领域模型设计,也就是DDD以及事件源(Event Sourcing)以及CQRS。让我们首先来看看开发人员在开发微服务的时候会遇到哪些问题吧。
微服务开发过程中的挑战
模块化在开发大型复杂的应用的时候是非常有必要的。
现在许多应用大到一个人根本无法完成。而且复杂到光靠一个人去理解是不可能的。
这种情况下,应用就必须被拆分成一个个模块。在单体应用中,模块被定义为比方一个java package。然而,这种做法在实践中并不是很理想,时间长了,单体应用就变得越来越庞大。微服务架构把服务作为一个模块单元。
每个服务对应一个业务能力,这个业务能力是组织为了创造价值而需要的。例如,基于微服务的在线商店包括各种服务,包括订购服务(Order Service),客户服务(Customer Service),目录服务(Catalog Service)。
每个服务都有一个不可渗透且很难违反的边界。也就是每个微服务要提供一种单独而独立的能力。这样的话,应用程序的模块化就更容易随时间保存。
微服务架构还有其他优点。包括独立地部署服务,独立地扩展服务等等这些能力。相比单体来说。
不幸的是,拆分并没有听起来那么容易。相当难。
应用的领域模型,事务,查询这三个东西就是拆分过程中和拆分后你所面临的拆分难题。让我们来看看具体原因吧。
问题1 – 拆分领域模型
领域模型模式是实现复杂业务逻辑的一种非常好的方式。比如针对一个在线商店,领域模型将会包含这么几个类: Order, OrderLineItem, Customer 和 Product。在微服务架构中,Order和OrderLineItem类是Order Service的一部分;Customer是Customer Service的一部分;Product属于Catalog Service的一部分。
拆分领域模型的挑战之一就是class们通常会引用一个或多个其他类。
比如,Order类引用了该订单的客户Customer;OrderLineItem引用了该订单所订产品Product。
对于这些想要横跨服务边界的引用,我们该怎么办呢?
稍后你将会看到一个来自领域模型设计的概念:聚合(Aggregate)。我们通过聚合来解决这个问题。
微服务和数据库
微服务架构的一个非常明显的功能就是一个服务所拥有的数据只能通过这个服务的API来访问。
在一个电商网站中,比如,OrderService占有一个数据库,里边有一张表ORDERS;CustomerService也有自己的数据库包含表CUSTOMERS。
通过这样的封装,微服务之间就解耦了。
在开发期间,开发人员可以独立修改自己服务的数据库shema而不需要与其他服务的开发协调勾兑。
在生产上,服务之间都是隔离的。比如,一个服务从来不会因为另外一个服务占有了数据库的锁而导致阻塞等待。
不幸的是,这种数据库的拆分让管理数据的一致性以及不同服务间跨表查询变得困难。
问题2 – 跨服务分布式事务实现
一个传统的单体应用可以通过ACID事务来强制业务规则从而实现一致性。
想象一下,比如,电商里的用户都有信用额度,就是在创建订单之前必须先看信用如何。
应用程序必须确保潜在的多个并发尝试去创建订单不超过客户的信用限额。
如果Orders和Customers都在同一个库中,那么就可以使用ACID事务来搞定:
BEGIN TRANSACTION … SELECT ORDER_TOTAL FROM ORDERS WHERE CUSTOMER_ID = ? … SELECT CREDIT_LIMIT FROM CUSTOMERS WHERE CUSTOMER_ID = ? … INSERT INTO ORDERS … … COMMIT TRANSACTION
不幸的是,在微服务架构中我们无法通过这种方式管理数据的一致性。
ORDERS和CUSTOMERS表被不同的服务所拥有,只能通过各自的服务API访问。他们甚至可能在不同的数据库。
一种比较常见的做法就是使用分布式事务来搞定,比如2PC等。但是这种做法对于现代应用来说也许不是一种可行的方案。CAP定理要求你必须在可用性和一致性之间选择,可用性通常是较好的选择。
而且,许多现代技术,例如大多数NoSQL数据库,甚至不支持ACID事务,更不用说2PC。
所以管理数据的一致性需要使用其他的方式。
稍后你将会看到我们使用事件驱动架构中的一种技术叫事件源(event sourcing)来解决分布式事务。
问题3 -查询
管理数据一致性不是唯一的挑战。还有一个问题就是查询问题。
在传统的单体应用中,我们通常使用join来实现跨表查询。
比如,我们可以通过下面的sql轻松的查询出最近客户所订的大额订单:
SELECT * FROM CUSTOMER c, ORDER o WHERE c.id = o.ID AND o.ORDER_TOTAL > 100000 AND o.STATE = 'SHIPPED' AND c.CREATION_DATE > ?
但我们无法在微服务架构中实现这样的查询。
就像前面提到的那样,ORDERS与CUSTOMERS表分属不同的服务,只能通过服务API来访问。
而且他们可能使用了不同的数据库。
而且,即使你使用事件源(Event Sourcing )处理查询问题可能更麻烦。
稍后,你将会学习到一种解决方案就是通过一种叫CQRS(Command Query Responsibility Segregation)做法来解决分布式查询问题。
但首先,让我们看看领域驱动设计(DDD)这个工具,在我们的微服务架构下基于领域模型开发业务逻辑是必要的。
DDD聚合是微服务的构建块
像你看到的那样,为了使用微服务架构成功的开发业务应用,我们必须去解决上面所说的那些问题。
这几个问题的解决办法你可以去Eric Evans的书Domain-Driven Design中找得到。
这本书,是2003年出版的,主要介绍了设计复杂软件的一些方法。这些方法对开发微服务也同样有用。
尤其是领域驱动设计可以让你创建一个模块化的领域模型,这个领域模型可以被多个微服务所使用。
什么是聚合?
在领域驱动设计中,Evans为领域模型定义了几个构建块。
许多已经成为日常开发人员语言的一部分,包括entity,就是指一个具有唯一标识的持久化对象。value object,也就是VO,你经常听说的,是用来存放数据的,可以与数据库表对应,也可以不对应,有点类似用来传输数据的DTO。service,就是指包含业务逻辑的服务。但不应归类到entity或者value object。
repository,表示一堆entity 的集合就是一个repository。
构建块(building block),聚合(aggregate)常常被开发人员忽略,除了那些DDD爱好者,或者叫“狂热分子”。
然而,聚合(aggregate)被证明是开发微服务的关键,非常重要。
一个聚合(aggregate)就是一组domain的集合,可以被当作一个单元来处理。这里说的一个单元就是可以当做原子来处理。
它包含了一个root entity以及可能还有一到多个关联的entity以及value object。
比如,针对一个在线商店的domain model就会有几个聚合,比如Order和Customer。
Order聚合又由一个root entity Order和一个以上的OrderLineItem value object组成,而且OrderLineItem还有可能关联有其他vo,比如快递地址(Address)以及支付账户信息PaymentInformation。
Customer聚合又由一个root entity Customer和其他的vo比如DeliveryInfo 和PaymentInformation组成。
使用聚合将领域模型(domain model)分散和参与到每个聚合中,这也使得领域模型更容易理解了。这也同时厘清了操作的scope,比如查询操作和删除操作等。
一个聚合通常作为一个整体被从数据库中load出来。删除一个聚合,也就是删除了里边所有的object。
然而,聚合的好处远远超出了模块化一个领域模型。 这是因为聚合必须遵守一定的规则。
聚合之间的引用必须使用主键
第一个规则就是聚合通过id(例如主键)来引用而不是通过对象引用 。
比如,Order通过customerId来引用Customer,而不是引用Customer的对象。
类似的,OrderLineItem通过productId来引用Product。
这种做法与传统的object modeling非常的不同。虽然后者认为通过外键引用在领域模型中这样做看起来怪怪的。
通过使用ID而不是object引用,意味着聚合是松耦合。你可以轻松地把不同的聚合放在不同的service。
事实上,一个微服务的业务逻辑是由一个领域模型组成。这个领域模型是几个聚合的一个组合。比如,OrderService包含了Customer聚合。
一个事务只创建或更新一个聚合
第二个规则就是聚合必须遵循一个事务只能对一个聚合进行创建或更新。
当我第一次看这些规则的时候,当时并没有什么感觉。因为那时候,我还在开发传统的单体应用,那种基于RDBMS的应用。所以事务可以更新任何的数据。今天,这些约束依然适用于微服务架构。它确保一个事务只被包含在一个微服务中。此约束还符合大多数NoSQL数据库的有限事务模型。
当开发一个领域模型,一个很重要的事情就是你必须确定每个聚合得搞多大。
一方面,聚合理想情况下应该是小的。它通过分离关注点来改善模块化。
这是更有效的,因为聚合通常被全部加载。
此外,由于对每个聚合的更新是顺序发生的,因此使用细粒度聚合将增加应用程序可以处理的并发请求数,从而提高可扩展性。
它还将改善用户体验,因为它降低了两个用户尝试更新同一聚合的可能性。
另一方面,因为聚合是事务的范围,您可能需要定义一个较大的聚合,以使特定的更新原子化。
例如,之前我描述了在在线商店领域模型中,Order和Customer是独立的聚合。
另一种设计可以是把Orders作为Customer聚合的一部分。
一个较大的Customer聚合的好处就是应用可以强制对于信用额度进行原子验证。这种方法的缺点是它将订单和客户管理功能组合到同一服务中。这也降低了可扩展性,因为更新同一客户的不同订单的事务将被顺序化。
类似的,两个用户去尝试编辑同一个客户下的不同订单有可能会冲突。而且,随着订单数量的增加,加载一个Customer聚合的成本也会变得更昂贵。
由于这些问题,尽可能的把聚合细粒度是最好的。
即使一个事务只能创建和更新一个单独的聚合,微服务应用中也依然必须去管理聚合之间的一致性。
在Order服务中必须验证一个新建的Order聚合将不超过Customer聚合的信用额度。
这里有两种不同的解决一致性的方法。
一个做法就是在单个事务中欺骗的创建和/或更新多个聚合。这种做法的前提是,所有的聚合都被一个服务所拥有并且这些聚合都被持久保存在同一个RDBMS中才有可能。
另一个做法就是使用最终一致的事件驱动(event-driven)方法来维护聚合之间的一致性。
使用事件驱动来维护数据一致性
在现代应用中,对事务有各种约束,这使得难以在服务之间维持数据一致性。
每个服务都有自己的私有的数据,这时候2PC的方案就变得不可行了。
更重要的是,很多的应用使用的是NoSQL数据库,这些数据库根本就不支持本地ACID事务,更不用说分布式事务了。
因此,现代应用程序必须使用事件驱动的,最终一致的事务模型。
什么是事件(Event)?
根据Merriam-Webster(一个单词网站),事件的意思就是:something that happens:
在本文中,我们将领域事件定义为聚合发生的事件。一个事件(event)通常表示一个状态的改变。现在还是拿电商系统举例,一个Order聚合。其状态更改事件包括订单已创建(Order Created),订单已取消(Order Cancelled),订单已下达(Order Shipped)。事件可以表示违反业务规则的动作,如客户(Customer)的信用额度。
使用Event-Driven架构
服务们使用事件来管理聚合之间的一致性,像下面这样的一个场景:一个聚合发布事件,比如,这个聚合的状态改变或者一次违反业务规则的尝试等等。
其它聚合订阅这个事件,然后负责更新他们自己的状态。
在线商店制创建一个订单(order)的时候验证客户(customer)信用额度使用下面一系列步骤:
1.一个订单(Order)聚合创建,并且状态为NEW,发布一个OrderCreated 事件。
2.客户(Customer)消费这个OrderCreated事件,然后保存为这个订单保存信用值然后发布一个CreditReserved事件。
3.订单(Order)聚合消费CreditReserved事件,然后修改自己的状态为APPROVED。
如果信用检查由于资金不足而失败,则客户(Customer)聚合发布CreditLimitExceeded事件。
这个事件不对应于一个状态的改变,而是表示一次违反业务规则的失败尝试。 订单(Order)聚合消费这个事件后,并将自己的状态更改为CANCELLED。
微服务架构可以比作事件驱动聚合的Web
在这个架构下,每个服务的业务逻辑都是由一个或多个聚合组成。
一个事务只能包含一个服务,并且是更新或创建一个单独的聚合。也就是聚合内事务。
服务们通过使用事件管理聚合之间的一致性。
这种做法一个非常明显的好处就是一个个聚合变成了松散而解耦的构建块。
他们可以被作为单体应用来部署或者作为一组服务来部署。
这种情况下,在一个project开始的时候,你可以使用单体架构。
之后,随着应用的体积和开发团队的规模的扩大,你就可以很容易的切换到微服务架构上来。
总结
微服务架构从功能上把一整个应用拆分成了一个个服务,每个服务又都对应一个业务能力。当我们开发基于微服务架构的业务应用的时候,一个关键的挑战就是事务、领域模型以及查询,这三个主要的麻烦都是拆分之后所带来的问题。你可以通过使用DDD聚合的概念来拆分领域模型。每个服务的业务逻辑是一个领域模型,然后这个领域模型是由一个或多个DDD聚合组成。
在每个服务中,一个事务只能创建或更新一个单独的聚合。由于2PC对于现代应用来说并不是一个可行的解决方案,所以我们需要使用事件机制来去实现聚合之间的一致性(以及服务之间)。在下一集,我们会描述使用event sourcing来实现一个事件驱动的架构。我们也会向你展示在微服务架构下通过使用CQRS来实现查询。
———————————————————————————
以上我们阐述了使用微服务体系架构的关键障碍是领域模型,事务和查询,这三个障碍似乎和功能拆分具有天然的对抗。只要功能拆分了,就涉及这三个难题。
然后我们向你展示了一种解决方案就是将每个服务的业务逻辑实现为一组DDD聚合。然后每个事务只能更新或创建一个单独的聚合。然后通过事件来维护聚合(和服务)之间的数据一致性。
在本集中,我们将会向你介绍使用事件的时候遇到了一个新的问题,就是怎么样通过原子方式更新聚合和发布事件。然后会展示如何使用事件源来解决这个问题,事件源是一种以事件为中心的业务逻辑设计和持久化的方法。之后,我们会阐述微服务架构下的查询困难的问题。然后向你介绍一种称为命令查询责任分离(CQRS)的方法来实现可扩展和高性能的查询。
可靠地更新状态和发布事件
从表面上看,使用事件来保持聚合之间的一致性似乎很简单。
当一个服务创建或更新数据库的一个聚合时,它只是简单地发布一个事件。
但是,这只是表象,其实还有一个核心问题就是:更新数据库和发布事件必须是原子的。否则,就会出现类似这样的情况:如果服务在更新数据库之后但在发布事件之前崩溃,则系统就出现了不一致的问题。
传统的解决方案是一般都是使用分布式事务来搞,一个涉及数据库和消息broker的分布式事务。但是,由于上一集所述的原因,2PC不是一个可行的选择。
其实除了2PC ,还有几种解决这个问题的方法。
一种解决方案就是,应用程序可以通过向类似Kafka这样的消息中间件的broker发布一个事件来执行更新。然后一个消息consumer订阅这个事件,通过消费该事件然后最终更新数据库。这种方法可以确保数据库被更新并且事件被发布。
但是缺点就是这种一致性模型过于复杂,至少有点复杂。而且应用程序不能够立即读取到自己刚刚的写入。
图1 – 通过发布事件到消息broker来更新数据库
另一种做法就是,如图2所示,就是应用程序追加事务日志到数据库(a.k.a.commit log),将每个记录的更改转换为事件,然后把事件发布到消息broker。这种做法的一个重要好处就是应用程序本身不需要任何的改变。
然而,一个缺点是,这种做法是一种底层(low-level)的事件,而不是上层业务事件。可能难以将上层业务事件(由于数据库更新的原因)从底层更改逆转到表中的行。
原文:it can be difficult toreverse engineer the high-level business event – the reason for the databaseupdate – from the low-level changes to the rows in the tables.
图2 – 追加数据库事务日志
第三种解决方案就是,图3所示的这种,使用数据库表来作为一种临时性的message queue。当一个服务更新一个聚合,它会insert一个事件到EVENTS表,作为本地ACID事务的一部分。然后一个单独的进程轮询EVENTS表并将事件发布到消息broker。
这种做法的好处就是service能够发布high-level的业务事件。
缺点是这种做法容易出错,有这种潜在的可能,因为事件发布代码必须与业务逻辑同步。
图3 – 使用数据库表作为message queue
上面三种做法都有比较典型的缺点。
发布一个事件到message broker并稍后更新的做法总是不能提供一种read-your-writes的一致性,也就是只能保证最终一致。
追加事务日志提供了一致的读取,但却不能发布高级业务事件。
使用数据库表作为message queue提供了一致的读取并且可以发布high-level业务事件,但
却对开发人员有依赖,就是开发人员得记得在状态发生改变的时候加上发布事件的逻辑。
幸运的是,我们还有另外一种解决方案,那就是event sourcing,事件源。它是一种针对持久化和业务逻辑的一种以事件为中心方法,称为事件源。这里解释的不够清楚,稍后慢慢展开。
使用事件源来开发微服务
事件源(Event sourcing)是一种以事件为中心的持久化方法。这不是一个新的概念。
我第一次了解到这个概念是在大概五年多以前,之后对这个新生事物一直充满了好奇,直到我开始开发微服务。接下来,你将会看到通过事件源来实现事件驱动的微服务架构是多么不错的一种方法。
一个service通过event sourcing使用一系列的事件来持久化每个聚合。
当创建或更新一个聚合的时候,这个service会在数据库里保存一个或多个事件,这种数据库里存储event的方式可以叫做是event store,以下我们就叫“事件数据库”。
它通过加载这些事件并replay这些事件,从而实现更新聚合的当前状态。
在函数式编程里,一个service通过执行一个函数式的fold或reduce来重构聚合,而不是事件。
由于事件就是状态,所以你就不会再有原子地更新状态和发布事件的问题了。
例如,比如订单服务(Order Service)。不是将每个订单作为一行存储在ORDERS表中,而是将每个订单聚合作为一系列的事件,比如订单已创建,订单已批准,订单已发货等持久化到EVENTS表中。图4显示了这些事件如何存储在基于SQL的事件数据库(event store)中。
图4 – 使用事件源来持久化一个订单
每列的意思:
- entity_type 和entity_id –唯一标识一个聚合
- event_id – 事件ID,唯一标识
- event_type – 事件类型
- event_data -事件属性的序列化JSON表示
一些事件包含大量数据。例如,订单创建(Order Created)事件包含完整订单,包括其订单项,付款信息和交货信息。其他事件,如订单出货(Order Shipped)事件,包含很少或没有数据,只是表示状态转换。
事件源(Event Sourcing)和发布事件
严格的讲,事件源只是简单的将聚合们作为事件进行了持久化。更直接的说,就是使用事件源来作为一种可靠的事件发布机制。保存一个事件是一个固有的原子操作,它可以确保事件数据库(event store)把事件传递给感兴趣的服务。
例如,如果事件被存储在上面所示的EVENTS表中,订阅者可以简单地轮询表以查找新事件。更复杂的事件数据库(event store)将使用另一种做法,这种做法具有更高性能和可扩展性。例如,Eventuate Local使用追加事务日志的方式。它从MySQL replication流中读取插入到EVENTS表中的事件,并将它们发布到Apache Kafka。
至于Eventuate Local是个什么鬼?你可以去github 搜搜。下面放一张图:
使用Snapshot改善性能
订单(Order)聚合具有相对较少的状态转换,因此它只有少量的事件。
所以,针对这些事件查询事件数据库(event store)并重构Order聚合,效率是不错的。然而,一些聚合有很多的事件。例如,客户(Customer)聚合可能有大量的预留信用(Credit Reserved)事件。随着时间的推移,加载和消费(fold)这些事件的效率会越来越低。
一个常见的解决方案是定期保存聚合状态的快照(snapshot)。应用程序通过加载最近的快照然后从快照创建之后发生的那些事件开始来恢复聚合的状态。
在函数式下,快照就是折叠(fold)的初始值。(原文:In functional terms, the snapshot is the initial value of thefold. )如果聚合是一个简单,容易序列化的结构,则快照可以简单地是JSON序列化格式。更复杂的聚合可以使用Memento模式(Mementopattern)进行快照。至于这种设计模式具体是什么鬼,你可以自己查阅。
在线商店示例中的客户(Customer)聚合具有非常简单的结构:客户的信息,他们的信用额度(credit limit)和他们的信用预留(credit reservations)。
客户(Customer)的快照只是其状态的JSON序列化。图5展现了如何从与事件#103的客户(Customer)的状态相对应的快照中重新创建一个客户(Customer)。客户服务(Customer Service)只需要加载快照和加载事件#103后发生的事件。
图5 – 使用快照来优化性能
客户服务(Customer Service)通过反序列化快照的JSON后加载并消费#104到#106的事件来重新创建那个客户(Customer)。
事件源实现
事件数据库(event store)是数据库和消息borker的混合体。它是一个数据库,因为它有一个API,用于通过主键插入和检索聚合的事件。事件数据库(event store)也是消息broker,因为它具有用于订阅事件的API。
有一些不同的方法来实现事件数据库(event store)。
一个做法是编写自己的事件源框架。例如,您可以在RDBMS中持久化事件。一种简单的,但性能略低的方式来发布事件,然后订阅者轮询事件的EVENTS表。
另一个做法是使用专用的事件数据库(event store),它通常能够提供更丰富的功能以及更好的性能和可扩展性。“事件源”的开发者之一Greg Young有一个基于.NET的开源事件数据库,称为Event Store。 Lightbend,这个公司以前叫Typesafe,有一个叫Lagom的微服务框架,是基于事件源的。这里推荐一个我自己的创业项目,Eventuate,一个用于微服务的事件源框架,你可以把它作为一个云服务,你也可以把它认为是一个基于Kafka 或RDBMS的开源项目。
事件源的好处与缺点
事件源有好处也有缺点。
事件源的一个主要优点是它可以在聚合的状态发生变化时可靠地发布事件。它为事件驱动的微服务架构打下了良好的基础。而且,由于每个事件都可以记录进行更改的用户的身份,因此事件源还提供了一个准确的审核日志。事件流可用于各种其他目的,包括向用户发送通知以及应用集成等等。
事件源的另一个好处是它存储每个聚合的整个历史。你可以轻松实现检索聚合的过去状态的时态查询。要确定在给定时间点的聚合的状态,您只需消费(fold)直到该点为止发生的事件。例如,可以直接计算过去某个时间点客户的可用信用额。
事件源也避免了O / R阻抗失衡的问题。这是因为它持久化了事件而不是聚合。事件通常具有简单,容易序列化的结构。服务(service)可以通过序列化其状态的记录来对复杂聚合进行快照。 Memento模式在聚合和它的序列化表示之间增加了一个中间层。
有关O/R impedance mismatch:
对象关系阻抗失衡(object-relational impedance mismatch )是当关系数据库管理系统(RDBMS)由以面向对象的编程语言或风格编写的应用程序(或多个应用程序)服务时经常遇到的一组概念和技术困难,特别是因为对象或类定义必须映射到关系模式定义的数据库表。
事件源当然不是完美的,它也有一些缺点。它是一个完全不一样的和而且你可能并不熟悉的编程模型,所以要花一些时间去学习。为了使现有应用程序使用事件源,你必须要重写业务逻辑。幸运的是,这是一个相当机械的转换,你可以在将应用程序迁移到微服务的时候做这件事情。
事件源的另一个缺点是消息broker通常保证至少一次(at-least once)传递。非幂等的事件处理handler必须检测并丢弃那些重复的事件。事件源框架可以通过为每个事件分配单调递增的id来解决这个问题。事件处理handler然后可以通过对最大事件ID跟踪来检测重复事件。
事件源的另一个局限就是事件(和快照!)的schema将随时间发展。 由于事件永久存储,当服务重建聚合时,服务可能需要折叠与多个schema版本对应的事件。 简化服务的一种方法是,当事件源框架从事件数据库(event store)加载它们时,将所有事件转换为最新版本的模式。因此,服务只需消费(fold)最新版本的事件。
事件源的另一个缺点是查询事件数据库(event store)可能比较困难。让我们想象一下,例如,您需要找到信用额度较低的客户。你不能简单地写SELECT * FROM CUSTOMERWHERE CREDIT_LIMIT <? AND c.CREATION_DATE>?。因为根本就没有信用额度(CREDIT_LIMIT)这样的列。相反,你不得不使用嵌套SELECT的更复杂而且还可能无效的查询,通过处理和消费(fold)事件来计算信用额度。更糟糕的是,基于NoSQL的事件数据库(event store)通常只支持基于主键的查找。因此,必须使用“命令查询责任分离“(CQRS)的方法实施查询。CQRS 的全称:Command Query Responsibility Segregation。
我们接下来的内容就是介绍CQRS。
使用CQRS实现查询
事件源是在微服务体系结构中实现高效查询的主要障碍。这还不是唯一的问题,还有比如你使用SQL去查找一些高价值订单的新客户。
SELECT * FROM CUSTOMER c, ORDER o WHERE c.id = o.ID AND o.ORDER_TOTAL > 100000 AND o.STATE = 'SHIPPED' AND c.CREATION_DATE > ?
在微服务架构中,你不能join CUSTOMER和ORDER这两张表。每个表由不同的服务所拥有,并且只能通过该服务的API访问。你不能编写连接多个服务所拥有的表的传统查询。事件源使事情变得更糟,阻碍你编写简单,直接的查询。让我们来看看在微服务架构中是如何实现类似查询的。
如何使用CQRS
实现查询的好方法是使用称为命令查询责任分离(CQRS)的体系结构模式: Command Query Responsibility Segregation。如名称所示,CQRS将应用程序分为两部分。第一部分是命令侧(command-side),其处理命令(例如,HTTP POST,PUT和DELETE)以创建,更新和删除聚合。前提是这些聚合是使用事件源实现的。应用程序的第二部分是查询侧(query-side),其通过查询聚合的一个或多个物化视图(materialized views)来处理查询(例如HTTP GET)。查询侧通过订阅由命令侧发布的事件来保持视图(view)与聚合(aggregate)同步。
查询侧(query-side)视图可以使用任何类型的能满足需求的数据库来实现。根据需求,应用程序的查询端可能使用一个或多个以下数据库:
表1. 查询侧视图数据库选择
在很多场合,CQRS是一个以事件为基础(event-based)的综合体,比如使用RDBMS作为记录系统再使用比如Elasticsearch来处理文本查询。CQRS的查询侧可以使用其它类型的数据库,支持多种类型的数据库,不仅仅是文本搜索引擎。而且,它通过订阅事件准实时地去更新查询侧的视图。
图6显示了应用于在线商店示例的CQRS模式。客户服务(Customer Service)和订单服务(Order Service)是命令端服务。它们提供用于创建和更新客户和订单的API。客户视图服务(Customer View Service)是查询侧服务。它提供了一个用于查询客户的API。
图6 – 在线商店中使用 CQRS
客户视图服务(Customer View Service)订阅命令端服务发布的客户(Customer)和订单(Order)事件。它更新那个用MongoDB实现的视图存储(view store)。该服务维护一个MongoDB文档集合,每个客户一个。每个文档都具有客户详细信息的属性。它还具有存储客户最近订单的属性。此集合支持各种查询,包括上面说到的那些查询。
CQRS的好处和缺点
CQRS既有优点也有缺点。 CQRS的一个主要优点是它可以在微服务架构中实现查询,特别是使用事件源的架构。它使应用程序有效地支持一组不同的查询。另一个好处就是把命令侧和查询侧分离,达到了解耦的作用。
CQRS也有一些缺点。一个缺点就是需要额外的工作来开发和维护这套系统。你需要开发和部署更新和查询视图的查询端服务。还有就是你需要部署视图数据库(view store)。
CQRS的另一个缺点是处理命令侧和查询侧视图之间的“滞后”。查询层相比命令侧存在一定的时延。更新聚合,然后立即查询视图的客户端应用程序可能会看到聚合的以前版本。所以必须通过一些手法来避免暴露这些潜在的不一致性给用户。
总结
使用事件来维护服务之间的数据一致性时的主要挑战是原子级地更新数据库和发布事件。传统的解决方案是使用跨数据库和消息broker的分布式事务。然而,2PC不是现代应用的可行技术。更好的方法是使用事件源,这是一种以事件为中心的方法来处理业务逻辑设计和持久化。
微服务架构中的另一个挑战是查询。查询通常需要join由多个服务拥有的数据。但是,join不能再使用了,因为数据对每个服务都是私有的。使用事件源还使得更加难以有效地实现查询,因为当前状态没有被显式地存储。解决方案是使用命令查询责任分离(CQRS)并维护可以容易查询的聚合的一个或多个物化视图。