消息传递可靠性

Akka 帮助你构建可靠的应用程序,这些应用程序可以在一台机器中使用多个处理器核心(scaling up,纵向扩展)或分布在计算机网络中(scaling out,横向扩展)。实现这一点的关键抽象是,代码单元 Actor 之间的所有交互都是通过消息传递进行的,这就是为什么 Actor 之间传递消息的精确语义应该有自己的章节。

为了给下面的讨论提供一些上下文,请考虑跨多个网络主机的应用程序。无论是发送到本地 JVM 上的 Actor 还是发送到远程 Actor,通信的基本机制都是相同的,但是在传递延迟(可能还取决于网络链路的带宽和消息大小)和可靠性方面会有明显的差异。在远程消息发送的情况下,涉及到更多的步骤,这意味着更多的步骤可能出错。另一个方面是本地发送将在同一个 JVM 中传递对消息的引用,而对发送的底层对象没有任何限制,而远程传输将限制消息的大小。

因此假设 Actor 之间的远程通信是安全的,这个一个悲观的赌注。它意味着只依赖于那些总是有保证的属性,这些属性将在下面详细讨论。这在 Actor 的实现中有一些开销。如果你愿意牺牲完整的位置透明性,例如在一组紧密合作的 Actor 的情况下,你可以将他们始终放在同一个 JVM 上,并在消息传递上享受更严格的保证。下文将进一步讨论这种权衡的细节。

作为补充部分,我们对如何在内置的基础上构建更强的可靠性给出了一些建议。本章最后讨论了“死信办公室(Dead Letter Office)”的作用。

一般规则

这些是发送消息的规则(即,tell!方法,也是ask模式的基础):

  • 至多一次传递(at-most-once delivery),即没有保证的传递
  • 每个sender–receiver对(pair)的消息排序

第一个规则通常也存在于其他 Actor 的实现中,而第二个规则则特定于 Akka。

讨论:“至多一次”是什么意思?

在描述传递机制的语义时,有三个基本类别:

  • 至多一次传递(at-most-once delivery)意味着对于传递给该机制的每个消息,该消息要么传递一次,要么根本不传递;更随意地说,它意味着消息可能会丢失。
  • 至少一次传递(at-least-once delivery)意味着对于传递给该机制的每个消息,可能会多次尝试传递它,从而至少一次成功;同样,在更随意的情况下,这意味着消息可能重复,但不会丢失。
  • 精确一次传递(exactly-once delivery)意味着对于传递给该机制的每个消息,只向接收者传递一次;消息既不能丢失也不能重复。

第一种是最廉价和高效的,而且拥有最低的实现开销,因为它可以在发送端或传输机制中以不保持状态的情况下以“即发即弃(fire-and-forget)”的方式完成。第二种需要重试以应对传输损失,这意味着在发送端保持状态,在接收端具有确认机制。第三种是最昂贵的,因此性能最差,因为除了第二种之外,它还要求状态保持在接收端,以便过滤出重复的传递。

讨论:为什么不保证传递?

问题的核心在于这个保证到底意味着什么:

  1. 消息在网络上发送?
  2. 消息是由另一个主机接收的?
  3. 消息被放入目标 Actor 的邮箱?
  4. 目标 Actor 正在开始处理消息?
  5. 目标 Actor 是否成功处理了消息?

其中每一个都有不同的挑战和成本,很明显,在某些条件下,任何邮件传递库都将无法遵守;例如,考虑可配置的邮箱类型以及绑定邮箱如何与第三点交互,甚至第五点考虑决定“成功”部分的意义。

同样的道理是,「没有人需要可靠的消息传递」。发送方了解交互是否成功的唯一有意义的方法是接收业务的确认消息,这不是 Akka 可以自己完成的,我们既不编写“按我的意思做”的框架,也不希望这样做。

Akka 采用分布式计算,并通过消息传递使通信的易出错性变得明确,因此它不会试图撒谎并模拟泄漏的抽象。这是一个在 Erlang 成功使用的模型,需要用户围绕它设计自己的应用程序。你可以在「Erlang 文档」的第 10.9 节和第 10.10 节中了解更多关于这种方法的信息,Akka 将密切关注它。

关于这个问题的另一个角度是,只提供基本的保证,那些不需要更高可靠性的用例不需要支付它们的实现成本;总是可以在基本用例之上添加更高的可靠性,但是不可能为了获得更多的性能而主动地删除可靠性。

讨论:消息排序

更具体的规则是,对于给定的一对 Actor,直接从第一个 Actor 发送到第二个 Actor 的消息不会被无序接收。该词直接强调,该保证仅适用于与tell运算符一起发送到最终目的地时,而不适用于使用中介或其他消息分发功能时,除非另有说明。

保证说明如下:

  • Actor A1A2发送消息M1M2M3
  • Actor A3A2发送消息M4M5M6

这意味着:

  1. 如果M1被接收,则必须在M2M3之前接收。
  2. 如果M2被接收,必须在M3之前接收。
  3. 如果M4被接收,则必须在M5M6之前接收。
  4. 如果M5被接收,则必须在M6之前接收。
  5. A2可以看到A1的消息与A3的消息交织在一起。
  6. 由于没有保证的传递,任何信息都可能被丢弃,即不能到达A2

在此,需要注意的是,Akka 的保证适用于邮件进入收件人邮箱的顺序。如果邮箱实现不遵循FIFO顺序(例如PriorityMailbox),那么 Actor 的处理顺序可能会偏离排队顺序。

请注意,此规则不可传递:

  • Actor A将消息M1发送给 Actor C
  • Actor A将消息M2发送给 Actor B
  • Actor B将消息M2转发给 Actor C
  • Actor C可以接受任何顺序的M1M2

因果传递排序(Causal transitive ordering)意味着M2M1之前从未在 Actor C收到过,尽管其中任何一个都可能丢失。当ABC驻留在不同的网络主机上时,由于不同的消息传递延迟,可能会违反此顺序,具体请参阅下面的详细信息。

  • 注释:Actor 创建被视为从父级发送到子级的消息,其语义与上面讨论的消息相同。以这种初始创建消息重新排序的方式向 Actor 发送消息意味着消息可能不会到达,因为 Actor 还不存在。消息可能来得太早的一个例子是,创建一个远程部署的 Actor R1,将其引用发送到另一个远程 Actor R2,并让R2R1发送消息。定义良好的排序示例是父级创建 Actor 并立即向其发送消息。

通信故障

请注意,上面讨论的排序保证仅适用于 Actor 之间的用户消息。Actor 的子级的失败是通过特定的系统消息进行通信的,这些消息不是相对于普通用户消息进行排序的。特别地:

  • 子 Actor C将消息M发送到其父 Actor P
  • 子 Actor 因错误F导致失败
  • 父 Actor P可能按MFFM的顺序接收这两个事件

这样做的原因是内部系统消息有自己的邮箱,因此用户和系统消息的排队调用顺序不能保证其出列时间的顺序。

在 JVM(本地)消息发送的规则

小心你对这部分的操作!

不建议依赖本节中更强的可靠性,因为它会将你的应用程序绑定到仅本地(local-only)部署:为了适合在计算机集群上运行,可能必须对应用程序进行不同的设计,而不是仅使用某些 Actor 本地的某些消息交换模式。我们的信条是“设计一次,以任何你想要的方式部署”,为了实现这一点,你应该只依赖于「一般规则」。

本地消息发送的可靠性

Akka 测试套件依赖于在本地上下文中不丢失消息(对于非错误条件测试也适用于远程部署),这意味着我们确实尽了最大努力保持测试的稳定性。但是,本地tell操作可能会失败,原因与在 JVM 上进行常规方法调用相同:

  • StackOverflowError
  • OutOfMemoryError
  • 其它VirtualMachineError

此外,本地发送可能会以 Akka 特定的方式失败:

  • 如果邮箱不接受邮件(例如,完全BoundedMailbox
  • 如果接收 Actor 在处理消息时失败或已终止

虽然第一个问题是配置问题,但第二个问题值得考虑:如果在处理过程中出现异常,则消息的发送者不会得到反馈,而是将该通知发送给监督者。对于外部观察者来说,这通常与丢失的消息没有区别。

本地消息发送顺序

假设严格的FIFO邮箱,在某些条件下,消除了消息排序保证的非传递性(non-transitivity)的上述警告。正如你将要注意到的,这些都是非常微妙的,而且将来的性能优化甚至有可能使整个段落(paragraph)失效。可能的非详尽的指示清单是:

  • 在接收到顶级 Actor 的第一个回复之前,存在一个保护内部临时队列的锁,而这个锁是不公平的;这意味着,根据低级线程调度,来自不同发送方的排队请求在 Actor 的构造过程中到达,可以重新排序。因为在 JVM 上不存在完全公平的锁,所以这是不可修复的。
  • 同样的机制在Router的构建过程中使用,更精确地说是路由的ActorRef,因此对于部署了路由器的 Actor 来说,同样的问题也存在。
  • 如上所述,在排队过程中涉及锁的任何地方都会出现问题,这也可能适用于自定义邮箱。

虽然此列表我们已经仔细考虑过了,但仍然可能存在其他的我们没有想到的问题。

本地顺序与网络顺序有什么关系?

对于给定的一对 Actor,直接从第一个 Actor 发送到第二个 Actor 的消息将不会被无序接收,这一规则适用于使用基于 TCP 的 Akka 远程传输协议通过网络发送的消息。

如前一节所述,本地消息在特定条件下发送服从传递因果排序。由于不同的邮件传递延迟,可能会违反此顺序。例如:

  • node-1上的 Actor Anode-3上的 Actor C发送消息M1
  • node-1上的 Actor A然后向node-2上的 Actor B发送消息M2
  • node-2上的 Actor B将消息M2转发给node-3上的 Actor C
  • Actor C可以接受任何顺序的M1M2

M1node-3的“传输”时间可能比M2通过node-2node-3的“传输”时间要长。

高级抽象

基于 Akka 核心中的一个小而一致的工具集,Akka 还提供了强大的、更高级的抽象。

消息模式

如上所述,对可靠传递需求的直接回答是一个明确的ACK-RETRY协议。以最简单的形式,这需要

  • 识别单个消息以将消息与确认关联的方法
  • 一种重试机制,如果不及时确认,将重新发送消息
  • 接收者检测和丢弃重复数据的一种方法

第三个是必要的,因为消息也不能保证到达。Akka 持久性模块的“至少一次传递”支持具有业务级确认的ACK-RETRY协议。通过跟踪通过”至少一次传递”发送的消息的标识符,可以检测到重复的消息。实现第三部分的另一种方法是使消息处理在业务逻辑级别上是等量的。

事件源

事件源(和分片)是大型网站扩展到数十亿用户的原因,其思想非常简单:当一个组件(思考 Actor)处理一个命令时,它将生成一个表示命令效果的事件列表。除了应用于组件的状态之外,还存储这些事件。这个方案的好处在于,事件只会被附加到存储中,不会发生任何变化;这样可以完美地复制和扩展这个事件流(event stream)的使用者(即,其他组件可能会使用事件流作为在不同区域复制组件状态或对更改作出反应的手段)。如果组件的状态由于机器故障或被推出缓存而丢失,则可以通过重放事件流(通常使用快照来加快进程)来重建。Akka 持久化支持「事件源」。

带明确确认的邮箱

通过实现自定义邮箱类型,可以在接收 Actor 端重试消息处理,以处理临时故障。此模式在本地通信上下文中最有用,因为在本地通信上下文中,传递保证在其他方面足以满足应用程序的需求。

请注意,对于「在 JVM(本地)消息发送的规则」的警告确实适用。

死信

无法传递(并且可以确定)的消息将传递给称为/deadLetters的虚拟 Actor。这种传递是在尽最大努力的基础上进行的;它甚至可能在本地 JVM 中失败(例如,在 Actor 终止期间)。通过不可靠的网络传输发送的消息将丢失,而不会显示为死信。

应该用死信做什么?

此工具的主要用途是调试,特别是当 Actor 发送的邮件不一致时(通常检查死信会告诉你发送者或收件者在某个地方设置错误)。为了有助于实现这一目的,最好避免在可能的情况下发送死信,也就是说,使用合适的死信记录器不时的运行应用程序,并清除日志输出。与所有其他方法一样,这个练习需要明智地应用常识:很可能避免发送到终止的 Actor 会使发送者的代码比调试输出的清晰性更差。

死信服务在传递保证方面遵循与所有其他消息发送相同的规则,因此不能用于实现保证传递。

如何收到死信?

Actor 可以订阅事件流上的类akka.actor.DeadLetter,请参阅「事件流」了解如何执行该操作。然后,订阅的 Actor 将收到(本地)系统中从那时起发布的所有死信。死信不会在网络上传播,如果要在一个位置收集死信,则必须为每个网络节点订阅一个 Actor,然后手动转发它们。还要考虑在该节点上生成死信,它可以确定发送操作失败,对于远程发送,死信可以是本地系统(如果无法建立网络连接)或远程系统(如果你要发送到的 Actor 在该时间点不存在)。

通常不令人担忧的死信

每当一个 Actor 不因自己的决定而终止时,它发送给自己的一些消息就有可能丢失。在通常是良性的复杂关闭场景中,有一种情况很容易发生:看到akka.dispatch.Terminate消息丢失意味着给出了两个终止请求,但只有一个可以成功。同样,你可能会看到akka.actor.Terminated来自子 Actor 的消息,而如果父级 Actor 在父级终止时仍在监视子 Actor,则会阻止一系列以死信形式出现的 Actor。


英文原文链接Message Delivery Reliability.