响应式编程(Reactive Programming)是相对于命令式编程的一种全新的编程模型,是基于数据流变化传播的编程范式。
响应式编程具备快速响应、不可变性、高并发、异步非阻塞、规模扩展、代码可读性高等诸多优势。Java编程领域出现了众多基于响应式流规范的编程开发框架。
本篇我们会从响应式编程动机、响应式编程基本概念、响应式宣言开始介绍,引出响应式编程的基本特性(数据流、背压等)和核心概念。
Spring Boot 2.x微服务框架在Spring Boot 1.x的基础上,基于Spring 5 Reactor框架实现了响应式的微服务基底技术。Reactor框架是Spring家族在响应式编程领域的子项目,由Pivotal公司开发,实现了基于响应式流编程规范。Reactor最大的优势是与Spring生态的无缝集成,Spring WebFlux框架正是基于Reactor框架实现的。此外,本文还会介绍基于响应式流规范开发的类库实现,包括Java Flow API、RxJava、Vert.X等响应式编程框架。
最后,我们会重点介绍WebFlux异步非阻塞Web框架(核心组件包括Flux、Mono、Scheduler等)和Spring Cloud Gateway项目。另外,还会介绍Spring WebFlux与Spring MVC在Web工作原理上的差异和关系,同时会说明WebFlux目前的主要使用场景及在生产环境下的使用局限性。
针对响应式编程,我们会介绍采用响应式编程的动机,并解释响应式编程的含义,然后讲解目前主流的响应式技术框架。
当前大多数公司使用Spring或者Spring Boot 1.x技术栈开发后端业务的Web服务器。假设在正常情况下,我们将Tomcat线程池配置为200(最大并发线程数),如果每个人的请求响应时间在200ms以内,并且根据每个请求的超时时间、资源限制和其他因素,粗略估算后,我们得出一个结论,系统在单位时间内最高可以支持1000个用户同时在线访问业务资源。然而,当业务场景是支持一个促销活动时,可能会出现用户规模快速增长或流量(QPS)激增的情况。这时,Tomcat服务器会出现短期资源耗尽及性能急速下降的问题,Tomcat后台爆出大量的服务超时、资源不可用、网页刷新慢等异常,而用户的直观体验就是服务不可用。
下面我们以Tomcat 7.x作为试验对象来模拟上述情景,使用BenchMark的方法比较客户端随着请求的增加对响应时间(延迟)、系统吞吐、负载均值等指标变化情况的影响。
OS:Linux Red Hat 4.4.7-11
Memery:16GB
CPU: 4 Processor(Intel(R) Xeon(R))
JDK:1.8
Web服务器:Tomcat 7.x
测试工具:Jmeter-2.9
Monitor Tools: Jconsole, Jvisualvm, Jstat
在Web容器中只执行一个Servlet服务实例,并且在请求方法中只返回一个“hello”响应。Tomcat模拟200个线程场景,不同线程数对应的服务性能指标结果如下表所示。
从上面Bench Mark的试验结果中发现,当客户请求的线程数增加时,用户的响应延迟时间逐渐增大,同时服务吞吐量也是增大的;但客户并发请求数增长超过1000时,用户的吞吐量下降,并且客户端显示大量请求超时异常:client Non Http response message Readtime out。下图是我们从Jconsole中看到的大量的运行态线程。
在默认情况下,在未开启Servlet 3.1异步功能特性时,Tomcat的Servlet处理逻辑是采用同步的编程模型,如下图所示,一个HTTP请求后端对应一个工作线程。
上图是一个典型的基于多线程模型下的Tomcat请求处理模型。由于在Servlet 3以前,Tomcat主要采用同步阻塞等待模式,当请求发生时,就会从线程池中加载对应的工作线程来处理HTTP请求;当客户端请求增加时,对应的工作线程会随之线性增长。当工作线程达到最大时,Tomcat就没有多余的线程供客户端使用,而剩余的请求缓存在Tomcat等待队列中,在请求超时后便会发生Timeout的异常。
可以看到,这种一个请求通过使用一条线程去推动执行控制流的方式是缺少灵活度和响应力的。明显的问题是,当工作线程调用下游服务或者数据库时,如果执行了一个慢操作,那么Tomcat中的工作线程会处于阻塞状态,同步等待在交互边界。在这种情况下,系统的资源被无效占用,当出现大量线程被阻塞占用时,就会发生客户端大量请求重试和重连情况,Web服务可能就会出现工作线程耗尽、内存溢出、雪崩效应、服务不可用等情况。
针对用户激增或流量增长的问题,我们常用的解决方案是水平增加硬件资源、扩容服务实例来增加系统的容量。当流量下降时,我们可以缩容服务,降低系统容量。这种依靠增加资源提升性能的方式,在解决系统的可伸缩性问题上会受到系统内部的结构性瓶颈的限制。
而这个问题从理论到实践也符合阿姆达尔定律(Amdahl’s Law),并且被Gunther的通用可伸缩模型(Gunther’s Universal ScalabilityModel)所证实。所以,我们需要构建一个弹性系统,使系统的资源能够得到更好利用,当流量增加时,我们的处理线程或者资源是收敛的,不会随着请求数量的增加使系统消耗也随之线性增长。
针对同步线程阻塞等待的问题,另外一种解决方案是采用Reactor设计模式。Netty就是一个典型的采用Reactor设计模式的响应式系统。这种系统架构的特征是基于事件触发机制,将服务器端的所有行为抽象成事件并注册监听等待执行,当对应事件到来时会由对应的处理器(Handler)处理,而配合响应式系统最好的方式是将所有任务分解成异步非阻塞的事件,方便异步执行。
总之,针对Java的异步(Asynchronous)编程模型,我们经常有下面两种解决方式。
基于多线程的方式,进行Callback。
基于Reactor设计模式,使用事件触发机制实现并发处理。
对于传统的Web服务器,我们大多使用第一种方式即异步线程回调的机制,下图展示的是基于线程回调方式的工作流程。
多线程方式的异步编程模型容易引起CPU资源的浪费,真正只有少部分CPU时间片用来处理业务逻辑,而线程的大部分时间都被阻塞在I/O事件等待,这对于稀缺的线程资源来说,是非常不合理的。另外,我们知道Java没有协程的概念,所以不能像有些新型编程语言那样使用“绿色线程”解决线程资源浪费的问题。虽然,JDK 8引入了CompleteFuture,但是异步回调很难组合,并产生了另外的问题——回调地狱,而且这种方式本质上还是基于线程控制流的方式,采用命令式编程对业务逻辑进行编排和管理。
针对系统的“韧性”或者容错性问题,微服务架构的开拓者Netflix使用Hystrix技术来解决。Hystrix可以解决线程阻塞产生的同步等待,实现线程熔断和降级的目的。虽然可以保护Web服务器在极端情况下的宕机,但是这种做法没有从根本上解决服务器面对用户增长、流量增加带来的服务响应力。当系统熔断发生时,用户的数据可能会丢失,客户端对服务器的消息处理能力和响应能力是无感知的,而使用响应式编程的“背压”特性,客户端可以实时感知并动态调节发送速率和异常响应,服务器也可以更好地保证系统的整体韧性。
异步编程的另外一个模式是基于事件触发机制。将复杂的业务逻辑拆分为异步非阻塞的独立执行单元,这些独立的逻辑执行步骤由数据流触发,通过数据流有效地推动业务逻辑的执行。通过函数的响应、组合达到对数据流的控制、过滤、组合、映射。对这种数据流响应模式的处理流程如下图所示。
总之,还有很多可以改善当前Java Web服务编程开发模式和系统架构现状的方式,我们就不一一列举了。可以说,传统Spring MVC架构的阻塞式编程模型的性能瓶颈是响应式系统架构和响应式编程模型开发的主要动机,我们需要考虑如何满足系统更加健壮、更具有弹性、更快响应、更加灵活、更易扩展等特性,从Spring早期基于XML配置进行Web开发模型,到Spring Boot 1.x基于注解方式简化Web的开发方式,再到Spring 5和Spring Boot 2.x全面拥抱响应式编程,软件技术的发展总是围绕成本、效率、性能等生产要素持续迭代演进,通过技术升级逐步实现降低开发成本、快速响应业务、增强用户体验的目标。
上面我们解释了传统编程模型存在的问题和响应式系统架构及使用响应式编程开发的动机,下面我们介绍一下响应式宣言。响应式宣言是一组架构与设计原则,符合这些原则的系统可以认为是响应式系统。而响应式系统与响应式编程是不同层面的内容。下图基本概括了响应式宣言的主要特性和核心理念。
快速、一致的“响应性”是响应式宣言中最重要的特性。即时响应是可用性和实用性的基石,而更加重要的是,即时响应意味着可以快速检测到问题并且有效地对其进行处理。即时响应的系统专注于提供快速而一致的响应时间,确立可靠的反馈上限,以提供一致的服务质量。这种行为将简化错误处理、建立最终用户的信任,并促使用户与系统形成进一步的互动。
“弹性”使系统在不断变化的工作负载下依然保持即时响应性。
反应式系统可以对输入(负载)的速率变化做出反应,比如增加或者减少用于服务这些输入(负载)的资源。这意味着设计上并没有中央瓶颈点,可以对组件进行分片或者复制,并在它们之间分别输入(负载)。通过提供相关的实时性能指标,反应式系统能支持预测及反应式的伸缩算法。这些系统可以在常规的硬件和软件平台上实现成本高效的弹性。
“韧性”指系统在出现失败时依然保持即时响应性。这适用于高可用的任务关键型系统,如果不具备回弹性,系统将在发生失败后丢失即时响应能力。回弹性通过复制、遏制、隔离及委托来实现系统的响应力。失败的扩散被遏制在每个组件内部,与其他组件相互隔离,
从而确保系统某部分的失败不会危及整个系统,并能独立恢复。每个组件的恢复都被委托给了另一个组件,此外在必要时可以通过复制来保证高可用。
反应式系统依赖异步的“消息传递”,它可以确保松耦合、隔离、位置透明的组件之间有着明确边界。这一边界还提供了将失败作为消息委托出去的方式。使用显式的消息传递,可以在系统中塑造并监视消息流队列,并在必要时应用回压,从而实现负载管理、弹性及流量控制。使用位置透明的消息传递作为通信手段,使得跨集群或者在单个主机中使用相同的结构成分和语义来管理失败成为可能。非阻塞式通信使得接收者可以只在活动时消耗资源,从而减少系统开销。
综上所述,响应式宣言也是规范响应式开发的一个行为准则。我们将具备这些特性的系统称为响应式系统。