使用異步Servlet改進應用性能
Nikita Salnikov Tarnovski是plumbr的高級開發者,也是一位應用性能調優的專家,他擁有多年的性能調優經驗。近日,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主要完成以下事情:
請求到達,表示開始監控某些事件。
線程被阻塞,直到事件發生為止。
在接收到事件后,編輯響應然后將其發回給客戶端。
為了簡化,代碼中將等待部分替換為一個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呢?
posted on 2014-06-09 09:59 順其自然EVO 閱讀(236) 評論(0) 編輯 收藏 所屬分類: 測試學習專欄