- 异步
- 回压
- 批量处理
- 组件
- 委托
- 弹性(与“可伸缩性”对照)
- 失败(和“错误”相对照)
- 隔离(和“遏制”相对照)
- 位置透明性
- 消息驱动(与“事件驱动”对照)
- 非阻塞的
- 协议
- 复制
- 资源
- 可伸缩性
- 系统
- 用户
牛津词典把“asynchronous(异步的)”定义为“不同时存在或发生的”。 在本宣言的上下文中, 我们的意思是: 在来自客户端的请求被发送到了服务端之后, 对于该请求的处理可以发生这之后的任意时间点。 对于发生在服务内部的执行过程, 客户端不能直接对其进行观察, 或者与之同步。 这是同步处理(synchronous processing)的反义词, 同步处理意味着客户端只能在服务已经处理完成该请求之后, 才能恢复它自己的执行。
当某个组件正竭力维持响应能力时, 系统作为一个整体就需要以合理的方式作出反应。 对于正遭受压力的组件来说, 无论是灾难性地失败, 还是不受控地丢弃消息, 都是不可接受的。 既然它既不能(成功地)应对(压力), 又不能(直接地)失败, 那么它就应该向其上游组件传达其正在遭受压力的事实, 并让它们(该组件的上游组件)降低负载。 这种回压(back-pressure)是一种重要的反馈机制, 使得系统得以优雅地响应负载, 而不是在负载下崩溃。 回压可以一路扩散到(系统的)用户, 在这时即时响应性可能会有所降低, 但是这种机制将确保系统在负载之下具有回弹性 , 并将提供信息,从而允许系统本身通过利用其他资源来帮助分发负载,参见弹性。
当前计算机为反复执行同一项任务而进行了优化: 在(CPU的)时钟频率保持不变的情况下, 指令缓存和分支预测增加了每秒可以被处理的指令数。 这就意味着,快速连续地将不同的任务递交给相同的CPU核心,将并不能获益于本有可能得到的完全(最高利用率的)性能: 如果有可能,我们应该这样构造应用程序, 它的执行逻辑在不同的任务之间交替的频率更低。 这就意味着可以成批地处理一组数据元素, 这也可能意味可以在专门的硬件线程(指CPU的逻辑核心)上执行不同处理步骤。
同样的道理也适用于对于需要同步和协调的外部资源的使用。 当从单一线程(即CPU核心)发送指令, 而不是从所有的CPU核心争夺带宽时, 由持久化存储设备所提供的I/O带宽将可以得到显著提高。 使用单一入口的额外的效益,即多个操作可以被重新排序, 从而更好地适应设备的最佳访问模式(当今的存储设备的线性存取性能要优于随机存取的性能)。
此外, 批量处理还提供了分摊昂贵操作(如I/O)或者昂贵计算的成本的机会。 例如, 将多个数据项打包到同一个网络数据包或者磁盘存储块中, 从而提高效能并降低使用率。
我们所描述的是一个模块化的软件架构, 它(实际上)是一个非常古老的概念, 参见Parnas(1972) [ACM]。 我们使用“组件(component)”(参见 C.2.8)这个术语, 因为它和“隔间(compartment)”联系紧密, 其意味着每个组件都是自包含的、封闭的并和其他的组件相隔离。 这个概念首先适用于系统的运行时特征, 但是它通常也会反映在源代码的模块化结构中。 虽然不同的组件可能会使用相同的软件模块来执行通用的任务, 但是定义了每个组件的顶层行为的程序代码则是组件本身的一个模块。 组件边界通常与问题域中的有界上下文(BoundedContext)紧密对齐。 这意味着,系统设计倾向于反应问题域, 并因此在保持隔离的同时也更加容易演化。 消息协议为多个有界上下文(组件)之间提供了自然的映射和通信层。
将任务异步地委托给另一个#组件意味着该任务将会在另一个组件的上下文中被执行, 举几个可能的情况: 这个被委托的内容甚至可能意味着运行在不同的错误处理上下文里,属于不同的线程,来自不同的进程,甚至在不同的网络节点上。 委托的目的是将处理某个任务的职责移交给另外一个组件, 以便发起委托的组件可以执行其他的处理、 或者有选择性地观察被委托的任务的进度, 以防需要执行额外的操作(如处理失败或者报告进度)。
弹性意味着当资源根据需求按比例地减少或者增加时, 系统的吞吐量将自动地向下或者向上缩放, 从而满足不同的需求。系统需要具有可伸缩性, 以使得其可以从运行时动态地添加或者删除资源中获益。 因此,弹性是建立在可伸缩性的基础之上的, 并通过添加自动的资源管理概念对其进行了扩充。
失败是一种服务内部的意外事件, 会阻止服务继续正常地运行。 失败通常会阻止对于当前的、 并可能所有接下来的客户端请求的响应。 和错误相对照, 错误是意料之中的,并且针各种情况进行了处理( 例如, 在输入验证的过程中所发现的错误), 将会作为该消息的正常处理过程的一部分返回给客户端。 而失败是意料之外的, 并且在系统能够恢复至(和之前)相同的服务水平之前,需要进行干预。 这并不意味着失败总是致命的(fatal), 虽然在失败发生之后, 系统的某些服务能力可能会被降低。 错误是正常操作流程预期的一部分, 在错误发生之后, 系统将会立即地对其进行处理, 并将继续以相同的服务能力继续运行。
失败的例子有: 硬件故障、 由于致命的资源耗尽而引起的进程意外终止,以及导致系统内部状态损坏的程序缺陷。
隔离可以定义为在时间和空间上的解耦。 在时间上解耦意味着发送者和接收者可以拥有独立的生命周期—— 它们不需要同时存在,从而使得相互通信成为可能。 通过在组件之间添加异步边界, 以及通过消息传递实现了这一点。 在空间上解耦(定义为位置透明性)意味着发送者和接收者不必运行在同一个进程中。 不管运维部门或者运行时本身决策的部署结构是多么的高效——在应用程序的生命周期之内,这一切都可能会发生改变。
真正的隔离超出了大多数面向对象的编程语言中的常见的封装概念, 并使得我们可以对下述内容进行划分和遏制:
-
状态和行为:它支持无共享的设计,并最大限度地减少了竞争和一致性成本(如通用伸缩性原则(Universal Scalability Law)中所定义的);
-
失败:它支持在细粒度上捕获、发出失败信号以及管理失败, 而不是将其扩散(cascade)到其他组件。
组件之间的强隔离性是建立在明确定义的协议的通信之上的, 并支持解耦, 从而使得系统更加容易被理解、扩展、测试和演化。
弹性系统需要能够自适应, 并不间断地对需求的变化做出反应。 它们需要优雅而高效地扩大或者缩减(部署)规模。 极大地简化这个问题的一个关键洞察是:认识到我们一直都在处理分布式计算。 无论我们是在一台单独的(具有多个独立CPU,并通过快速通道互联(QPI)通信的)节点之上, 还是在一个(具有多台通过网络进行通信的独立节点的)机器集群之上运行我们的系统, 都是如此。 拥抱这一事实意味着, 在多核心之上进行垂直缩放和在集群之上进行水平伸缩并没有什么概念上的差异。
如果我们所有的组件都支持移动性, 而本地通信只是一项优化。 那么我们根本不需要预先定义一个静态的系统拓扑和部署结构。 可以将这个决策留给运维人员或者运行时, 让他(它)们可以根据系统的使用情况来对其进行调整和优化。
这种通过异步的消息传递实现的在空间上的(请参见隔离的定义)解耦, 以及将运行时实例和它们的引用解耦,就是我们所谓的位置透明性。 位置透明性通常被误认为是“透明的分布式计算”, 然而实际上恰恰相反: 我们拥抱网络, 以及它所有的约束——如部分失败、 网络分裂、 消息丢失, 以及它的异步性和与生俱来的基于消息的性质,并将它们作为编程模型中的一等公民, 而不是尝试在网络上模拟进程内的方法调用(如RPC、XA等)。 我们对于位置透明性的观点与Waldo等人著的A Note On Distributed Computing 中的观点完全一致。
消息是指发送到特定目的地的一组特定数据, 事件是组件在达到了某个给定状态时所发出的信号。 在消息驱动的系统中, 可寻址的接收者等待消息的到来, 并对消息做出反应, 否则只是休眠(即异步非阻塞地等待消息的到来)。 而在事件驱动的系统中, 通知监听器被附加到了事件源, 以便在事件被发出时调用它们(指回调)。 这也就意味着, 事件驱动的系统关注于可寻址的事件源, 而消息驱动的系统则着重于可寻址的接收者。 消息可以包含编码为它的有效载荷的事件。
由于事件消耗链的短暂性, 所以在事件驱动的系统中很难实现回弹性 : 当处理过程已经就绪,监听器已经设置好, 以便于响应结果并对结果进行变换时, 这些监听器通常都将直接地处理成功或者失败, 并向原始的客户端报告执行结果。(这些监听器)响应组件的失败, 以便于恢复它(指失败的组件)的正常功能,而在另外一方面, 需要处理的是那些并没有与短暂的客户端请求捆绑在一起的, 但是影响了整个组件的健康状况的失败。
在并发编程中, 如果争夺资源的线程并没有被保护该资源的互斥所无限期地推迟执行, 那么该算法则被认为是非阻塞的。 在实践中, 这通常缩影为一个 API, 当资源可用时, 该API将允许访问该资源, 否则它将会立即地返回, 并通知调用者该资源当前不可用, 或者该操作已经启动了,但是尚未完成。 某个资源的非阻塞 API 使得其调用者可以进行其他操作, 而不是被阻塞以等待该资源变为可用。 此外,还可以通过允许资源的客户端注册, 以便让其在资源可用时,或者操作已经完成时获得通知。
协议定义了在组件之间交换或者传输消息的方法与规范。 协议由会话参与者之间的关系、 协议的累计状态以及允许发送的消息集所构成。 这意味着, 协议描述了会话参与者在何时可以发送什么样的消息给另外一个会话参与者。 协议可以按照其消息交换的形式进行分类, 一些常见的类型是:请求——响应模式、 重复的请求——响应模式(如 HTTP 中)、 发布——订阅模式、 以及(反应式)流模式(同时包含(动态地)推送和拉取)。
和本地编程接口相比, 协议则更加通用, 因为它可以包含两个以上的参与者, 并且可以预见到消息交换的进展, 而接口仅仅指定了调用者和接收者之间每次一次的交互过程。
需要注意的是, 这里所定义的协议只指定了可能会发送什么样的消息, 而不是它们应该如何被编码、解码(即编解码), 而且对于使用该协议的组件来说,传输机制是透明的。
在不同的地方同时地执行一个组件被称为复制。 这可能意味着在不同的线程或者线程池、 进程、 网络节点或者计算中心中执行。 复制提供了可伸缩性(传入的工作负载将会被分布发到跨组件的多个实例中) 以及回弹性 (传入的工作负载将会被复制到多个并行地处理相同请求的多个实例中)。 这些方式可以结合使用, 例如, 在确保该组件的某个确定用户的所有相关事务都将由两个实例执行的同时, 实例的总数则又根据传入的负载而变化,(参见 弹性)。
在复制有状态的组件时,必须要小心同步副本之间的状态数据,否则该组件的客户则需要知道同步的模式,并且还违反了封装的目的。通常,同步方案的选择需要在一致性和可用性之间进行权衡,如果允许被复制的副本可以在有限的时间段内不一致(最终一致性),那么将会得到最佳的可用性,同时,完美的一致性则要求所有的复制副本以一种步调一致(lock-step)的方式来推进它们的状态。在这两种“极端”之间存在着一系列的可能解决方案,所以每个组件都应该选择最适合于其需要的方式。
组件执行其功能所依赖的一切都是资源, 资源必须要根据组件的需要而进行调配。 这包括 CPU 的分配、 内存以及持久化存储以及网络带宽、 内存带宽、 CPU 缓存、 内部插座的 CPU 链接、 可靠的计时器以及任务调度服务、 其他的输入和输出设备、 外部服务(如数据库或者网络文件系统等)等等。 所有的这些资源都必须要考虑到 弹性和回弹性 , 因为缺少必需的资源将妨碍组件在被需要时发挥正常作用。
一个系统通过利用更多的计算资源来提升其性能的能力, 是通过系统吞吐量的提升比上资源所增加的比值来衡量的。 一个完美的可伸缩性系统的特点是这两个数字是成正比的。 所分配的资源加倍也将使得吞吐量翻倍。 可伸缩性通常受限于系统中所引入的瓶颈或者同步点, 参见Amdahl 定律以及 Gunther 的通用可伸缩模型( Amdahl’s Law and Gunther’s Universal Scalability Model)。
系统为它的用户或者客户端提供服务。 系统可大可小, 它们可以包含许多组件或者只有少数几个组件(参见 C.2.4)。 系统中的所有组件相互协作,从而提供这些服务。 在很多情况下, 位于相同系统中的多个组件之间,具有某种客户端——服务端的对应关系(例如,考虑一下,前端组件依赖于后端组件)。 一个系统中共享着一种通用的回弹性模型, 意即, 某个组件的失败将会在该系统的内部得到处理, 并由一个组件委托给另外一个组件。 如果系统中的某系列组件的功能、资源或者失败模型都和系统中的其余部分相互隔离, 那么将这一系列的组件看作是系统的子系统将更有利于系统设计。
我们使用这个术语来非正式地指代某个服务的任何消费者,可以是人类或者其他服务。