响应式编程实战:构建弹性、可伸缩、事件驱动的分布式系统
上QQ阅读APP看本书,新人免费读10天
设备和账号都新为新人

2.1 云平台中的Java

Java现在已经快30岁了!有时候这很难想象。从三层和客户端/服务器架构的时代开始,Java随着多年来多次架构的变化而演进。然而,当一门语言问世近30年时,总会有一些不适合现代发展的部分。

这是什么意思?当Java的最初版本问世时,云平台、容器、微服务、无服务器以及任何其他与当今计算相关的术语都是无法想象的。在三层和客户端/服务器架构的时代,我们不可能期望Java语言能够像今天在容器中所需要的那样运行。

是的,Java多年来取得了很多进步,尤其是在过去几年中,Java以一种新的、更快的发布节奏出现。与此同时,Java为自己没有破坏开发人员和用户的向后兼容性而自豪。这种方法的一个巨大成本是,Java仍然保留了那些在没有容器及其提供的好处和知识的情况下构思出来的片段。

对于许多应用程序来说,Java将在未来的许多年中继续保持良好的工作状态。然而,在过去几年中,随着微服务的爆炸式发展,以及最近向无服务器的演进,Java并不能自然地适应这些部署模型。

就在几年前,当我们发现Java忽略了cgroups(https://oreil.ly/Mbux3)时,Java在容器中的不适用性变得显而易见。对于Docker来说,这造成了一个巨大的问题:Java无法看到分配给容器的内存量,只能看到整个物理机器的内存。

什么是cgroups

术语cgroups是控制组(control groups)的缩写,这是一个内核特性,允许将特定数量的资源(如CPU时间、内存和带宽)分配给进程。在我们的情况下,cgroups用于限制容器可以从整个系统中所获得的资源量。

在每个容器都需要在有限的内存量内工作的环境中,Java并没有发挥得很好。Java很贪婪。此外,Java将根据CPU核心数量创建应用程序线程。这导致在一个有内存和CPU约束的容器中分配了更多的线程。

这有什么大不了的吗?如果将Java应用程序部署到Kubernetes节点,而其他容器保持在它们的内存限制内,那么在容器中部署Java应用程序可能会很幸运。然后有一天出现了负载高峰,Java虚拟机消耗的内存也出现了高峰,之后出现了暴涨!Kubernetes因为容器使用了太多内存而杀死了该容器。

Java和cgroups这个特定问题从Java 10开始就解决了,而且Java开发工具包(JDK)8u131选项也可以用于启用相同的行为。可查看Rafael Benevides在Red Hat开发者网站上的“Java Inside Docker”文章(https://oreil.ly/L5Yh7),了解所有细节。

你可能认为Java现在在容器或云平台中会表现良好,对吗?虽然上述的修复程序可以与适当的JDK版本一起使用,但许多企业都在使用JDK 8或更早版本,而且很可能没有使用带有可用标志的JDK 8。Java在云端的问题不仅仅是cgroups。

容器不会因为获取比预期更多的内存而被杀死,这很好。然而,容器中的Java引起了人们对应用程序开始接收请求的速度以及应用程序在运行时消耗内存量的担忧。与容器中运行的其他语言相比,Java应用程序并不符合启动速度和内存消耗的要求。也许对于今天运行的许多应用程序来说,启动速度不是一个问题,但启动速度可能会影响需要快速扩展以应对大流量峰值的微服务,或者影响无服务器应用程序的冷启动时间。

开始接收请求是什么意思?虽然用于构建应用程序的框架通常会记录其启动时间,但开始接收请求指的是框架启动所需的时间。该时间并不代表应用程序在开始接收请求之前所花费的时间。这个时间是容器和云平台的关键指标!

开始接收请求的时间也可以称为第一次请求的时间。如果在应用程序接收和处理任何流量之前还需要2s~3s,则框架能否在0.5s内启动就没有多大意义了。在这样的示例中,一个新应用程序实例可能需要2.5s~3.5s才能开始接收用户请求。

诚然,对于只有几百甚至几千个内部用户的单体应用程序来说,开始接收请求的时间和内存消耗可能并不重要。可以使用Quarkus开发单体应用程序,尽管我们谈论的Quarkus的好处在开发单体应用程序时不会那么突出。然而,对于微服务,尤其是无服务器服务,这两个因素都会影响运行服务的成本和用户的可用性。

框架的启动时间通常较低,因为它会将工作延迟直到收到第一个请求。任何启动任务的其余部分都是在处理第一个请求之前执行的。惰性初始化(lazy initialization)是这种行为的另一个名称,在应用程序真正准备就绪时提供错误指示。开始接收请求的时间是应用程序启动时间的最佳度量。在无服务器工作负载以及任何使用“零扩展”(scale-to-zero)方法(只有在需要时才启动服务)的机制中,具有较短的首次请求时间至关重要。在更常见的架构中,这样快的启动时间缩短了崩溃后的恢复时间。

我们如何度量启动时间?许多方法都可以实现,包括修改端点以便在访问这些端点时输出时间戳。为了简化起见,我们将使用一个由John O'Hara在Red Hat开发的Node.js脚本(https://github.com/cescoffier/reactive-systems-in-java)。该脚本使用应用程序启动命令和访问该命令的URL以在另一个进程中启动应用程序。在计算第一次请求的时间之前,脚本会等待URL直至返回200,这意味着成功。

为了便于使用,我们在chapter-2/startup-measurement目录中包含了GitHub存储库(https://github.com/cescoffier/reactive-systems-in-java)的内容和代码。确保你已经安装Node.js,并运行npm install request来保证脚本所需要的依赖项。

现在你可能会认为关于启动速度和内存消耗的讨论是一个非常主观的话题。我们完全同意,这就是为什么我们现在要使用传统的Java EE框架[如本例中是Thorntail(https://thorntail.io)]在实践中说明这些概念。我们选择Thorntail作为来自Red Hat的第一个微服务框架进行比较,而Quarkus是最新的。尽管Thorntail项目已不再被维护,这有些不幸,但好消息是Quarkus整合了Thorntail的许多想法。

在编写和运行应用程序之前,还有最后一件事。内存可能是一个模糊的术语,因为有许多类型的内存。当我们谈论内存时,通常指的是常驻集大小(RSS;https://oreil.ly/o4TzC),而不是JVM堆大小,因为JVM堆只是Java应用程序消耗的总内存的一部分。在JVM上运行应用程序时,分配的总内存可以包括以下内容:

• 类元数据

• 线程栈

• 编译代码

• 垃圾收集

RSS表示进程在主存(RAM)占用的内存量。RSS包含JVM运行应用程序所需的所有内存,为实际占用的内存量提供了更准确的值。当我们在单个JVM进程中运行单个应用程序时,可以很容易地确保没有测量其他非应用程序进程的内存消耗。

所有性能数据都来自我们的MacBook计算机。因此,你在本章中看到的结果可能会略有不同,具体取决于你的特定硬件配置。如果你碰巧有一个Apple M1,就可以看到更好的结果!

好了,是时候运行一些代码看看启动速度和内存消耗了。

2.1.1 Thorntail示例

我们首先使用Thorntail创建一个传统的应用程序,以提供内存或RSS以及首次请求时间指标的比较。对于任何不熟悉Thorntail的人来说,该项目的重点是可定制的WildFly服务器(https://wildfly.org)。Thorntail只获取特定应用程序所需的内存,删除其他所有内存。

Thorntail应用程序需要一个用于RESTful Web Services(JAX-RS;https://oreil.ly/eYjXF)的Java API,以及一个简单的资源端点,以便我们可以对其进行请求。Thorntail示例需要一个JAX-RS应用程序和一个JAX-RS资源,该资源带有一个返回HTTP GET请求问候语的方法。Thorntail示例的所有源代码都可以在/chapter-2/Thorntail-hello目录中找到。

这些类没有什么特别之处。它们只是提供一个带有JAX-RS的HTTP端点来进行请求的最低要求。让我们构建一个Thorntail应用程序,然后启动它,如示例2-1所示。

示例2-1:构建并运行Thorntail Hello World应用程序

应用程序启动后,使用curl或在浏览器上点击http://localhost:8080/hello端点。在你提出了一些请求之后,或者如果你愿意,可以提出更多请求,现在是时候看看流程使用的RSS内存了。在测量RSS之前访问端点很重要,因为应用程序在启动期间可能没有加载所有类,这意味着我们可能会看到一个误导性的数字。

为了能够找出所使用的内存,我们需要Thorntail应用程序正在运行的进程ID。在基于Linux的系统上,包括Mac在内,我们可以使用ps - e | grep thorntail列出所有活动进程,并将结果限制为名称中包含thorntail的进程。有了进程ID,就可以知道该进程使用了多少RSS(如示例2-2所示)。

示例2-2:测量Thorntail应用程序的RSS使用情况

ps检索RSS和命令,awk将RSS值转换为兆字节。

你将看到与前面的终端输出类似的内容,显示进程ID、转换为兆字节(M)的RSS和命令。有关如何查找流程RSS的完整详细信息,请访问Quarkus网站(https://oreil.ly/eYjXF)。

我们可以看到,一个“Hello World”风格的应用程序使用了441MB的内存,其中一个端点返回一个字符串。对于一个仅返回固定字符串的单个JAX-RS端点来说,这占用了大量内存!

我们应该注意,在OpenJDK 11上运行这些测试时,没有对JVM捕获的内存量进行任何配置,也没有对JVM进行任何其他调整。我们可以限制JVM能够获取的内容,并查看其对整个RSS的影响(见示例2-3)。

示例2-3:启动Thorntail应用程序来配置堆大小

现在我们得到示例2-4中的输出。

示例2-4:测量RSS的使用情况

这将内存使用量降至265MB!通过将JVM占用的堆数量限制在48MB,我们节省了近200MB的RSS。就吞吐量而言,48MB可能不是最佳选择,但这需要通过你自己的应用程序进行验证,以便在减少内存消耗和提高吞吐量之间找到平衡。

我们已经展示了RSS的使用情况,现在需要计算首次请求时间。继续之前,请确保Thorntail应用程序所有以前的实例都已停止。让我们查看首次请求时间,如示例2-5中所示。

示例2-5:测量Thorntail应用程序的首次请求时间

我们看到控制台中有大量的404消息飞过,直到应用程序返回200响应,然后我们看到所花费的时间。在我们的例子中,是6810ms!在微服务和函数的世界里,这并不是很快。你可以运行几次,看看时间变化是否很大。由于启动时间为7s,扩展微服务无法足够快地满足流量峰值,因此会导致用户延迟,甚至可能出现错误。从无服务器的角度来看,我们的情况甚至更糟,因为我们希望在7s之前启动、运行和停止无服务器函数。

time.js捕捉到的首次请求时间可能会比实际长一点,因为在子进程生成时,在JVM启动之前,只需要很短的时间。我们不太担心这么小的数量,因为这个影响适用于我们以相同方式测试的每个运行时。

所以,我们已经了解了传统应用程序的RSS消耗,以及到达首次请求所需的时间。现在是时候看看Quarkus的情况了。

2.1.2 Quarkus示例

我们将创建一个相同的Hello World端点,尽管这个程序不会说“Hello from Thorntail!”使用Quarkus,我们不需要JAX-RS应用程序类,除了该消息[2]外,只需要与Thorntail版本内容相同的JAX-RS资源。Quarkus示例的源代码可以在/chapter-2/Quarkus-hello目录中找到。

在2.3节中,我们将介绍如何创建Quarkus应用程序。现在构建并启动Quarkus Hello World应用程序,如示例2-6所示。

示例2-6:构建并启动Quarkus Hello World应用程序

与Thorntail一样,我们并没有优化JVM来查看原始RSS的使用情况。单击几次http://localhost:8080/hello,如同我们刚才用Thorntail的时候。希望你看到的信息是“Hello from Quarkus!”否则,你仍在运行Thorntail应用程序。

找到Quarkus应用程序的进程ID并查看RSS,见示例2-7。

示例2-7:测量Quarkus Hello World应用程序的RSS使用情况

这里我们看到Quarkus使用133MB的RSS,比使用Thorntail的441MB少308MB!对于本质上相同的应用程序来说,这是一个惊人的改进。

如果我们将最大堆内存限制为48MB,就像对Thorntail所做的那样,能得到多大的改进?重新运行示例2-8。应用程序启动后,不要忘记使用端点。

示例2-8:限制堆内存使用并测量RSS使用情况

这使Quarkus降到了114MB,但让我们看看能把Quarkus配置到多小的堆内存!参考示例2-9。同样,不要忘记在启动后使用端点。

示例2-9:进一步限制Quarkus应用程序的堆内存使用,并测量RSS使用情况

最大堆为24MB,我们的RSS容量降到了98MB!更重要的是,该应用程序仍然有效!在Quarkus无法启动之前,查看你可以将最大堆内存降低到多小。虽然对于常规应用程序,甚至是微服务,你不会将最大堆内存设置得这么小,但能够将其设置得这么小对于无服务器环境至关重要。

将堆内存大小设置为非常小的值可能会影响应用程序的性能,尤其是当应用程序执行大量分配时。不要追求尽可能小的值,而应根据预期的性能和部署密度增益来验证该值。请注意,Quarkus架构试图避免受到这样的惩罚。但是,我们强烈建议你查看你的应用程序。

启动速度的时间参见示例2-10。

示例2-10:测量首次请求Quarkus应用程序的时间

如果你的硬件与我们的类似,则应该会看到大约1001ms的首次请求时间!这比传统应用程序快近7倍!

这一切都很棒,但有什么意义呢?回顾我们之前关于容器中Java的讨论,让我们看看在容器中运行时的影响。假设我们有一个可用的且内存为2GB的节点,每个应用程序可以容纳多少个容器?看看图2-1。

图2-1:容器中的Java内存——Quarkus允许增加部署密度

容器密度是Kubernetes云部署的一个关键特征。给定一个特定的节点大小,比如2GB的RAM,在单个节点中运行的容器越多,可以提供的容器密度就越大。提高容器密度可以更好地利用可用资源。从图2-1中的示例来看,4个实例或14个实例能否实现更高的吞吐量?如果这14个容器中的每一个都比传统容器支持更少的吞吐量或每秒请求数,那么这并不重要。一个Docker吞吐量的小幅下降可以通过支持14个Docker而不是4个Docker来抵消。

容器密度是确定所需实例数的一个重要指标。开发人员需要确定他们的预期或期望吞吐量。也许更少的容器和更大的内存要求可以满足今天的需求,但请记住,情况会发生变化,你可能很容易需要4个以上的容器来满足你的用户!

现在,你了解了JVM上传统应用程序的RSS内存量和首次请求时间,以及Quarkus如何显著减少应用程序的这两方面。Quarkus希望用一种新方法解决改进容器中Java的挑战。这种方法提高了Java在容器中的启动速度和内存消耗。

下一节将详细解释Quarkus如何实现这一点,更重要的是,这种方法与传统框架方法之间的区别。