使用异步servlet提升性能

来源:互联网 发布:淘宝pc端是什么意思啊 编辑:程序博客网 时间:2024/05/18 13:07

本文发布之后, 收到了很多的反馈。基于这些反馈,我们更新了文中的示例,使读者更容易理解和掌握, 如果您发现错误和遗漏,希望能给我们提交反馈,帮助我们改进。

本文针对当今 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日

6 3
原创粉丝点击