使用Akka的Actor模型和领域驱动设计构建反应式系统

915 阅读27分钟
原文链接: www.infoq.com

核心要点

  • 面向Actor编程是面向对象编程的一种替代方案;
  • 借助Actor,开发高并发的系统会变得非常容易;
  • Actor并不局限于单个节点上的单个进程,它可以作为分布式集群运行;
  • Actor和Actor模型提供了“反应式”编程所需的所有内容;
  • Actor与领域驱动设计是绝佳的组合。

随着移动和数据驱动应用的爆发性增长,用户需要在任何地点实时访问任何内容。系统的弹性和响应性不再是“最好能有”的特征,而是已经成为重要的业务需求。业务越来越需要从静态、脆弱的架构转换为灵活、弹性的系统。

因此,反应式开发得到了迅速地增长。为了支持反应式开发,Actor模型结合领域驱动设计能够满足现代弹性的需求。

Actor模型的一些历史

Actor模型最初是随着Smalltalk的出现而构思出来的,当时面向对象编程(Object-Oriented Programming,OOP)本身刚刚出现不久。

大约在2003年左右,计算机的核心特性经历了一个重要的变化,处理器的速度达到了一个顶点。在接下来的近十五年里,时钟速度是呈线性增长的,而不是像以往那样以指数级的速度增长。

但是,用户的需求在持续增长,计算机领域必须要找到某种方式来应对,多核处理器应运而生。计算处理变成了“团队协作”,效率的提升通过多个核心的通信来实现,而不是传统的时钟速度的提升。

这也是线程发挥作用的地方,这个概念要比看上去复杂得多。以某个稀缺资源的简单计数器为例,比如仓库中某个货品的数量或者某个活动的可售门票。在这样的例子中,可能会有很多请求同时获取一个或多个仓库中的货品或门票。

我们考虑一种常用的实现方式,每个购买请求都对应一个线程。在这种方式中,很可能会有多个并发运行的线程都去调整计数器。为了满足语义方面的需求,我们的模型必须要确保在同一个时间只能有一个线程去递减计算器的值。这样做的原因在于递减操作涉及到两个步骤:

  1. 检查当前的计数器,确保它的值要大于或等于要减少的值;
  2. 递减计数器。

下面的样例阐述了这两个步骤为什么要作为一个整体操作来完成。每个请求代表了购买一个或多个货品,也可能是购买一张或多张门票。假设有两个线程并发地调整计数器,该计数器目前的值是5。线程一想要将计数器的值递减3,而线程二想要将计数器的值递减4。它们都会检查当前计数器的值,并且会断定计数器的值大于要递减的数量。然后,它们都会继续运行并递减计数器的值。最后的结果就是5 - 4 - 3 = -2。这样的结果会造成货品的过度分配,违反了特定的业务规则。

防止这种过度分配的一种原生方式就是将检查和递减这两个步骤放到一个原子操作中。将两个步骤锁定到一个操作中能够消除购买已售罄物品的可能性,比如两个线程同时尝试购买最后一件商品。如果没有锁的话,就有可能多个线程同时断定计数器的值大于或等于要购买的数量,然后错误地递减计数器,从而导致出现负数值。

每次一个线程的方式有一个潜在的问题,那就是在高度竞争的阶段,有可能出现很长的线程队列,它们都在等待递减计数器。在现实世界中,类似的例子就是人们排队等待购买某个活动的门票。

这种方式有个较大的弱点就是可能会造成众多的阻塞线程,每个线程都在等待轮到它们去执行一个序列化的操作。

如果应用的设计者不小心的话,内在的复杂性有可能会将多核心处理器、多线程的应用变成单线程的应用,或者导致工作线程之间存在高度竞争。

多线程环境的更好方案

Actor模型优雅地解决了这个难题,为真正多线程的应用提供了一个基础支撑。Actor模型的设计是消息驱动和非阻塞的,吞吐量自然也被考虑了进来。它为开发人员提供了一种简便的方式来进行多核心编程,不再需要关心复杂难懂的并发。让我们看一下它是如何运行的。

Actor包含发送者和接收者;设计简单的消息驱动对象实现异步性。

我们重新回顾一下上面所描述的门票计数器场景,将基于线程的实现替换为Actor。当然,Actor也要在线程中运行,但是Actor只有在有事情可做的时候才会使用线程。在我们的计数器场景中,请求者代表了Customer Actor。门票的数量现在由Actor来维护,它持有当前计数器的状态。Customer Actor和Tickets Actor在空闲(idle)或没有事情做的时候(也就是没有消息要处理)都不会持有线程。

要初始购买操作,Customer Actor需要发送一条buy消息给一个Tickets Actor。在这样的buy消息中包含了要购买的数量。当Tickets Actor接收到buy消息时,它会校验购买数量不超过当前剩余的数量。如果购买请求是合法的,数量就会递减,Tickets Actor会发送一条信息给Customer Actor,表明订单被成功接受。如果购买数量超出了剩余数量的话,Tickets Actor将会发送给Customer Actor一条消息,表明订单被拒绝了。Actor模型本身确保处理是按照同步的方式进行的。

在下面的图中,我们展现了一些Customer Actor,它们各自发送buy消息给Tickets Actor。这些buy消息会在Tickets Actor的收件箱(mailbox)中排队。

图3:Customer Actor发送buy消息

Ticket Actor处理每条消息。如下展示的是请求购买五张门票的第一条消息。

图4:Tickets Actor处理消息

Tickets Actor检查购买数量没有超出剩余门票的数量。在当前的情况下,门票数量是15,因此购买请求能够接受,剩余门票数量会递减,Tickets Actor还会发送一条消息给发出请求的Customer Actor,表明门票购买成功。

图5:Tickets Actor处理消息队列

Tickets Actor会处理其收件箱中的每条消息。需要注意,这里没有复杂的线程或锁。这是一个多线程的处理过程,但是Actor系统会管理线程的使用和分配。

在下面的图中,我们会看到当请求的数量超过剩余值时,Tickets Actor会如何进行处理。这里所展现的是当我们请求两张门票,但是仅剩一张门票时的情况。Tickets Actor会拒绝这个购买请求并向发起请求的Customer Actor发送一条“sold out”的消息。

图6:Tickets Actor拒绝购买请求

当然,在线程方面有一定经验的开发人员会知道,可划分为两个阶段的行为检查和门票数量递减能够通过同步的操作序列来完成。以在Java中为例,我们可以使用同步的方法或语句来实现。但是,基于Actor的实现不仅在每个Actor中提供了自然的操作同步,而且还能避免大量的线程积压,防止这些线程等待轮到它们执行同步代码区域。在门票样例中,每个Customer Actor会等待响应,此时不会持有线程。这样所形成的结果就是基于Actor的方案更容易实现,并且会明显降低系统资源的占用。

Actor:对象的合法继承者

Actor是对象的自然继承者,这并不是什么新的理念,事实上也不是那么具有革命性。Smalltalk的发明者Alan Kay定义的很多对象范式(paradigm)依然在使用。他强调消息的重要性,甚至还说对象的内部实现其实是次要的。

尽管Smalltalk最初并不是异步的,但它依然是基于消息的,本质上来讲一个对象是通过向另外一个对象发送消息来完成功能的。因此,现代的Actor模型遵循了Alan Kay最早的面向对象设计理念。

如下是Akka Actor系统中一个简单的Java Actor实现样例(我们为每个Actor设置了一个唯一的“魔力”序列数字,使用它来阐述状态)。 

__Fri Mar 23 2018 17:38:15 GMT+0800 (CST)____Fri Mar 23 2018 17:38:15 GMT+0800 (CST)__public class DemoActor extends AbstractActor {
  private final int magicNumber;
  public DemoActor(int magicNumber) {
    this.magicNumber = magicNumber;
  }
  @Override
  public Receive createReceive() {
    return receiveBuilder()
      .match(Integer.class, i -> {
        getSender().tell(i + magicNumber, getSelf());
      })
      .build();
  }
  public static Props props(int magicNumber) {
    // Akka Props is used for creating Actor instances
    return Props.create(DemoActor.class, () -> new DemoActor(magicNumber));
  }
}__Fri Mar 23 2018 17:38:15 GMT+0800 (CST)____Fri Mar 23 2018 17:38:15 GMT+0800 (CST)__

Actor的实现形式为一个类,这个类扩展自Akka的抽象基类。Actor的实现必须要覆盖一个方法,也就是createReceive方法,该方法负责创建一个消息接收的构建器,定义传入到Actor实现中的消息对象该如何进行处理。

还要注意这个Actor是有状态的。Actor的状态可以非常简单,就像本例中的魔数一样,也可以非常复杂。

要创建Actor的实例,我们需要一个ActorSystem。ActorSystem启动之后,创建Actor一般只需要一行代码。 

__Fri Mar 23 2018 17:38:15 GMT+0800 (CST)____Fri Mar 23 2018 17:38:15 GMT+0800 (CST)__ActorSystem system = ActorSystem.create("DemoSystem");
ActorRef demo = system.actorOf(DemoActor.props(42), "demo");__Fri Mar 23 2018 17:38:15 GMT+0800 (CST)____Fri Mar 23 2018 17:38:15 GMT+0800 (CST)__

Actor创建操作的返回值是一个actor引用(actor reference),这个actor引用用来给Actor发送消息。 

__Fri Mar 23 2018 17:38:15 GMT+0800 (CST)____Fri Mar 23 2018 17:38:15 GMT+0800 (CST)__demo.tell(123, ActorRef.noSender());__Fri Mar 23 2018 17:38:15 GMT+0800 (CST)____Fri Mar 23 2018 17:38:15 GMT+0800 (CST)__

上面展现了定义、创建运行实例以及给Actor发送消息的基本步骤。当然,实际要做的会比这个简单例子更多一些,但是在大多数情况下,使用Actor开发系统需要学习如何以Actor系统的形式实现应用和服务,这些Actor之间通过交换异步消息进行交互。

为更好的网络构建更好的应用

除了内核和线程以外,如今的环境还允许开发人员利用更快速的存储设备、大量的内存以及高度可伸缩且广泛连接的设备。这些技术都通过用户可接受的价格以云托管方案和快速网络的方式连接在一起。

但是随着系统越来越按照分布式的方式来实现,延迟的增加是不可避免的。分布式系统会被停机或网络分区所中断,这可能是由于一个或多个服务器脱离集群、生成新服务器所导致的延迟造成的。对象模型并不适合处理这种问题。因为每个请求和每个响应都是异步的,所以Actor模型能够帮助开发人员处理该问题。

借助Actor模型,我们能够很自然地减少延迟。鉴于此,我们不再预期得到即时的结果,系统只会在需要发送或接收消息的时候做出反应。当探测到延迟降级时,系统会自动做出反应和调整,而不是将系统关闭。

在节点所组成的分布式集群中,每个节点都运行Actor的一个子集,在这样的环境中,Actor之间通过异步消息进行交互是非常自然的事情。另外,Actor有一项基本的功能,那就是消息的发送者和接收消息的Actor并不一定局限在同一个JVM进程中。Akka最棒的特性之一就是我们可以构建在集群中运行的系统。Akka集群是运行在独立JVM中的一组节点。从编程的角度来说,将消息发送至另外一个JVM中运行的Actor与将消息发送给本地JVM中的Actor一样容易。如下面的图所示,分布在多个集群节点上的Actor可以发送消息给其他集群节点上的Actor。

能够在集群环境中运行增加了Actor系统在整体架构上的动态性。在一台服务器、一个进程和一个JVM中运行是一回事儿,在一个跨网络的JVM集群中运行系统则完全是另外一回事儿。

在单个JVM的场景下,Actor运行在Actor系统之中,JVM要么处于运行中,要么没有运行。但是在集群中运行的时候,任何一个时间点集群的拓扑结构都可能发生变化。集群节点可能瞬间就能添加进来或移除掉。

从技术上来讲,只要有一个节点处于启动状态,集群本身就是启动的。某个节点上的Actor可能会非常高兴地与其他节点上的Actor交换信息,然后,没有任何预警,节点突然就可能会宕机,位于该节点上的Actor也就被移除了。其他的Actor该如何应对这些变化呢?

集群节点的丢失会影响到消息的交换,既会影响消息的发送者也会影响消息的接收者。

对于消息的接收者来说,始终存在预期的消息永远接收不到的可能性。接收的Actor要将这种情况考虑进去,需要存在一个B计划。在处理异步消息时,预期的消息可能接收不到是难免的。在大多数场景中,处理传入消息的丢失并不需要感知到集群的存在。

而另一方面,对于消息的发送者来说,通常要在一定程度上感知到集群的存在。路由器Actor(Router Actor)负责将消息发送到其他Actor的物流事宜,接收消息的Actor可能会以分布式的方式存在于集群之中。路由器Actor接收到消息,但是它自己并不会处理消息。它将消息转发给一个工作者Actor。这些工作者Actor通常被称为被路由者(routee)。路由器Actor负责按照一定的路由算法将消息转发给其他的被路由者Actor。实际的路由算法因情况而异,它要依赖于每个路由器的需求。路由算法的例子包括轮询(round robin)、随机或最小收件箱等等。

考虑下图所示的样例场景(要记住发送消息的客户端并不知道接收消息的Actor会如何处理消息)。对于客户端来说,接收消息的Actor就是一个黑盒。接收消息Actor可能会将工作委托给其他的工作者Actor来完成。在这种情况下,接收消息的Actor就是一个路由器。它将接收到的消息路由给代理Actor,让它们来完成实际的工作。

在这个样例场景中,路由器Actor可能需要感知集群的存在,它会将消息路由到集群中分布式节点的Actor上。那么,集群感知意味着什么呢?

集群感知Actor会用到当前集群状态的组成信息,以便于决定如何将传入的消息路由到集群中分布式的其他Actor上。集群感知Actor最常见的场景之一就是路由器。集群感知路由器会基于当前集群的状态决定如何将消息路由至目标Actor。例如,路由器知道分布式集群中被路由的Actor的位置,然后按照分布式工作的算法将消息发送至目标Actor。

Actor模型如何支持反应式系统

正如反应式宣言(Reactive Manifesto)所定义的那样,“反应式是即时响应性的(responsive),反应式是具有弹性的(resilient),反应式是具有适应性的(elastic),反应式是消息驱动的”。本质上来讲,消息驱动组件促进了反应式的其他三项特点的实现。

提高系统的响应性

反应式是即时响应的,也就是系统能够动态适应不断变化的用户需求。对于一个反应式系统来说,以请求/响应的方式回应用户的需求并不少见。如果借助Actor模型来支持反应式编程,开发人员能够实现非常高的吞吐量。

支持系统的弹性

借助消息传递和消息驱动架构提供的功能,我们也能支持弹性。当客户端Actor发送一条消息给服务器Actor接收者时,客户端不必处理服务器对象或Actor可能抛出的异常。

请考虑一下典型的面向对象架构,在这种架构中,客户端发送一条消息或者调用接收者的一个方法,我们需要强迫客户端处理可能会出现的各种崩溃或抛出的异常。作为回应,客户端一般会重新抛出或者将异常传递给更高层的组件,希望其他人处理它。但是,客户端并不适合修复服务端的崩溃。

在Actor模型中,尤其是在使用Akka时,会建立一个用于监管的层级结构。当接收传入消息的过程中出现服务器崩溃或者抛出异常时,不是客户端来处理崩溃,而是由服务器Actor的父Actor或者负责服务器Actor的对象来进行处理。

父Actor能够更好地理解子Actor可能会出现的崩溃,因此能够对其作出反应并重启该Actor。客户端只需要知道接收到请求的响应或者没有接收到响应时分别该如何处理就可以了。如果在一个可接受的时间范围内,它没有接收到响应,那么它可以向同一个Actor发送相同的请求,希望得到再次处理。所以(如果正确构建的话)Actor系统是非常有弹性的。

如下是一个样例,实际展现了Actor的监管。在图7中,Actor R是一个监管者,它创建了四个工作者Actor。Actors A和B会发送消息给Actor R,请求它执行一些操作。Actor R将工作委托给某一个可用的工作者Actor。

图7——Actor A的消息会被R委托给工作者Actor

在这个样例中,工作者Actor遇到了问题并抛出了异常,如图8所示。异常会被监管者Actor R来处理。监管者Actor会遵循一个定义良好的监管策略,以便于处理工作者的错误。监管者可以选择恢复出现简单问题的Actor或者重启该工作者,也可能会将其停止掉,这依赖于问题的严重程度和恢复策略。

图8——工作者Actor抛出了异常

尽管异常会由监管者处理,但是Actor A还在期待收到响应信息。注意,Actor A只是期待会有响应消息,而不是一直在等待该消息。

这种异步消息的交互引入了一些很有意思的动态性。Actor A希望Actor R能够像预期的那样,对它的消息做出反应。但是,无法保证Actor A的消息会得到处理或者能够返回响应信息。这个过程中发生的任何问题都有可能破坏请求和响应的周期。例如,考虑这样一种情况,Actor A和Actor R运行在不同的节点上,Actor A和Actor R之间的消息发送要穿过一个网络连接。网络可能会被关闭,或者运行Actor R的节点可能会出现故障。另外,还有可能要执行的任务失败了,比如在数据库操作时,因为网络故障或服务器停机造成了操作失败。

鉴于这里缺乏任何保证,在实现Actor A时,有种常见的方式就是它预期会得到两种可能的结果。第一种结果是当它发送消息给Actor R后,它最终接收到了一条响应消息。还有一种可能的结果就是Actor A会预期得到一条替代消息,这条消息表明没有接收到预期的响应。如果按照这种方式的话,Actor A会涉及到发送两条消息:第一条是发送给Actor R的消息,第二条是在未来特定的时间点发送给自己的消息。

图9——Actor A接收一条超时的消息

在这里所使用的基本策略就是同时存在A计划和B计划。A计划指的是所有的事情都能正常运行。Actor A发送一条消息给Actor R,预期的任务得到执行并且响应消息能够返回给Actor A。B计划能够应对Actor R无法处理请求消息的场景。

扩展系统的适应性

反应式系统需要具有适应性,它们可以根据需要扩展和收缩。真正的反应式设计没有竞争点或中心化的瓶颈,所以我们可以共享或复制组件,并在它们之间分配输入。它们通过提供相关的实时性能度量数据,实现预测和反应式伸缩算法。

Actor模型对适应性的支持是通过动态响应用户活动的高峰和低谷来实现的,在有需要的时候,它会智能地提升性能,并在使用率较低的时候节省能源。消息驱动的特性能够带来更大程度的适应性。

适应性需要两个关键要素。第一个就是当负载增加或降低时,能够有一种机制扩展和收缩处理能力。第二个就是要有一种机制允许在处理能力发生变化的时候,系统对其作出适当的反应。

有很多方式来应对处理能力的扩展和收缩。通常来讲,处理能力的变化可以手动或自动进行。手动处理的样例场景就是为了准备客户季节性的流量高峰,提前增加处理能力。典型的例子就是黑色星期五(Black Friday)和剁手星期一(Cyber Monday)或者光棍节。自动扩展是很多云厂商所提供的很有用的特性,比如Amazon AWS。

Actor模型以及该模型的实现Akka并没有提供触发处理能力调整的机制,但它是一个理想的平台,能够借助它来构建当集群拓扑结构发生变化时,做出适当反应的系统。正如前文所述,在Actor级别,可以实现集群感知的Actor,它们专门设计用来应对节点离开或加入集群的场景。在很多场景下,当我们设计和实现Actor系统使其更有弹性的时候,其实我们也为适应性打下了良好的基础。当你的系统能够处理分布式节点因为故障而离开集群,并且支持新节点取代故障节点的时候,其实在本质上来说节点因为故障离开或加入集群,这与为了应对用户的活动而调整可用的处理能力并没有什么差异。

消息至关重要

正如我前面讲到的,Actor模型主要关注直接的异步消息。为了实现这一点,发送者有必要知道接收者的地址,这样才能将消息发送给接收者。

Actor是无锁的结构,它们不会共享任何内容。如果三个发送者分别向一个接收者发送消息,接收者会在它的收件箱中对这些消息进行排队,每次只处理一条消息。因此,接收者不需要在内部使用锁来保护它的状态,以防止多线程对状态的同时操作。接收者不会将它的内部状态与其他的Actor共享。

Actor模型还允许接收消息的Actor为下一条即将到达的消息做出调整。例如,假设有两个程序流序列。当第一个序列的发送者发送一条消息给接收者时,接收者对消息做出反应,然后将其自身转换为另外一种类型的消息监听者。现在,当第二个序列中的消息发送者发送消息给同一个Actor时,该Actor会在自己的receive代码块中使用不同的一组逻辑(当Actor改变状态时,它可以替换消息的接收逻辑。在Akka文档中,有一个这种类型的样例

用更少的资源做更多的事情

Actor模型帮助我们解决的另外一个主要问题就是用更少的资源做更多的事情。各种规模的系统都能从中受益,从Amazon和Netflix所使用的大型网络到更小的架构均是如此。Actor模型允许开发人员充分发挥每台服务器的最大能量,因此很有可能降低集群的规模。

基于Actor的服务可以有多少个Actor呢?五十个?一百个?都可以!基于Actor的系统非常灵活且具有适应性,它们可以支持大量的Actor,甚至能到百万级别。

在典型的N层架构中,也可以称为“端口与适配器(ports-and-adapters)”或六边形架构,存在大量不必要的复杂性,甚至会有偶发的复杂性。Actor模型最大的优势之一就是它能够消除很多的复杂性,它会将一组“控制器”适配器置于边界之上。控制器可以发送消息给领域模型,从而任务委托出去,领域模型进而发布事件。通过这种方式,Actor模型能够在很大程度上降低网络复杂性,允许设计者用有限的资源和预算完成更多的事情。

使用领域驱动设计加速业务开发

领域驱动设计(DDD)的本质是在边界上下文(bounded context)建模一个通用(ubiquitous)语言。让我稍作解释:考虑将某个特定类型的服务建模为边界上下文。这个边界上下文是一个语义边界,该边界内的所有的内容(包括领域模型)都有明确的定义,其中还包括一个团队成员所使用的语言,该语言能够帮助开发人员理解边界上下文中每个概念的含义。

接下来就是上下文映射(context-mapping)能够发挥作用的地方了,上下文映射会为每个边界上下文如何与其他的上下文对应、团队关系以及模型如何交互与集成等行为建模。使用上下文映射是非常重要的,因为边界上下文本身要比很多人在单体架构中所习惯的思维模式小得多。

对于任何企业和开发团队来说,响应快速变化的新业务方向都是很大的挑战。在处理这些不断演化的业务方向时,DDD可以进行必要的知识消化(knowledge crunching)。Actor和消息能够帮助开发人员快速实现领域模型以应对他们的需求,并且有助于对领域模型形成清晰的理解。

Actor和DDD:完美组合

Alan Kay说过,“Actor模型保留了对象理念更多的好特性”。Kay还说过,“最大的理念是消息”。在创建通用语言的过程中,开发人员可以关注Actor(将其作为对象或组件)、领域模型的元素以及它们之间的消息。

单个反应式服务无法构成完整的服务,反应式服务是来源于系统的。因此开发人员最终要试图完成的是构建整个系统,而不是单个服务。通过触发领域事件到其他的边界上下文或微服务,开发人员可以更容易地实现这一点。

为何Actor对业务更加有益?

Actor可以和DDD非常理想地协作,因为它们都使用通用语言来表述核心业务领域。它们的设计都非常优雅,能够处理业务故障,不管网络出现了何种问题都能维护系统的弹性和快速响应。它们帮助开发人员扩展系统以满足并发的需求,当面临峰值负载时,进行适应性地扩展,并在流量降低的时候,进行收缩,从而最小化基础设施占用和硬件需求。这种模型非常适合当今高度分布式、多线程的环境,并且能够产生远远超出服务器能力空间的业务收益。

关于作者

Markus Eisele 是一位Java Champion、前Java EE专家组成员、JavaLand的创始人,在世界范围内的Java会议上是享有盛誉的讲师,在企业级Java领域颇为知名。他在Lightbend担任开发人员,读者可以在Twitter上@myfear联系到他。

 

Hugh McKee 是Lightbend的开发人员。在职业生涯中,他曾经长期构建演化缓慢的应用程序,这些应用无法高效地利用其基础设施,并且非常脆弱和易于出错。在开始构建反应式、异步、基于Actor的系统之后,一切都发生了变化。这种全新的构建应用程序的方式震撼了他。按照这种方式还有一个附加的好处,那就是构建应用系统会变得比以往更加有趣。现在,他主要帮助人们探索构建反应式、弹性、适应性和消息驱动应用程序的重要优势和乐趣。

查看英文原文:Building Reactive Systems Using Akka’s Actor Model and Domain-Driven Design