为了说明为什么写非阻塞、响应式HTTP服务器是有价值的,并且有回报,我们将为每个实现运行一系列基准测试。有趣的是,我们选择的基准工具wrk也是非阻塞的;否则,它无法模拟相当于数万个并发连接的负载。另一个有趣的选择是Gatling,它是构建在Akka工具包之上的。传统的基于线程的加载工具(如JMeter和ab)无法模拟这样的负载,从而成为瓶颈。
每个基于jvm的实现都是针对10,000、20,000和50,000个并发的HTTP客户机,也即TCP / IP连接。我们感兴趣的是每秒(吞吐量)请求的数量,以及中值和第99%响应时间。提醒一下:中值意味着50%的请求是快速的,而99%则告诉我们只有1%的请求比给定的时间慢。
PS:Benchmark Environment
所有的基准测试都是在家用笔记本电脑上运行,运行Linux 3.13.0- 62的通用内核,带有Intel i7 CPU 2.4 GHz,8gb内存,以及固态硬盘驱动器。客户端机器运行官方wrk,Gatling和JMeter工具通过一个千兆以太网路由器连接到服务器机器上。客户机和服务器之间平均的ping是289μs(偏差42μs,最低160μs)。每一个基准测试都运行至少一分钟,在JDK 1.8.0_66上进行了30秒的预热。RxJava 1.0.14,RxNetty 0.5.1,Netty 4.0.32 final。利用htop对系统负载进行了测量
使用以下命令执行基准测试(使用不同的- c参数表示并发客户机的数量):
wrk -t6 -c10000 -d60s --timeout 10s --latency http://server:8080
Plain server returning 200 OKs
第一个基准比较了当不同的实现它们仅仅返回200 OK且不执行后端任务时候,各种实现是如何实现的。这是一个有点不切实际的基准,但是它会给我们一个关于服务器和以太网上限的概念。在接下来的测试中,我们将在每个服务器中添加一些任意的休眠。
下面的图表描述了每个实现每秒处理的请求数(注意对数尺度):
请记住,此基准只是在真实场景中涉及到服务器端工作的一个热身测试罢了。但我们已经看到了一些有趣的趋势:
使用原始TCP / IP的Netty和基于RxNetty的实现的吞吐量最大,几乎达到每秒200,000个请求。
不出意外的是,单线程实现要慢得多,能够处理大约每秒6000个请求,而不管并发程度如何。
然而,当只有一个客户机时,SingleThread是最快的实现。线程池、事件驱动(Rx)Netty的开销以及几乎所有其他实现都是可见的。当客户数量增长时,这种优势会迅速减少。此外,服务器的吞吐量高度依赖于客户机的性能。
令人惊讶的是,ThreadPool执行得非常好,但它在高负荷下变得不稳定(wrk报告中有很多错误),并且在面对50,000个并发客户机时完全失败(达到10秒超时)。
ThreadPerConnection的性能也很好,但超过100 - 200个线程德华,服务器会迅速降低吞吐量。还有50000个线程在的话,给JVM上施加了很大的压力,特别是一些额外的GB堆栈空间是很麻烦的。
我们不会花太多时间分析这个不实际的基准测试。毕竟我们的服务器很少会立即返回响应。相反,我们希望模拟在每个请求上发生的一些耗时的工作。
Simulating server-side work
为了模拟服务器端的某些工作,我们只需在请求和响应之间注入sleep()调用。这是公平的:通常服务器不会执行任何cpu密集型的工作来满足请求。传统的服务器阻塞外部资源,并在一个线程上消费它们。另一方面,响应式服务器只需要等待外部信号(类似事件或包含响应的消息),同时释放底层资源。
出于这个原因,对于阻塞式实现,我们只添加了sleep(),而对于非阻塞的服务器,我们将使用Observable.delay()和类似的模拟非阻塞的、缓慢响应的一些外部服务,如下例所示:
public static final Observable<String> RESPONSE = Observable.just(
"HTTP/1.1 200 OK\r\n" +
"Content-length: 2\r\n" +
"\r\n" +
"OK")
.delay(100, MILLISECONDS);
在阻塞实现中使用非阻塞延迟是没有意义的,因为它们仍然需要等待响应,即使底层实现是非阻塞的。也就是说,我们为每个请求注入了100毫秒的延迟,这样每一次与服务器的交互都至少需要1/10秒。基准测试现在变得更加现实和有趣。每秒钟与客户端连接的请求数如下图所示:
结果更接近真实生活中负荷的预期。位于顶部的两个基于Netty的实现(HttpTcpNettyServer和HttpTcpRxNettyServer)是最快的,每秒可以达到90000个请求(RPS)。事实上,在大约10,000个并发客户机之前,服务器是线性扩展的。这很容易证明:一个客户机生成大约10个RPS(每个请求大约需要100毫秒,所以10个请求将在1秒内满足)。两个客户端生成20个RPS,5个客户端多达50个RPS,等等。在大约10,000个并发连接中,我们应该期望100,000个RPS,而我们接近这个理论限制(90000个RPS)。
在底部,我们看到了SingleThread和ThreadPool服务器。他们的表现很糟糕,这并不令人意外。SingleThread只有一个线程处理请求,每个请求至少要花费100毫秒,显然不能处理超过10个RPS。ThreadPool要好一点,有100个线程,每个线程处理10个RPS,总计1000个RPS。与响应式Netty和RxJava实现相比,这些结果已经很糟糕了。而且,单线程实现几乎拒绝了高负载下的所有请求。在大约5万个并发连接的情况下,它接受了少量的请求,几乎从未遇到wrk强制执行的10秒超时(即被拒绝了)。
您可能会问,为什么要将ThredPool限制为100个线程呢?这个数字类似于流行的HTTP servlet容器的默认值,但是我们可以指定更多。因为所有的连接都是持久的,并且在连接的整个过程中,它会从池中取出并保留,而在使用完成后会归还到线程池中,所以您可以将ThreadPerConnection 认为是一个线程池,其线程数量是无限的,并且线程数量是无限的。令人惊讶的是,即使JVM必须管理50,000个并发线程(每个线程代表一个连接),这样的实现也运行良好。实际上,ThreadPerConnection并不比RxNettyHttpServer差很多。结果显示,吞吐量是以RPS度量的是不充分的,我们还必须查看每个请求的响应时间。这取决于您的需求,但通常您需要充分利用服务器和低响应时间以带来高吞吐量,那样感知的性能才是最好的。
平均响应时间很少是一个好的指标。一方面,平均值隐藏了异常值(只有少数请求才是那种不可接受的缓慢),另一方面,标准的响应时间(大多数客户机所观察到的)比平均值要小,这是由于异常值。事实证明,百分位数更具有指示性,有效地描述了特定值的分布。下图显示了每个实现与并发连接数(或客户端)之间的响应时间的99%百分值。Y轴上的值告诉我们,99%的请求都比给定值快。显然,我们希望这些数字尽可能地低(但它们不能比模拟的延迟时间100毫秒还要短),在增加负荷时尽量少增长,正如下图所示:
ThreadPerConnection的实现非常突出。在1000并发时候,所有的实现基本都是并排着。但从这个点之后,ThreadPerConnection的响应速度非常慢,比竞争对手慢了好几倍。这其中有几个主要原因:第一,在数千个线程之间切换过多的上下文,第二,更频繁的垃圾收集周期。基本上JVM花费了大量的时间管理,并没有太多的时间用于实际工作。成千上万的并发连接处于闲置状态,等待轮到它们工作。
您可能会感到奇怪,为什么ThreadPool实现具有如此出色的响应时间呢?它优于其他所有实现,即使在高负载下仍然保持稳定。让我们快速回顾一下ThreadPool的实现的,它看起来像下面这样:
BlockingQueue<Runnable> workQueue = new ArrayBlockingQueue<>(1000);
executor = new ThreadPoolExecutor(100, 100, 0L, MILLISECONDS, workQueue,
(r, ex) -> {
((ClientConnection) r).serviceUnavailable();
});
我们不使用Executors构建者,而是直接构建ThreadPoolExecutor,控制workQueue和RejectedExecutionHandler。后者是在前者耗尽空间后执行的。基本上,我们防止服务器过载,确保不能立即服务的请求立即被拒绝。其他的实现没有类似的安全特性,通常称为fail-fast(快速失败)。我们将在第291页的“Managing Failures with Hystrix”中介绍;下面我们看看ThreadPool和其他实现的错误率,如下所示:
wrk负载测试工具所报告的错误对于所有的实现都是不存在的,除了SingleThread和ThreadPool。这是一个有趣的折衷:ThreadPool总是尽可能快地响应,比竞争对手快得多。然而,当它在高负载时,它也非常急切的立即拒绝请求。当然,您也可以使用Netty / RxJava实现一个类似的机制。
总结,使用线程池和独立的线程不能保持与响应式实现同等的吞吐量和合适的响应时间。