消息发送语义

优质
小牛编辑
130浏览
2023-12-01

Akka帮助您在多核的单机上(“向上扩展”或纵向扩展)或分布式计算机网络中(“向外扩展”或横向扩展)构建可靠的应用程序。这里关键的抽象是,你的代码单元——actor——之间所有的交互都是通过消息传递完成,这也是为什么“消息是如何在actor之间传递”的准确语义应该拥有自己的章节。

为了给出下面讨论的一些背景,考虑一个跨越多个网络主机的应用。首先通信的基本机制是相同的,无论是发送到一个在本地JVM中的actor,还是一个远程actor,不过当然在投递延迟上会有可观察到的差异(也可能决定于网络带宽和消息大小)和可靠性。对远程消息发送,显然会有更多步骤,从而意味着更多出错的可能。另一方面,本地消息发送只会传递一个本地JVM中消息的引用,所以没有对发送的底层对象上做任何限制,而远程传输将对消息的大小进行限制。

如果你在编写actor时,认为每一次消息交互都可能是远程的,这是安全但悲观的赌注。这意味着,只依赖那些始终被保证的特性(下面将详细讨论这些特性)。这样做当然会在actor的实现中带来一些额外的开销。如果你愿意牺牲完全的位置透明性——例如有一组密切合作的actor——你可以总是将它们放在同一个JVM中,并享受更加严格的消息传递保证。这种折衷的细节在下面会进一步讨论。

作为一个补充,我们将为如何在内建机制上构建更强的可靠性,给出一些指导意见。本章以讨论“死信办公室”的角色作为结束。

一般规则

这些是消息发送的规则(即tell!方法,这也是ask的底层实现方式):

  • 至多一次投递,即不保证投递
  • 对每个 “发送者-接收者” 对,有消息排序

第一条规则是典型的,并在其他actor框架中有出现,而第二个则是Akka独有的。

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

当涉及到描述传递机制的语义时,有三种基本类型:

  • 至多一次投递的意思是对该机制下的每条消息,会被投递0或1次;更随意的说法就是,它意味着消息可能会丢失。
  • 至少一次投递的意思对该机制下的每条消息,有可能为投递进行多次尝试,以使得至少有一个成功;更随意的说法就是,消息可能重复,但不会丢失。
  • 恰好一次投递的意思对该机制下的每条消息,接收者会正好得到一次投递;消息既不能丢,也不会重复。

第一种是最廉价的——性能最高,实现开销最少——因为它可以用打后不管(fire-and-forget)的方式完成,不需要在发送端或传输机制中保留状态。第二种方式要求重试来对抗传输丢失,这意味着需要在发送端保持状态,并在接收端使用确认机制。第三种是最昂贵的——并因此表现最差——因为除了需要第二种方式的机制以外,还需要在接收端保持状态,以过滤重复的投递。

讨论:为什么没有投递保证?

这个问题的核心在于这个担保究竟具体是什么意思:

  1. 消息被发送在网络上?
  2. 消息被其他主机接收?
  3. 消息放到目标actor的邮箱中?
  4. 消息开始被目标actor处理?
  5. 消息被目标actor成功处理?

以上每个担保都具有不同的挑战和成本,并且很明显,存在一些情况导致任何消息传递框架都将无法遵守这些担保;想象例如可配置邮箱类型,以及一个有界的邮箱将如何与第3点互动,甚或第5点的“成功”由什么决定。

在上面的话中包含着同样的推理——没人需要可靠地消息机制。对发送者来说,确定交互是否成功的唯一有意义的方式是收到业务层级的确认消息,这不是Akka可以做到的(我们不会写一个“按我的意思来做”的框架,也没有人会想让我们这样做)。

Akka拥抱了分布式计算,将消息传递的不可靠性明确化,因此它不会尝试说谎和实现一个有问题的抽象。这是一个已经在Erlang中大获成功的模型,它要求用户围绕它进行设计。你可以在"Erlang 文档"中读到更多介绍(第10.9和10.10节),Akka紧密地沿用了这种方法。

对这个问题的另一个角度是,通过只提供基本保障,那些无需更强可靠性的用例也就不要支付额外的实施成本;总是可以在基本的基础上添加更强的可靠性,但不可能相反移除可靠性,来获得更高的性能。

讨论:消息顺序

该规则更具体的讲是,对于给定的一对actor,从第一个actor直接发送到第二个actor的消息不会被乱序接收直接这个词强调通过tell操作符直接发给最终的目的地,而没有使用中介者或其他信息传播特性时(除非另有说明)。

该担保说明如下:

Actor A1 发送消息 M1, M2, M3A2

Actor A3 发送消息 M4, M5, M6A2

意味着:

  1. 如果 M1 被投递,则它必须在 M2M3 前被投递
  2. 如果 M2 被投递,则它必须在 M3 前被投递
  3. 如果 M4 被投递,则它必须在 M5M6 前被投递
  4. 如果 M5 被投递,则它必须在 M6 前被投递
  5. A2 可以交织地看到 A1A3 的消息
  6. 因为没有投递保证,以上任意消息都有可能被丢弃,即没有到达 A2

注意

需要注意的是Akka保证消息被排队到收件人邮箱的顺序是很重要的。如果邮箱的实现不遵循FIFO的顺序(例如,一个PriorityMailbox),则actor的处理顺序可能偏离排队顺序。

请注意,这条规则不具有传递性

Actor A 发送消息 M1 给 actor C

Actor A 然后发送消息 M2 给 actor B

Actor B 转发消息 M2 给 actor C

Actor C 可以以任何顺序接收 M1M2

因果型顺序传递性意味着M2永远不会再M1之前到达actor C(尽管它们中的任何一个都可能会丢失)。这种顺序性无法保证,因为消息具有不同的传递延迟,例如当ABC位于不同的网络主机时,详见下文。

注意

Actor的创建被视为是从父节点发送到孩子的消息,和上面的讨论具有相同的语义。以消息可以被重排序的方式发送一个消息到一个actor,会导致消息丢失,因为创建的消息也许没有发送导致actor还不存在。一个消息可能过早到来的例子是,创建一个远程部署的actor R1,将其引用发送给另一个远程actor R2,并且让R2发送消息给R1。一个明确定义排序的例子是一个父节点创建一个actor,并立即向它发送消息。

失败消息的传达

请注意,上面只讨论了actor之间的用户消息的顺序保证。一个actor的孩子的失败是通过特殊的系统消息传达的,与普通用户发送的消息没有顺序关系。特别是:

子 actor C 发送 M 给其父节点 P

子 actor 失败并发送失败消息 F

父 actor P 可能以M, FF, M的顺序收到两个事件

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

JVM内(本地)消息发送规则

对本节介绍的内容要小心使用!

不建议依托本节所介绍的更强可靠性,因为它会绑定您的应用程序只能进行本地部署:为了适应在机器集群上运行,应用程序可能需要不同的设计(而不是仅仅是对actor采用一些本地消息交换模式)。我们的信条是“一次设计,按照任何你希望的方式部署”,要实现这一点,你应该只依靠一般规则

本地消息发送的可靠性

Akka测试套件依赖于本地上下文没有消息丢失(以及对远程部署的非错误条件测试也成立),也就是说,我们实际中确实是以最大努力来保证我们测试的稳定性。然而,就像一个方法调用可能在JVM上失败一样,本地的tell操作也可能会因为同样的原因而失败:

  • StackOverflowError
  • OutOfMemoryError
  • 其他VirtualMachineError

此外,本地传输可以以Akka特定的方式失败:

  • 如果邮箱不接收消息(例如已满的BoundedMailbox)
  • 如果接收的actor在处理消息时失败,或actor已终止

第一个显然是配置的问题,不过第二个是值得一些思考的:如果处理的时候有异常,则消息的发送者不会得到反馈,而是将通知发送给其父监管者了。对外部观察者来说,这和丢失这个消息没有区别。

本地消息发送顺序

假设使用严格的先进先出邮箱,则前面提到的消息非传递的排序担保,在一定条件下可以被消除。你会注意到,这些是很微妙的,甚至未来的性能优化有可能将本节的所有内容变为无效。反标志的一些可能如下:

  • 在收到顶层actor的第一个回应之前,有一个锁用于保护内部的临时队列,并且该锁是非公平的;言下之意是,在actor的构造过程中从不同的发送者发来的入队请求(这里只是比喻,细节会更为复杂),也许会因低级别的线程调度导致重新排序。由于完全公平锁在JVM上并不存在,这是不可修复的。
  • 路由器(更准确地说是路由ActorRef)的构造过程也是使用相同的机制,因此对使用路由部署的actor也存在同样的问题。
  • 如上所述,在入队过程中任何涉及锁的地方都会有此问题,这也适用于自定义邮箱。

这份清单经过精心编制,但其他有问题的场景仍然可能会逃过我们的分析。

本地消息排序和网络消息排序如何关联

正如上一段所解释的,本地消息发送在一定条件下服从传递因果顺序。如果远程信息传输也遵从这个排序规则,这将转化为跨越单个网络链接的传递因果顺序,也就是说,如果正好只有两个网络主机参与。涉及多个环节则无法作此保证,如上面提到的位于三个不同节点的三个actor。

目前的远程传输支持此排序规则(这同样是由于锁的唤醒顺序不满足FIFO,此时是指连接建立的序列)。

从一个投机观点来看,未来有可能支持这种排序的保证,通过用actor完全重写远程传输层来实现;同时我们正在研究提供如UDP或SCTP的底层传输协议,这将带来更高的吞吐或更低的延迟,不过将再次删除此保证,这将意味着在不同的实现之间进行选择就是在顺序担保和性能之间进行折中。

更高层次的抽象

基于Akka的核心中小而一致的工具集,Akka也在其上提供了强大的,更高层次的抽象。

消息模式

上面讨论的实现可靠投递的问题,一个直截了当的答案是使用明确的ACK-RETRY协议。其最简单的形式需要

  • 一种方法来识别个体信息,并将它与确认进行关联
  • 一个重试机制,如果没有及时确认,将重新发送消息
  • 一种接收方用来检测和丢弃重复消息的方法

第三步是必要的,因为确认消息本质上也是不能确保到达的。 一个企业级确认的ACK-RETRY协议,在Akka Persistence模块中以至少一次投递的方式支持了。至少一次投递的消息可以通过跟踪标识符的方式进行重复的检测。实现第三步的另一种方式是在业务逻辑中实现消息处理的幂等性(译者注:即每次消息处理的结果都是一样的)。

实现所有三个要求的另一个例子在可靠的代理模式中展示了(现在被至少一次投递所取代)。

事件源

事件源(和分片)使得大型网站能扩展到支持数以十亿计的用户,并且其想法很简单:当一个组件(想象为actor)处理一个命令,它会生成表示该命令效果的一组事件的列表。这些事件除了被应用到该组件的状态之外,也被存储。这个方案的好处是,事件永远只会被附加存储上,没有什么是可变的;这使得完美的复制和扩展这一事件流的消费者群体得到支持(即其他组件也可以消费这个事件流,只需要复制组件的状态到一个新的空间中,并且对变化进行反应即可)。如果组件的状态丢失——由于某台机器的故障,或者是被淘汰出缓存——它仍然可以很容易地通过重播事件流(通常使用快照来加快处理)进行重建。event-sourcing由Akka Persistence支持。

具有明确确认功能的邮箱

通过实现自定义邮箱类型,有可能在接收actor结束时重试消息处理,以处理临时故障。这种模式一般在本地通信上下文非常有用,否则投递担保不足以满足应用程序的需求。

请注意,”JVM内(本地)消息发送规则“中的警告仍然有效。

实现这种模式的示例展示在邮箱确认中。

死信

不能被投递(并且可以被确定没有投递成功)的消息,会被投递到一个名为/deadLetters的人造actor。该投递以尽力而为为基础;它甚至可以在本地JVM中失败(例如actor终止时)。在不可靠的网络传输丢失的消息将会被丢弃,而不会作为死信处理。

死信应该被用来做什么?

这个组件的主要用途是调试,特别是如果一个actor的发送始终没有送达的时候(通常查看死信,你会发现发件者或接收者在某个环节上设置错了)。为了能更好地用于该目的,最好的实践是,尽可能避免发送消息到deadLetters,即使用一个合适的死信日志记录器(详见下文)来运行应用程序,并时不时地清理日志输出。这个实践——和其他所有实践类似——需要按照常识构建明智的应用程序:有可能避免发送消息给一个已终止的actor,给发送者代码增加的复杂性,多于调试输出带来的清晰度。

死信服务与其他所有消息投递一样,对于投递保证遵循相同的规则的,因此它不能被用来实现投递保证。

怎样接收死信?

一个actor可以在事件流中订阅类akka.actor.DeadLetter,请参阅事件流(java)或事件流(scala)来了解如何做到这一点。那么订阅的actor会从该点起收到(本地)系统中发布的所有死信。死信不会在网络上传播,如果你想在一个地方收集他们,你将不得不在每个网络节点上使用一个actor订阅,并手动转发。同时注意,在该节点上生成的死信,能够确定一个发送操作失败,这对于远程发送来说,可以是本地系统(如果不能建立网络连接)也可以是远程系统(如果你要发送的目标actor在该时间点不存在的话)。

(通常)不用担心死信

每当一个actor不是通过自身决定终止的,则存在这样的可能——它发送到自身的一些消息丢失了。这在复杂关闭场景下是很容易发生的,而这些场景通常也是良性的:看到一个akka.dispatch.Terminate消息被丢弃意味着,两个中断请求被发送,当然只有一个可以成功。同样道理,在停止一个子树的actor时,如果父节点在终止的时候仍然观察着子节点,则你可能会看到从孩子发出的akka.actor.Terminated消息会转变为死信。