使用異步Servlet改進(jìn)應(yīng)用性能
Nikita Salnikov Tarnovski是plumbr的高級開發(fā)者,也是一位應(yīng)用性能調(diào)優(yōu)的專家,他擁有多年的性能調(diào)優(yōu)經(jīng)驗(yàn)。近日,Tarnovski撰文談到了如何通過異步Servlet來改進(jìn)常見的Java Web應(yīng)用的性能問題。
眾所周知,Servlet 3.0標(biāo)準(zhǔn)已經(jīng)發(fā)布了很長一段時(shí)間,相較于之前的2.5版的標(biāo)準(zhǔn),新標(biāo)準(zhǔn)增加了很多特性,比如說以注解形式配置Servlet、web.xml片段、異步處理支持、文件上傳支持等。雖然說現(xiàn)在的很多Java Web項(xiàng)目并不會直接使用Servlet進(jìn)行開發(fā),而是通過如Spring MVC、Struts2等框架來實(shí)現(xiàn),不過這些Java Web框架本質(zhì)上還是基于傳統(tǒng)的JSP與Servlet進(jìn)行設(shè)計(jì)的,因此Servlet依然是最基礎(chǔ)、最重要的標(biāo)準(zhǔn)和組件。在Servlet 3.0標(biāo)準(zhǔn)新增的諸多特性中,異步處理支持是令開發(fā)者最為關(guān)注的一個(gè)特性,本文就將詳細(xì)對比傳統(tǒng)的Servlet與異步Servlet在開發(fā)上、使用上、以及最終實(shí)現(xiàn)上的差別,分析異步Servlet為何會提升Java Web應(yīng)用的性能。
本文主要介紹的是能夠解決現(xiàn)代Web應(yīng)用常見性能問題的一種性能優(yōu)化技術(shù)。當(dāng)今的應(yīng)用已經(jīng)不僅僅是被動地等待瀏覽器來發(fā)起請求,而是由應(yīng)用自身發(fā)起通信。典型的示例有聊天應(yīng)用、拍賣系統(tǒng)等等,實(shí)際情況是大多數(shù)時(shí)間與瀏覽器的連接都是空閑的,等待著某個(gè)事件來觸發(fā)。
這種類型的應(yīng)用自身存在著一個(gè)問題,特別是在高負(fù)載的情況下問題會變得更為嚴(yán)重。典型的癥狀有線程饑餓、影響用戶交互等等。根據(jù)近一段時(shí)間的經(jīng)驗(yàn),我認(rèn)為可以通過一種相對比較簡單的方案來解決這個(gè)問題。在Servlet API 3.0實(shí)現(xiàn)成為主流后,解決方案就變得更加簡單、標(biāo)準(zhǔn)化且優(yōu)雅了。
在開始介紹解決方案前,我們應(yīng)該更深入地理解問題的細(xì)節(jié)。還有什么比看源代碼更直接的呢,下面就來看看下面這段代碼:
@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); } } |
上面這個(gè)Servlet主要完成以下事情:
請求到達(dá),表示開始監(jiān)控某些事件。
線程被阻塞,直到事件發(fā)生為止。
在接收到事件后,編輯響應(yīng)然后將其發(fā)回給客戶端。
為了簡化,代碼中將等待部分替換為一個(gè)Thread.sleep()調(diào)用。
現(xiàn)在,你可能會覺得這就是一個(gè)挺不錯(cuò)的Servlet。在很多情況下,你的理解都是正確的,上述代碼并沒有什么問題,不過當(dāng)應(yīng)用的負(fù)載變大后就不是這么回事了。
為了模擬負(fù)載,我通過JMeter創(chuàng)建了一個(gè)簡單的測試,我會啟動2,000個(gè)線程,每個(gè)線程運(yùn)行10次,每次都會向/BlockedServlet這個(gè)地址發(fā)出請求。將這個(gè)Servlet部署在Tomcat 7.0.42中然后運(yùn)行測試,得到如下結(jié)果:
平均響應(yīng)時(shí)間:19,324ms
最快響應(yīng)時(shí)間:2,000ms
最慢響應(yīng)時(shí)間:21,869ms
吞吐量:97個(gè)請求/秒
默認(rèn)的Tomcat配置有200個(gè)工作線程,此外再加上模擬的工作由2,000ms的睡眠時(shí)間來表示,這就能比較好地解釋最快與最慢的響應(yīng)時(shí)間了,每個(gè)線程都會睡眠2秒鐘。再加上上下文切換的代價(jià),因此97個(gè)請求/秒的吞吐量基本上是符合我們的預(yù)期的。
對于絕大多數(shù)的應(yīng)用來說,這個(gè)吞吐量還算是可以接受的。重點(diǎn)來看看最慢的響應(yīng)時(shí)間與平均響應(yīng)時(shí)間,問題就變得有些嚴(yán)重了。經(jīng)過20秒而不是期待的2秒才能得到響應(yīng)顯然會讓用戶感到非常不爽。 下面我們來看看另外一種實(shí)現(xiàn),利用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(); } } |
上面的代碼看起來有點(diǎn)復(fù)雜,因此在開始分析這個(gè)解決方案的細(xì)節(jié)信息之前,我先來概述一下這個(gè)方案:速度上提升了75倍,吞吐量提升了20倍。看到這個(gè)結(jié)果,你肯定迫不及待地想知道這個(gè)示例是如何做到的吧。
這個(gè)Servlet本身是非常簡單的。需要注意兩點(diǎn),首先是聲明Servlet支持異步方法調(diào)用:
@WebServlet(asyncSupported = true, value = "/AsyncServlet")
其次,重要的部分實(shí)際上是隱藏在下面這行代碼調(diào)用中的。
Work.add(request.startAsync());
整個(gè)請求處理都被委托給了Work類。請求上下文是通過AsyncContext實(shí)例來保存的,它持有容器提供的請求與響應(yīng)對象。
現(xiàn)在來看看第2個(gè),也是更加復(fù)雜的類,Work類實(shí)現(xiàn)了ServletContextListener接口。進(jìn)來的請求會在該實(shí)現(xiàn)中排隊(duì)等待通知,通知可能是上面提到的拍賣中的競標(biāo)價(jià),或是所有請求都在等待的群組聊天中的下一條消息。
當(dāng)通知到達(dá)時(shí),我們這里依然是通過Thread.sleep()讓線程睡眠2,000ms,隊(duì)列中所有被阻塞的任務(wù)都是由一個(gè)工作線程來處理的,該線程負(fù)責(zé)編輯與發(fā)送響應(yīng)。相對于阻塞成百上千個(gè)線程以等待外部通知,我們通過一種更加簡單且干凈的方式達(dá)成所愿,通過批處理在單獨(dú)的線程中處理請求。
還是讓結(jié)果來說話吧,測試配置與方才的示例一樣,依然使用Tomcat 7.0.24的默認(rèn)配置,測試結(jié)果如下所示:
平均響應(yīng)時(shí)間:265ms
最快響應(yīng)時(shí)間:6ms
最慢響應(yīng)時(shí)間:2,058ms
吞吐量:1,965個(gè)請求/秒
雖然說這個(gè)示例很簡單,不過對于實(shí)際項(xiàng)目來說通過這種方式依然能獲得類似的結(jié)果。
在將所有的Servlet改寫為異步Servlet前,請容許我多說幾句。該解決方案非常適合于某些應(yīng)用場景,比如說群組通知與拍賣價(jià)格通知等。不過,對于等待數(shù)據(jù)庫查詢完成的請求來說,這種方式就沒有什么必要了。像往常一樣,我必須得重申一下——請通過實(shí)驗(yàn)進(jìn)行度量,而不是瞎猜。
對于那些不適合于這種解決方案的場景來說,我還是要說一下這種方式的好處。除了在吞吐量與延遲方面帶來的顯而易見的改進(jìn)外,這種方式還可以在大負(fù)載的情況下優(yōu)雅地避免可能出現(xiàn)的線程饑餓問題。
另一個(gè)重要的方面,這種異步處理請求的方式已經(jīng)是標(biāo)準(zhǔn)化的了。它不依賴于你所使用的Servlet API 3.0,兼容于各種應(yīng)用服務(wù)器,如Tomcat 7、JBoss 6或是Jetty 8等,在這些服務(wù)器上這種方式都可以正常使用。你不必再面對各種不同的Comet實(shí)現(xiàn)或是依賴于平臺的解決方案了,比如說Weblogic FutureResponseServlet。
就如本文一開始所提的那樣,現(xiàn)在的Java Web項(xiàng)目很少會直接使用Servlet API進(jìn)行開發(fā)了,不過諸多的Web MVC框架都是基于Servlet與JSP標(biāo)準(zhǔn)實(shí)現(xiàn)的,那么在你的日常開發(fā)中,是否使用過出現(xiàn)多年的Servlet API 3.0,使用了它的哪些特性與API呢?
posted on 2014-06-09 09:59 順其自然EVO 閱讀(235) 評論(0) 編輯 收藏 所屬分類: 測試學(xué)習(xí)專欄