Quarkus 响应式 CRUD 性能:案例研究
我们被要求对 Quarkus 在响应式 CRUD 工作负载下的相对性能发表评论。这是一个关于性能测试设计、所需考虑因素以及需要克服的障碍的良好案例研究。我们如何设计方法论来确保我们进行的测试确实是我们期望的测试?
测试架构
与我们分享的测试是通过 REST 调用更新数据库的简单负载测试;

-
负载生成器向 REST API 发送持续的 HTTP POST 请求流。在本例中是 wrk
-
Quarkus 应用程序通过 RESTEasy Reactive 处理请求
-
Quarkus 应用程序通过 Hibernate Reactive 查询和更新 MySQL 数据库实例
要了解有关使用 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。
为了进行比较,我们需要确保每个框架执行相同的工作。 |

总结
这些结果是否表明 Quarkus 很快?嗯,有点,它们暗示了这一点,但仍然存在需要解决的方法论问题。
然而,在面对基准测试结果时,尤其是那些似乎不合理的测试结果时,您可以采取一些步骤来验证结果;
-
修复应用程序:是否存在错误?测试是否按预期运行?如果存在错误,请解决它们。
-
确保应用程序是瓶颈:测试的限制因素是什么?测试是受 CPU、网络 I/O、磁盘 I/O 限制吗?
-
当您看到一个“好”结果时,不要停止评估测试。为了进行比较,您需要确保每个框架都是性能的限制因素,而不是系统。
-
选择如何限制应用程序:无论是通过扩展系统还是缩小应用程序。
-
验证所有框架是否执行相同的工作。为了进行比较,框架是否执行相同的工作?
-
确保所有框架是否提供相同级别的安全性。语义是否相同?例如,相同的 TLS 编码?相同的数据库事务隔离级别?
被测系统包括系统本身。不要自动假设您的应用程序是瓶颈。 |
方法论说明
这个基准测试告诉我们关于 Quarkus 在负载下的行为的一切吗?不完全是!它给了我们一个数据点。 为了有意义地理解负载下的行为,需要解决以下方法论问题;
|