命令与事件的区别:分布式系统中的命令与事件

当我们在分布式系统中构建新组件时,往往会发现自己被很多看上去独特的用例所包围。 每个问题似乎都是一个新问题,需要对其进行单独分析和讨论才能解决。

实际上,我们会遇到的大多数用例都属于某些常见模式。 我们应该尽量制定指导方针或经验法则,以帮助我们做出快速、一致的决定,而不是一遍又一遍地发明轮子。

事实上,尽管分布式系统设计可能很复杂,但它们也可以归结为一些基本概念,其中一个重要的概念是命令和事件之间的区别。几乎每个微服务之间的所有交互都涉及这两个概念。如果我们能够识别出一个给定的用例是否包含命令处理或事件处理,我们就可以得到与系统其余部分一致的可靠设计。

因此,让我们看一下两者之间的区别。

命令

命令是作为软件工程师要处理的第一件事。从第一个简单的Hello World示例程序到第一个单数据库支撑的单体网站,我们都在不知不觉中处理命令。

那什么是命令呢?

**命令(Command)**是一个人或其它实体希望被执行的动作,重要的一点是,这个动作是尚未发生的。它可能在未来某个时刻发生,也可能根本不会发生。

我们可以思考一个例子,暂时不讨论编程,我们只例举一个生活中的场景。晚饭时间,你还在玩游戏,然后你妈会发出一个命令:“关游戏,过来吃饭了”。

一般来说,可能会出现两种情况:

  • 命令成功。 你跟朋友再见,然后下线游戏去吃饭;
  • 命令失败。你假装没听到,然后继续跟朋友玩游戏。

现在,我们讨论一个典型的软件场景,一位顾客访问了线上购物网站,然后选择了一些要购买的商品。为了完成购买,顾客在网站的支付表单中输入一些信息,然后点击“完成购买”按钮。

同样的,会有两种情况发生:

  • 命令成功。购买数据成功提交到数据库,而客户收到一个响应,告诉他们订单正在处理中。
  • 命令失败。出现这种情况的原因可能有很多。如账单信息不准确、收货地址不完整、购物车中的商品缺货,或者是后端程序出现了一些技术问题。

当然,在到达结账界面的过程中,也有很多命令已经成功结束。即使是点击链接查看商品详情的操作,都可以看作是一个命令。

事件

我们在前面讨论了,命令表示一个人或代理希望执行的动作。事件则表示已经成功完成的动作。

通常,当命令成功执行时,结果就是将一条记录写入某个持久性数据存储中。通常,这意味着提交了一个数据库事务,或者是将文件保存在文件系统中。

以经典的Hello World应用程序为例,我们会通过一个简单的HTTP响应来处理浏览器请求。在这种情况下,我们可能主要关注命令——接收GET请求,并执行它。但是,一旦请求得到执行,它就变成了一个事件。此外,这个事件可能已被记录在网络服务器的日志文件中。

因此,你可以将事件看作是命令的镜像。或者你可以把命令看着茧,而把事件视为蝴蝶。最重要的是,动作是以命令开始的,一旦成功,它就成为了事件。

命令和事件的区别

也就是说,我们收到的请求开始是命令,然后成为事件。 有什么大不了的?

嗯,事实证明,这对我们应如何处理它们有很大影响。

命令会更改,事件不会

我们前面讨论过,命令可能会失败。

失败可能是由于技术问题导致的,这种情况下,重新提交同一命令有可能会成功。

或者,也有可能是因为数据验证错误导致了执行失败。在这种情况下,重新提交同一命令将会继续失败。 但是,用户可以修改命令并重试。 如果客户的订单因为信用卡号已过期而被拒绝,则客户可以输入其他有效的信用卡号,然后重新提交订单。

然而,一旦命令成功,就无法回退了。其对应的结果事件已经发生,无法撤消。

此外,事件是不可变的。 无论命令在提交时处于什么状态,事件都将永远保持该状态。

一旦线上客户完成购买,那么购买就变成了一个事件。概念上讲,时间无法回退也无法否认该事件。当然,在软件工程中,任何事情都是可能的——我们可以简单地删除关于该事件的记录。但是,出于很多原因,我们不能这么做:

  • 无论我们是否删除自己的记录,该事件事实上仍然发生了,而我们的记录也应该反映现实。
  • 其它实体中很可能拥有该事件的记录。当然,客户会知道他们完成了一次购买。此外,我们可能已经联系了客户的信用卡机构、运输公司或我们下属的仓库,而这些机构都可能存有该事件的记录。

这并不是无法取消购买。客户可能会在其他地方谈到更优惠的价格,或者他们认为不需要这些产品。这是合理的,但是取消操作需要一个单独的后续命令,一旦该命令成功执行,它也将成为一个单独的后续事件。

命令是无序的,事件是有序的

上面,我们可以注意到,取消事件是在购买事件之后。 这个很重要,命令可以在我们的系统中以任意顺序触发和处理。 但是一旦成功(成为事件),它们的顺序是不可改变的。

保持这种顺序与保持每个事件本身的不变性一样重要。 否则,我们的事件处理服务可能会收到不正确或损坏的数据。举例来说,一个服务尝试在处理原始订单事件之前处理订单取消事件。

命令可以被拒绝,事件无法拒绝

处理命令的服务可能会按例拒绝包含错误或无效数据的命令。此外尽管这并不理想,但如果一个命令由于技术故障而失败也并不是特别严重的问题。

但是,事件处理服务必须处理发生的每个事件。它不能以任何理由拒绝或允许取消某些事件。

为什么呢?回想一下,命令表示尚未发生的事情,如果某个命令由于任何原因而失败,那么其中涉及的所有系统——客户、我们的系统本身、下游系统——都需要达成一致,也就是任何事都没有发生。而我们的客户端代理会受到失败通知,他们可以决定接下来如何操作(如放弃、重试等等)。

另一方面,事件表示已经发生的事情。对于我们的用户代理来说,它们的请求已经成功了。因此,如果我们是处理事件的服务,我们不能简单地决定拒绝一些事件。否则,我们的数据将在整个组织中处于永久不一致的状态。

还是举一个具体的例子,想象一下那些线上购买成功却没有收到商品的用户,他们会是什么心情。

这意味着数据验证是命令处理程序而不是事件处理程序的工作。 此外,尽管我们可以容忍偶尔因技术问题而丢失命令,但至关重要的是,我们的事件处理服务能够承受中断,以确保不会丢失任何事件。

命令在当下,事件会延后

所以,我们处理命令的方式和处理事件的方式是完全不同的。事实上,如果您对本文没有什么其他意见,那么请遵循下面的经验法则:

  • 我们处理自己负责的命令;而且,我们是立即处理的,直到其成功或失败为止。
  • 稍后,其他系统会处理我们的命令生成的事件;而且,他们要确保每个事件都得到处理,不管处理事件需要花费多少时间。

分布式系统中的命令和事件

接下来,让我们探讨一下在分布式系统中如何处理命令和事件。为了便于理解,让我们简要地讨论一个重要的设计模式,即”限界上下文“。

限界上下文

限界上下文是一种源自Eric Evans领域驱动设计(DDD)的模式,通过使用此模式,我们可以更智能地设计微服务及维护微服务的团队。

假设我们经营一个典型的零售网站,毫无疑问,我们需要一些协助在线购买的服务。 为此,我们可以形成一个用于支付的Checkout限界上下文,它包括:

  • 一个团队——前端和后端工程师、设计师、产品经理、DBA等,他们将负责创建和维护结帐服务。
  • 这个团队将构建的服务、应用程序和基础架构

一旦成功下达订单,该团队对于实际执行订单很可能是一无所知的。 因此,我们需要一个负责订单执行的“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事件,然后我们的搜索服务订阅话题来使用这些事件,以便将新菜谱添加到搜索索引中。

Consuming our own events

当然,新的菜谱不会立即出现在搜索索引中(这是一种称为最终一致性的属性)。然而,在现实中,用户几乎不会注意到延迟。此外,延迟通常不是毫秒就是秒。

我们如何处理来自第三方的事件?

在整篇文章中,我们都讨论了自己系统内的事件处理情况。 但是,有时我们需要使用我们希望之外的第三方发布的事件。 出于某些原因,这可能会很棘手:

  • 通常,我们不能简单地订阅第三方的Kafka集群,并以处理内部消息方式进行处理。 尽管有一些新兴的、特定于云的解决方案,例如Google Pub / Sub,但这些解决方案并不适用于所有用例。
  • 我们需要验证从外部源进入系统的任何数据。这与我们在自己的系统中处理事件的方式可能不一致。

一般来说,跨平台发布事件的通用技术是Webhooks。如果你不熟悉Webhooks的概念,其实是很简单的。我们不需要订阅Kafka主题,只需要实现一个Http端点,一般来说就是一个POST请求处理器。该端点需要请求内容满足第三方系统定义的格式。

实现端点后,我们向第三方注册端点的URL(通常通过某些带外机制,例如在安全合作伙伴门户中提交表单)。 现在,只要第三方系统发生事件,就会向我们的端点发出相应的POST请求。

这个方法是可行的。但我们通常倾向于像处理其他HTTP POST一样处理Webhook请求,也就是作为命令。实际上,第三方最有可能通知我们已经发生的事件,因此,我们将希望尽一切可能响应该事件。

这与我们之前的一些系统要求之间存在冲突。比如,我们需要验证系统输入,此外,HTTP请求处理程序的临时中断意味着我们可能会面临丢失事件的风险。

为了正确实施这样的解决方案,我们必须确保以下几点:

  • 如果我们的HTTP端点不可用(或者它返回5xx响应),则第三方系统应该具有可靠的重试机制,以确保我们的系统最终能够响应该事件。
  • 我们要尽快执行数据验证。 这样,我们可以立即返回4xx(错误请求)响应,或者将消息保留在系统中(可能是我们自己定义的Kafka主题),以确保不会丢失事件。
  • 第三方系统应该拥有某种机制来处理我们返回的任何4xx(错误请求)响应,以便可以修正消息并将其重新发布,以供后续使用。

总结

我们在微服务架构中构建的几乎每个服务都将涉及命令或事件的处理。 这两个概念之间存在根本差异。

理解这些差异将帮助我们识别出常见的用例,从而使我们更容易设计出更一致、功能更强的系统。

作者:GuoYaxiang
  • 2
    点赞
  • 5
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
结构型设计模式和行为型设计模式在分布式系统都有广泛的应用。其,结构型设计模式主要用于解决分布式系统的通信和数据传输问题,而行为型设计模式则主要用于解决分布式系统的协作和同步问题。 以下是一些常见的结构型设计模式在分布式系统的应用: 1. 代理模式:在分布式系统,代理模式可以用于实现远程过程调用(RPC)和远程方法调用(RMI)等功能,从而使得分布式系统的不同节点之间可以方便地进行通信和数据传输。 2. 适配器模式:在分布式系统,适配器模式可以用于将不同节点之间的数据格式进行转换,从而使得这些节点可以更加方便地进行数据交换和共享。 3. 桥接模式:在分布式系统,桥接模式可以用于将不同节点之间的通信协议进行转换,从而使得这些节点可以更加方便地进行通信和数据传输。 以下是一些常见的行为型设计模式在分布式系统的应用: 1. 观察者模式:在分布式系统,观察者模式可以用于实现分布式事件处理,从而使得不同节点之间可以方便地进行协作和同步。 2. 命令模式:在分布式系统命令模式可以用于实现分布式事务处理,从而使得不同节点之间可以方便地进行协作和同步。 3. 状态模式:在分布式系统,状态模式可以用于实现分布式锁和分布式同步等功能,从而使得不同节点之间可以方便地进行协作和同步。

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值