事件驱动架构风格是一种流行的分布式异步架构风格,用于构建高可扩展和高性能的应用程序。它的适应性也很强,既可以用于小型应用,也可以用于大型复杂应用。事件驱动架构由异步接收和处理事件的解耦的事件处理组件组成。它可以作为独立的架构风格使用,也可以嵌入到其他架构风格中(例如事件驱动的微服务架构)。
大多数应用程序遵循所谓的基于请求的模型(如图14-1所示)。在此模型中,向系统发出的执行某种操作的请求被发送到请求编排器。请求编排器通常是一个用户界面,但也可以通过API层或企业服务总线来实现。请求编排器的作用是确定地、同步地将请求定向到不同的请求处理器。请求处理器处理请求,检索或更新数据库中的信息。
基于请求的模型的一个很好的例子是客户请求检索他们过去六个月的订单历史记录。检索订单历史信息是一种数据驱动的、确定性的请求,它是为了获取特定上下文中的数据而向系统发出的,而不是系统必须响应的事件。
另一方面,基于事件的模型对特定情况作出反应,并根据该事件采取行动。基于事件的模型的一个例子是在线拍卖中提交对特定物品的出价。提交报价不是向系统发出的请求,而是在当前要价公布后发生的事件。系统必须通过比较同一时间收到的其他报价来响应此事件,以确定谁是当前的最高出价者。
拓扑结构
事件驱动架构中有两种主要拓扑结构:中介拓扑和代理拓扑。中介拓扑通常用于当你需要控制事件处理的工作流时,而代理拓扑则用于当你需要对事件处理进行高度响应和动态控制的情况。由于这两种拓扑结构的架构特性和实现策略是不同的,所以理解每种拓扑结构以知道哪种拓扑结构最适合于特定情况是很重要的。
代理拓扑
代理拓扑与中介拓扑的不同之处在于没有中央事件中介器。相反,消息流通过轻量级消息代理(如RabbitMQ、ActiveMQ、HornetQ等)以链式广播方式发布到多个事件处理器组件上。当你具有相对简单的事件处理流程并且不需要中央事件编排和协调时,此拓扑结构非常有用。代理拓扑中有四个主要的架构组件:一个启动事件、事件代理、一个事件处理器和一个处理事件。启动事件是启动整个事件流的初始事件,可以是在网上拍卖中出价这样的简单事件,还可以是在医疗福利系统中如换工作或结婚这种更复杂的事件。启动事件被发送到事件代理中的事件通道进行处理。由于在代理拓扑中没有一个中介组件去管理和控制事件,因此单个事件处理器从事件代理中接受启动事件并开始处理该事件。接受启动事件的事件处理器执行与该事件处理相关联的特定任务,然后通过创建一个“处理事件”来异步地将它所做的事情对系统其余部分进行播报。然后,如果需要,该处理事件将被异步发送到事件代理进行进一步处理。其他事件处理器监听此处理事件,通过执行某些操作对该事件作出反应,然后通过一个新的处理事件来公布他们所做的事情。这个过程一直持续直到没有人对最后一个事件处理器所做的事情感兴趣。图14-2说明了该事件处理流程。
事件代理组件通常是联合的(意味着多个基于领域的集群实例),其中每个联合代理包含该特定领域的事件流中使用的所有事件通道。由于代理拓扑的非耦合、异步、“即发即弃”的广播特性,主题(或者在AMQP的情况下是主题交换)通常利用发布和订阅消息模型在代理拓扑中使用。
在代理拓扑中,对于每个事件处理器来说,向系统的其余部分公布它所做的事情总是一个很好的实践,而不管其他事件处理器是否关心该操作是什么。如果处理该事件需要其他功能,则此实践提供了架构的可扩展性。例如,假设作为一件复杂事件过程的一部分,如图14-3所示,生成一封电子邮件并发送给客户,通知他们所采取过的一个特定操作。Notification事件处理器将生成并发送电子邮件,然后通过发送到一个主题的新处理事件将该操作通告给系统的其余部分。但是,在这种情况下,没有其他事件处理器在监听有关该主题的事件,因此消息就这样消失了。
这是架构可扩展性的一个很好的例子。虽然发送被忽略的消息似乎是在浪费资源,但事实并非如此。假设有一个新的需求出现来分析发送给客户的电子邮件。这个新的事件处理器可以以最小的工作量添加到整个系统中,因为电子邮件信息可以通过电子邮件主题提供给新的分析器,而不必添加任何额外的基础设施或对其他事件处理器作出任何更改。
为了说明代理拓扑是如何工作的,请考虑一个典型的零售订单输入系统中的处理流程,如图14-4所示,在这个系统中,为一个项目下订单(比如,像这样的一本书)。在本例中,OrderPlacement事件处理器接收启动事件(PlaceOrder),在数据库表中插入订单,并将订单ID返回给客户。然后,它通过order-created处理事件向系统其余部分通告它创建了一个订单。请注意,有三个事件处理器对该事件感兴趣:Notification事件处理器、Payment事件处理器和Inventory事件处理器。这三个事件处理器都在并行执行它们的任务。
Notification事件处理器接收order-created处理事件并向客户发送电子邮件。然后生成另一个处理事件(email-sent)。请注意,没有其他事件处理器在监听该事件。这是正常的,并说明了前面描述架构可扩展性的一个就绪钩子的示例,以便其他事件处理器最终可以在需要时访问该事件源。
Inventory事件处理器还监听order-created的处理事件,并减少该书相应的库存。然后,它通过一个inventory-updated处理事件来通告此操作,继而由Warehouse事件处理器提取该事件,以管理仓库之间的相应库存,并在供应量过低时重新编排物料。
Payment事件处理器还接收order-created处理事件,并为刚刚创建的订单向客户的信用卡收费。请注意在图14-4中,Payment事件处理器所采取的操作会生成两个事件:一个用于通知系统其余部分已完成支付(payment-applied),另一个处理事件用于通知系统其他部分支付被拒绝(payment-denied)。请注意,Notification事件处理器对payment-denied处理事件感兴趣,因为它必须反过来向客户发送电子邮件,通知他们必须更新信用卡信息或选择其他付款方式。
OrderFulfillment事件处理器监听payment-applied处理事件,并执行订单拣选和打包。完成后,它会通过order-fulfilled处理事件向系统的其他部分通告它完成了订单。请注意,Notification处理单元和Shipping处理单元都监听此处理事件。同时,Notification事件通知客户订单已完成并准备好装运,同时Shipping事件处理器选择一种装运方法。Shipping事件处理器发送订单并发送order-shipped处理事件,Notification事件处理器还监听该事件以通知客户订单状态变更。
在分析前面的示例时,请注意,所有的事件处理器都高度解耦并且彼此独立的。理解代理拓扑的最佳方法是将其视为接力赛。在一场接力赛中,赛跑者拿着接力棒(一根木棒)跑一段距离(比如1.5公里),然后把接力棒交给下一个赛跑者,如此下去,直到最后一个赛跑者越过终点线。在接力赛中,一旦一个赛跑者把接力棒交出去,这个赛跑者就完成了比赛并转向做其他事情。代理拓扑也是如此。一旦一个事件处理器移交事件,它就不再参与该特定事件的处理,可以对其他启动或处理事件作出反应。此外,每个事件处理器可以独立于其他处理器进行扩展,以处理该事件中处理的不同负载条件或备份。这些主题提供了如果一个事件处理器由于某些环境问题而关闭或速度减慢时的背压点。
虽然性能、响应性和可扩展性都是代理拓扑的巨大优势,但它也有一些缺点。首先,无法控制与启动事件(在本例中为PlaceOrder事件)关联的整个工作流。根据各种情况,它是非常动态的,系统中没有人真正知道下单的业务事务何时真正完成。错误处理在代理拓扑中也是一个很大的挑战。因为没有中介器监视或控制业务事务,所以如果发生故障(例如Payment事件处理器崩溃,并且没有完成分配的任务),系统中没有人会知道该崩溃。如果没有某种自动化或手动干预,业务流程就会卡住而无法移动。此外,所有其他进程都在不考虑错误的情况下继续前进。例如,Inventory事件处理器仍会减少库存,而所有其他事件处理器也都按一切正常的情况作出反应。
代理拓扑也不具备支持重新启动业务事务(可恢复性)的能力。由于在初始处理启动事件时已异步执行了其他操作,因此无法重新提交启动事件。代理拓扑中没有组件知道甚至不拥有原始业务请求的状态,因此在该拓扑中没有人负责重新启动业务事务(启动事件)并知道它是在哪里停止的。表14-1总结了代理拓扑的优缺点。
中介拓扑
事件驱动架构的中介拓扑解决了前一节中描述的代理拓扑的一些缺点。此拓扑的中心是事件中介器,它管理和控制需要多个事件处理器协调的启动事件工作流。构成中介拓扑的架构组件包括一个启动事件、一个事件队列、一个事件中介器、多个事件通道和事件处理器。
与代理拓扑一样,启动事件是开始整个事件过程的事件。与代理拓扑不同,启动事件被发送到一个由事件中介器接受的启动事件队列。事件中介器只知道处理启动事件所涉及的步骤,因此生成相应的处理事件,这些事件以点到点消息传递方式发送到专用事件通道(通常是队列)。然后,事件处理器监听专用的事件通道,处理接收到的事件并通常向中介器返回他们已经完成工作的响应。与代理拓扑不同,中介拓扑中的事件处理器不会向系统的其余部分公布它们所做的事情。中介拓扑如图14-5所示。
在中介拓扑的大多数实现中有多个中介器,通常与特定的领域或事件组相关联。这减少了与此种拓扑相关的单点故障问题,并提高了总体吞吐量和性能。例如,可能有一个客户中介器处理所有与客户相关的事件(例如新客户注册和档案更新),另一个中介器负责处理与订单相关的活动(例如将项目添加到购物车和签出)。
事件中介器可以以多种方式实现,这取决于它正在处理的事件的性质和复杂度。例如,对于需要简单错误处理和编排的事件,一个中介器(如Apache Camel、Mule ESB或Spring Integration)通常就足够了。这些类型的中介器中的消息流和消息路由通常是用编程语言(如Java或C#)自定义编写的,以控制事件处理的工作流。但是,如果事件工作流需要大量条件处理和具有复杂错误处理指令的多个动态路径,那么Apache ODE或Oracle BPEL流程管理器等中介器将是一个不错的选择。这些中介器基于业务流程执行语言(BPEL),以一种类似于XML的结构,描述了处理事件所涉及的步骤。BPEL构件还包含用于错误处理、重定向、广播等的结构化元素。BPEL是一种功能强大但相对难以学习的语言,因此通常使用产品的BPEL引擎套件中提供的图形界面工具来创建。
BPEL适用于复杂和动态的工作流,但对于那些需要在整个事件过程中进行人工干预的长时间运行事务的事件工作流并不适用。例如,假设一个交易是通过一个place-trade启动事件进行的。事件中介器接受此事件,但在处理过程中发现需要手动批准,因为交易超过一定数量的股份。在这种情况下,事件调停者必须停止事件处理,向高级交易者发送通知以获得手动批准,并等待批准发生。在这些情况下,需要一个业务流程管理(BPM)引擎,如jBPM。
为了正确选择事件中介器的实现方式,了解将通过中介器处理的事件的类型是很重要的。选择Apache Camel来处理那些复杂和长时间运行的涉及到人机交互的事件将非常难于编写和维护。出于同样的原因,使用BPM引擎处理简单的事件流需要花费数月的时间,而在Apache Camel中同样的事情可以在几天内完成。
考虑到很少有所有事件只有一类复杂度的情况,我们建议将事件分为简单、困难或复杂,并让每个事件始终通过一个简单的中介器(如Apache Camel或Mule)。然后,简单中介器可以判断事件的分类,并基于该分类处理事件本身或将其转发给另一个处理更复杂事件的事件中介器。通过这种方式,所有类型的事件都可以被该事件所需的中介器类型有效地处理。图14-6说明了这种中介器委派模型。
请注意,在图14-6中,当事件工作流很简单并且可以由简单中介器处理时,简单事件中介器生成并发送一个处理事件。但是,请注意,当进入简单事件中介器的启动事件被分类为困难或复杂事件时,它会将原始启动事件转发给相应的中介器(BPEL或BMP)。简单事件中介器在截获了原始事件之后,可能仍然负责知悉该事件何时完成,或者它只是将整个工作流(包括客户端通知)委托给其他中介器。
为了说明中介拓扑是如何工作的,请考虑前面的代理拓扑部分中描述的零售订单输入系统示例,但这次使用的是中介拓扑。在这个示例中,中介器知道处理此特定事件所需的步骤。这个事件流(中介器组件的内部)如图14-7所示。
当收到启动事件,Customer中介器生成一个create-order处理事件,并将此消息发送到order-placement-queue队列(见图14-8)。OrderPlacement事件处理器接受此事件,验证并创建订单,并将一个确认回复与订单ID一起返回给中介器。此时,中介器可能会将该订单ID发送回客户,示意订单已下单,或者可能必须继续直到所有步骤都完成(这将基于有关下订单的特定业务规则)。
现在第1步已经完成,中介器现在转到第2步(见图14-9),同时生成三条消息:email-customer、apply-payment和adjust-inventory。这些处理事件都被发送到各自的队列。所有三个事件处理器都接收到这些消息和执行各自的任务,并通知中介器处理已完成。请注意,中介器必须等到收到来自所有三个并行处理的确认回复之后,才能继续转到第3步。此时,如果某个并行事件处理器中发生错误,中介器可以采取纠正措施来解决问题(这将在本节后面更详细地讨论)。
当中介器在步骤2中从所有事件处理器获得成功的确认,它就可以继续执行步骤3来执行订单(见图14-10)。请再次注意,这两个事件(fulfill-order和order-stock)可以同时发生。OrderFulfillment和Warehouse事件处理器接受这些事件,执行它们的工作,并向中介器返回一个确认。
当这些事件完成后,中介器就转到第4步(见图14-11)来发送订单。此步骤生成另一个email-customer处理事件,其中包含有关要执行的操作的特定信息(在本例中,通知客户订单已准备就绪)以及ship-order事件。
最后,中介器转到第5步(见图14-12),并生成另一个上下文相关的email_customer事件,以通知客户订单已经发货。此时,工作流完成,中介器将启动事件流标记为完成,并删除与启动事件关联的所有状态。
中介器组件拥有对工作流的知识和控制权,这是代理拓扑所不具备的。因为中介器控制工作流,所以它可以维护事件状态并管理错误处理、可恢复性和重启功能。例如,假设在前面的示例中,由于信用卡过期而未完成付款。在这种情况下,中介器接收到这个错误情况,并且知道在完成付款之前订单无法完成(步骤3),它会停止工作流并在自己的持久化数据存储中记录请求的状态。一旦最终完成了支付,工作流就可以从停止的地方重新启动(在本例中,是步骤3的开始)。
代理中介和中介拓扑之间的另一个内在的差异是处理事件的意义和使用方式的不同。在上一节中的代理拓扑示例中,处理事件被发布为系统中发生的事件(例如order-created、payment-applied和email-sent)。事件处理器执行了一些操作,而其他事件处理程序则对该操作作出反应。然而,在中介拓扑中,处理事件(如place-order、send-email和fulfill-order)是命令(需要发生的事情),而不是事件(已经发生的事情)。此外,在中介拓扑中,处理事件必须被处理(命令),而在代理拓扑中可以忽略它(反应)。
虽然中介拓扑解决了一些与代理拓扑相关的问题,但其自身也存在一些缺点。首先,很难对复杂事件流中发生的动态处理进行声明式建模。因此,中介器中的许多工作流只处理一般事件,而结合中介和代理拓扑的混合模式用于解决复杂事件处理的动态特性(例如缺货情况或其他非典型错误)。此外,尽管事件处理器可以轻松地以与代理拓扑相同的方式进行扩展,但中介器也必须进行扩展,这有时会在整个事件处理流程中产生瓶颈。最后,事件处理器在中介拓扑中没有像代理拓扑那样高度解耦,并且由于中介器控制事件的处理,性能也不如代理拓扑好。表14-2总结了这些权衡情况。
在代理中介和中介拓扑之间的选择本质上归结为追求工作流控制和错误处理能力与追求高性能和可扩展性之间的权衡。尽管中介拓扑中的性能和可扩展性仍然很好,但是它们没有代理拓扑中的那么高。
异步能力
事件驱动的架构风格与其他架构风格相比具有一个独特的特性,即它完全依赖于异步通信来进行“即发即弃“处理(无需响应)以及请求/应答处理(需要来自事件消费者的响应)。异步通信是提高系统整体响应能力的强有力的技术。
考虑图14-13所示的例子,其中一个用户在一个网站上发布一个针对特定产品的评论。假设本例中的评论服务需要3000毫秒来发布评论,因为它要经过几个解析引擎:一个敏感词检查程序来检查不可接受的单词,语法检查程序来确保句子结构没有侮辱性的话,最后一个上下文检查器来确保评论是关于特别的产品,而不仅仅是政治上的抱怨。请注意,在图14-13中,顶端路径使用一个同步的RESTful调用来发布评论:服务接收发布的延迟为50毫秒,发布评论的延迟为3000毫秒,而对发布评论的用户做出响应的网络延迟为50毫秒。用户将花费3100毫秒的响应时间来发布评论。现在看看底部的路径,注意到使用异步消息传递,从最终用户的角度来看,在网站上发布评论的响应时间只有25毫秒(而不是3100毫秒)。发布评论仍然需要3025毫秒(25毫秒来接收消息,3000毫秒来发布评论),但是从最终用户的角度来看这已经完成了。
这是一个很好的例子来说明响应能力和性能之间的区别。当用户不需要任何信息返回(除了确认或感谢消息),为什么要让用户等待?响应能力就是通知用户操作已被接受并将立即被处理,而性能则是使端到端的处理更快。请注意评论服务处理在文本上并没有做任何优化– 两种情况仍然需要花费3000毫秒。解决性能在于优化评论服务,在使用缓存和其他类似技术的同时运行所有文本和语法解析引擎。图14-13中的底部示例解决了系统的总体响应能力,而不是系统的性能。
图14-13中两个例子的响应时间从3100毫秒到25毫秒之间的差异是惊人的。有一点需要说明。在图表顶部显示的同步路径上,用户可以保证评论已经发布。然而,在底部的路径上只有对发布的确认,并承诺最终会发布评论。从用户的角度来看,评论已经发布。但是如果用户在评论中键入了一个敏感词会发生什么?在这种情况下,评论将被拒绝,但无法把结果返回到用户。还有其他办法吗?在这个例子中,假设用户在网站上注册了(必须是注册了才能发布评论),那么就可以向用户发送一条消息,指出评论有问题并给出一些修复建议。这是一个简单的例子。股票的购买是异步进行的(称为股票交易),并且没有办法返回给用户,对于这种更复杂的例子怎么样呢?
异步通信的主要问题是错误处理。虽然响应能力显著提高了,但很难解决错误情况,这增加了事件驱动系统的复杂性。下一节将使用一种称为工作流事件模式的反应式架构模式来解决此问题。
错误处理
反应式架构的工作流事件模式是解决异步工作流中与错误处理相关问题的一种方法。这种模式是一种反应式架构模式,它同时解决了可恢复性和响应性问题。换句话说,系统在错误处理方面具有弹性,而不会影响响应能力。工作流事件模式通过使用工作流委托来利用委派、控制和修复,如图14-14所示。事件生产者通过消息通道将数据异步传递给事件消费者。如果事件消费者在处理数据时遇到错误,它会立即将该错误委托给工作流处理器,并继续处理事件队列中的下一条消息。这样,总体响应性不会受到影响,因为下一条消息将被立即处理。如果事件消费者要花时间试图弄清错误原因,那么它不会读取队列中的下一条消息,因此不仅会影响下一条消息的响应能力,还会影响队列中等待处理的所有其他消息的响应。
当工作流处理器接收到错误,它将尝试弄清消息中的错误。这可能是一个静态的、不可逆转的错误,或者它可以利用一些机器学习算法来分析消息,从而在数据中发现一些异常。不管是哪种方式,工作流处理器都会以编程方式(无需人工干预)对原始数据进行更改并尝试修复它,然后将其发送回原始队列。事件消费者将此消息视为一条新消息,并尝试再次处理它,希望这次能取得成功。当然,工作流处理程序尝试很多遍也无法确定消息中的错误。在这些情况下,工作流处理器将消息发送给另一个队列。然后在通常称为“仪表板”(dashboard)的应用程序中接收,该应用程序看起来类似于Microsoft的Outlook或Apple的邮件。这个仪表板通常位于重要人物的桌面上,然后他查看消息,对其进行手动修复,然后将消息重新提交到原始队列(通常通过一个reply-to消息头变量)。
为了说明工作流事件模式,假设一个国家的某个交易顾问代表该国另一个地区的一家大型交易公司接受交易订单(关于购买什么股票和购买多少股票的指示)。这个顾问把交易订单打包(通常称为一篮子),然后异步地将这些订单通过经纪人发送给大型交易公司,这样股票就可以被购买了。为了简化示例,假设贸易指示的合同必须遵守以下格式:
ACCOUNT(String),SIDE(String),SYMBOL(String),SHARES(Long)
假设大型贸易公司从交易顾问处收到以下一篮子苹果公司(AAPL)交易订单:
12654A87FR4,BUY,AAPL,1254
87R54E3068U,BUY,AAPL,3122
6R4NB7609JJ,BUY,AAPL,5433
2WE35HF6DHF,BUY,AAPL,8756 SHARES
764980974R2,BUY,AAPL,1211
1533G658HD8,BUY,AAPL,2654
注意,第四个交易指令(2WE35HF6DHF,BUY,AAPL,8756 SHARES)在交易股数后有“SHARES”一词。当大型贸易公司在没有任何错误处理能力的情况下处理这些异步交易订单时,交易配售服务中会发生以下错误:
Exception in thread "main" java.lang.NumberFormatException:
For input string: "8756 SHARES"
at java.lang.NumberFormatException.forInputString
(NumberFormatException.java:65)
at java.lang.Long.parseLong(Long.java:589)
at java.lang.Long.(Long.java:965)
at trading.TradePlacement.execute(TradePlacement.java:23)
at trading.TradePlacement.main(TradePlacement.java:29)
当发生此异常时,因为这是一个异步请求,交易配售服务无能为力,除了可能记录错误条件之外。换句话说,没有用户可以同步响应和修复错误。
应用工作流事件模式可以以编程方式修复此错误。由于大型贸易公司无法控制交易顾问及其发送的相应交易订单数据,它必须自己做出反应来修复错误(如图14-15所示)。当发生相同的错误(2WE35HF6DHF,BUY,AAPL,8756 SHARES)时,交易配售服务立即通过异步消息将错误委托给Trade Placement Error 服务进行错误处理,并传递有关异常的错误信息:
Trade Placed: 12654A87FR4,BUY,AAPL,1254
Trade Placed: 87R54E3068U,BUY,AAPL,3122
Trade Placed: 6R4NB7609JJ,BUY,AAPL,5433
Error Placing Trade: "2WE35HF6DHF,BUY,AAPL,8756 SHARES"
Sending to trade error processor <-- delegate the error fixing and move on
Trade Placed: 764980974R2,BUY,AAPL,1211
...
Trade Placement Error服务(充当工作流代理)接收错误并检查异常。鉴于“股票数”字段中的“SHARES”一词有问题,Trade Placement Error服务会删除“SHARES”一词,并再次提交交易进行重新处理:
Received Trade Order Error: 2WE35HF6DHF,BUY,AAPL,8756 SHARES
Trade fixed: 2WE35HF6DHF,BUY,AAPL,8756
Resubmitting Trade For Re-Processing
然后,交易安置服务会成功处理修复好的交易:
...
trade placed: 1533G658HD8,BUY,AAPL,2654
trade placed: 2WE35HF6DHF,BUY,AAPL,8756 <-- this was the original trade in error
应用工作流事件模式的一个后果是,出错的消息在重新提交时处理顺序不正确。在我们的交易示例中,消息的顺序很重要,因为给定帐户中的所有交易都必须按顺序处理(例如,在同一个经纪帐户中,对IBM的卖出必须在对AAPL的买入之前发生)。尽管并非不可能,但在给定的上下文(在本例中是经纪帐号)中维护消息顺序是一项复杂的任务。解决这个问题的一种方法是通过Trade Placement服务排队并存储错误交易的账号。任何具有相同帐号的交易都将被存储在一个临时队列中以供后续处理(按先进先出顺序)。一旦最初出错的交易被修复和处理,Trade Placement服务将对同一账户的剩余交易进行排队,并按顺序进行处理。
防止数据丢失
在处理异步通信时,数据丢失一直是一个主要的问题。不幸的是,在事件驱动架构中,有许多地方会发生数据丢失。我们所说的数据丢失是指消息被丢弃或永远无法到达其最终目的地。幸运的是,在使用异步消息传递时,可以利用一些基本的开箱即用的技术来防止数据丢失。
为了说明事件驱动架构中与数据丢失相关的问题,假设事件处理器A异步地向一个队列发送消息。事件处理器B接受消息并将消息中的数据插入数据库。如图14-16所示,在这种典型场景下有三个地方会发生数据丢失:
1. 消息从未从事件处理器A到达队列;或者即使它到达队列,在下一个事件处理器检索到消息之前代理程序发生故障。
2. 事件处理器B对下一个可用的消息进行出列,并在处理事件之前崩溃。
3. 由于某些数据错误,事件处理器B无法将消息持久化到数据库中。
这些数据丢失的每一个方面都可以通过基本的消息传递技术得到缓解。通过利用持久化消息队列和所谓的同步发送,问题1(消息永远不会到达队列)很容易得到解决。持久化消息队列支持所谓的保证传递。当消息代理接收到消息时,它不仅将其存储在内存中以便快速检索,而且还将消息保存在某种物理数据存储(如文件系统或数据库)中。如果消息代理发生故障,消息将物理地存储在磁盘上,这样当消息代理恢复时,消息就可以进行处理。同步发送在消息生产者中执行阻塞等待,直到代理确认消息已被持久化。有了这两种基本技术,事件生产者和队列之间就不会丢失消息,因为消息要么仍在消息生产者中,要么持久存储于队列中。
问题2(事件处理器B对下一条可用消息进行出列并在处理事件之前崩溃)也可以使用称为客户端确认模式的基本消息传递技术来解决。默认情况下,当消息退出队列时,它会立即从队列中移除(称为自动确认模式)。客户端确认模式将消息保留在队列中,并将客户端ID附加到消息上,以便其他消费者无法读取该消息。在这种模式下,如果事件处理器B崩溃,消息仍然保留在队列中,防止消息在消息流的这一部分丢失。
问题3(由于某些数据错误,事件处理器B无法将消息持久化到数据库)通过利用数据库提交的ACID(原子性、一致性、隔离性、持久性)事务来解决。一旦数据库提交发生,数据就被保证保存在数据库中。利用称为最后参与者支持(last participant support,LPS)的方法,通过确认处理已完成且消息已持久化,从持久化队列中删除消息。这可以保证消息在从事件处理器A传输到数据库的整个过程中不会丢失。这些技术如图14-17所示。
广播能力
事件驱动架构的另一个独特特性是能够广播事件,而无需知道谁(如果有人)正在接收消息以及他们如何处理消息。这种技术如图14-18所示,表明当一个生产者发布一条消息时,同一条消息会被多个订阅者接收。
广播可能是事件处理器之间解耦的最高级别,因为广播消息的生产者通常不知道哪个事件处理器将接收广播消息,更重要的是,他们将如何处理消息。广播功能是实现最终一致性、复杂事件处理(CEP)和许多其他情况下的模式的重要组成部分。考虑股票市场上交易工具的股票价格的频繁变化。每一个股票行情(特定股票的当前价格)都可能影响许多事情。然而,发布最新价格的服务只是简单地广播它,而不会去了解这些信息将如何使用。
请求-应答
本章到目前为止我们讨论了不需要事件消费者立即响应的异步请求。但是如果在订购图书时需要订单ID呢?如果订机票时需要一个确认编号怎么办呢?这些是需要某种同步通信的服务或事件处理器之间通信的示例。
在事件驱动架构中,同步通信是通过请求-应答消息(有时称为伪同步通信)来实现的。请求-应答消息传递中的每个事件通道由两个队列组成:请求队列和应答队列。对信息的初始请求被异步地发送到请求队列,然后将控制权返回给消息生产者。然后消息生产者在应答队列上执行阻塞等待,等待响应返回。消息消费者接收并处理消息,然后将响应发送到应答队列。然后,事件生产者接收包含响应数据的消息。这个基本流程如图14-19所示。
有两种主要技术来实现请求-应答消息传递。第一种(也是最常见的)技术是使用消息头中包含的correlation ID。correlation ID是应答消息中的一个字段,通常设置为原始请求消息的消息ID。如图14-20所示,这种技术的工作原理如下,消息ID用ID表示,correlation ID用CID表示:
1. 事件生产者向请求队列发送消息并记录唯一的消息ID(在本例中为id=124)。注意,本例中的correlation ID(CID)为空。
2. 事件生产者现在使用消息过滤器(也称为消息选择器)在应答队列上执行阻塞等待,其中消息头中的correlation ID等于原始消息ID(在本例中为124)。请注意,在应答队列中有两条消息:correlation ID为120的消息(ID=855)和correlation ID为122的消息(ID=856)。这两条消息都不会被获取,因为correlation ID与事件消费者正在查找的内容不匹配(CID=124)。
3. 事件消费者接收消息(ID=124)并处理请求。
4. 事件消费者创建包含响应的应答消息,并将消息头中的correlation ID(CID)设置为原始消息ID(124)。
5. 事件消费者将新消息(ID 857)发送到应答队列。
6. 事件生产者接收消息,因为correlation ID(124)与步骤2中的消息选择器匹配。
用于实现请求-应答消息传递的另一种技术是使用临时队列作为应答队列。临时队列专用于特定请求,在请求发出时创建,在请求结束时删除。这种技术如图14-21所示,不需要correlation ID,因为临时队列是一个专用队列,只有事件生产者知道用于特定请求。临时队列技术的工作原理如下:
1. 事件生产者创建一个临时队列(或者自动创建一个队列,具体取决于消息代理)并向请求队列发送一条消息,在reply-to头中传递临时队列的名称(或消息头中的其他一些约定的自定义属性)。
2. 事件生产者对临时应答队列执行阻塞等待。这里不需要消息选择器,因为发送到此队列的任何消息都只属于最初发送消息的事件生产者。
3. 事件消费者接收消息并处理请求,然后将响应消息发送到reply-to头中指定的应答队列。
4. 事件处理器接收消息并删除临时队列。
虽然临时队列技术要简单得多,但是消息代理必须为每个请求创建一个临时队列,之后立即将其删除。大量消息传递会显著降低消息代理的速度,并影响整体性能和响应能力。因此,我们通常建议使用correlation ID技术。
在基于请求和基于事件之间进行选择
基于请求的模型和基于事件的模型都是设计软件系统的可行方法。然而,选择正确的模型对整个系统的成功至关重要。当需要对工作流进行确定性和控制时,我们建议为结构良好、数据驱动的请求(例如检索客户概要数据)选择基于请求的模型。我们建议为灵活的、基于行为的事件选择基于事件的模型,这些事件需要高级别的响应性和规模,并具有复杂和动态的用户处理。
了解基于事件的模型的权衡也有助于决定哪种模型最适合。表14-3列出了事件驱动架构的基于事件模型的优缺点。
混合事件驱动架构
虽然许多应用利用事件驱动架构风格作为主要的总体架构,但在许多情况下,事件驱动架构与其他架构风格结合使用,形成了所谓的混合架构。一些常见的架构风格利用事件驱动架构作为另一种架构风格的一部分,包括微服务和基于空间的架构。其他可能的混合体包括事件驱动的微内核架构和事件驱动的管道架构。
向任何架构风格中添加事件驱动架构有助于消除瓶颈,在备份事件请求时提供一个背压点,并提供在其他架构风格中达不到的用户响应级别。微服务和基于空间的架构都利用消息传递作为数据泵,将数据异步发送到另一个处理器,后者反过来更新数据库中的数据。当使用消息传递进行服务间通信时,两者都利用事件驱动架构为微服务架构中的服务和基于空间的架构中的处理单元提供一定程度的可编程扩展性。
架构特性评级
特性评级表中的一星级评级(如图14-22所示)意味着特定的架构特性在某种架构中没有得到很好的支持,而五星评级意味着架构特性是某种架构风格中最强大的特性之一。记分卡中确定的每个特性的定义见第4章。
事件驱动架构主要是一种技术上分区的体系结构,任何特定的领域都分布在多个事件处理器上,并通过中介器、队列和主题连接在一起。对某个领域的变更通常会影响许多事件处理器、中介器和其他消息传递构件,因此事件驱动架构不是按照领域划分的。
事件驱动架构中的量子数可以从一个量子到多个量子,这通常基于每个事件处理器和请求-应答处理中的数据库交互。尽管事件驱动架构中的所有通信都是异步的,但是如果多个事件处理器共享一个数据库实例,它们都将包含在同一个架构量子中。对于请求-应答处理也是如此:即使事件处理器之间的通信仍然是异步的,但是如果事件消费者立即需要一个请求,它会将这些事件处理器同步地连接在一起;因此它们属于同一个量子。
为了说明这一点,考虑一个事件处理器向另一个事件处理器发送请求来下一个订单的例子。第一个事件处理器必须等待另一个事件处理器的订单ID来继续。如果下订单并生成订单ID的第二个事件处理器崩溃,则第一个事件处理器无法继续。因此,它们是同一个架构量子的一部分并共享相同的架构特性,尽管它们都是发送和接收异步消息。
事件驱动架构在性能、可扩展性和容错性方面获得了五颗星评级,这是这种架构风格的主要优势。高性能是通过异步通信与高度并行处理相结合来实现的。高可扩展性是通过事件处理器(也称为竞争消费者)的编程负载均衡实现的。随着请求负载的增加,可以通过编程方式添加额外的事件处理器来处理增加的请求。容错性是通过高度解耦的异步事件处理器实现的,这些处理器提供了事件工作流的最终一致性和最终处理。如果用户界面或发出请求的事件处理器不需要立即响应,如果其他下游处理器不可用则可以利用保证处理的承诺在往后的时间来处理事件。
事件驱动架构的总体简单性和可测试性相对较低,这主要是由于这种架构风格中通常存在的不确定性和动态事件流。虽然基于请求的模型中的确定流相对容易测试,因为路径和结果通常是已知的,但事件驱动模型不是这样。有时不知道事件处理器将如何响应动态事件,以及它们可能生成哪些消息。这些“事件树图”可能非常复杂,可以生成数百到数千个场景,这使得管理和测试非常困难。
最后,事件驱动架构是高度进化的,因此被评为五星级。通过现有或新的事件处理器添加新特性相对简单,特别是在代理拓扑中。在代理拓扑中通过发布的消息提供钩子,数据已经可以使用,因此不需要在基础设施或现有事件处理器中进行更改来添加新功能。