BT

如何利用碎片时间提升技术认知与能力? 点击获取答案

使用异步Servlet改进应用性能

| 作者 张龙 关注 12 他的粉丝 发布于 2013年11月17日. 估计阅读时间: 15 分钟 | GMTC大前端的下一站,PWA、Web框架、Node等最新最热的大前端话题邀你一起共同探讨。

Nikita Salnikov Tarnovskiplumbr的高级开发者,也是一位应用性能调优的专家,他拥有多年的性能调优经验。近日,Tarnovski撰文谈到了如何通过异步Servlet来改进常见的Java Web应用的性能问题。

众所周知,Servlet 3.0标准已经发布了很长一段时间,相较于之前的2.5版的标准,新标准增加了很多特性,比如说以注解形式配置Servlet、web.xml片段、异步处理支持、文件上传支持等。虽然说现在的很多Java Web项目并不会直接使用Servlet进行开发,而是通过如Spring MVC、Struts2等框架来实现,不过这些Java Web框架本质上还是基于传统的JSP与Servlet进行设计的,因此Servlet依然是最基础、最重要的标准和组件。在Servlet 3.0标准新增的诸多特性中,异步处理支持是令开发者最为关注的一个特性,本文就将详细对比传统的Servlet与异步Servlet在开发上、使用上、以及最终实现上的差别,分析异步Servlet为何会提升Java Web应用的性能。

本文主要介绍的是能够解决现代Web应用常见性能问题的一种性能优化技术。当今的应用已经不仅仅是被动地等待浏览器来发起请求,而是由应用自身发起通信。典型的示例有聊天应用、拍卖系统等等,实际情况是大多数时间与浏览器的连接都是空闲的,等待着某个事件来触发。

这种类型的应用自身存在着一个问题,特别是在高负载的情况下问题会变得更为严重。典型的症状有线程饥饿、影响用户交互等等。根据近一段时间的经验,我认为可以通过一种相对比较简单的方案来解决这个问题。在Servlet API 3.0实现成为主流后,解决方案就变得更加简单、标准化且优雅了。

在开始介绍解决方案前,我们应该更深入地理解问题的细节。还有什么比看源代码更直接的呢,下面就来看看下面这段代码:

@WebServlet(urlPatterns = "/BlockingServlet")
public class BlockingServlet extends HttpServlet {
  private static final long serialVersionUID = 1L;

  protected void doGet(HttpServletRequest request,
                       HttpServletResponse response) throws ServletException, IOException {
    try {
      long start = System.currentTimeMillis();
      Thread.sleep(2000);
      String name = Thread.currentThread().getName();
      long duration = System.currentTimeMillis() - start;
      response.getWriter().printf("Thread %s completed the task in %d ms.", name, duration);
    } catch (Exception e) {
      throw new RuntimeException(e.getMessage(), e);
    }
  }

上面这个Servlet主要完成以下事情:

  1. 请求到达,表示开始监控某些事件。
  2. 线程被阻塞,直到事件发生为止。
  3. 在接收到事件后,编辑响应然后将其发回给客户端。

为了简化,代码中将等待部分替换为一个Thread.sleep()调用。

现在,你可能会觉得这就是一个挺不错的Servlet。在很多情况下,你的理解都是正确的,上述代码并没有什么问题,不过当应用的负载变大后就不是这么回事了。

为了模拟负载,我通过JMeter创建了一个简单的测试,我会启动2,000个线程,每个线程运行10次,每次都会向/BlockedServlet这个地址发出请求。将这个Servlet部署在Tomcat 7.0.42中然后运行测试,得到如下结果:

  • 平均响应时间:19,324ms
  • 最快响应时间:2,000ms
  • 最慢响应时间:21,869ms
  • 吞吐量:97个请求/秒

默认的Tomcat配置有200个工作线程,此外再加上模拟的工作由2,000ms的睡眠时间来表示,这就能比较好地解释最快与最慢的响应时间了,每个线程都会睡眠2秒钟。再加上上下文切换的代价,因此97个请求/秒的吞吐量基本上是符合我们的预期的。

对于绝大多数的应用来说,这个吞吐量还算是可以接受的。重点来看看最慢的响应时间与平均响应时间,问题就变得有些严重了。经过20秒而不是期待的2秒才能得到响应显然会让用户感到非常不爽。

下面我们来看看另外一种实现,利用Servlet API 3.0的异步支持:

@WebServlet(asyncSupported = true, value = "/AsyncServlet")
public class AsyncServlet extends HttpServlet {
  private static final long serialVersionUID = 1L;

  protected void doGet(HttpServletRequest request, HttpServletResponse response) throws ServletException, IOException {
    Work.add(request.startAsync());
  }
}
public class Work implements ServletContextListener {
  private static final BlockingQueue queue = new LinkedBlockingQueue();

  private volatile Thread thread;

  public static void add(AsyncContext c) {
    queue.add(c);
  }

  @Override
  public void contextInitialized(ServletContextEvent servletContextEvent) {
    thread = new Thread(new Runnable() {
      @Override
      public void run() {
        while (true) {
          try {
            Thread.sleep(2000);
            AsyncContext context;
            while ((context = queue.poll()) != null) {
              try {
                ServletResponse response = context.getResponse();
                response.setContentType("text/plain");
                PrintWriter out = response.getWriter();
                out.printf("Thread %s completed the task", Thread.currentThread().getName());
                out.flush();
              } catch (Exception e) {
                throw new RuntimeException(e.getMessage(), e);
              } finally {
                context.complete();
              }
            }
          } catch (InterruptedException e) {
            return;
          }
        }
      }
    });
    thread.start();
  }

  @Override
  public void contextDestroyed(ServletContextEvent servletContextEvent) {
    thread.interrupt();
  }
}

上面的代码看起来有点复杂,因此在开始分析这个解决方案的细节信息之前,我先来概述一下这个方案:速度上提升了75倍,吞吐量提升了20倍。看到这个结果,你肯定迫不及待地想知道这个示例是如何做到的吧。

这个Servlet本身是非常简单的。需要注意两点,首先是声明Servlet支持异步方法调用:

@WebServlet(asyncSupported = true, value = "/AsyncServlet")

其次,重要的部分实际上是隐藏在下面这行代码调用中的。

Work.add(request.startAsync());

整个请求处理都被委托给了Work类。请求上下文是通过AsyncContext实例来保存的,它持有容器提供的请求与响应对象。

现在来看看第2个,也是更加复杂的类,Work类实现了ServletContextListener接口。进来的请求会在该实现中排队等待通知,通知可能是上面提到的拍卖中的竞标价,或是所有请求都在等待的群组聊天中的下一条消息。

当通知到达时,我们这里依然是通过Thread.sleep()让线程睡眠2,000ms,队列中所有被阻塞的任务都是由一个工作线程来处理的,该线程负责编辑与发送响应。相对于阻塞成百上千个线程以等待外部通知,我们通过一种更加简单且干净的方式达成所愿,通过批处理在单独的线程中处理请求。

还是让结果来说话吧,测试配置与方才的示例一样,依然使用Tomcat 7.0.24的默认配置,测试结果如下所示:

  • 平均响应时间:265ms
  • 最快响应时间:6ms
  • 最慢响应时间:2,058ms
  • 吞吐量:1,965个请求/秒

虽然说这个示例很简单,不过对于实际项目来说通过这种方式依然能获得类似的结果。

在将所有的Servlet改写为异步Servlet前,请容许我多说几句。该解决方案非常适合于某些应用场景,比如说群组通知与拍卖价格通知等。不过,对于等待数据库查询完成的请求来说,这种方式就没有什么必要了。像往常一样,我必须得重申一下——请通过实验进行度量,而不是瞎猜。

对于那些不适合于这种解决方案的场景来说,我还是要说一下这种方式的好处。除了在吞吐量与延迟方面带来的显而易见的改进外,这种方式还可以在大负载的情况下优雅地避免可能出现的线程饥饿问题。

另一个重要的方面,这种异步处理请求的方式已经是标准化的了。它不依赖于你所使用的Servlet API 3.0,兼容于各种应用服务器,如Tomcat 7、JBoss 6或是Jetty 8等,在这些服务器上这种方式都可以正常使用。你不必再面对各种不同的Comet实现或是依赖于平台的解决方案了,比如说Weblogic FutureResponseServlet。

就如本文一开始所提的那样,现在的Java Web项目很少会直接使用Servlet API进行开发了,不过诸多的Web MVC框架都是基于Servlet与JSP标准实现的,那么在你的日常开发中,是否使用过出现多年的Servlet API 3.0,使用了它的哪些特性与API呢?

评价本文

专业度
风格

您好,朋友!

您需要 注册一个InfoQ账号 或者 才能进行评论。在您完成注册后还需要进行一些设置。

获得来自InfoQ的更多体验。

告诉我们您的想法

允许的HTML标签: a,b,br,blockquote,i,li,pre,u,ul,p

当有人回复此评论时请E-mail通知我

两段代码测试的根本就不是同一个逻辑! by 盗泉 -

第一段测试的是“在请求到来后等待2秒再响应”,第二段从结果看就不成立。“最快响应时间:6ms”,开玩笑!

Re: 两段代码测试的根本就不是同一个逻辑! by 龙 张

其实吧,我觉得最好还是先把程序读懂了。

Re: 两段代码测试的根本就不是同一个逻辑! by nie runshine

例子中两份代码确实不是一个逻辑...
一段过程确实需要2秒完成,无论什么样的异步也不可能缩短执行时间。虽然实际中没有用过,感觉上异步Servlet应用场景应该主要是面对长连接的情形,比如web聊天程序这样的,可以减轻不断轮询的开销...实际有用过的欢迎讨论。

文中异步例子的逻辑需要改为如下形式才能算是与同步例子的逻辑类似:

public void run()
{
while(true)
{
AsyncContext context;
while((context = queue.poll())!=null)
{
try
{
long start = System.currentTimeMillis();
Thread.sleep(2000);
String name = Thread.currentThread().getName();
long duration = System.currentTimeMillis()-start;
ServletResponse response = context.getResponse();
response.getWriter().printf("Thread %s completed the task in %d ms.",name,duration);
}
catch(Exception e)
{
throw new RuntimeException(e.getMessage(),e);
}
finally
{
context.complete();
}
}
}
}

注意sleep的位子。 这样再测试,异步servlet响应时间与同步基本一直,大约都是2秒。

Re: 两段代码测试的根本就不是同一个逻辑! by 龙 张

赞楼上,通过这个文章希望了解Servlet 3.0在项目中的使用情况,毕竟出来好多年了,原文中的逻辑确实存在点问题,第2个例子将逻辑放到ServletContextListener中了,导致执行时机与第一个示例不同。

Re: 两段代码测试的根本就不是同一个逻辑! by 盗泉 -

怎么没读懂,请指教?前面每个请求固定占用2秒,后面一段却不需要。这样说明第二个结果比第一个快。坑谁呢

Re: 两段代码测试的根本就不是同一个逻辑! by 李 俊

异步为什么比同步快?文章没有解释

我来解释一下 by 温 悦

我来给大家解释一下,是这样的:性能测试中显示的“平均”、“最快”、“最慢”响应时间,均是指servlet的请求处理线程的等待时间;在非异步的例子中,所有servlet的请求处理线程,都亲自等待这业务逻辑的完成,这样一来,由于“业务逻辑”至少会消耗2000ms,所以非异步的例子中,3项时间最少都在2000ms以上,也就是所有的servlet请求处理线程至少等待了2000ms; 而第二个例子,也就是异步例子,servlet请求线程所做的事情仅仅是“把工作放入queue中”而已,然后就返回了,返回后被容器回收去处理其它请求了;所以servlet请求线程所耗时间大大减少;但其实这时候工作还未完成,servlet线程仅仅是把工作放入了一个queue中而已;真正的工作(等待这2000ms)其实是由跟queue在一起的一个单独的线程完成的,由于queue中的工作全是非资源密集型,仅仅是等待而已(这跟我们很多io密集型应用场景很像,虽然仅仅是sleep模拟而已),所以queue里面的任务只需一个线程即可完成;其实更后面的细节这文章还没介绍完:queue中的任务一个个都完成了后怎么办?总要有线程负责把结果写回给客户端吧?可以想象,后续肯定是会有相应的一个或多个线程来负责回写这些response数据的,这些线程可能同样来自于servlet请求处理线程池;
总结起来,异步模式比同步模式确实省,省在服务端,即把“n个线程等待”变成了“1个线程等待”

第二段代码是不是写错了? by 于 经文

第一段代码速度慢,是由于大量请求而延迟两秒的响应造成线程耗尽。
而第二段代码是将大量请求编程单线程操作。如果耗时代码写在阻塞队列遍历的外面,那么逻辑和第一段代码完全不一样。只是初始化时候耗时两秒。
如果将耗时代码写在阻塞队列遍历的循环体里面,那将是一场灾难啊。得慢成啥样啊。

看了下评论,关于第二段代码的实现,贴一下作者的解释 by Ding Libo

Thank you for your kind words :) But I still believe that second implementation is absolutely correct. We try to simulate the situation, where some events arrive every 2 seconds, e.g. brokerage info. When it arrives, every waiting request is notified at once, without further delays.
I have modified the blog post to make this clearer. 2 seconds are not the request processing time. It is time between events that requests are waiting for. When event arrives, further processing is very fast.

Nikita Salnikov-Tarnovski Post authorNovember 26, 2014

Re: 两段代码测试的根本就不是同一个逻辑! by 谢 俊权

两个循环

这样比较不合理 by 郑 大侠

从线程模型来说,servlet的异步比同步不会快多少,很多情况反而会变慢,毕竟多了上下文切换。
这个比较确实不合理,文章中异步代码如果不是伪代码,不用压测多久会有会内存溢出,谈不上性能好坏。

第二段的例子是不太合适 by Wei Xiang

首先,Work里的队列在生产环境肯定不能设置成无限大,否则会导致OOM,而如果限制了队列大小,那么实际的性能远没有那么高,队列满了以后,实际上瓶颈就转移到堆队列处理的速度上了。
在文中给出的例子中采用了add方式加入队列,那么后续的请求都会失败的。

感觉不对哇 by lion phoenix

貌似这例子是个坑

没有说明为什么变快了 by 于 jhon

我理解,单看web应用本身,异步化没有任何效率上的提升,原来是多久,异步以后还是多久,甚至还多了些线程调度和切换的工作。网上都说变快了,但是为啥变快了,一直搞不懂啊…………
只是在调用其它服务的时候,允许异步调用再通知,从而节省其它服务线程的占用率。

允许的HTML标签: a,b,br,blockquote,i,li,pre,u,ul,p

当有人回复此评论时请E-mail通知我

允许的HTML标签: a,b,br,blockquote,i,li,pre,u,ul,p

当有人回复此评论时请E-mail通知我

14 讨论

登陆InfoQ,与你最关心的话题互动。


找回密码....

Follow

关注你最喜爱的话题和作者

快速浏览网站内你所感兴趣话题的精选内容。

Like

内容自由定制

选择想要阅读的主题和喜爱的作者定制自己的新闻源。

Notifications

获取更新

设置通知机制以获取内容更新对您而言是否重要

BT