前言
HiveServer2 屬于 Hive 組件的一個服務,主要提供 Hive 訪問接口,例如可通過 JDBC 的方式提交 Hive 作業,HiveServer2 基于 Java 開發,整個服務運行過程中,內存的管理回收均由 JVM 進行控制。在 JVM 語言中的內存泄漏與 C/C++ 語言的內存泄漏會有些差異,JVM 的內存泄漏更多的是業務代碼邏輯錯誤引起大量對象引用被持有,導致多次 GC 均無法被回收,或者部分對象占用內存過大,直接超過 JVM 分配的內存上限,導致 JVM 內存耗盡,引起 JVM 的 OOM。這種情況下該 JVM 服務會停止響應并且退出,但是并不會引起操作系統的崩潰。
背景
近期收到反饋,一套開啟高可用的 EMR 集群中的 HiveServer2 一段時間后便會停止服務,此集群的 HiveServer2 一共有3個節點,狀態信息注冊至 Zookeeper 中,提供 HA 的能力,一段時間后幾乎3個節點都會停止服務,通過對 HiveServer2 的日志查看發現是大量的 FULL GC后出現 OOM:

了解到該集群是一套從線下私有化部署的集群遷移而來,遷移前的集群中 HiveServer2 的 heapsize 為 2G,于是為了對齊業務參數將 heapsize 調整至 2G,間隔一天后,再次收到反饋,OOM 的問題依舊存在,查看日志,問題依舊是 HiveServer2 發生了 OOM,由于參數已經對齊之前的配置,那么問題可能不單純是內存不足,可能會有其他問題。于是首先將 HiveServer2 的 heapsize 調整為 4G,確保可以在一定時間內穩定運行,留下定位時間。
定位
定位方向為兩個方向:一個是分析 dump file,查看在內存不足的時候,內存消耗在哪些地方;第二個方向是針對日志進行細粒度分析,確保整個流程執行順序是合理的。

通過對 JVM 的 dump 文件進行分析,定位到在發生 HiveServer2 的 OOM 的時候,queryIdOperation 這個 ConcurrentHashMap 占據了大量的內存,而此時 HiveServer2 的負載非常低。
![]()
再基于具體的 QueryId 進行跟蹤日志,HiveServer2 對作業處理的邏輯為在建立 Connection 的時候會調用一次 OpenSession,拿到一個HiveConnection 對象,此后便通過 HiveConnection 對象調用 ExecuteStatement 執行 SQL,后臺每接收到一個 SQL 作業便生成一個 Operation 對象用來對 SQL 作業實現隔離。
每一個 Operation 有自己獨立的 QueryId,每條 SQL 作業會經歷編譯,執行,關閉環節,注意此關閉指的是關閉當前執行的 SQL 作業,而不是關閉整個 HiveServer2 的連接,基于此思路追蹤日志,發現部分 QueryId 沒有執行 Close operation 方法。

有了這個思路后,再對 Hive 的源碼進行查閱,發現 Close operation 方法被調用的前提是在一個名稱為 queryIdOperation 的 Map 對象中可以找出 QueryId,如果沒有從 queryIdOperation 找到合法的 QueryId,則不會觸發 Close 方法。
再結合前面的堆棧圖,其中 queryIdOperation 占據了大量的內存,于是基本可以確定定位出問題的原因,為當 SQL 執行結束后,有一個 queryIdOperation 的 Map 對象,沒有成功的移除內部的內容,導致該 Map 越來越大,最后導致 HiveServer2 內存耗盡,出現 OOM,有了這個大概的思路,就需要仔細分析為什么會出現這個問題,從而找到具體的解決方案。
分析
在解決這個問題之前,先對 HiveServer2 本身做一個分析,HiveServer2 不同于一般的數據庫服務,HiveServer2 是由一系列的 RPC 接口組成,具體的接口定義在 org.apache.hive.service.rpc.thrift 包下的 TCLIService.Iface 中,部分接口如下:

更多關于接口和服務器的知識可查看:干貨 | 在字節跳動,一個更好的企業級SparkSQL Server這么做
每一個 RPC 接口之間相互獨立,一個作業從連接到執行 SQL 再到作業結束,會調用一系列的 RPC 接口組合完成這個動作,中間通過 OperationHandle 中的 THandleIdentifier 作為唯一 session id,由客戶端每次執行的時候進行傳遞,THandleIdentifier 在 OpenSession 的時候被創建。
HiveServer2 基于此對整個作業的執行進行管理。具體的調用順序,以及調用何種接口,對于使用者是透明的,常用的客戶端例如 Hive JDBC Driver 或者 PyHive 等已經封裝了對應的調用順序,使用者只需要關心正常的打開連接,執行 SQL,關閉連接即可,與標準的數據庫操作邏輯保持一致。

一個簡單的調用邏輯如上圖所示,當一個 Connection 執行多條 SQL 后,每一條 SQL 都是一個 Operation 進行記錄,并且各自擁有各自的 Query Id,HiveServer 基于此 Query Id 做一些狀態的管理,當連接結束后,調用 CloseOperation 清理所有內容。
每一條 SQL 執行結束后,都會調用 CloseOperation 進行相關的狀態清除,如果清除失敗,當 connection 被 close 的時候,也會循環調用 CloseOperation 去清理狀態,確保狀態的一致性。這里需要注意的是,既然 HiveServer2 是一系列的獨立 RPC 接口,那么必然會出現萬一用戶不調用某些接口怎么辦,例如不調用 CloseSession,HiveServer2 為了解決這個問題內置了一個超時機制,當 Connection 達到超時的閾值后,會執行 close 動作,清除 Session 和 Operation 的狀態,具體的實現在 SessionManager 中的 startTimeoutChecker 方法中:

有了這些知識,再來分析前面出現 OOM 的問題,出現 OOM 是一個名叫 queryIdOperation 的 ConcurrentHashMap 對象占據了大量的內存,對這個對象分析會發現這個對象位于:

一個 Hive Connection 被打開后,可以執行多條 SQL,每一條 SQL 都是一個獨立的 Operation,此 Map 維護一個 queryId 和 Operation 的關系。
當一個新的 SQL 作業到達的時候,QueryState 對象的 build 方法會構建出一個 queryState,在這里生成此 SQL 的唯一標記,也就是 QueryId:

并且將該 QueryId 添加至 Connection 對象持有的 Hive Session,同時調用 OperationManager 的 addOperation 方法將此對象添加至 Map 中:

當作業執行結束后,通過 OperationManager.closeOperation 調用 removeOperation 移除該 Map 中的映射:

而 Query Id 是通過頂層的 Connection 中的 HiveSession 中去獲取:

即使這里 removeOperation 失敗了,在 CloseSession,或者 HiveServer2 觸發超時動作后,都會再次回收該 Map 對象中的內容。
有了這個思路,于是再去對日志進行深度分析,發現:

很多 SQL 作業在執行后,并沒有調用 removeOperation 的行為,可以看到也就自然沒有觸發移除 queryIdOperation 的內容,那么內存被耗盡自然就可以理解,同時在 SQL 執行后會緊接著產生一個非法 Operation 的堆棧:

思路理到這里,需要想的問題是:為什么沒有觸發 removeOperation 的行為,或者說 removeOperation 沒有執行成功,基于前面的理解來看,removeOperation 會有3種觸發時機,分別是:
SQL 作業執行結束調用 CloseOperatipn。
Connection 斷開調用 CloseSession。
HiveServer2 自身的狀態判斷 Connection 超時發起 Close。
所以沒有被調用的可能性不大,那么只剩下調用了,但是沒有執行成功,沒有執行成功也有2種情況:
執行了,但是失敗了。
執行成功了,但是沒有移除。
失敗可能性不大,因為失敗了,那么一定會留下堆棧信息,于是只剩下執行了但是沒有移除,出現這樣的情況基本就是只能是:

里面查詢出的 QueryId 并不是當前作業的 QueryId,這個 ID 發生了篡改,那么什么樣的情況下會發生篡改?再來理一理 HiveServer 的狀態邏輯:

一個 Connection 執行 SQL 的時候,會先產生一個 Operation,并且生成一個 Query Id,將這個 Query Id 設置成全局 HiveSession的內容:

同時把這些信息存儲到這兩個 Map 中:

在 close 的時候再從 HiveSession 中去查詢出來,由于 HiveServer2 是一系列的獨立 RPC 請求,因此不能保證整個流程的原子性,那么想一種情況,假設 N 個并行線程,同時持有一個 Hive Connection,且同時開始發送 SQL 會怎樣?

可以看到如果兩個子線程同時使用同一個 Connection 執行 SQL,于是會出現一個線程把另一個線程的 Query Id 進行覆蓋,導致其中一個線程丟失自己的 Query Id,導致無法成功的從 Map 中移除對象,具體的執行思路為:
t1: 線程 A 將 conf 中的 queryId 設成 A;
t2: 線程 B 將 conf 中的 queryId 設成 B;
t3: 線程 A 從 conf 中拿到 queryId 為 B,并 close B;
t4: 線程 B 從 conf 中拿到 queryId 為 B,并 close B,出現異常。
于是一直遺留了 queryId A,因為兩個線程同時變成了相同的 Query Id,當其中一個線程執行了 remove 動作后,另一個線程要基于當前 Query Id 再去查詢內容的時候,便會出現緊接著的第二個錯誤,也就是非法的 Session Id。
由于本次出現問題的使用場景是 Airflow 進行調用,Airflow 具有工作流的能力可同時在一個 Dag 中并發開啟 N 個并行節點,而這些并行節點在同一個 Dag 下,因此共享同一個 Connection,于是觸發了這個問題。
但是我們要知道,多個線程使用同一個 Connection 是非常常見的現場,特別是在數據庫的連接池的概念中,那么為什么沒有出問題呢?這里也就涉及到 HiveServer2 本身的架構問題,HiveServer2 本身不是一個數據庫,僅僅提供了兼容 JDBC 接口的協議和 Driver 而已,因此相比傳統的數據庫的連接池,它并不能保證串行,也就是不具有排它效果,當然這只是次要問題,主要還是 HiveServer2 實現的缺陷。
對于此問題的復現,只需要創建一個 HiveConnection,同時并行開啟多個線程同時使用該 Connection 對象執行 SQL,便可復現這個問題。執行過程中觀察 HiveServer2 內存變化,可以發現 HiveServer2 的內存上升后,并沒有發生下降,隨著使用時間的增加,最后直至 OOM。
解決
既然找到了問題,那么解決方案就清楚了,那便是將 Query Id 這個值設置成 Operation 級別,而不是 HiveSession 級別,此問題影響 Hive3.x 版本,2.x 暫時沒有這個特性,因此不受影響。再對照官方已知的 issue,此問題是已知 issue,目前 Hive 已經將此問題修復,且合入了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 進行重新打包,在現有集群上對 hive-service-x.x.x.jar 進行替換,即可修復此問題。
結尾
雖然有些問題在官方 issue 上已經有發布,但是實際業務過程中我們依舊需要仔細定位,確保當前的問題,與已知問題是一致的,盡可能少的留下隱患,同時也有助于更加掌握引擎本身的原理和實現邏輯。只有對問題有清晰的認知,且對解決方案的邏輯有足夠的了解,才能保證整個集群在生產環境下的穩定。
文章內容僅供閱讀,不構成投資建議,請謹慎對待。投資者據此操作,風險自擔。
海報生成中...
海藝AI的模型系統在國際市場上廣受好評,目前站內累計模型數超過80萬個,涵蓋寫實、二次元、插畫、設計、攝影、風格化圖像等多類型應用場景,基本覆蓋所有主流創作風格。
IDC今日發布的《全球智能家居清潔機器人設備市場季度跟蹤報告,2025年第二季度》顯示,上半年全球智能家居清潔機器人市場出貨1,2萬臺,同比增長33%,顯示出品類強勁的市場需求。