HiveServer2 內(nèi)存泄漏問題定位與優(yōu)化方案
前言
HiveServer2 屬于 Hive 組件的一個服務,主要提供 Hive 訪問接口,例如可通過 JDBC 的方式提交 Hive 作業(yè),HiveServer2 基于 Java 開發(fā),整個服務運行過程中,內(nèi)存的管理回收均由 JVM 進行控制。在 JVM 語言中的內(nèi)存泄漏與 C/C++ 語言的內(nèi)存泄漏會有些差異,JVM 的內(nèi)存泄漏更多的是業(yè)務代碼邏輯錯誤引起大量對象引用被持有,導致多次 GC 均無法被回收,或者部分對象占用內(nèi)存過大,直接超過 JVM 分配的內(nèi)存上限,導致 JVM 內(nèi)存耗盡,引起 JVM 的 OOM。這種情況下該 JVM 服務會停止響應并且退出,但是并不會引起操作系統(tǒng)的崩潰。
背景
近期收到反饋,一套開啟高可用的 EMR 集群中的 HiveServer2 一段時間后便會停止服務,此集群的 HiveServer2 一共有3個節(jié)點,狀態(tài)信息注冊至 Zookeeper 中,提供 HA 的能力,一段時間后幾乎3個節(jié)點都會停止服務,通過對 HiveServer2 的日志查看發(fā)現(xiàn)是大量的 FULL GC后出現(xiàn) OOM:
了解到該集群是一套從線下私有化部署的集群遷移而來,遷移前的集群中 HiveServer2 的 heapsize 為 2G,于是為了對齊業(yè)務參數(shù)將 heapsize 調(diào)整至 2G,間隔一天后,再次收到反饋,OOM 的問題依舊存在,查看日志,問題依舊是 HiveServer2 發(fā)生了 OOM,由于參數(shù)已經(jīng)對齊之前的配置,那么問題可能不單純是內(nèi)存不足,可能會有其他問題。于是首先將 HiveServer2 的 heapsize 調(diào)整為 4G,確保可以在一定時間內(nèi)穩(wěn)定運行,留下定位時間。
定位
定位方向為兩個方向:一個是分析 dump file,查看在內(nèi)存不足的時候,內(nèi)存消耗在哪些地方;第二個方向是針對日志進行細粒度分析,確保整個流程執(zhí)行順序是合理的。
通過對 JVM 的 dump 文件進行分析,定位到在發(fā)生 HiveServer2 的 OOM 的時候,queryIdOperation 這個 ConcurrentHashMap 占據(jù)了大量的內(nèi)存,而此時 HiveServer2 的負載非常低。
再基于具體的 QueryId 進行跟蹤日志,HiveServer2 對作業(yè)處理的邏輯為在建立 Connection 的時候會調(diào)用一次 OpenSession,拿到一個HiveConnection 對象,此后便通過 HiveConnection 對象調(diào)用 ExecuteStatement 執(zhí)行 SQL,后臺每接收到一個 SQL 作業(yè)便生成一個 Operation 對象用來對 SQL 作業(yè)實現(xiàn)隔離。
每一個 Operation 有自己獨立的 QueryId,每條 SQL 作業(yè)會經(jīng)歷編譯,執(zhí)行,關閉環(huán)節(jié),注意此關閉指的是關閉當前執(zhí)行的 SQL 作業(yè),而不是關閉整個 HiveServer2 的連接,基于此思路追蹤日志,發(fā)現(xiàn)部分 QueryId 沒有執(zhí)行 Close operation 方法。
有了這個思路后,再對 Hive 的源碼進行查閱,發(fā)現(xiàn) Close operation 方法被調(diào)用的前提是在一個名稱為 queryIdOperation 的 Map 對象中可以找出 QueryId,如果沒有從 queryIdOperation 找到合法的 QueryId,則不會觸發(fā) Close 方法。
再結合前面的堆棧圖,其中 queryIdOperation 占據(jù)了大量的內(nèi)存,于是基本可以確定定位出問題的原因,為當 SQL 執(zhí)行結束后,有一個 queryIdOperation 的 Map 對象,沒有成功的移除內(nèi)部的內(nèi)容,導致該 Map 越來越大,最后導致 HiveServer2 內(nèi)存耗盡,出現(xiàn) OOM,有了這個大概的思路,就需要仔細分析為什么會出現(xiàn)這個問題,從而找到具體的解決方案。
分析
在解決這個問題之前,先對 HiveServer2 本身做一個分析,HiveServer2 不同于一般的數(shù)據(jù)庫服務,HiveServer2 是由一系列的 RPC 接口組成,具體的接口定義在 org.apache.hive.service.rpc.thrift 包下的 TCLIService.Iface 中,部分接口如下:
public TOpenSessionResp OpenSession(TOpenSessionReq req) throws org.apache.thrift.TException; public TCloseSessionResp CloseSession(TCloseSessionReq req) throws org.apache.thrift.TException; public TGetInfoResp GetInfo(TGetInfoReq req) throws org.apache.thrift.TException; public TExecuteStatementResp ExecuteStatement(TExecuteStatementReq req) throws org.apache.thrift.TException; public TGetTypeInfoResp GetTypeInfo(TGetTypeInfoReq req) throws org.apache.thrift.TException; public TGetCatalogsResp GetCatalogs(TGetCatalogsReq req) throws org.apache.thrift.TException; public TGetSchemasResp GetSchemas(TGetSchemasReq req) throws org.apache.thrift.TException; public TGetTablesResp GetTables(TGetTablesReq req) throws org.apache.thrift.TException; public TGetTableTypesResp GetTableTypes(TGetTableTypesReq req) throws org.apache.thrift.TException; public TGetColumnsResp GetColumns(TGetColumnsReq req) throws org.apache.thrift.TException;
- 1.
- 2.
- 3.
- 4.
- 5.
- 6.
- 7.
- 8.
- 9.
- 10.
更多關于接口和服務器的知識可查看:干貨 | 在字節(jié)跳動,一個更好的企業(yè)級SparkSQL Server這么做
每一個 RPC 接口之間相互獨立,一個作業(yè)從連接到執(zhí)行 SQL 再到作業(yè)結束,會調(diào)用一系列的 RPC 接口組合完成這個動作,中間通過 OperationHandle 中的 THandleIdentifier 作為唯一 session id,由客戶端每次執(zhí)行的時候進行傳遞,THandleIdentifier 在 OpenSession 的時候被創(chuàng)建。
HiveServer2 基于此對整個作業(yè)的執(zhí)行進行管理。具體的調(diào)用順序,以及調(diào)用何種接口,對于使用者是透明的,常用的客戶端例如 Hive JDBC Driver 或者 PyHive 等已經(jīng)封裝了對應的調(diào)用順序,使用者只需要關心正常的打開連接,執(zhí)行 SQL,關閉連接即可,與標準的數(shù)據(jù)庫操作邏輯保持一致。
一個簡單的調(diào)用邏輯如上圖所示,當一個 Connection 執(zhí)行多條 SQL 后,每一條 SQL 都是一個 Operation 進行記錄,并且各自擁有各自的 Query Id,HiveServer 基于此 Query Id 做一些狀態(tài)的管理,當連接結束后,調(diào)用 CloseOperation 清理所有內(nèi)容。
每一條 SQL 執(zhí)行結束后,都會調(diào)用 CloseOperation 進行相關的狀態(tài)清除,如果清除失敗,當 connection 被 close 的時候,也會循環(huán)調(diào)用 CloseOperation 去清理狀態(tài),確保狀態(tài)的一致性。這里需要注意的是,既然 HiveServer2 是一系列的獨立 RPC 接口,那么必然會出現(xiàn)萬一用戶不調(diào)用某些接口怎么辦,例如不調(diào)用 CloseSession,HiveServer2 為了解決這個問題內(nèi)置了一個超時機制,當 Connection 達到超時的閾值后,會執(zhí)行 close 動作,清除 Session 和 Operation 的狀態(tài),具體的實現(xiàn)在 SessionManager 中的 startTimeoutChecker 方法中:
有了這些知識,再來分析前面出現(xiàn) OOM 的問題,出現(xiàn) OOM 是一個名叫 queryIdOperation 的 ConcurrentHashMap 對象占據(jù)了大量的內(nèi)存,對這個對象分析會發(fā)現(xiàn)這個對象位于:
一個 Hive Connection 被打開后,可以執(zhí)行多條 SQL,每一條 SQL 都是一個獨立的 Operation,此 Map 維護一個 queryId 和 Operation 的關系。
當一個新的 SQL 作業(yè)到達的時候,QueryState 對象的 build 方法會構建出一個 queryState,在這里生成此 SQL 的唯一標記,也就是 QueryId:
并且將該 QueryId 添加至 Connection 對象持有的 Hive Session,同時調(diào)用 OperationManager 的 addOperation 方法將此對象添加至 Map 中:
當作業(yè)執(zhí)行結束后,通過 OperationManager.closeOperation 調(diào)用 removeOperation 移除該 Map 中的映射:
而 Query Id 是通過頂層的 Connection 中的 HiveSession 中去獲?。?
即使這里 removeOperation 失敗了,在 CloseSession,或者 HiveServer2 觸發(fā)超時動作后,都會再次回收該 Map 對象中的內(nèi)容。
有了這個思路,于是再去對日志進行深度分析,發(fā)現(xiàn):
很多 SQL 作業(yè)在執(zhí)行后,并沒有調(diào)用 removeOperation 的行為,可以看到也就自然沒有觸發(fā)移除 queryIdOperation 的內(nèi)容,那么內(nèi)存被耗盡自然就可以理解,同時在 SQL 執(zhí)行后會緊接著產(chǎn)生一個非法 Operation 的堆棧:
思路理到這里,需要想的問題是:為什么沒有觸發(fā) removeOperation 的行為,或者說 removeOperation 沒有執(zhí)行成功,基于前面的理解來看,removeOperation 會有3種觸發(fā)時機,分別是:
- SQL 作業(yè)執(zhí)行結束調(diào)用 CloseOperatipn。
- Connection 斷開調(diào)用 CloseSession。
- HiveServer2 自身的狀態(tài)判斷 Connection 超時發(fā)起 Close。
所以沒有被調(diào)用的可能性不大,那么只剩下調(diào)用了,但是沒有執(zhí)行成功,沒有執(zhí)行成功也有2種情況:
- 執(zhí)行了,但是失敗了。
- 執(zhí)行成功了,但是沒有移除。
失敗可能性不大,因為失敗了,那么一定會留下堆棧信息,于是只剩下執(zhí)行了但是沒有移除,出現(xiàn)這樣的情況基本就是只能是:
里面查詢出的 QueryId 并不是當前作業(yè)的 QueryId,這個 ID 發(fā)生了篡改,那么什么樣的情況下會發(fā)生篡改?再來理一理 HiveServer 的狀態(tài)邏輯:
一個 Connection 執(zhí)行 SQL 的時候,會先產(chǎn)生一個 Operation,并且生成一個 Query Id,將這個 Query Id 設置成全局 HiveSession的內(nèi)容:
同時把這些信息存儲到這兩個 Map 中:
在 close 的時候再從 HiveSession 中去查詢出來,由于 HiveServer2 是一系列的獨立 RPC 請求,因此不能保證整個流程的原子性,那么想一種情況,假設 N 個并行線程,同時持有一個 Hive Connection,且同時開始發(fā)送 SQL 會怎樣?
可以看到如果兩個子線程同時使用同一個 Connection 執(zhí)行 SQL,于是會出現(xiàn)一個線程把另一個線程的 Query Id 進行覆蓋,導致其中一個線程丟失自己的 Query Id,導致無法成功的從 Map 中移除對象,具體的執(zhí)行思路為:
- t1: 線程 A 將 conf 中的 queryId 設成 A;
- t2: 線程 B 將 conf 中的 queryId 設成 B;
- t3: 線程 A 從 conf 中拿到 queryId 為 B,并 close B;
- t4: 線程 B 從 conf 中拿到 queryId 為 B,并 close B,出現(xiàn)異常。
于是一直遺留了 queryId A,因為兩個線程同時變成了相同的 Query Id,當其中一個線程執(zhí)行了 remove 動作后,另一個線程要基于當前 Query Id 再去查詢內(nèi)容的時候,便會出現(xiàn)緊接著的第二個錯誤,也就是非法的 Session Id。
由于本次出現(xiàn)問題的使用場景是 Airflow 進行調(diào)用,Airflow 具有工作流的能力可同時在一個 Dag 中并發(fā)開啟 N 個并行節(jié)點,而這些并行節(jié)點在同一個 Dag 下,因此共享同一個 Connection,于是觸發(fā)了這個問題。
但是我們要知道,多個線程使用同一個 Connection 是非常常見的現(xiàn)場,特別是在數(shù)據(jù)庫的連接池的概念中,那么為什么沒有出問題呢?這里也就涉及到 HiveServer2 本身的架構問題,HiveServer2 本身不是一個數(shù)據(jù)庫,僅僅提供了兼容 JDBC 接口的協(xié)議和 Driver 而已,因此相比傳統(tǒng)的數(shù)據(jù)庫的連接池,它并不能保證串行,也就是不具有排它效果,當然這只是次要問題,主要還是 HiveServer2 實現(xiàn)的缺陷。
對于此問題的復現(xiàn),只需要創(chuàng)建一個 HiveConnection,同時并行開啟多個線程同時使用該 Connection 對象執(zhí)行 SQL,便可復現(xiàn)這個問題。執(zhí)行過程中觀察 HiveServer2 內(nèi)存變化,可以發(fā)現(xiàn) HiveServer2 的內(nèi)存上升后,并沒有發(fā)生下降,隨著使用時間的增加,最后直至 OOM。
解決
既然找到了問題,那么解決方案就清楚了,那便是將 Query Id 這個值設置成 Operation 級別,而不是 HiveSession 級別,此問題影響 Hive3.x 版本,2.x 暫時沒有這個特性,因此不受影響。再對照官方已知的 issue,此問題是已知 issue,目前 Hive 已經(jīng)將此問題修復,且合入了4.0的版本,具體可查看:https://issues.apache.org/jira/browse/HIVE-22275
但是由于該 issue 是針對 4.0.0 的代碼修復的,對于 3.x 系列并沒有 patch,直接 cherry-pick 將會有大量的代碼不兼容,因此需要自行參考進行修復,修復的思路為給 Operation 新增:
將 Query Id 從 HiveSession 級別移除,存入 Operation 級別,同時更新 Query Id 的獲取和設置:
對 Hive 進行重新打包,在現(xiàn)有集群上對 hive-service-x.x.x.jar 進行替換,即可修復此問題。
結尾
雖然有些問題在官方 issue 上已經(jīng)有發(fā)布,但是實際業(yè)務過程中我們依舊需要仔細定位,確保當前的問題,與已知問題是一致的,盡可能少的留下隱患,同時也有助于更加掌握引擎本身的原理和實現(xiàn)邏輯。只有對問題有清晰的認知,且對解決方案的邏輯有足夠的了解,才能保證整個集群在生產(chǎn)環(huán)境下的穩(wěn)定。