本文发布之后, 收到了很多的反馈。基于这些反馈,我们更新了文中的示例,使读者更容易理解和掌握, 如果您发现错误和遗漏,希望能给我们提交反馈,帮助我们改进。
本文针对当今 webapp 中一种常碰到的问题,介绍相应的性能优化解决方案。如今的WEB程序不再只是被动地等待浏览器的请求, 他们之间也会互相进行通信。 典型的场景包括 在线聊天, 实时拍卖等 —— 后台程序大部分时间与浏览器的连接处于空闲状态, 并等待某个事件被触发。
这些应用引发了一类新的问题,特别是在负载较高的情况下。引发的状况包括线程饥饿, 影响用户体验、请求超时等问题。
基于这类应用在高负载下的实践, 我会介绍一种简单的解决方案。在 Servlet 3.0成为主流以后, 这是一种真正简单、标准化并且十分优雅的解决方案。
在演示具体的解决方案前,我们先了解到底发生了什么问题。请看代码:
@WebServlet(urlPatterns = "/BlockingServlet")
public class BlockingServlet extends HttpServlet {
protected void doGet(HttpServletRequest request, HttpServletResponse response) {
waitForData();
writeResponse(response, "OK");
}
public static void waitForData() {
try {
Thread.sleep(ThreadLocalRandom.current().nextInt(2000));
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
此 servlet 所代表的情景如下:
- 每2秒会有某些事件发生, 例如, 报价信息更新, 聊天信息抵达等。
- 终端用户请求对某些特定事件进行监听。
- 线程暂时被阻塞, 直到收到下一次事件。
- 接收到事件时, 处理响应信息并发送给客户端
下面解释一下这个等待场景。 我们的系统, 每2秒触发一次外部事件。当收到用户请求时, 需要等待一段时间,大约是 0 到 2000 毫秒之间, 直到下一次事件发生. 为了演示的需要, 此处通过调用 Thread.sleep()
来模拟随机的等待时间。平均每个请求等待1秒左右。
现在,你可能会觉得这是一个十分普通的servlet。在多数情况下,确实是这样 —— 代码并没有错误, 但如果系统面临大量的并发负载时就会力不从心了。
为了模拟这种负载,我用 JMeter 创建了一个简单的测试, 启动 2000 个线程, 每个线程执行 10 次请求来进行系统压力测试。
请求的URI为 /BlockedServlet
, 部署在 Tomcat 8.0.30 默认配置下, 测试结果如下:
- 平均响应时间: 9,492 ms
- 最小响应时间: 205 ms
- 最大响应时间: 11,368 ms
- 吞吐量: 195 个请求/秒
Tomcat 默认配置的是 200个 worker 线程, 再加上模拟的工作量(平均线程休眠 1000 ms ), 很好地解释了吞吐量数据 - 200 个线程每秒应该能够完成200次执行周期, 平均1秒钟左右. 但有一些上下文切换的成本, 所以吞吐量为 195个请求/秒, 很符合我们的预期。
对 99.9% 的应用来说, 这个吞吐量数据看上去也很正常。但看看最大响应时间, 以及平均响应时间, 就会发现问题实在是太严重了。 在最坏情况下客户端居然需要11秒才能得到响应, 而预期是2秒,这对用户来说一点都不友好。
下面我们看另一种实现, 使用了 Servlet 3.0 的异步特性:
@WebServlet(asyncSupported = true, value = "/AsyncServlet")
public class AsyncServlet extends HttpServlet {
protected void doGet(HttpServletRequest request, HttpServletResponse response) throws ServletException, IOException {
addToWaitingList(request.startAsync());
}
private static ScheduledExecutorService executorService = Executors.newScheduledThreadPool(1);
static {
executorService.scheduleAtFixedRate(AsyncServlet::newEvent, 0, 2, TimeUnit.SECONDS);
}
private static void newEvent() {
ArrayList clients = new ArrayList<>(queue.size());
queue.drainTo(clients);
clients.parallelStream().forEach((AsyncContext ac) -> {
ServletUtil.writeResponse(ac.getResponse(), "OK");
ac.complete();
});
}
private static final BlockingQueue queue = new ArrayBlockingQueue<>(20000);
public static void addToWaitingList(AsyncContext c) {
queue.add(c);
}
}
上面的代码稍微有一点复杂, 所以我先透露一下此方案的性能表现: 响应延迟(latency)只有原来的1/5; 而吞吐量(throughput-wise)也提升了 5 倍。 看到这样的结果, 你肯定想深入了解第二种方案了吧。
servlet 的 doGet
方法看起来很简单。有两个地方值得提一下:
一是声明 servlet,以及支持异步方法调用:
@WebServlet(asyncSupported = true, value = "/AsyncServlet")
二是方法 addToWaitingList
中的细节:
public static void addToWaitingList(AsyncContext c) {
queue.add(c);
}
在其中, 整个请求的处理只有一行代码,将 AsyncContext 实例加入队列中。 AsyncContext 里含有容器提供的 request 和 response 对象, 我们可以通过他们来响应用户请求. 因此传入的请求在等待通知 —— 可能是监视的拍卖组中的报价更新事件, 或者是下一条群聊消息。这里需要注意的是, 将 AsyncContext 加入队列以后, servlet 容器的线程就完成了 ·doGet· 操作, 然后释放出来, 可以去接受另一个新请求了。
现在, 系统通知每2秒到达一次, 当然这部分我们通过 static
块中的调度事件实现了, 每2秒会执行一次 newEvent 方法. 当通知到来时, 队列中所有在等待的请求都由同一个 worker 线程负责处理并发送响应消息。 这次的代码, 没有阻塞几百个线程来等待外部事件通知, 而是用更简洁明了的方法来实现了, 把感兴趣的请求放在一个group中, 由单个线程进行批量处理。
结果不用说, 同样的配置,同样的测试, Tomcat 8.0.30 服务器跑出了以下结果:
- 平均响应时间: 1,875 ms
- 最小响应时间: 356 ms
- 最大响应时间: 2,326 ms
- 吞吐量: 939 个请求/秒
虽然示例是手工构造的, 但类似的性能提升在现实世界中却是很普遍的。
现在, 请不要急着去将所有的 servlet 重构为异步servlet。 因为这种方案, 只在满足某些特征的任务才会得到大量性能提升, 比如聊天室, 或者拍卖价格提醒之类的。 而对于需要请求底层数据库之类的操作, 很可能没有性能提升。 所以,就像以前一样, 我必须重申, 我最喜欢的性能优化忠告 —— 请权衡考虑整件事情,不要想当然。
但如果确实符合此方案适应的情景, 那我就恭喜你啦! 不仅能明显改进吞吐量和延迟, 还能在大量的并发压力下表现出色, 避免可能的线程饥饿问题。
另一个重要信息是 —— 异步请求的处理终于标准化了。兼容 Servlet 3.0 的应用服务器 —— 比如 Tomcat 7+, JBoss 6 或者 Jetty 8+ —— 都支持这种方案. 再也不用陷进那些耦合具体平台的解决方案里, 例如 Weblogic FutureResponseServlet
。
原文链接: https://plumbr.eu/blog/java/how-to-use-asynchronous-servlets-to-improve-performance
翻译人员: 铁锚 http://blog.csdn.net/renfufei
翻译时间: 2016年12月08日