Quarkus 响应式 CRUD 性能:案例研究

我们被要求对 Quarkus 在响应式 CRUD 工作负载下的相对性能发表评论。这是一个关于性能测试设计、所需考虑因素以及需要克服的障碍的良好案例研究。我们如何设计方法论来确保我们进行的测试确实是我们期望的测试?

“为什么 Quarkus 比 {INSERT_FRAMEWORK_HERE} 慢 600 倍?!”

最近一份关于 Quarkus 表现不佳的报告引发了进一步调查。表面上看,Quarkus 的结果很糟糕,非常糟糕。

简而言之

通过纠正基准测试中的实现错误,并精心设计测试环境以确保只对应用程序进行压力测试,Quarkus 的处理能力从每秒 1.75 个请求提升到近 26,000 个请求。每个请求都使用相同的负载驱动器和硬件查询并写入 MySQL 数据库。

测试架构

与我们分享的测试是通过 REST 调用更新数据库的简单负载测试;

reactiveBenchmark
  1. 负载生成器向 REST API 发送持续的 HTTP POST 请求流。在本例中是 wrk

  2. Quarkus 应用程序通过 RESTEasy Reactive 处理请求

  3. Quarkus 应用程序通过 Hibernate Reactive 查询和更新 MySQL 数据库实例

测试的源代码可以在这里找到:https://github.com/thiagohora/tutorials/tree/fix_jmeter_test

要了解有关使用 Quarkus 创建响应式应用程序的更多信息,请阅读 响应式入门指南

初步结果 不高兴

Quarkus 的初步结果并不理想;

$ wrk -t2 -c10 -d1m -s ./post_zipcode.lua --timeout 2m -H 'Host: localhost' https://:8080
Running 1m test @ https://:8080
  2 threads and 10 connections
  Thread Stats   Avg      Stdev     Max   +/- Stdev
    Latency     6.26s    10.29s   30.03s    77.78%
    Req/Sec    72.55     97.66   270.00     81.82%
  105 requests in 1.00m, 20.69KB read
  Socket errors: connect 0, read 10, write 0, timeout 0
  Non-2xx or 3xx responses: 10
Requests/sec:      1.75
Transfer/sec:     352.77B

这是 60 秒内 105 个请求,有 10 个错误。60 秒内只有 95 个请求成功发送,即每秒 1.75 个请求

在我的机器上运行比较测试;

$ wrk -t2 -c10 -d1m -s ./post_zipcode.lua --timeout 2m -H 'Host: localhost' https://:8080
Running 1m test @ https://:8080
  2 threads and 10 connections
  Thread Stats   Avg      Stdev     Max   +/- Stdev
    Latency    35.78ms   43.69ms 568.52ms   92.67%
    Req/Sec   171.93    113.83   777.00     80.61%
  20228 requests in 1.00m, 3.70MB read
Requests/sec:    336.86
Transfer/sec:     63.04KB

总的来说,Quarkus 支持的请求速率仅为每秒 1.75 个请求!!好吧,它没有慢 600 倍,但在我的机器上它慢了 192 倍。

但是……有什么不对劲,Quarkus 在服务日志中显示以下异常;

2022-06-17 15:20:44,507 ERROR [org.hib.rea.errors] (vert.x-eventloop-thread-45) HR000057: Failed to execute statement [select zipcode0_.zip as zip1_0_0_, zipcode0_.city as city2_0_0_, zipcode0_.county as county3_0_0_, zipcode0_.state as state4_0_0_, zipcode0_.timezone as timezone5_0_0_, zipcode0_.type as type6_0_0_ from ZipCode zipcode0_ where zipcode0_.zip=?]: could not load an entity: [com.baeldung.quarkus_project.ZipCode#08231]: java.util.concurrent.CompletionException: io.vertx.core.impl.NoStackTraceThrowable: Timeout
	at java.base/java.util.concurrent.CompletableFuture.encodeThrowable(CompletableFuture.java:332)
	at java.base/java.util.concurrent.CompletableFuture.completeThrowable(CompletableFuture.java:347)
	at java.base/java.util.concurrent.CompletableFuture$UniApply.tryFire(CompletableFuture.java:636)
	at java.base/java.util.concurrent.CompletableFuture.postComplete(CompletableFuture.java:510)
	at java.base/java.util.concurrent.CompletableFuture.completeExceptionally(CompletableFuture.java:2162)
	at io.vertx.core.Future.lambda$toCompletionStage$2(Future.java:362)
	...
	at io.netty.channel.nio.NioEventLoop.run(NioEventLoop.java:503)
	at io.netty.util.concurrent.SingleThreadEventExecutor$4.run(SingleThreadEventExecutor.java:986)
	at io.netty.util.internal.ThreadExecutorMap$2.run(ThreadExecutorMap.java:74)
	at io.netty.util.concurrent.FastThreadLocalRunnable.run(FastThreadLocalRunnable.java:30)
	at java.base/java.lang.Thread.run(Thread.java:833)
Caused by: io.vertx.core.impl.NoStackTraceThrowable: Timeout

初步调查显示,测试期间打开的 MySQL 连接数非常高:96 个打开的连接

mysql> show status like '%onn%';
+-----------------------------------------------+---------------------+
| Variable_name                                 | Value               |
+-----------------------------------------------+---------------------+
...
| Max_used_connections                          | 96                  |
| Max_used_connections_time                     | 2022-06-17 14:20:07 |
...
| Threads_connected                             | 96                  |
+-----------------------------------------------+---------------------+
16 rows in set (0.01 sec)

并检查应用程序在 1 分钟内成功执行的插入次数;

mysql> select count(*) from ZipCode;
+----------+
| count(*) |
+----------+
|       95 |
+----------+
1 row in set (0.00 sec)

数据库连接显然有问题!每个连接只向数据库提交一个值,并且没有进一步的进展。数据库中的条目数量精确地与成功的 HTTP 请求数量相符。

查看 Quarkus 进程的 CPU 时间确认,在向数据库提交初始 95 次提交后没有进行更多工作,应用程序已死锁;

$ pidstat -p 869871 1
Linux 5.17.11-200.fc35.x86_64 (localhost.localdomain) 	17/06/22 	_x86_64_	(32 CPU)

15:32:41      UID       PID    %usr %system  %guest   %wait    %CPU   CPU  Command
15:32:42     1000    869871    0.00    0.00    0.00    0.00    0.00    22  java
15:32:43     1000    869871    0.00    0.00    0.00    0.00    0.00    22  java
15:32:44     1000    869871    0.00    0.00    0.00    0.00    0.00    22  java
15:32:45     1000    869871    0.00    0.00    0.00    0.00    0.00    22  java
15:32:46     1000    869871    0.00    0.00    0.00    0.00    0.00    22  java

应用程序是否按预期运行?

如果应用程序出现错误,则结果无效。在继续之前,请调查错误发生的原因并修复应用程序。

代码初步检查

快速审查代码揭示了死锁问题;

@POST
@Transactional
public Uni<ZipCode> create(ZipCode zipCode) {
    return getById(zipCode.getZip())
        .onItem()
        .ifNull()
        .switchTo(createZipCode(zipCode))
        .onFailure(PersistenceException.class)
        .recoverWithUni(() -> getById(zipCode.getZip()));
}

啊哈!该端点被注解为 @Transactional。应用程序正在使用 Hibernate Reactive,因此我们需要改用 @ReactiveTransactional 注解。有关更多详细信息,请阅读 Hibernate Reactive with Panache 简化指南。这可能会令人困惑,但我们已经开始讨论如何澄清不同的要求并警告用户是否存在问题。

Quarkus 应用程序已修复 高兴

@POST
@ReactiveTransactional
public Uni<ZipCode> create(ZipCode zipCode) {
    return getById(zipCode.getZip())
        .onItem()
        .ifNull()
        .switchTo(createZipCode(zipCode))
        .onFailure(PersistenceException.class)
        .recoverWithUni(() -> getById(zipCode.getZip()));
}

我们再试一次

$ wrk -t2 -c10 -d1m -s ./post_zipcode.lua --timeout 2m -H 'Host: localhost' https://:8080
Running 1m test @ https://:8080
  2 threads and 10 connections
  Thread Stats   Avg      Stdev     Max   +/- Stdev
    Latency    30.06ms   33.67ms 351.38ms   87.66%
    Req/Sec   197.60    145.88     1.14k    82.24%
  23427 requests in 1.00m, 4.60MB read
  Socket errors: connect 0, read 3, write 0, timeout 0
  Non-2xx or 3xx responses: 3
Requests/sec:    390.21
Transfer/sec:     78.40KB

390.21 req/sec!! 这好多了!!

随着测试修复,我们在数据库表中可以看到更多数据;

mysql> select count(*) from ZipCode;
+----------+
| count(*) |
+----------+
|    10362 |
+----------+
1 row in set (0.00 sec)
测试设计为在尝试插入新的邮政编码之前查询数据库以查看其是否已存在。邮政编码的数量是有限的,因此随着测试的进行,邮政编码条目的数量将趋向于最大数量。工作负载从写密集型转向读密集型。

相同的结果 不高兴

但是……我的机器上的硬盘在测试期间发出了很大的噪音!Quarkus 的结果 390.21 req/sec 与比较基线 336.86 req/sec 惊人地相似,而且……

$ pidstat -p 873146 1
...
15:46:29      UID       PID    %usr %system  %guest   %wait    %CPU   CPU  Command
15:46:30     1000    873146   59.00    6.00    0.00    0.00   65.00    12  java
15:46:31     1000    873146   57.00    4.00    0.00    0.00   61.00    12  java
15:46:32     1000    873146   50.00    3.00    0.00    0.00   53.00    12  java
15:46:33     1000    873146   27.00    5.00    0.00    0.00   32.00    12  java
15:46:34     1000    873146   32.00    3.00    0.00    0.00   35.00    12  java
15:46:35     1000    873146   50.00    4.00    0.00    0.00   54.00    12  java
15:46:36     1000    873146   27.00    3.00    0.00    0.00   30.00    12  java
15:46:37     1000    873146   27.00    4.00    0.00    0.00   31.00    12  java
15:46:38     1000    873146   39.00    4.00    0.00    0.00   43.00    12  java
15:46:39     1000    873146   48.00    2.00    0.00    0.00   50.00    12  java
15:46:40     1000    873146   40.00    2.00    0.00    0.00   42.00    12  java
15:46:41     1000    873146   28.00    5.00    0.00    0.00   33.00    12  java
15:46:42     1000    873146   23.00    4.00    0.00    0.00   27.00    12  java

应用程序仅使用了 32 核机器的 0.5 核以下……嗯!

应用程序是瓶颈吗?

如果某个系统组件是性能瓶颈(即不是待测应用程序),那么我们实际上并没有对应用程序进行压力测试。

移至更快的磁盘 高兴

我们将数据库文件移至更快的磁盘;

$ docker run -d --rm --name mysqldb --network=host -e MYSQL_ROOT_PASSWORD=root -e MYSQL_DATABASE=baeldung -v /home/user/mysqlData:/var/lib/mysql  -d mysql:5.7.38 --character-set-server=utf8mb4 --collation-server=utf8mb4_unicode_ci

然后重新运行测试

$ wrk -t2 -c10 -d1m -s ./post_zipcode.lua --timeout 2m -H 'Host: localhost' https://:8080
Running 1m test @ https://:8080
  2 threads and 10 connections
  Thread Stats   Avg      Stdev     Max   +/- Stdev
    Latency     2.97ms   17.85ms 319.79ms   98.44%
    Req/Sec    12.99k     6.45k   18.88k    77.23%
  1538167 requests in 1.00m, 301.75MB read
  Socket errors: connect 0, read 4, write 0, timeout 0
  Non-2xx or 3xx responses: 4
Requests/sec:  25599.85
Transfer/sec:      5.02MB

坐下,放松,盈利!25,599.85 req/sec!

不要止步于此!

虽然声称我们已解决问题很容易,但为了进行比较,我们仍然没有一个受控的环境来运行测试!

系统瓶颈依然存在 不高兴

Quarkus 进程现在使用了 4.5 核……

]$ pidstat -p 884208 1
Linux 5.17.11-200.fc35.x86_64 (localhost.localdomain) 	17/06/22 	_x86_64_	(32 CPU)

16:12:50      UID       PID    %usr %system  %guest   %wait    %CPU   CPU  Command
16:12:51     1000    884208  294.00  175.00    0.00    0.00  469.00    26  java
16:12:52     1000    884208  305.00  173.00    0.00    0.00  478.00    26  java
16:12:53     1000    884208  304.00  173.00    0.00    0.00  477.00    26  java
16:12:54     1000    884208  299.00  169.00    0.00    0.00  468.00    26  java
16:12:55     1000    884208  296.00  173.00    0.00    0.00  469.00    26  java
16:12:56     1000    884208  298.00  171.00    0.00    0.00  469.00    26  java
16:12:57     1000    884208  308.00  175.00    0.00    0.00  483.00    26  java
16:12:58     1000    884208  301.00  177.00    0.00    0.00  478.00    26  java
16:12:59     1000    884208  305.00  166.00    0.00    0.00  471.00    26  java
16:13:00     1000    884208  304.00  169.00    0.00    0.00  473.00    26  java
16:13:01     1000    884208  307.00  172.00    0.00    0.00  479.00    26  java
16:13:02     1000    884208  301.00  174.00    0.00    0.00  475.00    26  java

但是……系统有 60% 的空闲

$ vmstat 1
procs -----------memory---------- ---swap-- -----io---- -system-- ------cpu-----
 r  b swpd    free    buff   cache     si   so    bi    bo    in     cs us sy id wa st
14  0 5254976 9665088 590824 4895220    0    0     0     0 50997 715648 25 16 59  0  0
16  0 5254976 9667204 590824 4895220    0    0     0  1372 50995 710429 24 16 60  0  0
15  0 5254976 9666244 590824 4895232    0    0     0     0 51544 707477 24 16 59  0  0
11  0 5254976 9664892 590872 4895160    0    0     0   980 51178 700680 24 16 60  0  0
14  0 5254976 9662968 590880 4895232    0    0     0    12 54800 710039 25 16 59  0  0

我们仍然存在应用程序之外的瓶颈,最有可能在 MySQL 中,或者我们仍然受 I/O 限制!

此时,我们有几个选择,我们可以;

A) tune MySQL/IO so that they are no longer the bottleneck

B) constrain that application below the maximum, such that the rest of the system is operating within its limits

最简单的选择是简单地限制应用程序。

选择您的扩展方法

我们可以选择扩展系统或调整系统,或者将应用程序缩小到系统限制以下。

选择扩展系统或限制应用程序取决于测试目标。

限制应用程序 高兴

我们将通过将应用程序限制为 4 个 CPU 核心来消除 MySQL/系统瓶颈,从而降低应用程序可以驱动到数据库的最大负载。我们通过在 docker 中运行应用程序来实现这一点;

$ docker build -f ./src/main/docker/Dockerfile.jvm -t quarkus-project:0.1-SNAPSHOT .
...
Successfully built 0cd0d50404ac
Successfully tagged quarkus-project:0.1-SNAPSHOT

$ docker run --network host --cpuset-cpus=0-3 quarkus-project:0.1-SNAPSHOT

并重新运行测试;

$ wrk -t2 -c10 -d1m -s ./post_zipcode.lua --timeout 2m -H 'Host: localhost' https://:8080
Running 1m test @ https://:8080
  2 threads and 10 connections
  Thread Stats   Avg      Stdev     Max   +/- Stdev
    Latency     5.36ms   44.30ms 766.89ms   98.94%
    Req/Sec     9.50k     4.45k   15.37k    78.52%
  1121692 requests in 1.00m, 220.06MB read
  Socket errors: connect 0, read 1, write 0, timeout 0
  Non-2xx or 3xx responses: 1
Requests/sec:  18667.87
Transfer/sec:      3.66MB

好的,我们还没有达到最大吞吐量,但我们已经消除了应用程序之外的系统瓶颈。瓶颈现在是应用程序

创建使比较有效的环境

通过限制应用程序,我们不是在绝对最大的可能吞吐量下运行,*但是*我们创建了一个允许框架之间进行比较的环境。

在受限的应用程序环境中,我们不会出现一个或多个框架维持吞吐量水平达到系统极限的情况。

如果任何应用程序达到系统极限,结果将无效。

所有网络流量并非都相等! 不高兴

进一步调查显示,Quarkus 与数据库之间的通信没有启用 TLS,因此数据库网络流量是未加密的。让我们修复这个问题;

quarkus.datasource.reactive.url=${DB_URL:mysql://:3306/baeldung?useSSL=false&tlsVersion=TLSv1.2}
quarkus.datasource.reactive.max-size=95
quarkus.datasource.reactive.mysql.ssl-mode=required
#"don't do this in prod, don't do this @ home, don't do this !"
#required for this test as mysql cert is self-signed
quarkus.datasource.reactive.trust-all=true

然后重新运行

$ wrk -t2 -c10 -d1m -s ./post_zipcode.lua --timeout 2m -H 'Host: localhost' https://:8080
Running 1m test @ https://:8080
  2 threads and 10 connections
  Thread Stats   Avg      Stdev     Max   +/- Stdev
    Latency     2.44ms   12.94ms 354.67ms   98.17%
    Req/Sec     7.55k     3.55k   11.94k    77.93%
  898541 requests in 1.00m, 176.26MB read
  Socket errors: connect 0, read 2, write 0, timeout 0
  Non-2xx or 3xx responses: 2
Requests/sec:  14955.61
Transfer/sec:      2.93MB

这为我们提供了最终的可比较吞吐量结果 14,955.61 req/sec

为了进行比较,我们需要确保每个框架执行相同的工作。

apples to oranges

结果 高兴

Quarkus 真的比框架 X/Y/Z 慢 600 倍吗?当然不是!

在我的机器上;

  1. 初步结果是 1.75 req/sec

  2. 修复应用程序后,该结果提高到 390.21 req/sec

  3. 修复一些系统瓶颈后,我们获得了 25,599.85 req/sec

  4. 限制应用程序,以便能与其它框架进行更公平的比较,结果为 18,667.87 req/sec

  5. 最后,启用到数据库的 TLS 加密,最终结果为 14,955.61 req/sec

results
运行 5 为我们提供了比较基线,即 14,955.61 req/sec

Quarkus 与框架 X/Y/Z 相比如何?

嗯……这是一个留给读者的练习 ;-)

总结

这些结果是否表明 Quarkus 很快?嗯,有点,它们暗示了这一点,但仍然存在需要解决的方法论问题。

然而,在面对基准测试结果时,尤其是那些似乎不合理的测试结果时,您可以采取一些步骤来验证结果;

  • 修复应用程序:是否存在错误?测试是否按预期运行?如果存在错误,请解决它们。

  • 确保应用程序是瓶颈:测试的限制因素是什么?测试是受 CPU、网络 I/O、磁盘 I/O 限制吗?

  • 当您看到一个“好”结果时,不要停止评估测试。为了进行比较,您需要确保每个框架都是性能的限制因素,而不是系统。

  • 选择如何限制应用程序:无论是通过扩展系统还是缩小应用程序。

  • 验证所有框架是否执行相同的工作。为了进行比较,框架是否执行相同的工作?

  • 确保所有框架是否提供相同级别的安全性。语义是否相同?例如,相同的 TLS 编码?相同的数据库事务隔离级别?

被测系统包括系统本身。不要自动假设您的应用程序是瓶颈。

方法论说明

这个基准测试告诉我们关于 Quarkus 在负载下的行为的一切吗?不完全是!它给了我们一个数据点。

为了有意义地理解负载下的行为,需要解决以下方法论问题;

  • 负载生成、数据库和应用程序都在同一台机器上运行。当前的测试没有对网络堆栈进行任何压力测试,并且由于服务共址存在副作用。应用程序拓扑需要能够代表生产环境。

  • 此测试不测量应用程序从用户角度的响应能力。需要像 Hyperfoil 这样不出现 协调遗漏 的工具来准确测量服务响应时间,包括系统等待时间。吞吐量 != 响应时间,而响应时间对用户才重要!

  • 对数据库的读写混合在测试期间会发生变化。最初负载非常偏重写入,随着时间的推移,数据库负载主要偏重读取。在测试期间应保持更一致的读写模式。

  • 应用程序没有得到正确“预热”的时间,因此结果是 Java 代码在解释模式和编译模式下运行的混合。

  • 由于上述问题,无法从此测试中推断出框架在真实生产流量下的行为。

  • 与任何基准测试一样,最好是测试您生产流量的模拟