Author:文初
Email:wenchu.cenwc@alibaba-inc.com
Blog:http://blog.csdn.net/cenwenchu79
問題凸現:
年關到了,商家忙著促銷,網站忙著推廣,阿里軟件的服務集成平臺也面臨第一次多方大規模的壓力考驗,根據5.3版本的壓力測試結果,估算了一下現有的推廣會帶來的壓力,基本上確定了服務集成平臺年底不需要擴容。SA為了保險起見還是通過請求方式來做定時的心跳檢測,保證服務集成平臺的可靠性。結果旺旺推廣開始的第一天,SA的報警短信就在幾個忙時段不停的發告警,但是察看生產環境的服務器狀況以及應用狀況也看不出有什么問題,開始懷疑是否告警機制不是很合理。但幾日的訪問記錄統計報告看過以后,發現了幾個問題,首先由于推廣是在IM登錄時段集中式的推廣,因此高峰期比較集中,壓力也很大,而告警發生的也是那些時候,再則,發現那些推廣使用的API的處理時間比較長,同時還有一些出現了問題,這幾天除了服務集成平臺告警以外,那些API服務器也在告警,因此可以看出問題應該是由于API提供商響應速度慢而拖累了服務集成平臺的處理能力,監控機制在高峰情況下沒有得到及時的響應,就認為是服務器已經處于無效狀態。其實這類問題在我們現在的應用體系架構中常常出現,現在很少有純粹“封閉式”應用,對DB的依賴,對存儲的依賴,對第三方系統的依賴等等。這也讓我回憶到就是前一陣子參加的安全會議中,騰迅的安全技術團隊的負責人說起關于安全現在最大的問題就在于第三方合作安全的不受控而引發安全潛在影響。Web應用未嘗不是,從最基本的事務處理要小粒度,不要包含第三方依賴到事務中,到心跳檢測,容錯方案的制定,都已經讓我們對這方面的問題有所注意。但是往往這類問題不是局部設計可以看到的,如果沒有一個總體架構設計者對于全局的把握以及協調和防范,那么問題出現并且帶來的影響將會很大。
早先對于服務集成平臺的壓力測試主要是在ISP服務“基本正常”的情況下做的,但是這次問題的暴露就要求我們對于這種第三方依賴出現邊界問題需要做出一些措施或者改進的設計。
問題分析以及解決方案:
問題原因:
1. Http請求處理的阻塞方式。
2. 后端服務處理時間過長,服務質量不穩定。
3. Web Container接受請求線程資源有限。
解決方案:
1. 改阻塞方式為非阻塞方式處理請求。
2. 設置后端超時時間,主動斷開連接,回收資源。
3. 修改容器配置,增加線程池大小以及等待隊列長度。
解決方案一是最難做到的,后面的篇幅講描述對于這方面技術的探索。
解決方案二比較容易,允許各個ISP設置自己API容許的最大超時時間。
解決方案三Tomcat,JBoss在Connector中有兩個參數配置(maxThreads和acceptCount)可以做調整。
第一個方案其實和Jdk1.5支持的NIO就是一種想法,只是我們在Socket中都已經采用了,而在Http請求處理中要依賴于Web Container開發商的實現所以至今還沒有被廣泛應用,不過在開源社區已經有用Mina實現的Http協議處理的框架,但是現在的Web應用高效的Web請求處理僅僅是很小的一方面,還有很多類似于安全,緩存,監控等等附加功能也占據著很重要的地位。
Servlet 3規范經過快一年的推廣,已經被各大Web Container廠商所接受,Tomcat6、JBoss5、Jetty7都宣稱自己對Servlet3作了較好的支持,而在Servlet3中最廣為關注的一個特性就是異步服務處理Servlet(Async Servlet),這點也是解決我目前面臨問題的最好的手段。
Servlet 3 與服務異步處理:
Servlet 3主要的新特性分成四部分:內嵌式的使用模式,Annotation的支持,Async Servlet的支持,安全提升。內嵌式的使用很早就在Jetty中被實現,也成為Jetty的優勢之一,Annotation也只能說是錦上添花的部分,安全暫時沒有怎么用到,最關心的還是Async Servlet部分。Async Servlet到底是什么樣的概念,這里就大致描述一下在Servlet3規范中的介紹:
1. 支持 Comet(彗星)。最早期Http請求就是無狀態的請求和響應,所有的數據一次性在請求后返回給客戶端由客戶端渲染。后來發展到AJAX,頁面的請求和渲染由全局變成了局部。而Comet適合事件驅動的 Web 應用和對交互性和實時性要求很強的應用,通過建立客戶端和服務端的長連接通道,在一次請求后可以主動推送服務端數據的變更情況到客戶端。長連接建立的策略有兩種:Http Streaming和Http Long Polling。前者客戶端打開一個單一的與服務器端的 HTTP 持久連接。服務器通過此連接把數據發送過來,客戶端增量的處理它們。后者由客戶端向服務器端發出請求并打開一個連接。這個連接只有在收到服務器端的數據之后才會關閉。服務器端發送完數據之后,就立即關閉連接。客戶端則馬上再打開一個新的連接,等待下一次的數據。
2. 支持Suspending a request。通過在ServletRequest中增加suspend,resume,complete將Http請求處理的block模式轉變成為not block模式,同時支持對于狀態的查詢(suspend,resume,timeout)。
3. 請求處理過程中支持事件機制。響應也支持狀態查詢。
圖 異步服務請求基本流程
現實中的異步服務處理:
Tomcat 的異步服務處理
這里使用的是Tomcat 6.0.14版本。在Tomcat中對于異步處理描述在Advanced IO中作了說明,主要分成兩部分:Comet的支持和異步輸出。
Comet的支持作用分成兩部分:請求讀數據的非阻塞,響應處理的異步執行。前者可以防止在大流量數據上傳時在傳輸過程中信道空閑等待的資源浪費,后者用于在處理請求時,依賴于第三方或者本身處理比較耗時的情況下,懸掛起請求處理線程,提高請求處理能力,完成處理后異步輸出結果。
Servlet不再是原來對于幾個標準的Http請求類型的方法實現,而是對于事件響應的處理。Comet定義了4個基礎的事件:
1.EventType.BEGIN:客戶端建立起連接時激發的事件,可以用于資源初始化。
2.EventType.READ:有數據可以被讀入的事件。(熟悉NIO的事件模式應該可以了解)
3.EventType.END:請求處理結束時激發的事件,可以用于資源清理。
4.EventType.ERROR:當請求處理出現問題時激發的事件。(IO異常,超時等)
還有一些子事件類型,例如超時就屬于ERROR的子事件類型,可以在事件處理中更加精確的定位事件類型。
必需的配置:在server.xml中配置如下(紅色部分):
<Connector port="8080" protocol="org.apache.coyote.http11.Http11NioProtocol"
connectionTimeout="20000"
redirectPort="8443" />
實際代碼范例如下:
//CometProcessor接口必需被實現,一旦實現以后,則該Servlet在配置好以后不會再調用service,get,post等方法的實現。
publicclass SIPCometTomcatServlet extends HttpServlet implementsCometProcessor
{
@Override
//事件處理響應方法實現
publicvoid event(CometEvent event) throws IOException, ServletException
{
if (event.getEventType() == CometEvent.EventType.BEGIN)
{
//設置事件超時時間
event.setTimeout(10 * 1000);
//另起線程處理后臺工作,異步返回結果,事件響應將不等待后臺處理直接返回
new Handler(event.getHttpServletRequest(),event.getHttpServletResponse()).start();
}
elseif (event.getEventType() == CometEvent.EventType.ERROR)
{
//結束事件,回收request,response資源
event.close();
}
elseif (event.getEventType() == CometEvent.EventType.END)
{
event.close();
}
}
//另起一個線程異步處理請求。
class Handler extends java.lang.Thread
{
private HttpServletResponse response;
private HttpServletRequest request;
public Handler(HttpServletRequest request,HttpServletResponse response)
{
this.response = response;
this.request = request;
}
@Override
publicvoid run()
{
try
{
String id;
id = request.getParameter("id");
if (id == null)
id = "no id";
Thread.sleep(5000);
PrintWriter pw = response.getWriter();
pw.write(id);
pw.flush();
} catch (Exception e)
{
e.printStackTrace();
}
}
}
}
使用的一些總結:
1. 事件響應框架將服務的請求由完整的一次服務處理切割成為細粒度的多事件處理,為請求多階段并行處理提供了框架基礎。
2. Event對象在事件處理方法結束后就被回收了,但是request和response在事件處理完以后還可以繼續使用,因此可以看出原來的阻塞式的方式已經可以通過事件的切分成為非阻塞的方式。
3. 沒有提供Servlet3中描述suspend,resume,complete方法,無法主動控制request的異步處理。上面的代碼可以看出我只使用了Begin方法啟動了一個線程,但是由于無法主動地結束請求,因此在向客戶端返回數據以后還要等到超時才會結束這次會話。(看了Tomcat的代碼,也想模仿close的動作但是由于它使用了protected無法獲取封裝的request對象,因此無法釋放資源)。當然也可以通過客戶端配合,由客戶端主動發起再次的數據傳輸激發READ事件來結束會話。這么做對客戶端的依賴比較強,同時也增加了客戶端的處理復雜度。
4. Tomcat支持異步輸出:在APR或者NIO的模式下,Tomcat支持在系統壓力增大的時候,支持異步回寫大文件數據。
總體上來說實現了部分對于Comet的支持,但是沒有對異步服務流程作很好的支持,無法在開發中使用(簡單順暢的使用)。
JBoss的異步服務處理
JBoss 4.2.3版本配置和使用與Tomcat6類似,沒有什么差異。
JBoss 5剛剛發布了RC版本,對于異步服務處理作了很大的改動,與Tomcat配置很不同這里具體的說一下JBoss5中的異步服務使用。
JBoss5已經將Tomcat中的Http11NioProtocol給刪除了,取而代之的是JBoss自己的servlet包內增加了一個HttpEventServlet接口,這個接口和Tomcat的CometProcessor類似。
首先,必須配置JBoss內置的Web容器為APR模式,也就是配置jbossweb.sar下面的server.xml中Connector 如下:
<Connector protocol="org.apache.coyote.http11.Http11AprProtocol" port="8080" address="${jboss.bind.address}"
connectionTimeout="2000" redirectPort="8443" />
其次異步服務處理的Servlet必須實現HttpEventServlet接口,接口只有一個方法,就是事件處理方法:public void event(HttpEvent event)。事件定義與Tomcat稍有不同,在BEGIN,ERROR,READ,END基礎上增加了TIMEOUT,EOF,EVENT,WRITE四個事件,同時去掉了SubType。
1. TIMEOUT其實是從原來的Error的SubType分離出來的,這個方法是在最后一次處理事件到當前時間超過設定的超時時間而被激發的,同時TIMEOUT被激發并不會關閉請求處理流程,必須顯示調用事件的close方法才會結束會話。
2. EOF事件將會在客戶端主動斷連的情況下被觸發,就好比IE窗口在請求過程中被關閉就會被觸發。
3. EVENT事件在事件對象被調用resume的時候被激發,按照原意應該最好可以附帶上一些自定義信息來做一些工作,但是我自己使用過程中還沒有發現有什么好的辦法可以在事件中附帶信息到事件處理中。
4. WRITE方法在調用isWriteReady方法時被激發,可以在網絡出現問題或者繁忙的時候異步等待輸出。
再則,JBoss的事件對象還支持幾個方法來實現異步處理以及Comet機制,方法如下:
1.close方法:表示一次請求處理的結束,會告知客戶端沒有數據返回了,同時也會激發END事件。
2.setTimeout方法:設置連接超時時間(單位毫秒),計算超時是從最近的事件處理時間開始記錄的,如果發生超時,則會激發TIMEOUT事件。
3.isReadReady方法:如果連接有數據可以讀取則返回true,如果這個方法返回false,servlet還試圖去讀去數據,則會阻塞。
4.isWriteReady方法:如果返回true,則連接可以無阻塞的寫出數據,如果返回false,servlet必須停止寫數據,如果強制寫出,則可能會發生IO錯誤或者會采用異步輸出。當客戶端的輸出通道可用以后,則會激發write事件。
5.suspend方法:suspend連接處理線程直到timeout發生或者resume被調用,實際上意味著servlet在suspend以后不再收到READ事件,READ事件將會在后臺被不斷的激發,除非被suspend.
6.resume方法:會激發event事件,可以利用這個方法來結束異步處理。同時也可以激活因為suspend停止的read事件,同時也可以在resume以后再調用suspend方法。注意,這里未必是要求必須先suspend以后再resume。
7.event,request,response在事件響應過程中都可以被使用,但是線程不安全,同時在調用了close以后,request,response資源會被釋放,可以通過對event對象做同步來保證線程安全的問題。當READ事件和END事件都發生的時候,首先會完成READ事件,然后再去完成END。
具體的實現代碼:
publicclass SIPCometJBossServlet extends HttpServlet implementsHttpEventServlet
{
@Override
publicvoid event(HttpEvent event) throws IOException, ServletException
{
switch (event.getType())
{
//will be called at the beginning of the processing of the connection
caseBEGIN:
{
event.setTimeout(100 * 1000);//設置超時時間
//event.suspend();//resume之前不必要一定使用suspend
new Handler(event).start();
break;
}
//Error will be called by the container in the case
//where an IO exception or a similar unrecoverable error occurs
caseERROR:
{
event.close();
break;
}
//End may be called to end the processing of the request
caseEND:
{
//event.close();//可以寫也可以不寫,因為進入這個方法也就是調用了close方法,起碼暫時還不知道有其他什么入口
break;
}
//This indicates that input data is available,
//and that at least one read call can be made without blocking
caseREAD:
{
break;
}
//The connection timed out according to the timeout value which has been set
//,but the connection will not be closed unless the servlet uses the close method of the event
caseTIMEOUT:
{
event.close();//如果不主動關閉,Timeout方法會被循環調用,會話不會結束
break;
}
//The end of file of the input has been reached, and no further data is available
caseEOF:
{
event.close();
break;
}
//Event will be called by the container after the resume() method is called,
//during which any operation can be performed, including closing the connection using the close() method.
caseEVENT:
{
event.close();//作為resume方法調用后主動釋放連接資源的一種手段
break;
}
//Write is sent if the servlet is using the isWriteReady method
caseWRITE:
{
break;
}
}
}
class Handler extends java.lang.Thread
{
private HttpEvent event;//event的生命周期已經不限制于事件處理方法,因此隨時可以關閉請求處理
private HttpServletResponse response;
private HttpServletRequest request;
public Handler(HttpEvent event)
{
this.event = event;
this.response = event.getHttpServletResponse();
this.request = event.getHttpServletRequest();
}
@Override
publicvoid run()
{
try
{
String id;
id = request.getParameter("id");
if (id == null)
id = "no id";
Thread.sleep(5000);
//危險!!!其實event,response,request都是線程不安全的,因此此時可能response已經被釋放,需要同步住event的對象來操作,效率可能會降低
PrintWriter pw = response.getWriter();
pw.write(id);
pw.flush();
event.resume();//發送結束調用resume方法,進入event方法,結束請求處理
} catch (Exception e)
{
e.printStackTrace();
}
}
}
}
使用總結:
1. 對于Servlet描述的異步服務處理有了較好的支持。
2. 事件方法比較豐富,但是對于可定義事件支持不夠完善。
3. 對象并發控制需要開發者自己設計,權衡多線程處理的高效以及資源爭奪的消耗。
下面對異步服務處理Servlet和普通Servlet做了一下簡單的性能測試。
首先我原本想用ab來做一下簡單的壓力測試即可,但是ab好像對于apr模式下的測試支持的不好,一壓就報錯(apr_poll: The timeout specified has expired (70007)),也可能是自己不會用吧,因此就自己寫了一段測試代碼來做測試。
測試場景如下:
兩類Servlet都可以設置處理時Hold的時間,來達到消耗連接數的目的。測試客戶端可以設置并發多少用戶,每個用戶發起多少次請求。下表就是測試的結果:
這里設置的是Servlet都hold1秒鐘,APR啟動時配置的最大連接數為默認的200個。
客戶端設置 |
普通Servlet總耗時(ms) |
異步Servlet總耗時(ms) |
普通Servlet單個線程耗時(ms) |
異步Servlet單個線程耗時(ms) |
100并發線程,每個線程執行1次請求 |
263866 |
274430 |
2638 |
2744 |
300并發線程,每個線程執行1次請求 |
550718 |
617082 |
1835 |
2056 |
100并發線程,每個線程執行10次請求 |
1087747 |
1207920 |
10877 |
12079 |
300并發線程,每個線程執行10次請求 |
retrying request,connect reject |
5193644 |
retrying request,connect reject |
17312 |
從上表可以看出,就純粹從處理效率來說,采用事件處理方式在線程切換過程中存在著一定的損失,但是就我們使用異步請求處理的本意來看,對于在高并發下對后端依賴無法避免的性能損耗情況下,異步請求解決了連接耗盡的問題。
最后在來看我在測試過程中用JProfiler來截取的一些線程創建和使用狀況:
上圖是最初的線程創建情況,還沒有任何請求被發送到服務端,因此線程池也沒有開任何一個連接。
這是普通的Servlet在壓力測試下的線程狀況,線程就開到了200最大值,圖中由于程序來Hold請求處理線程出現了紅色阻塞和黃色等待,同時客戶端已經開始出現拒絕連接的錯誤。下圖就是錯誤的截圖:
上圖是異步服務處理Servlet在壓力測試開始的情況,可以發現它的http線程還是200,但是其他事件處理線程在不斷增長。下圖已經增長到了3000多個線程。(這里需要注意的就是這種異步處理資源申請沒有設置上限,因此對于資源消耗來說也是比較大的,同時要防范攻擊性請求造成服務端垮掉)
上圖是壓力測試結束以后,異步服務事件處理線程都被釋放恢復到了初始狀態。
后語:
多線程、分布式計算、erlang其實這些編程方式、框架設計、語言都在實現這一個理論,那就是分而治之,多線程是站在單應用的角度去考慮解決方案,分布式計算是在多機協作考慮解決方案,erlang在單機多處理器的角度去考慮解決方案。但彼此的理念都是一樣,將能夠分割的不相關聯的獨立任務并行處理,最終實現最優化的處理效果。
對于服務集成平臺是否采用這種技術,我自己還沒有最終的決定,首先就如上面的測試結果來看,有的還是有失的,其次這種并發異步處理帶來的多線程維護控制復雜度,也需要考慮到成本中。Jetty的開發者對于是否將異步服務處理Servlet來交由開發者控制而不是容器本身來控制表示出了反對意見,的卻將這樣復雜的控制交給開發者來處理會增加開發者的學習成本以及維護成本。