当我们在分布式系统中构建新组件时,往往会发现自己被很多看上去独特的用例所包围。 每个问题似乎都是一个新问题,需要对其进行单独分析和讨论才能解决。
实际上,我们会遇到的大多数用例都属于某些常见模式。 我们应该尽量制定指导方针或经验法则,以帮助我们做出快速、一致的决定,而不是一遍又一遍地发明轮子。
事实上,尽管分布式系统设计可能很复杂,但它们也可以归结为一些基本概念,其中一个重要的概念是命令和事件之间的区别。几乎每个微服务之间的所有交互都涉及这两个概念。如果我们能够识别出一个给定的用例是否包含命令处理或事件处理,我们就可以得到与系统其余部分一致的可靠设计。
因此,让我们看一下两者之间的区别。
命令是作为软件工程师要处理的第一件事。从第一个简单的Hello World示例程序到第一个单数据库支撑的单体网站,我们都在不知不觉中处理命令。
那什么是命令呢?
**命令(Command)**是一个人或其它实体希望被执行的动作,重要的一点是,这个动作是尚未发生的。它可能在未来某个时刻发生,也可能根本不会发生。
我们可以思考一个例子,暂时不讨论编程,我们只例举一个生活中的场景。晚饭时间,你还在玩游戏,然后你妈会发出一个命令:“关游戏,过来吃饭了”。
一般来说,可能会出现两种情况:
现在,我们讨论一个典型的软件场景,一位顾客访问了线上购物网站,然后选择了一些要购买的商品。为了完成购买,顾客在网站的支付表单中输入一些信息,然后点击“完成购买”按钮。
同样的,会有两种情况发生:
当然,在到达结账界面的过程中,也有很多命令已经成功结束。即使是点击链接查看商品详情的操作,都可以看作是一个命令。
我们在前面讨论了,命令表示一个人或代理希望执行的动作。事件则表示已经成功完成的动作。
通常,当命令成功执行时,结果就是将一条记录写入某个持久性数据存储中。通常,这意味着提交了一个数据库事务,或者是将文件保存在文件系统中。
以经典的Hello World应用程序为例,我们会通过一个简单的HTTP响应来处理浏览器请求。在这种情况下,我们可能主要关注命令——接收GET请求,并执行它。但是,一旦请求得到执行,它就变成了一个事件。此外,这个事件可能已被记录在网络服务器的日志文件中。
因此,你可以将事件看作是命令的镜像。或者你可以把命令看着茧,而把事件视为蝴蝶。最重要的是,动作是以命令开始的,一旦成功,它就成为了事件。
也就是说,我们收到的请求开始是命令,然后成为事件。 有什么大不了的?
嗯,事实证明,这对我们应如何处理它们有很大影响。
我们前面讨论过,命令可能会失败。
失败可能是由于技术问题导致的,这种情况下,重新提交同一命令有可能会成功。
或者,也有可能是因为数据验证错误导致了执行失败。在这种情况下,重新提交同一命令将会继续失败。 但是,用户可以修改命令并重试。 如果客户的订单因为信用卡号已过期而被拒绝,则客户可以输入其他有效的信用卡号,然后重新提交订单。
然而,一旦命令成功,就无法回退了。其对应的结果事件已经发生,无法撤消。
此外,事件是不可变的。 无论命令在提交时处于什么状态,事件都将永远保持该状态。
一旦线上客户完成购买,那么购买就变成了一个事件。概念上讲,时间无法回退也无法否认该事件。当然,在软件工程中,任何事情都是可能的——我们可以简单地删除关于该事件的记录。但是,出于很多原因,我们不能这么做:
这并不是无法取消购买。客户可能会在其他地方谈到更优惠的价格,或者他们认为不需要这些产品。这是合理的,但是取消操作需要一个单独的后续命令,一旦该命令成功执行,它也将成为一个单独的后续事件。
上面,我们可以注意到,取消事件是在购买事件之后。 这个很重要,命令可以在我们的系统中以任意顺序触发和处理。 但是一旦成功(成为事件),它们的顺序是不可改变的。
保持这种顺序与保持每个事件本身的不变性一样重要。 否则,我们的事件处理服务可能会收到不正确或损坏的数据。举例来说,一个服务尝试在处理原始订单事件之前处理订单取消事件。
处理命令的服务可能会按例拒绝包含错误或无效数据的命令。此外,尽管这并不理想,但如果一个命令由于技术故障而失败也并不是特别严重的问题。
但是,事件处理服务必须处理发生的每个事件。它不能以任何理由拒绝或允许取消某些事件。
为什么呢?回想一下,命令表示尚未发生的事情,如果某个命令由于任何原因而失败,那么其中涉及的所有系统——客户、我们的系统本身、下游系统——都需要达成一致,也就是任何事都没有发生。而我们的客户端代理会受到失败通知,他们可以决定接下来如何操作(如放弃、重试等等)。
另一方面,事件表示已经发生的事情。对于我们的用户代理来说,它们的请求已经成功了。因此,如果我们是处理事件的服务,我们不能简单地决定拒绝一些事件。否则,我们的数据将在整个组织中处于永久不一致的状态。
还是举一个具体的例子,想象一下那些线上购买成功却没有收到商品的用户,他们会是什么心情。
这意味着数据验证是命令处理程序而不是事件处理程序的工作。 此外,尽管我们可以容忍偶尔因技术问题而丢失命令,但至关重要的是,我们的事件处理服务能够承受中断,以确保不会丢失任何事件。
所以,我们处理命令的方式和处理事件的方式是完全不同的。事实上,如果您对本文没有什么其他意见,那么请遵循下面的经验法则:
接下来,让我们探讨一下在分布式系统中如何处理命令和事件。为了便于理解,让我们简要地讨论一个重要的设计模式,即”限界上下文“。
限界上下文是一种源自Eric Evans领域驱动设计(DDD)的模式,通过使用此模式,我们可以更智能地设计微服务及维护微服务的团队。
假设我们经营一个典型的零售网站,毫无疑问,我们需要一些协助在线购买的服务。 为此,我们可以形成一个用于支付的Checkout限界上下文,它包括:
一旦成功下达订单,该团队对于实际执行订单很可能是一无所知的。 因此,我们需要一个负责订单执行的“Order Fulfillment”上下文,它也有自己的团队以及对应服务、应用程序、基础架构。
我们的Checkout
限界上下文负责验证和处理在线订单命令。一旦命令完成,Checkout
的工作就完成了。当然,任何成功的订单都需要履行,但这并不是Checkout
的责任。
相反,我们的Order Fulfillment
限界上下文将负责履行,首先要解决的就是如何让它知道订单的信息。
我们想到的第一个简单的答案可能是由Checkout
调用接口进行通知,例如,通过Order Fulfillment
中提供的ReST URL。
实际上,这种方法会存在一些问题:
Checkout
的工作是处理并提交订单。但是一旦要求其执行该POST调用,它就还需要负责启动订单履行流程。如果对Order Fulfillment
的调用失败了怎么办?我们之前认为已经成功的order命令突然变成失败的吗?实际上,这样做我们是把Checkout
处理的命令和由Checkout
发起的第二个命令链接在一起了。Checkout
了解对Checkout
事件感兴趣的所有其它限界上下文。Checkout
团队将需要编写和维护调用Order Fulfillment
服务的代码。 每当新的限界上下文(例如,清算或分析)对订单事件感兴趣时,Checkout
都需要维护类似的代码。Checkout
需要对其它限界上下文进行同步调用,而这些上下文可能会进一步对其它资源进行调用。即使在最好的情况下,这种爆炸式的调用也会消耗掉大量处理时间。而且任何失败都可能对Checkout
程序造成严重破坏。也许Checkout
可以通过对Order Fulfillment
进行异步调用来缓解其中的一些问题,或者还可以引入重试机制,以防止第一次对Order Fulfillment
的调用失败。
这已经接近正确的方向了。但是还有更好的方法,Checkout
只需要保证命令的结果,也就是结果事件,可以被其它限界上下文所使用。
换句话说,Checkout
应该将每个成功的命令都当作一个事件发布。
然后,Order Fulfillment
以及其它需要做响应的任何限界上下文都可以订阅这些事件,并在事件到达时进行消费。
如今,我们通常将事件发布到所谓的事件总线上,例如Kafka。
有很多其它文章都对Kafka进行了详细的探讨,我们这里就不做深入了。但从本质上说,Kafka是一个可以按照主题进行划分的数据存储(在我们的例子中,online-orders
就可以被认为是一个主题)。一个服务(发布者)会将事件(表示为Kafka主题上的消息)发布到某个主题。消息只是一个自包含的数据结构,并遵循预定义的模式(可能是通过JSON定义的,或者是通过类似Avro的二进制格式进行定义),并提供事件相关的详细信息。
之后,任意数量的其它服务(消费者)都可以订阅该主题以消费对应的消息。
此外,通过使用类似Kafka这样的事件总线,我们可以保证:
最重要的是,它确保一个限界上下文可以专注于处理其负责的命令,然后迅速将相应的事件传递给其它的限界上下文。
这就引出了在构建微服务时经常面临的一个问题:我们的服务什么时候应该同步通信,什么时候应该异步通信?
要回答这个问题,我们可以回顾一下前面提到的经验法则。我们期望能够立即处理我们负责的命令,直到命令成功(或失败)。一段时间后,其它模块会处理对应的结果事件。
因此,我们可以询问自己:用例是用于处理命令,还是用于通知其它服务发生了事件?一般来说,如果我们要处理命令,就需要同步处理。客户端代理会发出请求并等待响应,在响应返回时,命令已经成功(或者失败)。
但是,如果我们正在处理事件,通常没有其它模块在等待我们完成,而且,我们也不希望有任何模块在等待我们的程序结束。相反,我们期望按照自己的节奏处理事件,如有必要的话,可以进行重试。因此,通常我们会异步地对事件进行发布和处理。
根据经验,存在以下关联关系:
这条规则是否有例外?当然。
例如,我们有时候需要做很多工作才可以提交命令。通常,我们需要对命令中的某些部分进行验证,反过来说,这可能意味着对其它系统的调用(有时是我们内部的系统,有时是第三方系统)。
现在,大多数用户习惯于线上购物时会等待一段时间。 但是在某些情况下,预期的等待时间可能会过长。
在这些情况下,我们可以异步地将请求的一部分移交给另一个组件。 例如,我们可以预先进行快速检查,这样也许能够捕获一定比例的故障。 然后,我们再将消息发布到消息队列,例如RabbitMQ。 发布消息后,我们就能够立即回复客户代理。
与此同时,我们有另外订阅了RabbitMQ的组件将在我们中断的地方继续工作,并完成命令的处理。
在这个场景中,我们会以异步的方式处理命令。我们可能会问:异步处理命令是否与发布事件相同?
实际上,这两者之间有一个根本的区别,而且与耦合有关。
处理命令所涉及的组件都是紧密耦合的。当我们以同步方式处理时,确实是这样的,当我们添加异步消息传递时,仍然是这样的。当我们发布信息到RabbitMQ时,我们期望它恰好被另一端的一个组件消费和处理。事实上,这个组件很可能是我们自己编写和维护的。
实际上,这就是为什么我们通常使用RabbitMQ之类的消息队列(更适合点对点通信)来异步处理命令的原因。
相比之下,当我们发布一个事件时,我们(理论上)不知道谁会消费该消息。也许这个消息只会有一个消费者订阅,也许没有,也许会有100个。关键是,我们的生产者和任何潜在的消费者都是解耦的。
因此,我们可以这样修改上述的经验法则:
前面我们提到过,我们也许习惯于处理命令,但是处理事件可能不那么习惯。 因此,在开始发布和处理事件时,我们需要解决一些常见问题。
此时,我们可能会有个疑问:什么时候应该发布事件?我们前面已经提到过,事件是一些任务完成之后的结果。那所有的任务都是如此吗?无论何时,我们的服务完成了一些事情,就需要发布一个相应的事件?
我们可以从业务角度考虑这个问题。我们是否刚刚执行了一个具有业务重要性的任务?如果是这样,那么很有可能其他系统也想知道它。在这种情况下,我们应该发布一个事件。否则,我们就不需要费心发布事件。
请注意,某些组织可能还对失败的命令感兴趣。 例如,出于分析或审计目的可能认为这些事件很重要。因此,此时将命令失败发布为事件是非常合理的。
还要注意,处理一个事件可能会引入另一个具有业务重要性的事件。 因此,事件消费者通常会自行生产事件。 在前面的示例中,我们的Order Fulfillment
限界上下文仅基于事件消费进行操作,它从不接收要处理的命令。 但是我们可以肯定,当订单完成后,我们内部的其它模块会希望得到通知。
前面我们说过,我们生成事件供其他人消费。但我们也可以消费自己的事件。
我们为什么要这样做? 可以举一个新的例子来说明。 想象一下,我们公司提供了一个网站,允许用户发布自己喜欢的食谱或浏览要制作的食谱。 处理这些活动的服务归我们的Recipe Inventory
限界上下文负责。
Recipe Inventory
限界上下文中,我们会提供一个数据微服务,后台由CRUD数据库支撑,允许用户提交他们的菜单。但是,为了支持用户浏览菜谱,我们会在更优化的搜索框架(如Elastic Search)基础之上构建一个搜索服务。
当客户添加新菜谱时,他们显然是在发出命令。我们希望验证它们的提交数据并将其保存到数据库中。
我们还希望我们的搜索服务能意识到这个新配方,但应该如何实现呢?
既然我们拥有这两个服务,我们的第一个想法可能是从数据微服务向搜索服务发送一个POST请求。然而,由于我们之前讨论过的原因,我们最好选择发布add-recipe
事件,然后我们的搜索服务订阅话题来使用这些事件,以便将新菜谱添加到搜索索引中。
当然,新的菜谱不会立即出现在搜索索引中(这是一种称为最终一致性的属性)。然而,在现实中,用户几乎不会注意到延迟。此外,延迟通常不是毫秒就是秒。
在整篇文章中,我们都讨论了自己系统内的事件处理情况。 但是,有时我们需要使用我们希望之外的第三方发布的事件。 出于某些原因,这可能会很棘手:
一般来说,跨平台发布事件的通用技术是Webhooks。如果你不熟悉Webhooks的概念,其实是很简单的。我们不需要订阅Kafka主题,只需要实现一个Http端点,一般来说就是一个POST请求处理器。该端点需要请求内容满足第三方系统定义的格式。
实现端点后,我们向第三方注册端点的URL(通常通过某些带外机制,例如在安全合作伙伴门户中提交表单)。 现在,只要第三方系统发生事件,就会向我们的端点发出相应的POST请求。
这个方法是可行的。但我们通常倾向于像处理其他HTTP POST一样处理Webhook请求,也就是作为命令。实际上,第三方最有可能通知我们已经发生的事件,因此,我们将希望尽一切可能响应该事件。
这与我们之前的一些系统要求之间存在冲突。比如,我们需要验证系统输入,此外,HTTP请求处理程序的临时中断意味着我们可能会面临丢失事件的风险。
为了正确实施这样的解决方案,我们必须确保以下几点:
我们在微服务架构中构建的几乎每个服务都将涉及命令或事件的处理。 这两个概念之间存在根本差异。
理解这些差异将帮助我们识别出常见的用例,从而使我们更容易设计出更一致、功能更强的系统。
作者:GuoYaxiang