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