本章内容
- 为什么扩展(扩容)很难
- 一次编写,随处扩展
- Actor 编程模型简介
- Akka Actor
- 什么是 Akka?
在本书中,你将学习到:Akka 工具包如何用一种简单统一的编程模型——Actor 编程模型——来编写并发与分布式应用。Actor 自身并不新奇,独特之处在于 Akka 在 JVM(Java 虚拟机)上提供 Actor 的方式:既能向上扩展(scale up)也能向外扩展(scale out)。正如你将看到的,Akka 能高效利用资源,并在应用扩展的同时尽量控制复杂度。
Akka 的首要目标,是让构建部署在云端或运行于多核设备上的应用变得更简单,并高效利用可用的计算能力。它是一个工具包,提供 Actor 编程模型、运行时以及构建可扩展应用所需的工具。本章将讨论 Akka 诞生的背景、它要解决的问题,以及其关键的架构组件。
1.1 什么是 Akka?
直到 20 世纪 90 年代中期、互联网革命爆发之前,应用通常都只在单台计算机(单个 CPU)上运行。如果应用不够快,标准做法就是等更快的 CPU 出来;代码无需改动。问题就“解决了”。全球程序员都在“白吃午餐”,日子很美好。
注意 2005 年,Herb Sutter 在 Dr. Dobb’s Journal 上写道需要一次根本性转变(www.gotw.ca/publication…):他指出 CPU 主频提升已到极限,“免费的午餐结束了”。
大约在 2005 年(Sutter 撰文时),企业把应用跑在多处理器集群服务器上(通常不超过两三台——留一台做容错)。编程语言对并发的支持虽然存在但有限,在很多普通程序员看来近乎“黑魔法”。Sutter 预言:“编程语言……将越来越不得不很好地处理并发。”
看看之后发生了什么。快进到今天,应用运行在云中的大量服务器上;许多系统跨多个数据中心集成。终端用户不断增长的需求,推动你构建的系统在性能与稳定性上提出更高要求。并发已无处不在,并且会长期存在。
然而,大多数编程语言(尤其在 JVM 上)对并发的支持并没有本质改变。尽管并发 API 的实现细节有所改进,你仍常常需要与线程、锁等低层构件打交道,或者与“纤程(轻量级线程)”周旋。线程众所周知难以使用;而纤程(lightweight threads)又迫使你思考诸如 await、interrupt、join 等其他低层构件。
并发是一种实现可扩展性的手段:前提是当需要时,可以为服务器增加更多 CPU,而应用会自动开始使用它们。这几乎是仅次于“免费午餐”的好事。可扩展性是指系统在不明显降低性能的情况下,适应资源需求变化的能力。
与“向上扩展”(在现有服务器上增加资源,如 CPU)不同,“向外扩展”指动态地向集群增加更多服务器。自 1990 年代以来,编程语言在网络通信支持方面变化也不大——许多技术仍使用远程过程调用(RPC)在网络上传输。
与此同时,云计算服务与多核 CPU 架构的进步,使得计算资源更加充裕。平台即服务(PaaS)大大简化了超大规模分布式应用的供应与部署——这曾是 IT 行业少数巨头的专属领域。诸如 AWS EC2 和 Google Compute Engine 这样的云服务可以在数分钟内启动成千上万台服务器,而 Docker 与 Kubernetes 让在虚拟服务器上打包与管理应用更为容易。
设备中的 CPU 核心数量也在不断上升:即便是手机和平板也有多核。但这并不意味着你可以不计成本地用资源“砸”问题。归根结底,一切都与成本和效率相关:有效扩展应用,最大化性价比。正如你不会用时间复杂度呈指数级的排序算法一样,思考扩展的成本也是有意义的。
当你扩展一个应用时,应该有两个预期:
- 用有限资源去应对任何幅度的需求增长是不现实的。因此,理想情况下,当需求增长时,所需资源应缓慢增加:线性关系或更好。图 1.1 展示了需求与所需资源之间的关系。
- 如果必须增加资源,理想状态是应用的复杂度保持不变或缓慢增加。(还记得当年“免费午餐”的美好:更快的应用并不需要增加复杂度!)图 1.2 展示了资源与复杂度之间的关系。
两方面都会抬高扩展成本:一是所需资源的数量,二是应用本身的复杂度。虽然这只是“信手算算”,省略了许多因素,但不难看出,这两条“增长率”对总成本影响巨大。
一种“末日”场景是:你为大量未被充分利用的资源支付越来越多的钱。另一种是:一旦加资源,应用的复杂度飙升。于是我们有了两个目标:在扩展应用时,既要尽可能压低复杂度,又要高效利用资源。
能否用当今常见的工具(线程或纤程,以及 RPC)来同时满足这两个目标?用 RPC 横向扩展、用底层线程纵向扩展,都不是好主意。RPC 假装“跨网络调用”和“本地方法调用”没有区别。只要是同步 RPC,每次调用都要阻塞当前线程等待网络返回,才能维持“像本地调用一样”的抽象,这代价不低,且妨碍了高效用资。若改成异步 RPC,又被迫传入回调,由此引发“回调地狱”:回调套回调、层层嵌套,业务逻辑难以追踪。
这个路线的另一个问题是:究竟该在哪里向上扩、向外扩并不清晰。多线程编程与基于 RPC 的网络编程就像“苹果和梨”——运行环境不同、语义不同、抽象层级也不同。结果就是你把应用里哪些部分用线程扩、哪些部分用 RPC 扩硬编码了进去。
当你把运行在不同抽象层的方法硬编码交织在一起,复杂度会大幅上升。快问快答:是用两套缠在一起的编程构件(RPC + 线程)简单,还是只用一个构件简单?这种多头并进的扩展方式,比起为应对需求变化而需的灵活性来说,复杂得多。
今天要“拉起”成千上万台服务器很容易,但正如你将在本章看到的,编程它们并不容易。Akka 提供了一个处理并发与可扩展性的单一抽象:Actor 模型。它给出一致的语义,让你专注业务逻辑,而不必操心程序究竟跑在一千台服务器上,还是只跑在一台上。
1.2 Actor:速览
Akka 的核心是 Actor。Akka 中的大多数组件都以某种方式为使用 Actor 提供支撑:配置 Actor、把 Actor 接到网络上、调度 Actor、或把 Actor 组成集群。Akka 的独特之处在于,它让你几乎不费力就能获得这些支撑与工具,从而把心思放在“用 Actor 思考与编程”上。
简言之,Actor 很像消息队列,但没有繁琐的配置与消息代理安装成本;更像是可编程的微型消息队列——你可以轻松创建成千上万、乃至上百万个。它们“啥也不做”,除非收到一条消息。
消息是不可变的简单数据结构。Actor 一次只接收一条消息,并在接收时执行相应行为。与队列不同,Actor 还能发送消息(给其他 Actor)。
Actor 的一切工作都是异步的:你可以把消息发给某个 Actor 而不等待响应。Actor 本身不是线程,但发给它的消息最终会在某个线程上被推动执行。Actor 与线程之间如何关联是可配置的(后文会讲);目前只要知道,这并非硬绑定关系。
我们稍后会深入剖析“什么是 Actor”。眼下最重要的一点是:你通过发送/接收消息来构建应用。一条消息既可以在本机某个可用线程上处理,也可以在远端服务器上处理。至于是本地处理还是远端处理、Actor 究竟住在哪台机器上,可以以后再决定——这与你把线程或 RPC 风格的网络硬编码进程序截然不同。Actor 让你把应用拆成许多小部件,它们看起来像网络服务,但体量更小、管理开销更低。
Akka 团队将 Actor 的一些基础理念,和业界关于“如何构建更好的系统”的经验相结合,凝练成了Reactive Manifesto(反应式宣言) 。
反应式宣言(The Reactive Manifesto)
反应式宣言(<www.reactivemanifesto.org>)倡导设计更健壮、更弹性、更灵活、更能满足现代需求的系统。Akka 团队自始参与了宣言的撰写,Akka 也正是宣言思想的产物。
高效用资与应用**自动伸缩(弹性)**的能力,是宣言的驱动力:
- 阻塞 I/O 会限制并行机会,因此非阻塞 I/O更可取。
- 同步交互 会限制并行机会,因此异步交互更可取。
- 轮询 降低了节省资源的可能,因此更偏好事件驱动风格。
- 如果一个节点就能拖垮所有节点,那就是资源浪费。所以需要错误隔离(弹性) ,避免“一损俱损”。
- 系统需要弹性:需求少时用更少资源;需求多时用更多资源,但绝不超量。
- 复杂度是成本中的大头:如果你难以测试、难以修改、难以加新功能,那就是个大问题。
1.3 两种扩展思路:示例铺垫
接下来我们以一个企业聊天应用为例,看看当它必须扩展到大量服务器(处理数百万并发事件)时会遇到的挑战。我们将先看你可能熟悉的传统路线(线程与锁、RPC 等),再与 Akka 的做法对比。
传统路线从一个内存内的小应用起步,最终演变为完全依赖数据库来处理并发与可变状态的应用。一旦需要更强的交互性,你只能轮询数据库。当再加上更多网络服务,与数据库打交道 + 基于 RPC 的网络组合,会让复杂度显著增加。想在这样的应用里做故障隔离变得越来越难。你很可能对此似曾相识。
然后我们会看看 Actor 编程模型如何简化应用,以及 Akka 如何让你一次编写,任意扩展(从而在任何规模上处理并发问题)。下表概览两种方法的差异,阅读后文时请记住这个全景:
表 1.1 传统方法 vs Akka 方法
| 目标 | 传统方法 | Akka 方法 |
|---|---|---|
| 扩展方式 | 线程 + 共享可变数据库状态(增删改查)+ Web 服务 RPC | Actor 之间收发消息;无共享可变状态;以不可变事件日志为核心 |
| 提供交互信息 | 轮询获取最新信息 | 事件驱动:事件发生即推送 |
| 网络横向扩展 | 同步 RPC、阻塞 I/O | 异步消息、非阻塞 I/O |
| 处理故障 | 试图捕获一切异常;只有“一切正常”才继续 | 允许让出错的部分崩溃,隔离失败,其余部分继续前行 |
想象你要用一个颠覆性的聊天应用席卷世界,面向企业用户,帮助团队轻松互联协作。遵循精益创业精神,你从 MVP 起步,以便尽快从潜在用户那里学习需求。如果成功,潜在用户可能是千万级(谁还不用聊天与协作呢)。但两股力量可能把你的进度按下“刹车”:
- 复杂度——应用太复杂而无法再加功能。哪怕最小的改动都要巨量工作,测试也越来越困难:这次又会挂哪儿?
- 缺乏适应性——用户每一次大跃升,你都得重写应用。每次重写都既耗时又复杂。你既要维持旧系统,又要重写以支撑更多用户,左右为难。
假设你有多年经验,决定按以往的“传统法”来做:底层线程、锁与 RPC;阻塞 I/O;以及——首先在下一节登场的——用数据库维护可变状态。
1.4 传统式扩展
你从一台服务器起步。动手做聊天应用的首个版本,先设计了一个数据模型(见图 1.3)。目前先把这些对象放在内存里。
Team 对象是一组 Users,而许多 Users 可以同时属于一个 Conversation。Conversation 对象则是 Messages 的集合。到目前为止,一切顺利。
你进一步完善应用的行为,并做了一个基于 Web 的用户界面(UI)。现在已经能把应用拿给潜在用户演示了。代码也很简单、好维护。但目前应用只在内存中运行,一重启所有会话就会丢失,而且它也只能跑在一台服务器上。你用【某个闪亮的新 JavaScript 库】做出来的 Web UI 太吸睛了,相关利益方迫不及待想立刻上线,尽管你一再强调这只是用于演示。是时候上更多服务器,搭建生产环境了。
1.4.1 传统式扩展与持久化:把一切都搬进数据库
你决定把数据库引入进来。为了可用性,打算让 Web 应用跑在两台前端 Web 服务器上,并在应用前放一个负载均衡器。图 1.4 展示了新的部署方式。
代码开始变得更复杂:你不能再只操作内存里的对象了——否则在两台服务器之间如何保持对象一致?团队里有人说:“我们需要无状态化!”于是你把那些功能丰富的对象都去掉,改用数据库代码来取代。
对象的状态不再驻留在 Web 服务器的内存中,这意味着对象的方法无法直接作用于状态;本质上,所有重要的逻辑都被搬到了数据库语句里。该变化如图 1.5 所示。
这种向无状态化的转变,促使你用一种“数据库访问抽象”来替换原来的对象。至于选哪种抽象在这里并不重要;就当你走复古路线,使用数据访问对象(DAO——执行数据库语句),随后由定义业务逻辑的控制器(controller)来调用它们。
许多事情随之改变:
- 你不再拥有之前那样的保证——比如在
Conversation对象上调用一个用于添加Message的方法。过去这是对内存列表的一个简单操作(除非 JVM 内存耗尽这种极端情况),基本可以保证addMessage不会失败。现在,每次addMessage调用,数据库都有可能返回错误:插入可能失败,或者数据库因宕机或网络问题不可用。 - 内存版里你撒了一点锁以确保并发用户不会把数据搞乱。现在用了“Database X”,你得自己处理这个问题,避免产生重复记录或其他不一致的数据,还要研究“Database X”库里该怎么做。原来对对象的那些简单方法调用实质上都变成了数据库操作,而且有些操作必须协同完成。比如,发起一段会话至少需要在
Conversation表和Message表里各插入一行。 - 内存版很好测,单元测试飞快。现在你要在本地跑 “Database X” 进行测试,还得加数据库测试工具来隔离用例,单元测试慢了不少。但你安慰自己:“至少我也在测试 Database X 的操作”,只不过这些操作并没有你想象中那么直观——和你以前用过的数据库大不相同。
你把内存代码直接改成数据库调用时遇到了性能问题,因为每次调用都要付出网络开销。于是你开始为所选数据库(不管是 SQL 还是 NoSQL)专门设计查询优化用的数据库结构。对象则沦为“贫血”的数据载体,几乎只剩下字段;有趣的代码都挪到了 DAO 和 Web 应用的各个组件里。最糟的是,几乎没有任何早先的代码能复用——代码结构已经面目全非。
Web 应用里的“控制器”为了修改数据(例如 findConversations、insertMessage 等)会把多个 DAO 方法拼接在一起。这种组合方式导致与数据库之间的交互难以预判;控制器可以以任意方式组合这些数据库操作,如图 1.6 所示。
该图展示了向会话中添加消息时,代码可能经过的一条流程。你可以想象,使用 DAO 访问数据库会有无数种变体。允许任意一方在任意时间去修改或查询记录,可能导致不可预期的性能问题,比如死锁以及其他故障——而这恰恰是你想要避免的复杂性。
数据库调用本质上就是 RPC,而几乎所有标准数据库驱动(比如 JDBC)都使用阻塞式 I/O。于是你已经落入先前所说的境地:同时在用线程和 RPC。用来同步线程的内存锁,与保护表记录不被并发修改的数据库锁,并不是一回事,把它们放到一起时必须格外小心。
你从一种编程模型变成了两种交织的编程模型。第一次重写应用花的时间远超预期。
这是戏剧化的表达
这个“用传统方式做团队聊天应用”的故事被刻意放大到了灾难级。尽管如此,你大概也见过项目遇到其中至少一部分问题(我就亲眼见过类似情况)。引用 Dean Wampler 在其演讲《Reactive Design, Languages, and Paradigms》(mng.bz/1q7n)中的话:“现…
所以,用传统方法做这个示例项目就一定不可能吗?不是,但它并不理想。随着应用扩展,要想同时保持低复杂度和高灵活性,会非常困难。
1.4.2 传统扩展与交互式使用:轮询(Polling)
你以这种架构运行了一阵子,用户数量增长了。Web 应用服务器本身并没有消耗多少资源;大多数资源花在请求与响应的(反)序列化上。主要的处理时间都耗在数据库里。Web 服务器上的代码大多在等待数据库驱动的响应。
基础功能有了之后,你想做更多交互式特性。用户已经习惯了 Facebook、Twitter,希望一旦有人在团队会话里提到他们的名字就能收到通知,以便及时加入讨论。
你决定构建一个 Mentions 组件,解析每条新消息,把被 @ 的联系人写入到一个通知表中;Web 应用通过轮询这个表来通知被提及的用户。为了让用户更快看到变化,Web 应用还会更频繁地轮询其他信息,因为你想给他们“真正的交互体验”。
你不想在主应用里直接加入数据库代码从而拖慢对话发送,所以你加了一个消息队列。每条写入的消息都会异步发送到这个队列,另一个进程从队列取消息、查找用户、并向通知表写入记录。
这时数据库开始被“锤爆”了。你发现自动轮询数据库再加上 Mentions 组件,正在引发数据库性能问题。于是你把 Mentions 组件拆成一个独立服务,给它配一套自己的数据库,里面有通知表和一份用户表的拷贝;这两张表通过数据库同步任务保持最新,如图 1.7 所示。
图 1.7 服务组件
复杂性再次上升,而且要增加新的交互式功能变得越来越困难。对于这类应用而言,轮询数据库并不是一个好主意,但也没什么别的选择,因为所有逻辑都在 DAO 里,而 “Database X” 又不能把任何东西“推”到 Web 服务器。
你还通过引入消息队列给应用增加了复杂度:它需要安装、配置,也需要部署相应代码。消息队列有自己的一套语义与上下文;它和数据库 RPC 调用、与内存中的线程代码都不一样。要把这些代码负责任地融合在一起,再一次会更复杂。
1.4.3 传统扩展:事务(Transactions)
用户开始反馈:他们希望在查找联系人时能用联想输入(typeahead——用户键入部分名字时应用能给出建议),并且能基于他们近期的邮件往来自动推荐团队与当前会话。你构建了一个 TeamFinder 对象,它会调用若干 Web 服务,比如 Google Contacts API、Microsoft Outlook.com API。你编写了这些 Web 服务客户端,并把“查找联系人”的功能整合进来,如图 1.8 所示。
图 1.8 TeamFinder 组件
接着你发现,其中一个服务经常以最糟糕的方式失败:要么长时间超时,要么流量慢到每分钟只有几个字节。由于这些 Web 服务是串行依次访问的,TeamFinder 在等待响应时,会在很长时间后宣告查找失败——即便那些工作正常的服务本可以给用户提供不少有效建议。
更糟的是,尽管你把数据库方法收敛进了 DAO,把联系人查找放进了 TeamFinder 对象,控制器依然像调用普通方法那样去调用它们。这意味着有时在两个数据库方法之间会去做一次用户查找,导致数据库连接被占用的时间比你期望的更长,从而吞噬数据库资源。如果 TeamFinder 失败了,与之在同一业务流程里的其他部分也会跟着失败。控制器抛出异常,无法继续。你要如何把 TeamFinder 与其余代码安全地隔离开来?
是时候再来一次重写了,而复杂度看起来并没有好转。你现在已经在使用四种编程模型:一种是内存中的线程模型,一种是数据库操作,一种是 Mentions 消息队列,还有一种是联系人 Web 服务。
如果要把服务器从 3 台扩到 10 台、再到需要时的 100 台,该怎么做?显然,这种路线并不怎么可扩展:每遇到一个新挑战,你都得换一次方向。下一节你会看到,是否存在一种不需要不停“换路”的设计策略。
1.5 使用 Akka 扩展
让我们看看能否兑现“只用 Actor 就满足应用的扩展需求”这一承诺。鉴于你此刻对 Actor 还不够熟悉,下面我会交替使用“对象”和“Actor”两个词,更侧重讲这种方法与传统方法在理念上的差异。表 1.2 展示了两种方法的对比。
表 1.2 传统方法 vs. Akka(Actor)方法
| 目标 | 传统方法 | Akka 方法(Actor) |
|---|---|---|
| 让会话数据在应用重启或崩溃后仍然可用 | 重写为 DAO;把数据库当成一个所有人都会去创建/更新/插入/查询的共享可变状态 | 继续使用内存中的状态;将对状态的每次变更作为“消息”写入日志;只有在应用重启时才从日志回放 |
| 提供交互特性(如提及 @mentions) | 轮询数据库;即便数据无变化也会消耗大量资源 | 将事件“推送”给感兴趣的方;仅在发生重要事件时通知,降低开销 |
| 解耦服务(@mentions 与聊天功能不应相互干扰) | 为异步处理引入消息队列 | 不需要额外队列;Actor 天生异步,继续以收发消息来建模即可 |
| 当关键服务失败或在一段时间内性能异常时,避免全局失效 | 试图预测所有失败场景并捕获异常 | 异步发送消息;若接收方崩溃,未处理的消息不会影响其他组件的稳定性 |
如果能把应用代码写“一次”,然后随需所欲地扩展,那就太好了。你肯定想避免像 1.4.1 节那样,把内存对象的主要逻辑统统替换成 DAO。
你最先想解决的是会话数据的安全保存。直接对数据库编程,把你从一个简单的内存模型“拉”到了混合编程模型:原本直截了当的方法,变成了数据库 RPC 调用。我们需要另一种方式,既保证会话不丢,又保持简单。
1.5.1 用 Akka 保持持久性:发送与接收消息
先解决“让会话可持久化”的初始问题:应用中的对象必须以某种方式保存会话;当应用重启时,必须能恢复会话。图 1.9 展示了 Conversation Actor 如何在内存里添加一条消息的同时,向数据库日志发送一条 MessageAdded 事件。每当 Web 服务器(重新)启动时,都可以从数据库中存储的这些对象重建会话,如图 1.10 所示。
图 1.9 持久化会话
图 1.10 恢复会话
第 9 章会讲这个过程的细节。此处你可以看到,数据库只用来恢复会话消息;你不会把业务代码改写成数据库操作。Conversation Actor 把消息写入日志,并在启动时再把它们读回来。你无需学习全新抽象:还是在“收发消息”。
用事件序列保留变更
所有变更都以事件序列的形式保存——在这里是 MessageAdded 事件。通过在内存中的 Conversation 回放这些事件,就能重建其当前状态,从上次停下的位置继续运行。此类数据库通常称为 日志(journal) ,该技术称为 事件溯源(event sourcing) 。
这里要点是:日志作为一个统一服务,只需保证顺序写入与按同样顺序读取(先忽略序列化等细节——等不及的话可翻第 9 章)。
把数据分散开:对会话分片(Sharding)
接下来,你仍然把所有鸡蛋放在了一台服务器上:重启时它要把所有会话读回内存并继续工作。传统走“无状态”的主要原因之一,是很难想象如何在多台服务器上保持会话一致;再者,如果会话太多以至于一台机器装不下呢?
解决方案是可预期地把不同会话分布到不同服务器上,或者记录每个会话所在位置——这就是分片/分区(sharding/partitioning) 。图 1.11 展示了会话如何跨两台服务器分片。
图 1.11 分片
只要你有一个通用的事件日志和一种指示“如何分片会话”的方式,就可以继续使用这一套简单的“内存会话”模型。这两项能力的细节分别在第 9 章和第 15 章会讲。此处不妨假设它们已经可用。
1.5.2 用 Akka 强化交互:推送消息
与其为每个 Web 用户轮询数据库,不如在发生重要变更(事件)时,直接把消息推到用户的浏览器。同时,应用内部也可以通过事件消息触发特定任务。应用中的每个对象在发生“有意思”的事情时都会发布一个事件;应用中其他对象可以自行判断该事件是否与己相关,并据此行动,如图 1.12 所示。
图 1.12 事件
这些(图中椭圆表示的)事件把系统从原先各组件之间“不受欢迎的耦合”中解开。Conversation Actor 只需发布“已新增一条消息”,然后继续干活。事件通过发布–订阅机制传播,而不是组件之间彼此直接调用。事件最终会送达订阅者——比如 Mentions 组件。再一次,你可以用“收发消息”来建模整套方案。
1.5.3 用 Akka 面对失败:异步解耦
即便 Mentions 组件崩溃,用户也应该能继续聊天。同样,即便 TeamFinder 组件挂了,已有会话也应继续。会话在发布事件的同时,订阅者(如 Mentions、TeamFinder)可以各自崩溃并重启。NotifyUser 组件可以维护已连接的浏览器列表,一旦发生 UserMentioned 事件就直接把消息推送到浏览器,从而免去应用端的轮询。
这种事件驱动方式的优点包括:
- 最小化直接依赖。 Conversation 并不了解 Mentions 的存在,也不关心事件之后会发生什么。即便 Mentions 崩溃,会话也能继续。
- 时间解耦。 只要最终能收到,Mentions 晚一点拿到事件也没关系。
- 位置解耦。 Conversation 和 Mentions 可以在不同服务器上;事件就是消息,可以跨网络传输。
事件驱动的做法既解决了 Mentions 的轮询问题,也消除了对 TeamFinder 的直接耦合。依旧是那句老话:你可以用收发消息来建模整套方案。
1.5.4 Akka 的做法:发送与接收消息
回顾一下你到目前为止做出的改变:Conversation 现在是有状态的内存对象(Actor) ,它们自行持有内部状态、通过事件恢复、按服务器分片部署,并以消息进行收发。你已经看到,用“消息”在对象之间通信而不是直接调用方法,是一种更胜一筹的设计策略。
一个核心要求是:当事件彼此有依赖时,每个 Actor 必须按顺序、一次只处理一条消息;否则就会产生意外结果。为此,Conversation 必须对其他任何组件封装其消息处理;如果其他组件能够直接干预这些消息,就不可能保证顺序。
无论是在本机给某个 Actor 发送消息,还是跨机发到另一台服务器,都不该有差别。因此你需要一个服务,在必要时负责把消息发送到远端 Actor,并且跟踪 Actor 的位置并提供引用,以便其他服务器能与之通信。Akka 正是为你完成这些工作的(第 8 章介绍分布式 Akka 应用的基础,第 13 章讲解 Akka 集群——由分布式 Actor 组成的组)。
Conversation Actor 并不关心 Mentions 组件会发生什么;但在应用层面,你需要知道 Mentions 什么时候停摆,以便(例如)向用户显示它暂时离线。因此你需要能够监控 Actor,并在必要时重启它们。此类监控既要能跨服务器,也要能在单机上工作,所以本质上它也通过收发消息来实现。图 1.13 展示了一个可能的应用高层结构。
图 1.13 应用的高层结构
Supervisor 负责监管各组件,并在它们崩溃时采取行动。比如:当 Mentions 或 TeamFinder 不工作时,它可以决定让系统继续运行;而如果 Conversation 和 NotifyUser 都停了,Supervisor 可能会决定重启或停止应用,因为继续运行已无意义。某个组件在失败时可以向 Supervisor 发送消息,而 Supervisor 也可以向组件发消息让其停止或尝试重启。正如你将在第 4 章看到的,这基本上就是 Akka 提供故障恢复的概念方式。
下一节你将先了解Actor 的通用概念,随后再学习 Akka Actor。
1.6 Actor:统御“向上扩展”和“向外扩展”的单一编程模型
大多数通用编程语言都是顺序式书写的(Scala 与 Java 也不例外)。要把顺序定义与并行执行之间的鸿沟弥合,就需要一个并发编程模型。
需要区分的是:并行强调同时执行多个过程;而并发关注的是定义可以同时进行或时间上交错的过程,但它们不一定要在同一时刻运行。按定义,并发系统并不等同于并行系统。并发过程完全可以在一颗 CPU 上通过时间片方式运行——每个过程按顺序轮到一定的 CPU 时间。
在 JVM 上,存在一个标准的并发编程模型(见图 1.14):大体上,过程由对象与方法来表达,由线程来执行。线程可以在多核 CPU 上并行运行,也可以在单核上通过时间片共享执行。正如前文所述,线程并不能直接用于向外扩展(加机器),它更适用于向上扩展(加 CPU 核/资源)。
图 1.14 并发编程模型
我们期望的并发编程模型,应当既能在单核或多核上工作,也能在单机或多机上工作。Actor 模型选择了发送与接收消息这一抽象,从而把程序与“用了多少线程或多少服务器”解耦。
1.6.1 异步模型
如果你希望应用能够扩展到许多服务器,编程模型就必须异步——让某些组件在尚未收到其他组件响应时仍能继续工作,就像聊天应用那样。
图 1.15 展示了一个扩展到五台服务器的可能部署。Supervisor 负责创建并监控其余组件。此时 Supervisor 必须通过网络通信——网络可能失败,任何一台服务器都可能宕机。若 Supervisor 采用同步通信、等待每个组件逐一响应,就可能陷入麻烦:只要某个组件不回应,其它调用就全部被阻塞。例如,当 Conversation 所在服务器正在重启、暂不可通过网络接口响应时,而 Supervisor 又需要向所有组件发送消息,会发生什么?
图 1.15 扩展后的应用
1.6.2 Actor 的四种操作
在 Actor 模型中,Actor 是最基本的构建块。示例应用中的所有组件都是 Actor(见图 1.16)。一个 Actor 是一种轻量级“进程”,只有四个核心操作:创建(create) 、发送(send) 、指定下一步行为(designate next behavior) 、监督(supervise) 。这些操作全部是异步的。
图 1.16 应用组件
Actor 模型并不新鲜
Actor 模型诞生已久:1973 年由 Carl Hewitt、Peter Bishop 和 Richard Steiger 提出。爱立信于 1986 年左右开发了 Erlang 语言与其 OTP 中间件库,支持 Actor 模型,并被用于构建大规模、高可用系统。著名的 AXD 301 交换机产品就达到了 99.9999999%(九个 9) 的可靠性。Akka 的 Actor 实现与 Erlang 在一些细节上不同,但深受其影响并共享大量理念。
Send(发送)
Actor 只能通过发送消息与其他 Actor 通信,这把封装提升到新层次。面向对象中,你可以声明哪些方法与状态是公开可见的;而在 Actor 中,内部状态不对外可见(例如对话中的消息列表)。Actor 不能共享可变状态;它们不能指向同一份可变列表并并发修改。
因此 Conversation 不能直接去调用其他 Actor 的方法(那会导致潜在的共享可变状态);它必须发送消息。发送始终是异步的,即**“发后即忘”(fire-and-forget) 。如果必须确认对方已收到,可由接收方返回一条确认(ack)**消息。
Conversation 给 Mentions 发完消息后无需等待结果,可以继续干自己的事。异步消息让聊天应用的组件解耦——这也是你当初给 Mentions 加消息队列的原因(而现在已不再需要)。
消息必须是**不可变(immutable)**的——一旦创建就不可更改。这样可避免两个 Actor 误改同一条消息,或一个 Actor 以两种不同方式改同一条消息而引发的意外行为。
那用户想编辑一条消息怎么办?向对话发送一条 EditMessage:其中携带这条消息的修改副本,而不是就地更新共享列表。Conversation 收到 EditMessage 后,用该副本替换已有消息。
不可变性是并发中的硬性前提——它以约束换来更少的“活动部件”,从而简化问题。
另外,消息顺序在同一发送者与同一接收者之间得以保持,且 Actor 一次只接收一条消息。想象用户多次编辑同一条消息:最终看到的是最后一次编辑的结果才合理。需要注意,顺序仅对每个发送者单独保证;若多个用户同时编辑同一条消息,最终结果会取决于这些消息在时间上的交错方式。
Create(创建)
Actor 可以创建其他 Actor。图 1.17 展示 Supervisor 创建 Conversations Actor 的过程。可以看到,这会自然形成层级:应用首先创建 Supervisor,Supervisor 再创建其它组件。Conversations 会从日志(journal)中恢复所有对话,并为每个对话创建一个 Conversation Actor;每个 Conversation 自行从日志中恢复。
图 1.17 创建 Actor
指定下一步行为(Designate the next behavior)
状态机是保证“在特定状态只执行特定动作”的好工具。Actor 一次只处理一条消息,非常适合实现状态机。Actor 可以通过切换行为来改变它处理后续消息的方式:它必须指定下一条消息要采用的行为。
例如,用户希望能关闭一个对话。Conversation 初始处于 started 状态,收到 CloseConversation 后进入 closed 状态。此后发给该对话的任何消息都被忽略。也就是把行为从“接收并追加消息”切换为“忽略所有消息”。
Supervise(监督)
Actor 需要监督自己创建的子 Actor。图 1.18 中,Supervisor 监管主要组件、并在组件失败时做决策。例如:若 Mentions 与 NotifyUser 崩溃,Supervisor 可以让聊天应用继续运行(它们并非关键路径)。当某个 Actor 崩溃时,Supervisor 会收到特殊消息(包含哪个 Actor、因何崩溃),据此决定重启它或将其下线。
图 1.18 监管 Actor
任何 Actor 都可以充当监督者,但仅限于它自己创建的那些 Actor。图 1.19 中,TeamFinder 监督两个联系人查询连接器;如果 OutlookContacts 失败过于频繁,TeamFinder 可将其下线,并继续仅用 Google 进行查询。
图 1.19 TeamFinder 监督联系人 Actor
Actor:在三层维度上的解耦
从可扩展性看,Actor 在以下三层上实现了解耦:空间/位置、时间、接口。
- 空间(Space) ——Actor 对其他 Actor 所在位置不做任何保证/不抱任何期望。
- 时间(Time) ——Actor 对自身工作何时完成不做保证/期望。
- 接口(Interface) ——Actor 没有共享接口与共享可变数据;信息只通过消息传递。
在位置、时间、接口三个维度上耦合组件,是构建可恢复、可按需扩展系统的最大障碍。把这三者都耦在一起的系统,只能存在于单一运行时,且任一组件失败都可能导致全盘崩溃。
现在你已了解 Actor 能执行的操作,接下来看看 Akka 如何支持 Actor,以及让 Actor 处理消息需要哪些运行时能力。
1.7 Akka 的 Actor
目前为止,你已经从概念层面了解了 Actor 编程模型,以及为何要使用它。现在看看 Akka 如何实现该模型,并更贴近“落地编码”。本节从创建 Actor 开始,讲清各部分如何衔接——Akka 里的哪些组件负责做什么。
1.7.1 ActorSystem
先从 Actor 是如何被创建的说起。Actor 可以创建其他 Actor,但第一个是谁创建的呢?
图 1.20 中的所有 Actor 都属于同一个聊天应用。应用的第一个 Actor 是 Supervisor。那如何把这些 Actor 组织成一个整体?Akka 的答案是 ActorSystem。每个 Akka 应用做的第一件事就是创建一个 ActorSystem。ActorSystem 能创建所谓的顶级(top-level) Actor,常见做法是只创建一个应用的顶级 Actor——在此例中即负责监控一切的 Supervisor。
图 1.20 TeamChat ActorSystem
此前我们提到,Actor 需要一些支撑能力,例如远程通信和用于持久化的日志。ActorSystem 也是这些能力的枢纽。多数能力以 Akka 扩展(extension) 形式提供:可针对特定 ActorSystem 进行配置的模块。一个简单的支撑能力示例是调度器(scheduler) ,它可以按计划给 Actor 发送消息。
ActorSystem 在创建顶级 Actor 时返回的不是 Actor 本体,而是该 Actor 的地址,称为 ActorRef。你可以使用 ActorRef 向该 Actor 发送消息。这很合理——毕竟这个 Actor 可能在另一台服务器上。
有时你需要按路径查找系统中的某个 Actor,这就用到 ActorPath。可以把 Actor 的层级结构类比为 URL 路径。每个 Actor 都有名称,且在同一层级必须唯一(兄弟节点不能重名)。若不显式命名,Akka 会为你生成,但为 Actor 命名通常是好习惯。任何 Actor 都可以通过绝对或相对的 ActorPath 被定位。
1.7.2 ActorRef、邮箱(mailbox)与 Actor
消息是发给 Actor 的 ActorRef 的。每个 Actor 都有一个邮箱(很像一个队列)。发到 ActorRef 的消息会暂存在邮箱,稍后按到达顺序、一次一条地被处理。图 1.21 展示了 ActorRef、邮箱与 Actor 的关系。Actor 如何处理消息,将在下一节说明。
图 1.21 ActorRef、邮箱与 Actor
1.7.3 调度器(Dispatchers)
Actor 最终由**调度器(dispatcher)**驱动执行。调度器可以理解为把邮箱中的消息“推”过 Actor(见图 1.22)。
图 1.22 调度器把消息推过邮箱
调度器的类型决定了用于“推消息”的线程模型。许多 Actor 的消息可以在多条线程上被推进(见图 1.23)。
图 1.23 调度器在多 Actor 上推进消息
图中,调度器在线程 1、2 上推动消息 m1–m6,在线程 3、4 上推动消息 x4–x9。别把这图解读为你可以或应该精确控制“哪条消息在哪条线程上被推进”。重要的是:你可以充分配置线程模型。各种调度器都能以某种方式配置,你也可以把某个调度器分配给某个 Actor、一组 Actor,或整个系统。
因此,当你给一个 Actor 发送消息时,本质上是把消息投递到它的邮箱;随后会有某个调度器把消息推送给该 Actor。Actor 又可以把下一条消息放进别的 Actor 的邮箱,那条消息也会在某个时刻被推送处理。
Actor 很轻量,因为它们运行在调度器之上;Actor 数量不一定与线程数量成正比。Akka 的 Actor 占用远小于线程:约 270 万个 Actor 能放进 1GB 内存,而 1GB 内存通常只能容纳约 4096 条线程。因此你可以比直接使用线程更自由地创建各种 Actor。
你可以选择不同种类的调度器,并根据具体需求调优。能在应用范围内配置和调优调度器与邮箱,会给性能优化带来很大灵活性。
回调地狱(Callback hell)
许多框架通过回调提供异步编程。如果你用过,很可能到过所谓的回调地狱:一个回调里再传回调,再往下层层嵌套,直至“地狱深渊”。
对比之下,调度器只是把邮箱中的消息切分推进到指定线程。Actor 无需一层层嵌套回调。Actor 只需把消息丢进邮箱,其余都交给调度器处理。
1.7.4 Actor 与网络
Akka 的 Actor 之间如何跨网络通信?ActorRef 本质上是 Actor 的地址,因此需要改变的只是地址与 Actor 的绑定方式。如果工具包能处理“地址既可本地也可远程”这件事,你就能仅通过配置地址解析方式来扩展系统。
Akka 提供了开箱即用的发现机制(章节 6 与 9 会介绍),以实现你想要的透明性。Akka 会把发往远程 Actor的消息转交至该 Actor 所在机器,并把结果再跨网络返回。
唯一需要改变的是如何查找远程 Actor 的引用;而这仅需配置即可,稍后你会看到。代码保持不变,因此你常常可以从向上扩展(scale up)切换到向外扩展(scale out) ,而无需改一行代码。这种地址解析的灵活性在 Akka 中被广泛利用:远程 Actor、集群,甚至测试工具包都依赖它。
到这里,你应该已经明白:在可控复杂度下,Actor 能给你带来更大的灵活性,让应用更容易扩展。当然,细节很多,“魔鬼藏在细节里”。下一章我们就来动手,把 Actor 跑起来!
总结
扩展一向很难做好。一旦进入扩展阶段,不灵活与复杂性都可能迅速失控。Akka 的 Actor 借助一系列关键设计决策,提供了更灵活的扩展能力。
Actor 是同时支持纵向与横向扩展的编程模型,核心是一切围绕消息的发送与接收展开。它并非万能银弹,但坚持用单一编程模型,能显著降低扩展带来的部分复杂性。
Akka 以 Actor 为中心。它的独特之处在于:几乎不费力地提供了构建基于 Actor 应用所需的支撑与工具,让你可以把精力专注在用 Actor 思考与编程本身。