1. 程式人生 > >記一次內存溢出的分析經歷

記一次內存溢出的分析經歷

默認 RKE always 一點 開源 iyu rpc 完成 風險

  背景:
  
  有一個項目做一個系統,分客戶端和服務端,客戶端用c++寫的,用來收集信息然後傳給服務端(客戶端的數量還是比較多的,正常的有幾千個),
  
  服務端用Java寫的(帶管理頁面),屬於RPC模式,中間的通信框架使用的是thrift。
  
  thrift很多優點就不多說了,它是facebook的開源的rpc框架,主要是它能夠跨語言,序列化速度快,但是他有個不討喜的地方就是它必須用自己IDL來定義接口
  
  thrift版本:0.9.2.
  
  問題定位與分析
  
  步驟一.初步分析
  
  客戶端無法連接服務端,查看服務器的端口開啟狀況,服務端口並沒有開啟。於是啟動服務端,啟動幾秒後,服務端崩潰,重復啟動,服務端依舊在啟動幾秒後崩潰。

  
  步驟二.查看服務端日誌分析
  
  如果想學習Java工程化、高性能及分布式、深入淺出。微服務、Spring,MyBatis,Netty源碼分析的朋友可以加我的Java高級交流:854630135,群裏有阿裏大牛直播講解技術,以及Java大型互聯網技術的視頻免費分享給大家。
  
  分析得知是因為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 www.jiahuayulpt.com called but state is invalid ("www.thd178.com/ + 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> www.dfgjyl.cn {<br>
  
  public long maxReadBufferBytes =www.120xh.cn 9223372036854775807L;
  
  public AbstractNonblockingServerArgs(TNonblockingServerTransport transport) {
  
  super(transport)www.yongshiyule178.com;
  
  this.transportFactory(new www.yongshi123.cn Factory());
  
  }
  
  }
  
  >從上面源碼可以看出,默認值居然給到了long的最大值9223372036854775807L。
  
  所以thrift的開發者是覺得使用thrift程序員不夠覺得內存不夠用嗎,這個換算下來就是1045576TB,這個太誇張了,這等於沒有限制啊,所以肯定不能用默認值的。
  
  步驟七.通信數據抓包分析
  
  需要可靠的證據證明一個客戶端通信的數據包的大小。
  
  記一次內存溢出的分析經歷
  
  如果想學習Java工程化、高性能及分布式、深入淺出。微服務、Spring,MyBatis,Netty源碼分析的朋友可以加我的Java高級交流:854630135,群裏有阿裏大牛直播講解技術,以及Java大型互聯網技術的視頻免費分享給大家。
  
  這個是我抓到包最大的長度,最大一個包長度只有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服務沒有內存可申請,無法處理請求。
  
  歡迎工作一到八年的Java工程師朋友們加入Java高級交流:www.dfgjpt.com
  
  本群提供免費的學習指導 架構資料 以及免費的解答
  
  不懂得問題都可以在本群提出來 之後還會有直播平臺和講師直接交流噢

記一次內存溢出的分析經歷