从 Servlet 3.0开始, 异步Servlet成为了标准, 在此之前类似jetty这样的web服务器都已经有了自己的实现. 从2011年3月份 Servlet 3.0 的最终规范出来到现在4年已经过去了, 似乎在实际项目上看到的用异步方式处理HTTP请求的例子并不多. 我想不是因为异步Servlet太复杂, 也不是因为异步Servlet的实现不稳定, 而是多数情况下人们找不到应用他的场景. 我在2年前的一个即时通信工具的web版上采用了异步Servlet, 但是由于没有做完整的性能测试, 也不知道那个选择是否正确.
这几天做了一些小测试, 想验证一下什么样的场景下适合用异步Servlet.
代码地址:
https://github.com/zjumty/spring-async-perftest
首先讲一下异步Servlet的基本用法:
从Servlet 3.0开始, HttpServletRequest多了一个startAsync方法, 这个方法返回一个AsyncContext对象. 简单来讲异步Servlet就是用这个东西.
想要你的Servlet支持异步处理,你还需要在web.xml的servlet上加上如下属性:
Xml代码
<async-supported>true
</async-supported>
在你的Servlet的doXXX方法中, 调用request.startAsync()方法, 得到一个AsyncContext对象, 你然后就可以让你的doXXX方法结束了, 这时这个HTTP请求的处理线程就已经完成任务了, 可以继续为后续的请求提供服务器. 再此之前你需要把AsyncContext传递给其他的工作线程(多采用线程池或或异步回调方法), 在那个线程的处理完成后, 把数据通过AsyncContext里的Response对象发送给浏览器, 然后调用AsyncContext中的complete方法完成本次HTTP的流程.
在客户端浏览器来看, 仍然是一个Request, 一个Response, 中间没有什么中断. 在startAsync以后, 当前的请求就会pending在一个队列中而不用持续占用一个线程.
当时实际上, 异步Servlet有很多方法, 处理逻辑也可以很复杂.
在本文的代码中没有采用上面这种原始的方式,而是采用Spring MVC的异步功能, 实现起来更简单.
Java代码
@Controller @RequestMapping(
"/foo")
public class FooController {
@Autowired @Qualifier(
"workerPool")
private ExecutorService workerPool;
@RequestMapping(
"/async-100ms")
public @ResponseBody DeferredResult<FooBean> async100ms()
throws Exception { DeferredResult<FooBean> defer =
new DeferredResult<>(
120000); workerPool.submit(() -> {
try { Thread.sleep(
100); }
catch (InterruptedException e) { e.printStackTrace(); } defer.setResult(
new FooBean()); });
return defer; } }
同时@Conntroller, 同样是@RequestMapping, 不同的只有返回值是DeferredResult, 一切就这么简单.
后面Spring其实也是用异步Servlet的那些方法和对象实现的. 上面的代码中我们设置了timeout时间为2分钟. 默认是30秒, 如果处理时间一长, 客户端就收到503错误.
为了有对照这里还增加了一些同步方法.
Java代码
@RequestMapping(
"/sync-100ms")
public @ResponseBody FooBean sync100ms()
throws Exception { Future<FooBean> future = workerPool.submit(() -> { Thread.sleep(
100);
return new FooBean(); });
return future.get(); }
还有一个没有延迟的处理:
Java代码
@RequestMapping(
"/nodelay")
public @ResponseBody FooBean nodelay()
throws Exception {
return new FooBean(); }
考虑到jetty的工作线程多少也会影响到结果. 所以我分别用16线程和200线程两个环境做了测试.
因为我是用的spring-boot的内嵌jetty模式, 改变工作线程数稍微有点小复杂.
Java代码
@Configuration public class JettyConfiguration {
@Value(
"${jetty.threadPool.minSize}")
private int minSize;
@Value(
"${jetty.threadPool.maxSize}")
private int maxSize;
@Bean public JettyEmbeddedServletContainerFactory jettyEmbeddedServletContainerFactory() { JettyEmbeddedServletContainerFactory factory =
new JettyEmbeddedServletContainerFactory(); factory.addServerCustomizers(server -> { QueuedThreadPool threadPool = (QueuedThreadPool) server.getThreadPool(); threadPool.setMaxThreads(maxSize); threadPool.setMinThreads(minSize); threadPool.setName(
"jetty-"); });
return factory; } }
其实也没复杂到哪里去
. 我在application.yml文件里预置了16和200线程两种.通过启动参数可以选择:
Java代码
java -jar -Xmx2048m async-
0.0.
1.jar --server.port=
9000 --spring.profiles.active=dev
当然也可以通过-Djetty.threadPool.maxSize=n来任意指定.
测试环境: 这次小奢侈一把, 动用了公司的测试服务器:Intel(R) Xeon(R) CPU E5-2643 v2 @ 3.50GHz 6*4 (反正闲着也是闲着嘛
)
首先看看单场景异步模式和同步模式的区别:
引用
./wrk -c 100 -t 8 -d 60 --timeout=120 http://192.168.200.23:9000/foo/async-100ms
Running 1m test @ http://192.168.200.23:9000/foo/async-100ms
8 threads and 100 connections
Thread Stats Avg Stdev Max +/- Stdev
Latency 609.43ms 32.22ms 724.79ms 98.11%
Req/Sec 25.65 16.62 90.00 74.23%
9360 requests in 1.00m, 2.41MB read
Requests/sec: 155.83
Transfer/sec: 41.09KB
./wrk -c 100 -t 8 -d 60 --timeout=120 http://192.168.200.23:9000/foo/sync-100ms
Running 1m test @ http://192.168.200.23:9000/foo/sync-100ms
8 threads and 100 connections
Thread Stats Avg Stdev Max +/- Stdev
Latency 605.78ms 43.94ms 706.07ms 96.43%
Req/Sec 28.31 23.22 90.00 73.35%
9408 requests in 1.00m, 2.42MB read
Requests/sec: 156.64
Transfer/sec: 41.30KB
哦! wrk是啥? 看这里:
Java代码
https:
上面的结果可以看出, 基本没有区别.无论你用那种模式由于延迟的存在, CPU的利用率都不高. 所有即便同步模式下有频繁的线程切换, 只是CPU的利用率稍稍搞了一点. 吞吐量是一样的.
在来看看服务器线程数对性能的影响
Java代码
./wrk -c
5000 -t
16 -d
60 --timeout=
120 http:
200 Threads
Java代码
Running 1m test @ http:
16 threads and
5000 connections Thread Stats Avg Stdev Max +/- Stdev Latency
104.23ms
31.37ms
2.00s
93.20% Req/Sec
2.98k
276.67 6.04k
87.11%
2844140 requests in
1.00m,
732.35MB read Requests/sec:
47324.93 Transfer/sec:
12.19MB
16 Threads
Java代码
Running 1m test @ http:
16 threads and
5000 connections Thread Stats Avg Stdev Max +/- Stdev Latency
91.36ms
76.54ms
3.97s
98.46% Req/Sec
3.49k
473.18 11.44k
86.49%
3341535 requests in
1.00m,
860.42MB read Requests/sec:
55628.92 Transfer/sec:
14.32MB
线程少时吞吐量更好. 意料之中, 频繁线程切换回消耗一定的资源.
再来看看异步模式下的16线程和200线程
Java代码
./wrk -c
5000 -t
16 -d
60 --timeout=
120 http:
200 Threads
Java代码
Running 1m test @ http:
16 threads and
500 connections Thread Stats Avg Stdev Max +/- Stdev Latency
3.05s
502.13ms
4.64s
93.47% Req/Sec
20.05 21.61 121.00 87.01%
9373 requests in
1.00m,
2.41MB read Requests/sec:
155.99 Transfer/sec:
41.13KB
16 Threads
Java代码
Running 1m test @ http:
16 threads and
500 connections Thread Stats Avg Stdev Max +/- Stdev Latency
3.05s
448.49ms
3.29s
94.05% Req/Sec
25.65 31.66 161.00 84.48%
9408 requests in
1.00m,
2.42MB read Requests/sec:
156.62 Transfer/sec:
41.30KB
基本没有区别.原因跟第一个场景一样.
混合场景: 延迟和非延迟 1:9的请求, 也就是说有少量的高延迟访问, 大量的低延迟访问
高延迟采用异步模式:
Java代码
./wrk -c
900 -t
8 -d
60 --timeout=
120 http: Running 1m test @ http:
8 threads and
900 connections Thread Stats Avg Stdev Max +/- Stdev Latency
16.13ms
24.54ms
863.32ms
97.81% Req/Sec
8.36k
689.01 11.18k
87.42%
3993216 requests in
1.00m,
1.00GB read Requests/sec:
66512.06 Transfer/sec:
17.13MB ./wrk -c
100 -t
8 -d
60 --timeout=
120 http: Running 1m test @ http:
8 threads and
100 connections Thread Stats Avg Stdev Max +/- Stdev Latency
610.82ms
35.10ms
816.95ms
97.39% Req/Sec
24.43 23.64 111.00 88.33%
9392 requests in
1.00m,
2.42MB read Requests/sec:
156.39 Transfer/sec:
41.23KB
高延迟采用同步模式
Java代码
./wrk -c
900 -t
8 -d
60 --timeout=
120 http: Running 1m test @ http:
8 threads and
900 connections Thread Stats Avg Stdev Max +/- Stdev Latency
213.21ms
214.30ms
863.02ms
75.67% Req/Sec
640.82 1.75k
11.60k
94.33%
306108 requests in
1.00m,
78.82MB read Requests/sec:
5098.09 Transfer/sec:
1.31MB ./wrk -c
100 -t
8 -d
60 --timeout=
120 http: Running 1m test @ http:
8 threads and
100 connections Thread Stats Avg Stdev Max +/- Stdev Latency
610.71ms
30.23ms
831.14ms
98.95% Req/Sec
30.99 30.55 111.00 82.36%
9392 requests in
1.00m,
2.42MB read Requests/sec:
156.38 Transfer/sec:
41.23KB
Oh,Yeah! 终于看到区别了, 差了10倍多!
在同步模式下由于Http线程被高延迟处理霸占, 没有其他线程处理低延迟请求. 而异步模式下由于高延迟处理不霸占HTTP线程, 所以低延迟的请求基本上没有受到影响.
上面的测试是在服务器设置了16线程的情况下执行的. 如果高延迟处理占用了线程, 那我们把服务器线程设置为200再试试.
同步模式:
Java代码
./wrk -c
900 -t
8 -d
60 --timeout=
120 http: Running 1m test @ http:
8 threads and
900 connections Thread Stats Avg Stdev Max +/- Stdev Latency
25.36ms
99.18ms
3.33s
99.21% Req/Sec
5.90k
0.87k
8.96k
92.58%
2819464 requests in
1.00m,
725.99MB read Requests/sec:
46961.89 Transfer/sec:
12.09MB ./wrk -c
100 -t
8 -d
60 --timeout=
120 http: Running 1m test @ http:
8 threads and
100 connections Thread Stats Avg Stdev Max +/- Stdev Latency
610.49ms
37.34ms
689.02ms
97.20% Req/Sec
25.67 21.57 101.00 72.72%
9359 requests in
1.00m,
2.41MB read Requests/sec:
155.80 Transfer/sec:
41.08KB
可以看到同步模式比之前的结果好多了, 也就是高延迟处理不会对低延迟有很大影响, 但是任然比异步模式差.
再看看200线程的异步模式的结果:
Java代码
./wrk -c
900 -t
8 -d
60 --timeout=
120 http: Running 1m test @ http:
8 threads and
900 connections Thread Stats Avg Stdev Max +/- Stdev Latency
20.05ms
18.33ms
890.99ms
92.99% Req/Sec
6.01k
561.72 13.69k
90.15%
2874649 requests in
1.00m,
740.20MB read Requests/sec:
47831.09 Transfer/sec:
12.32MB ./wrk -c
100 -t
8 -d
60 --timeout=
120 http: Running 1m test @ http:
8 threads and
100 connections Thread Stats Avg Stdev Max +/- Stdev Latency
613.31ms
27.23ms
721.98ms
96.03% Req/Sec
24.51 18.56 90.00 70.81%
9349 requests in
1.00m,
2.41MB read Requests/sec:
155.68 Transfer/sec:
41.05KB
可以看到在200线程的异步模式只比同步模式好一点点.
所以综合上面的测试结果, 可以得到如下结论: 异步Servlet模式在单场景和同步模式下几乎没有差别. 由于开发上更复杂所以没什么可取之处. 建议直接用同步模式. 在混合场景中如果是大量低延迟处理+少量高延迟处理的情况下, 由于异步模式不占用服务器线程, 可以有效减少服务器上的线程切换的资源消耗, 并且充分利用系统资源, 是有可取之处的.
上面测试的最佳结果的配置:
jetty服务器线程池16线程, 低延迟处理立刻返回, 高延迟处理延迟100毫秒. 低延迟和高延迟请求比9:1.
最后想说的是, 如果想让异步处理在系统的整体性能方面起作用, 最好把整个系统都设计成异步的, 也就是Reactive模式的. 否则有一个同步的地方卡在那里, 整体上就不会看到异步带来哦好处.