第1章 初始Akka
本章主要包括:
- Akka介绍
- Actors和Actor系统
- Akka适用场景
本章将介绍Akka的各个不同方面的内容,它是怎么创造各种可能性,以及别的已有的解决方案有什么不一样。我们主要展示如何使用这些特性来构建强大的高并发分布式应用。Akka是极具革命性的,突破了原有使用容器的传统,借鉴了一个已有的概念:Actor系统(如果没有听说过这个Actors,别担心,我们会在本书中详细地介绍)。你还将学会如何简化实现一个异步并发的任务,并且如何提供一个分布式的容错的解决方案。最后我们还将讨论这些概念是如何集成到Akka运行环境中的,实现应用所承诺的高可用,易扩展但更容易写(更少的重写)。
开发人员经常在缺陷或者扩展性问题出现的时候,才会考虑这些问题。无论是从第一行代码开始的项目,还是已有的项目,都可以很容易的使用Akka。这些都不是极端情况,在后期需求中,每个应有都有处理请求,加载或者管理很多任务的工作的部分。常规的方法是将这些问题转嫁到已有代码所使用的解决方案来处理。甚至向已有的代码库中添加的新代码,从一开始,也要求是并发,可扩展和容错的。通过本书,你将充分认识到,Akka能够如此轻量简洁地实现这些功能,甚至认为不使用它就是不合理的。
1.1 什么是Akka
Akka是使用Scala实现的,但是在Java或者Scala中,都可以使用Akka。Akka的主要目标是更简单地实现高性能,高可用和易扩展。这是Akka一个非常令人兴奋的方面:他们可以让开发者关注于如何有效地实现解决方案,而不是必须也让代码处理类似于批任务或者资源管理这样的扩展性方面的问题。很多解决方案,在发展的过程中,需要同时考虑两个坐标的问题:既要考虑扩展容量,又要考虑应用功能的进化。Actors可以让开发人员只关注与如何更好地实现业务,代码层面之外的系统来考虑需求增长导致的扩展性问题。
对于实时或者近似实时系统,常规基于同步组件的模型中,经常由于服务突发的不可用而变得很尴尬。为每一个请求创建独立的进程或者线程更是不可持续的。当容器部署了线程池,一旦执行线程调用了对应的应用的代码,没有办法阻止任务尽可能久地执行,包括别的系统组件的可能的等待请求。随着云计算的兴起,真实的分布式计算很快变为现实。这意味着通过利用现有的服务快速组装一个新的应用的可能性更大,同时更意味着,这些服务在性能方面的需求,不可能使用简单的串行编程来实现。
考虑下面的这些美好的愿望:
- 并行处理大量请求
- 服务和客户端的并发交互
- 响应式异步交互
- 异步编程模型
使用Akka,上面这些都可以实现。这里将开始展示的贯穿整本书的例子。本章的例子将粗略的展示Akka是如何支持并发和容错的,后面我们将通过大量的示例处理其他的内容。
Akka项目组希望将Akka描述为一个工具集,而不是一个框架。框架倾向于是一种机制,一种提供栈上独立组件的机制(如UI,或者Web的服务层等)。Akka提供了一系列的工具来实施栈的任何部分组件,并且提供不组件提供通信连接。Akka通过使用一种特殊结构的配置来实现这些。这种结构的配置可以让接口Seeker只需要简单的调用方法,而不用操心入队的消息,这些都被Akka运行环境完美地解决了。
Akka的组成模块是以Jar文件的形式分发的。可以像使用其他库一样使用Akka。所有的模块需要的依赖都已经被很好地最小化。akka-actor模块不需要除了Scala库之外的任何依赖。事实上,该模块从Scala 2.1版本起,已经随着Scala一起发布。Akka模块可以更简单地使用在技术栈的不同部分:路由、集群、事务和并发数据流(稍候会看到)。也有专注于如何与其他系统集成的模块,像camel和zeromq模块。图1.1展示了Akka栈的主要组成部分,以及客户端代码如何从大部分细节中完全隔离开来。
图1.1 Akka栈
Akka同时也提供运行环境。运行时的核心是基础actor服务和一些需要使用的模块的的配置文件。一个叫Play-mini的微核可以用来部署应用。在这样一个占用很小空间的运行环境中,只需要很少的代码,你就会发现可以做很多,很容易扩展,这是多么令人震惊。
一个典型的Akka应用是由什么组成的呢?Actors。Akka是基于Actor编程模型的,这个模型将在本书中详细讨论。Actors已经有40多年的历史了。它最早出现在一种被用来通过使用大量CPU机器来解决大规模问题的方法。后来受到早起面向对象语言中所遇到的困难的启发,最后变成Erlang和Fantom这些函数式编程语言中“进程演化”的概念。但是再次强调下,Akka既可以部署在Actor模型的语言方面,也可以部署运行环境。下一节将通过展示Actor编程模型是如何简化并发编程,逐渐解开Akka的神秘面纱。
根据出版任务,我们强烈相信,当你看到这些问题的一些通用的解决反感的Akka版本,你也会确信这些优势,更加简单优雅地完成任务。接下来的这一节,我们将好好看看Akka的具体元素,它是怎么让这一切成为可能的!
1.1.1 单纯的并发
本节将继续考察一个需要处理并发请求的例子。为了让应用真正地实现并发,处理请求必须也是要同时进行的,执行单元通过协作完成随时出现的任务。在JVM中有时候会使用线程来实现并发,一些非常困难的问题就出自这个过程。我们将使这个传统的过程尽可能的困难,这样你就会更加喜欢一种比线程和锁来实现并发更简单的模型,而且只需要书写更少的代码。我们首先考虑概念层面的并发,然后看这两种方式(共享变量/消息传递(Akka))是如何解决售票问题的。
示例:买票问题
这个例子中,顾客从购票代理那里购买某次活动的票。本书后面会更加深入的了解这个例子。现在,我们使用这个例子说明两种并发方式:传统的方式使用多线程分散负载,基于消息的方式发送消息,通过消息队列,使用一系列简单的工作来处理消息。这两种方式还有个简单的差别:原始的使用变量,消息的方式使用不变量。
什么是不变量?简单说来,就是如果一个事物是不变的,那这意味着所有的状态都是在狗仔函数给定的,并且之后不能再进行修改。在编程世界的最近20年里,不变量的重要性越来越大(在C++和Java社区中)。这是基于消息的模型的关键方面。通过使用不变量进行交互协作,就可以解决困难的主要来源:多方传递同一个对象。这个例子表明,虽然在初始化过程中需要多做点工作,但是,再考虑下这所带来的缺陷的降低和扩展方面的好处,这简直就是微不足道的代价(结果是更加简洁的实现,代码更加清爽,更加可读和可维护)。代码也更加容易验证,因为单元测试很少会导致由于多方协作状态变化导致的问题。图1.2展示了最基础的售票流程:当一个买票的顾客出现的时候,会发生什么。
图1.2 购票示例
考虑下卖票给客户这个问题,看上去是一个很好的并发模型,因为模型的需求方可能会爆发性的,如果有100万个人同时发起购买的请求会发生什么?在基于多线程的模型中,最简单的实现是:为每个请求创建一个线程,这会导致灾难性的崩溃。就算用线程池也会有失败的风险,因为进来的请求可能会有超时设置,所以如果执行线程太忙碌导致响应时间过长,客户端就会在事件处理前导致失败。此外,在这种模型中,大多数开发人员的假设是:线程在数量方面总是线性改进的。但这大部分时候是错误的,因为这些线程经常需要跟别的线程竞争他们共同使用转换的共享资源。消息的组合,没有共享可变状态,将会带你脱离这个苦海!
在考虑这两种方法之前,先来简单了解下领域模型。显然,首先应该有一个集合地点,这里有位置并且调度事件。每个位置对应一张可以被票务代理售出给顾客的票。你可能预料到可能发生问题的地方:每个购票请求都需要找到一张票,看顾客是否想要票,如果是的话,就将票的状态从可用改为已售出,然后再把票打印出来给顾客。如果我们通过使用更多的票务代理来扩展这个,地让他们明确地按顺序访问可用票池(所有情况下),将是一个成功结果的关键因素。(如果失败了呢?在下一节,我们将会讨论下面这些经常出现的问题:容错,并发讨论经常会有很多关于容错策略的)。图1.3展示了如何调解这些约束的,通过多个票务代理为可能随时请求服务的顾客进行处理请求。
图1.3 顾客、票务、打印室关系图
我们只有一个打印室,但这并不意味着出现一个问题导致全局崩溃(我们将在下节讨论)。还有很重要的一点是,无论多少数量的票务代理处理顾客购票的请求,我们都尽量不会阻塞对票池的访问。
选项1:共享状态方式
如图所示,票务代理彼此之间必须竞争访问可用票的列表。这可能导致有的顾客需要等待别的顾客打印票,这是不合理的。这显然是侵入领域模型的一个缺陷。
也许有一个方法可以优化这个问题。图1.4显示了一个修改后的方案。
图1.4 售票序列
显而易见,并没有必要让票务代理阻塞别的代理访问票池。如果有后续的访问并且需要重写并发的售票序列,结果也是更加清晰的。
这里有三个陷阱可能导致灾难性的失败:
- 线程饿死——如果所有的执行线程都在忙碌或者被锁着,就没有多余的线程来处理新的请求,这实际上就是关闭了服务器(虽然它看上去还跟在运行中的一样)
- 竞态条件——共享的数据被别的线程在的执行方法的时候修改了,这会破坏内部逻辑,导致意想不到的结果
- 死锁——如果两个线程开始操作,同时需要锁定多个资源,但是他们并没有按照同样的顺序锁定,那就可能造成死锁:线程1锁定资源A,线程2锁定资源B,此时线程2试图锁定资源A而线程1企图锁定资源B,这就死锁了。
即时你想办法避免了这些问题,处理基于锁的代码的优化也是非常痛苦的。锁经常被滥用或者用得不够。如果使用过多的锁,基本上就不会有什么是同时发生的。如果用的太少,有会可能发生复杂的bug。找到这个平衡点是非常困难的。
票务代理有时候需要彼此等待,这也就意味着顾客需要等待很长的时间才能买到票。如果打印票的那个地方很忙的话,票务代理也需要等待。有些等待时间是没有必要的。为什么这个票务代理的顾客需要等待另一个票务代理呢?这显然又是一个侵入领域模型的缺陷。
选项2:消息传递方式
为了把事情变得简单(通过Akka),我们的策略的关键在于规避刚才所讨论的问题的根源:共享状态。我们需要做出三个改变:
- 在票务代理和打印室之间,事件和票只能以不变的消息的方式进行传递。
- 票务代理和打印室的请求已异步消息入队的方式进行,这样就不需要线程等待方法的执行和甚至接受。
- 票务代理和打印室可以持有彼此的引用,这个引用包含了彼此之间可以发送消息的地址。这些消息会被暂时存在一个信箱里,然后根据到底的顺序一个个执行。(别担心,等会我们会好好解释这个工具集是怎么实现这个的)
这种协作的重新配置会带来一个新的需求:票务代理在售完他们自己的票的时候,需要一个获得更多的票的途径。这只是额外的很小的需求,一旦我们通过简单的传递不可变消息实现了这个,我们就不需要再担心应用能否抵挡住爆发性的需求的增长。图1.5显示了该票务代理的处理顺序。
图1.5 票务代理序列
那么票务代理是如何取票的呢?打印室会给代理发送票务消息。每个票务消息包括一些票和一些关于事件的统计信息。需要有方法给每个票务代理发送票务信息,这样我们可以选择让它们彼此之间发送消息给对方,系统就更加基于点对点的,只需要在通信的时候给点简单的规则,细节的东西稍候再讨论。如图1.6所示,票务代理知道彼此的地址,可以彼此独立地传递票务消息。(在Actor的世界中,会经常看到链式结构,简单的像这种协作的状态,复杂的像分布式的,基于actor的职责链模式)。
图1.6 分布式票务
打印室会在需要的时候发送票务消息给票务代理链,这些消息中包括下一批票的信息。票务消息可以提示票是不是都卖完了或者已经打印完。如果代理链上的某个票务代理还有剩余的票,我们可以选择转发这些票的信息给另外的代理,而不是直接将它们返回到票池中。另一个方法是让那些票过期,每个票务代理都给票,但是一旦某个时间点过去了,他们就没有有效的票来出售。
使用消息传递的方法有这么几个好处。首先,不需要管理多余的锁!任何数量的票务代理可以在不使用锁的情况下并行地卖票,他们都可以卖自己所拥有的票。票务代理在发出一个需要更多的票的消息的时候,不需要等待打印室返回响应,无论打印室什么时候可以准备好,都可以发送一个响应。还可以设计一个缓冲的结构,一旦需要更多的新票的消息发出来之后,自己立刻丢弃一定数量的到那里(缓存被清空之前,会送到)。
即使打印室崩溃了,所有的票务代理还是可以继续卖票,直到卖完为止。如果一个票务代理发送了