記一次記憶體溢位的分析經歷——thrift帶給我的痛orz
說在前面的話
朋友,你經歷過部署好的服務突然記憶體溢位嗎?
你經歷過沒有看過Java虛擬機器,來解決記憶體溢位的痛苦嗎?
你經歷過一個BUG,百思不得其解,頭髮一根一根脫落的煩惱嗎?
我知道,你有過!
但是我還是要來說說我的故事..................
背景:
有一個專案做一個系統,分客戶端和服務端,客戶端用c++寫的,用來收集資訊然後傳給服務端(客戶端的數量還是比較多的,正常的有幾千個),
服務端用Java寫的(帶管理頁面),屬於RPC模式,中間的通訊框架使用的是thrift。
thrift很多優點就不多說了,它是facebook的開源的rpc框架,主要是它能夠跨語言,序列化速度快,但是他有個不討喜的地方就是它必須用自己IDL來定義介面
thrift版本:0.9.2.
問題定位與分析
步驟一.初步分析
客戶端無法連線服務端,檢視伺服器的埠開啟狀況,服務埠並沒有開啟。於是啟動服務端,啟動幾秒後,服務端崩潰,重複啟動,服務端依舊在啟動幾秒後崩潰。
步驟二.檢視服務端日誌分析
分析得知是因為java.lang.OutOfMemoryError: Java heap space(堆記憶體溢位)導致的服務崩潰。
客戶端蒐集的主機資訊,主機策略都是放在快取中,可能是因為快取較大造成的,但是通過日誌可以看出是因為Thrift服務丟擲的堆記憶體溢位異常與快取大小無關。
步驟三.再次分析服務端日誌
可以發現每次丟擲異常的時候都會伴隨著幾十個客戶端在向服務端傳送日誌,往往在傳送幾十條日誌之後,服務崩潰。可以假設是不是堆記憶體設定的太小了?
檢視啟動引數配置,最大堆記憶體為256MB。修改啟動配置,啟動的時候分配更多的堆記憶體,改成java -server -Xms512m -Xmx768m。
結果是,能堅持多一點的時間,依舊會記憶體溢位服務崩潰。得出結論,一味的擴大記憶體是沒有用的。
**為了證明結論是正確的,做了這樣的實驗:**
> 記憶體設定為256MB,在公司伺服器上部署了服務端,使用Java VisualVM遠端監控伺服器堆記憶體。
>
> 模擬客戶現場,註冊3000個客戶端,使用300個執行緒同時傳送日誌。
>
> 結果和想象的一樣,沒有出現記憶體溢位的情況,如下圖:
> 上圖是Java VisualVM遠端監控,在壓力測試的情況下,沒有出現記憶體溢位的情況,256MB的記憶體肯定夠用的。
步驟四.回到thrift原始碼中,查詢關鍵問題
服務端採用的是Thrift框架中TThreadedSelectorServer這個類,這是一個NIO的服務。下圖是thrift處理請求的模型:
**說明:**
>一個AcceptThread執行accept客戶端請求操作,將accept到的Transport交給SelectorThread執行緒,
>
>AcceptThread中有個balance均衡器分配到SelectorThread;SelectorThread執行read,write操作,
>
>read到一個FrameBuffer(封裝了方法名,引數,引數型別等資料,和讀取寫入,呼叫方法的操作)交給WorkerProcess執行緒池執行方法呼叫。
>
>**記憶體溢位就是在read一個FrameBuffer產生的。**
步驟五.細緻一點描述thrift處理過程
>1.服務端服務啟動後,會listen()一直監聽客戶端的請求,當收到請求accept()後,交給執行緒池去處理這個請求
>
>2.處理的方式是:首先獲取客戶端的編碼協議getProtocol(),然後根據協議選取指定的工具進行反序列化,接著交給業務類處理process()
>
>3.process的順序是,**先申請臨時快取讀取這個請求資料**,處理請求資料,執行業務程式碼,寫響應資料,**最後清除臨時快取**
>
> **總結:thrift服務端處理請求的時候,會先反序列化資料,接著申請臨時快取讀取請求資料,然後執行業務並返回響應資料,最後請求臨時快取。**
>
> 所以壓力測試的時候,thrift效能很高,而且記憶體佔用不高,是因為它有自負載調節,使用NIO模式快取,並使用執行緒池處理業務,每次處理完請求之後及時清除快取。
步驟六.研讀FrameBuffer的read方法程式碼
可以排除掉沒有及時清除快取的可能,方向明確,極大的可能是在申請NIO快取的時候出現了問題,回到thrift框架,檢視FrameBuffer的read方法程式碼:
public boolean read() {
// try to read the frame size completely
if (this.state_ == AbstractNonblockingServer.FrameBufferState.READING_FRAME_SIZE) {
if (!this.internalRead()) {
return false;
}
// if the frame size has been read completely, then prepare to read the actual time
if (this.buffer_.remaining() != 0) {
return true;
}
int frameSize = this.buffer_.getInt(0);
if (frameSize <= 0) {
this.LOGGER.error("Read an invalid frame size of " + frameSize + ". Are you using TFramedTransport on the client side?");
return false;
}
// if this frame will always be too large for this server, log the error and close the connection.
if ((long)frameSize > AbstractNonblockingServer.this.MAX_READ_BUFFER_BYTES) {
this.LOGGER.error("Read a frame size of " + frameSize + ", which is bigger than the maximum allowable buffer size for ALL connections.");
return false;
}
if (AbstractNonblockingServer.this.readBufferBytesAllocated.get() + (long)frameSize > AbstractNonblockingServer.this.MAX_READ_BUFFER_BYTES) {
return true;
}
AbstractNonblockingServer.this.readBufferBytesAllocated.addAndGet((long)(frameSize + 4));
this.buffer_ = ByteBuffer.allocate(frameSize + 4);
this.buffer_.putInt(frameSize);
this.state_ = AbstractNonblockingServer.FrameBufferState.READING_FRAME;
}
if (this.state_ == AbstractNonblockingServer.FrameBufferState.READING_FRAME) {
if (!this.internalRead()) {
return false;
} else {
if (this.buffer_.remaining() == 0) {
this.selectionKey_.interestOps(0);
this.state_ = AbstractNonblockingServer.FrameBufferState.READ_FRAME_COMPLETE;
}
return true;
}
} else {
this.LOGGER.error("Read was called but state is invalid (" + this.state_ + ")");
return false;
}
}
**說明:**
>MAX_READ_BUFFER_BYTES這個值即為對讀取的包的長度限制,如果超過長度限制,就不會再讀了/
>
>這個MAX_READ_BUFFER_BYTES是多少呢,thrift程式碼中給出了答案:
public abstract static class AbstractNonblockingServerArgs<T extends AbstractNonblockingServer.AbstractNonblockingServerArgs<T>> extends AbstractServerArgs<T> {
public long maxReadBufferBytes = 9223372036854775807L;
public AbstractNonblockingServerArgs(TNonblockingServerTransport transport) {
super(transport);
this.transportFactory(new Factory());
}
}
>從上面原始碼可以看出,預設值居然給到了long的最大值9223372036854775807L。
所以thrift的開發者是覺得使用thrift程式設計師不夠覺得記憶體不夠用嗎,這個換算下來就是1045576TB,這個太誇張了,這等於沒有限制啊,所以肯定不能用預設值的。
步驟七.通訊資料抓包分析
需要可靠的證據證明一個客戶端通訊的資料包的大小。
這個是我抓到包最大的長度,最大一個包長度只有215B,所以需要限制一下讀取大小
步驟八:踏破鐵鞋無覓處
在論壇中,看到有人用http請求thrift服務端出現了記憶體溢位的情況,所以我抱著試試看的心態,在瀏覽器中發起了http請求,
果不其然,出現了記憶體溢位的錯誤,和客戶現場出現的問題一摸一樣。這個讀取記憶體的時候數量過大,超過了256MB。
> 很明顯的一個問題,正常的一個HTTP請求不會有256MB的,考慮到thrift在處理請求的時候有反序列化這個操作。
>
> 可以做出假設是不是反序列化的問題,不是thrift IDL定義的不能正常的反序列化?
>
> 驗證這個假設,我用Java socket寫了一個tcp客戶端,向thrift服務端傳送請求,果不其然!java.lang.OutOfMemoryError: Java heap space。
> 這個假設是正確的,客戶端請求資料不是用thrift IDL定義的話,無法正常序列化,序列化出來的資料會異常的大!大到超過1個G的都有。
步驟九. 找到原因
某些客戶端沒有正常的序列化訊息,導致服務端在處理請求的時候,序列化出來的資料特別大,讀取該資料的時候出現的記憶體溢位。
檢視維護記錄,在別的客戶那裡也出現過記憶體溢位導致服務端崩潰的情況,通過重新安裝客戶端,就不再復現了。
所以可以確定,客戶端存在著無法正常序列化訊息的情況。考慮到,客戶端量比較大,一個一個排除,再重新安裝比較困難,工作量很大,所以可以從服務端的角度來解決問題,減少維護工作量。
最後可以確定解決方案了,真的是廢了很大的勁,不過也是頗有收穫
問題解決方案
非常簡單
在構造TThreadedSelectorServer的時候,增加args.maxReadBufferBytes = 1*1024 * 1024L;也就是說修改maxReadBufferBytes的大小,設定為1MB。
客戶端與服務端通過thrift通訊的資料包,最大十幾K,所以設定最大1MB,是足夠的。程式碼部分修改完成,版本不做改變**
修改完畢後,這次進行了異常流測試,傳送了http請求,使服務端無法正常序列化。
服務端處理結果如下:
thrift會丟擲錯誤日誌,並直接沒有讀這個訊息,返回false,不處理這樣的請求,將其視為錯誤請求。
3.國外有人對thrift一些server做了壓力測試,如下圖所示:
使用thrift中的TThreadedSelectorServer吞吐量達到18000以上
由於高效能,申請記憶體和清除記憶體的操作都是非常快的,平均3ms就處理了一個請求。
所以是推薦使用TThreadedSelectorServer
4.修改啟動指令碼,增大堆記憶體,分配單獨的直接記憶體。
修改為java -server -Xms512m -Xmx768m -XX:MaxPermSize=256m -XX:NewSize=256m -XX:MaxNewSize=512m -XX:MaxDirectMemorySize=128M。
設定持久代最大值 MaxPermSize:256m
設定年輕代大小 NewSize:256m
年輕代最大值 MaxNewSize:512M
最大堆外記憶體(直接記憶體)MaxDirectMemorySize:128M
5.綜合論壇中,StackOverflow一些同僚的意見,在使用TThreadedSelectorServer時,將讀取記憶體限制設定為1MB,最為合適,正常流和異常流的情況下不會有記憶體溢位的風險。
之前啟動指令碼給服務端分配的堆記憶體過小,考慮到是NIO,所以在啟動服務端的時候,有必要單獨分配一個直接記憶體供NIO使用.修改啟動引數。
增加堆記憶體大小直接記憶體,防止因為服務端快取太大,導致thrift服務沒有記憶體可申請,無法處理請求。
總結:
真的是一次非常酸爽的過程,特此發個部落格記錄一下,如果有說的不對的對方,歡迎批評斧正!如果覺得寫的不錯,歡迎給我點個推薦,您的一個推薦是我莫大的動力!