微服务----幂等与发件箱模式

当你为看电视而购买一套家庭影院时,你得到一堆遥控器。一个遥控器用于电视,一个用于接收器,一个用于DVD/蓝光播放器、一个用于有线电视盒、卫星电视或机顶盒。也许你有一个多碟CD播放器,甚至是一个转盘,也许你还带着录像机等等,但是以后不会再有了,解决方案在下面。

解决方案不是处理一堆远程设备,而是让一个通用远程设备来管理其他的远程设备。一旦你这样做了,你会发现不是所有的组件都是一样的,至少在他们在处理红外线信号的代码(遥控器发出的实际信号)方面是不同的。

一个廉价的A/V接收器可能包含PowerToggle(电源开关)或NextInput(next菜单)之类的代码。这使得它几乎不可能可靠地打开设备并设置正确的输入,尤其是当有人手动弄乱了接收器。

一个好的接收器会有额外的开机和关机代码,即使遥控器上没有这些按钮,而且每个输入也会有一个独立的代码,如InputCable、InputSatellite和InputBluRay。

这就是幂等性的本质。不管你的万能遥控器发出多少次开机和InputBluRay命令,接收器都会打开,随时准备观看《阿甘正传》。然而,PowerToggle和InputNext不是幂等的。如果多次重复这些命令,组件每次都将处于不同(未知)状态。(言外之意 如果命令结果是某一个指定的或者可预期的状态,那么它就是幂等性,比如开始/结束命令、读命令、部分更新状态的命令)

什么是幂等

幂等性的在分布式系统中是非常重要,因为很难保证对一个命令无论调用或处理多少次都能获得正确一致的响应。

首先网络基本上是不可靠的,大多数分布式系统都不能保证一次消息传递或处理就能成功,即使使用RabbitMQ、Azure服务总线或amazonsqs之类的消息代理时也是如此。大多数代理至少提供 at-least-once 消息传递(语义上就是 至少一次),这也依赖于业务逻辑根据需要可以重试处理多次,直到确认消息处理完成为止。

这意味着如果一个消息由于任何原因无法被正确处理,它将被重试。假设我们有一个像上面的A/V接收器一样的消息处理程序。如果消息都像InputBluRay一样是明确无误的,那么无论消息被重试多少次,代码都可以很容易地按照用户的预期处理它。另一方面,如果消息是InputNext,那么代码很难在未知重试次数的情况下,来实现用户意图。

简而言之,如果我们系统中的每个消息处理程序都是幂等的,我们可以多次重试任何消息,并且不会影响系统的整体正确性。

听起来不错,那我们为什么不这么做呢?

幂等性很难实现

假设您需要执行一个相当简单的操作:在数据库中创建一个新用户,然后发布一个UserCreated事件,通知系统的其他组件。

看起来很简单,让我们尝试一些伪代码:

Handle(CreateUser message){
  DB.Store(new User());
  Bus.Publish(new UserCreated());
}

这在理论上看起来不错,但是如果消息代理不支持任何形式的事务呢?(剧透提醒:大多数人不会!)如果这两行代码之间发生故障,那么将创建数据库记录,但不会发布UserCreated消息。如果这时重试,将会写入新的数据库记录,然后发布消息。那么第一次创建的用户数据就成了僵尸数据,那些在数据库中创建的僵尸记录,大部分情况都是重复有效记录,并且不会有消息发送到系统的其他组件,我们很难注意到这种情况的发生,也很难在以后清理这些混乱的数据。

或许这很容易解决,让我们改变语句的顺序来解决僵尸记录问题:

Handle(CreateUser message){
    Bus.Publish(new UserCreated());
    DB.Store(new User());
}

现在我们有了一个跟之前相反的问题。如果在发布之后,但在数据库调用之前发生了异常,我们会生成一个幽灵消息,向系统的其他组件发出一个从未真正发生过的事件通知。如果有其他的系统组件试图查找该用户,他们会发现根本找不到,因为它从未被创建过。但其余的下游流程继续运行在该消息上,甚至可能会对信用卡进行计费,但无法实际发送订单!

如果你相信下游的交易系统会拯救你,那么还是再想想吧。将整个消息处理程序包装在数据库事务中只会将所有数据库操作重新排序到进程的末尾,即Commit()发生的位置。实际上,数据库事务在执行时会将第一个代码段(首先是数据库)转换为第二个代码段(最后是数据库)。

在设计一个可靠的系统时,你需要考虑,如果有人拔出了服务器的电源线,会发生什么。这里有三个操作在起作用: 接收传入消息、数据库操作和发送消息,由于缺少分布式事务,因此很难在没有僵尸记录、幽灵消息和其他陷阱的情况下进行编码。

发件箱模式

我们需要的是保证消息传递操作(包括使用传入消息和发送传出消息)与数据库中的业务数据更改之间的一致性。使用Outbox模式,我们可以利用本地数据库事务来实现这一点,并将message broker 的保证至少传递一次(at-least-once),转换为一次处理保证(exactly-once)。

为了实现发件箱模式,消息处理逻辑分为两个阶段:消息处理程序阶段和调度阶段。

在消息处理程序阶段,我们不会立即向message broker发送消息,而是将它们存储在内存中,直到消息处理程序结束。此时,我们使用与业务数据相同的事务将所有累积的消息存储到数据库表中,并使用MessageId作为主键。消息处理程序阶段到此结束。

insert into OutboxData (MessageId, TransportOperations)
values (@MessageId, @OutgoingMessagesJson)

接下来,在调度阶段,存储在发件箱中的所有消息都被发送到message broker。如果一切顺利,传出的消息被发送出去,传入的消息被消费,一切都很好。但是,此时仍然有可能出现问题,并且只有一部分消息发送成功,这迫使我们再试一次,于是生成重复的消息,但就是这样设计的。

发件箱模式与收件箱应该匹配,这样当处理任何重复的消息(或重试发送阶段失败的邮件)时,将首先从数据库检索发件箱数据。如果存在,则表示消息已经成功处理,我们应该跳过消息处理阶段,直接进入调度阶段。如果消息碰巧是重复的,并且传出的消息已经被调度,那么也可以跳过调度阶段。

 

用伪代码表示,整个发件箱+收件箱流程如下所示:

var message = PeekMessage();

// 检查是否有重复数据,注意:下面会出现两处校验
var outbox = DB.GetOutboxData(message.Id);

// 消息处理阶段
if(outbox == null)
{
   using(var transaction = DB.StartTransaction())
  {
    var transportOperations = ExecuteMessageHandler(message);
    outbox = new OutboxData(message.Id, transportOperations);
    DB.StoreOutboxData(outbox);
    transaction.Commit();
  }
}

// 调度阶段
if(!outbox.IsDispatched)
{
  Bus.DispatchMessages(outbox.TransportOperations);
  //这个地方如果失败了,可能会重试,并产生重复的消息
  DB.SetOutboxAsDispatched(message.Id);
}

AckMessage(message);

使用这个模式,可以实现了处理端的幂等性(其实是相对的,这里只是实现了消息发送端的幂等性,那消息的接收端呢?应该是要通过幂等),我们只需查看MessageId就可以分辨出重复项。多次按下PowerToggle,更像是发送重复的消息,但由于MessageId值不同,所以本质上,像PowerToggle这样的操作不是幂等的,并且基础设施对此无能为力,但这是另一篇文章的主题。https://particular.net/blog/infrastructure-soup

总结

幂等性是分布式系统的一个重要属性,但要可靠地实现它,也很困难。由于设计不合理而突然出现的异常通常很容易被忽视,然后又很难诊断出来。

利用发件箱这样的设计模式,可以更容易地利用存储业务数据的本地数据库事务,并使用该事务在接收/发送消息和保存业务数据之间建立一致性。

补充:这里只是实现消息发送端的幂等性,但是消息接收端呢?仍然需要通过校验来过滤掉重复的消息。

 

 

 

你可能感兴趣的:(微服务)