問題現象
生產環境hiveserver2使用過程中佔用大量內存,甚至將內存上限增大到32G左右後hiveserver2仍會達到內存上限。使用G1GC,單次full GC耗時很長,且絕大部分內存無法被回收(只能回收幾百M內存),然後發生OOM退出。懷疑存在內存泄漏問題。本文圍繞hiveserver2內存泄漏問題展開分析。
生產環境Hive版本爲3.1.0。
Heap dump 分析
在生產服務器獲取到hiveserver2 OOM時候的heap dump之後,使用MAT工具分析。獲取它的leak suspect報告。具體分析步驟參見Java Heap Dump 分析步驟。
我們使用瀏覽器打開leak suspect報告。發現裏面有一個內存泄漏懷疑點。打開詳情,內容如下圖所示:
上圖中可以很明顯的看出operationManager
中有一個叫做queryIdOperation
的ConcurrentHashMap
,佔用了大量的內存。這個報告給出了問題分析的方向。下面的分析圍繞着queryIdOpereation
展開。
原因分析
我們查看源代碼,發現OperationManager
類中的queryIdOperation
爲私有變量。因此queryIdOperation
只可能在OperationManager
中操作。繼續尋找操作queryIdOperation
的方法,發現有如下三個:
- addOperation
- removeOperation
- getOperationByQueryId
其中前兩個方法分別爲向集合中添加和移除元素。我們接下來分析這兩個方法。
OperationManager
的addOperation
方法代碼如下:
private void addOperation(Operation operation) {
LOG.info("Adding operation: " + operation.getHandle());
// 通過getQueryId方法從operation中獲取queryId,然後將queryId作爲key,存放入queryIdOperation
queryIdOperation.put(getQueryId(operation), operation);
handleToOperation.put(operation.getHandle(), operation);
if (operation instanceof SQLOperation) {
synchronized (webuiLock) {
liveQueryInfos.put(operation.getHandle().getHandleIdentifier().toString(),
((SQLOperation) operation).getQueryInfo());
}
}
}
getQueryId
方法內容如下:
private String getQueryId(Operation operation) {
// 獲取parent session的HiveConf對象
// queryId在這個HiveConf對象當中存放
return operation.getParentSession().getHiveConf().getVar(ConfVars.HIVEQUERYID);
}
這個方法是重點,此處先埋個伏筆,後面還會再次分析這個方法。
removeOperation
方法邏輯如下:
private Operation removeOperation(OperationHandle opHandle) {
Operation operation = handleToOperation.remove(opHandle);
// 通過上面的邏輯,獲取queryId
String queryId = getQueryId(operation);
// 從queryIdOperation集合中remove掉
queryIdOperation.remove(queryId);
LOG.info("Removed queryId: {} corresponding to operation: {}", queryId, opHandle);
if (operation instanceof SQLOperation) {
removeSafeQueryInfo(opHandle);
}
return operation;
}
接下來我們需要順着向上層找,分別追蹤addOperation
和removeOperation
方法的調用鏈。
addOperation
方法在OperationManager
的newExecuteStatementOperation
方法中調用,內容如下:
public ExecuteStatementOperation newExecuteStatementOperation(HiveSession parentSession,
String statement, Map<String, String> confOverlay, boolean runAsync, long queryTimeout)
throws HiveSQLException {
// 創建一個ExecuteStatementOperation
ExecuteStatementOperation executeStatementOperation =
ExecuteStatementOperation.newExecuteStatementOperation(parentSession, statement,
confOverlay, runAsync, queryTimeout);
// 調用addOperation
addOperation(executeStatementOperation);
return executeStatementOperation;
}
追溯這個方法調用,我們來到HiveSessionImpl
的executeStatementInternal
方法,內容如下:
private OperationHandle executeStatementInternal(String statement,
Map<String, String> confOverlay, boolean runAsync, long queryTimeout) throws HiveSQLException {
acquire(true, true);
ExecuteStatementOperation operation = null;
OperationHandle opHandle = null;
try {
// 此處調用了newExecuteStatementOperation
operation = getOperationManager().newExecuteStatementOperation(getSession(), statement,
confOverlay, runAsync, queryTimeout);
opHandle = operation.getHandle();
addOpHandle(opHandle);
operation.run();
return opHandle;
} catch (HiveSQLException e) {
// Refering to SQLOperation.java, there is no chance that a HiveSQLException throws and the
// async background operation submits to thread pool successfully at the same time. So, Cleanup
// opHandle directly when got HiveSQLException
if (opHandle != null) {
removeOpHandle(opHandle);
getOperationManager().closeOperation(opHandle);
}
throw e;
} finally {
if (operation == null || operation.getBackgroundHandle() == null) {
release(true, true); // Not async, or wasn't submitted for some reason (failure, etc.)
} else {
releaseBeforeOpLock(true); // Release, but keep the lock (if present).
}
}
}
再繼續向上追蹤,發現上面的方法在HiveSessionImpl
的executeStatement
和executeStatementAsync
方法中調用(忽略了重載方法)。這兩個方法分別爲阻塞方式執行SQL statement和異步執行SQL statement。如果繼續向上追蹤調用,我們能夠找到CLIService
類。ThriftCLIService類又再次包裝了CLIService
類,它擁有ExecuteStatement
方法。這個方法是thrift RPC調用的endpoint,通過TExecuteStatementReq
類傳遞調用參數。繼續追蹤調用端的話我們陸續跟蹤到HiveStatement
的runAsyncOnServer
方法->execute
方法(具體邏輯不再分析,只分析調用鏈)。HiveStatement
是java.sql.Statement
的子類,因此再往上分析就是JDBC使用的範疇了。調用鏈分析到這裏爲止。我們得到的結論是Hive JDBC調用statement
的execute
方法,會在執行SQL前創建一個operation。一條SQL的執行對應着一個operation。
接下來我們轉到RemoveOperation
的分析。它的調用位於HiveSessionImpl
的close
方法。close方法會關閉所有的operation。代碼如下所示,其他不相關的邏輯此處不分析。
@Override
public void close() throws HiveSQLException {
try {
acquire(true, false);
// Iterate through the opHandles and close their operations
List<OperationHandle> ops = null;
synchronized (opHandleSet) {
ops = new ArrayList<>(opHandleSet);
opHandleSet.clear();
}
// 遍歷各個operationHandle,一個operationHandle對應着一個operation
// 然後關閉他們
for (OperationHandle opHandle : ops) {
operationManager.closeOperation(opHandle);
}
// Cleanup session log directory.
cleanupSessionLogDir();
HiveHistory hiveHist = sessionState.getHiveHistory();
if (null != hiveHist) {
hiveHist.closeStream();
}
try {
sessionState.resetThreadName();
sessionState.close();
} finally {
sessionState = null;
}
} catch (IOException ioe) {
throw new HiveSQLException("Failure to close", ioe);
} finally {
if (sessionState != null) {
try {
sessionState.resetThreadName();
sessionState.close();
} catch (Throwable t) {
LOG.warn("Error closing session", t);
}
sessionState = null;
}
if (sessionHive != null) {
try {
Hive.closeCurrent();
} catch (Throwable t) {
LOG.warn("Error closing sessionHive", t);
}
sessionHive = null;
}
release(true, false);
}
}
除此之外還有一處調用位於closeOperation
方法,內容如下。
@Override
public void closeOperation(OperationHandle opHandle) throws HiveSQLException {
acquire(true, false);
try {
operationManager.closeOperation(opHandle);
synchronized (opHandleSet) {
opHandleSet.remove(opHandle);
}
} finally {
release(true, false);
}
}
繼續追蹤調用,我們發現它的調用端也在CLIService
的closeOperation
方法。繼續跟蹤到ThriftCLIService
的CloseOperation
方法,它也是thrift RPC endpoint。通過TCloseOperationReq
傳遞RPC調用參數。追蹤到RPC調用端,我們跟蹤到HiveStatement
的closeStatementIfNeeded
方法。在往上追蹤,調用鏈爲closeClientOperation
-> close
方法。其中close
方法重寫了java.sql.Statement
的同名方法。到這裏我們得到結論,close Hive的statement的時候,會調用removeOperation
,從而將operation從queryIdOperation
中移除。
按照JDBC標準使用方式,statement使用完畢之後是必須要close的。也就是說正常情況下addOperation
和removeOperation
必然是成對出現。我們先假設用戶使用問題,沒有及時close掉statement。
接着繼續分析還有哪些時機會調用removeOperation
方法。我們找到HiveSessionImpl
的close
方法 -> SessionManager
的closeSession
方法。除了正常關閉session外,SessionManager
中還有一個startTimeoutChecker
。這個方法週期運行,當session超時的時候會自動關閉session。從而關閉所有的statement。這些措施確保了removeOperation
是一定會被調用到的。就算是用戶使用問題,沒有close掉statement,這些operation也是可以被清理掉的。
造成OOM的原因是某些operation
始終不能夠被remove掉。查看日誌我們的確發現部分query id的確沒有被remove掉(removeOperation
中LOG.info("Removed queryId: {} corresponding to operation: {}", queryId, opHandle);
這一行代碼會打印日誌,存在一些query id沒有這一行日誌)。問題可能在於OperationManager
的getQueryId
方法。無法通過operation獲取到它對應的query id。
我們回到OperationManager
的getQueryId
方法。發現query id並沒有存儲在operation中,而是存儲在HiveConf
中。
private String getQueryId(Operation operation) {
return operation.getParentSession().getHiveConf().getVar(ConfVars.HIVEQUERYID);
}
一路跟蹤operation的parentSession是什麼時候賦值進去的。最終找到了HiveSessionImpl
的executeStatementInternal
方法。下面只貼出關鍵的一行,其他無關部分省略。
operation = getOperationManager().newExecuteStatementOperation(getSession(), statement,
confOverlay, runAsync, queryTimeout);
getSession
方法返回的是this
。說明這些operation共用同一個Hive Session(同一個JDBC連接下所有操作公用session)。自然HiveConf
也是公用的。到這裏爲止分析的重點來到了這個HiveConf
保存的內容上。
Hive的query id存儲在HiveConf中,key爲ConfVars.HIVEQUERYID
。猜測這個key一定有某個地方被set。跟蹤HiveConf
的set這個key的調用,我們發現QueryState
的build
方法。
QueryState
的build
方法中分配新的queryId。方法內容如下:
public QueryState build() {
HiveConf queryConf;
if (isolated) {
// isolate query conf
if (hiveConf == null) {
queryConf = new HiveConf();
} else {
queryConf = new HiveConf(hiveConf);
}
} else {
queryConf = hiveConf;
}
// Set the specific parameters if needed
if (confOverlay != null && !confOverlay.isEmpty()) {
// apply overlay query specific settings, if any
for (Map.Entry<String, String> confEntry : confOverlay.entrySet()) {
try {
queryConf.verifyAndSet(confEntry.getKey(), confEntry.getValue());
} catch (IllegalArgumentException e) {
throw new RuntimeException("Error applying statement specific settings", e);
}
}
}
// Generate the new queryId if needed
// 如果需要生成新的query id
if (generateNewQueryId) {
// 分配新的query id
String queryId = QueryPlan.makeQueryId();
queryConf.setVar(HiveConf.ConfVars.HIVEQUERYID, queryId);
// FIXME: druid storage handler relies on query.id to maintain some staging directories
// expose queryid to session level
// 將query id存放到hive session中
if (hiveConf != null) {
hiveConf.setVar(HiveConf.ConfVars.HIVEQUERYID, queryId);
}
}
QueryState queryState = new QueryState(queryConf);
if (lineageState != null) {
queryState.setLineageState(lineageState);
}
return queryState;
}
下面我們要確認下這個build
方法是否在執行SQL查詢的過程中調用。跟蹤調用我們發現Operation
類的構造函數。內容如下:
protected Operation(HiveSession parentSession,
Map<String, String> confOverlay, OperationType opType) {
this.parentSession = parentSession;
this.opHandle = new OperationHandle(opType, parentSession.getProtocolVersion());
beginTime = System.currentTimeMillis();
lastAccessTime = beginTime;
operationTimeout = HiveConf.getTimeVar(parentSession.getHiveConf(),
HiveConf.ConfVars.HIVE_SERVER2_IDLE_OPERATION_TIMEOUT, TimeUnit.MILLISECONDS);
scheduledExecutorService = Executors.newScheduledThreadPool(1);
currentStateScope = updateOperationStateMetrics(null, MetricsConstant.OPERATION_PREFIX,
MetricsConstant.COMPLETED_OPERATION_PREFIX, state);
// 這裏創建出了queryState
// 這個queryState被operation持有
queryState = new QueryState.Builder()
.withConfOverlay(confOverlay)
// 指定需要生成query id
.withGenerateNewQueryId(true)
.withHiveConf(parentSession.getHiveConf())
.build();
}
跟蹤這個構造函數,不難發現ExecuteStatementOperation
是Operation
的子類。創建ExecuteStatementOperation
的時候調用了這個方法。
public ExecuteStatementOperation(HiveSession parentSession, String statement,
Map<String, String> confOverlay, boolean runInBackground) {
super(parentSession, confOverlay, OperationType.EXECUTE_STATEMENT);
this.statement = statement;
}
ExecuteStatementOperation
是HiveCommandOperation
的父類。HiveCommandOperation
的構造函數中自然需要調用上面的方法。
ExecuteStatementOperation
還有一個方法newExecuteStatementOperation
。這個方法我們上面已經分析過了,它最後創建了一個HiveCommandOperation
對象並返回。經過這段分析我們驗證了Hive每次執行SQL statement的時候都會設置一個新的query id。那麼問題來了,如果上一個query id還被來得及被remove就設置了新的query id,上一個query id就再也沒有機會被remove,造成OOM的問題。同一個session只會保存最後一個query id。到此問題的根源已經找到。
問題解決
跟蹤社區我們發現在Hive項目的branch-3.1
分支中有一個HIVE-26530
patch。這個patch合併的時間明顯晚於Hive 3.1.0發佈的時間,是一個hotfix。它對應的正是OperationManager
的getQueryId
方法的修改。這個patch將OperationManager
的getQueryId
方法從:
private String getQueryId(Operation operation) {
return operation.getParentSession().getHiveConf().getVar(ConfVars.HIVEQUERYID);
}
修改爲:
private String getQueryId(Operation operation) {
return operation.getQueryId();
}
Operation
類增加如下代碼:
public String getQueryId() {
return queryState.getQueryId();
}
該patch做出的改動將query id保存在每個operation
專有的queryState
中,從而杜絕了query id被覆蓋的情況。將本地Hive3.1.0代碼合入這個patch後重新編譯。替換集羣中的hive-service-xxx.jar
爲新編譯輸出的jar後重啓集羣,問題解決。目前使用Hive 3.x版本的環境都存在此隱患,建議緊急修復此問題。
本博客爲作者原創,歡迎大家參與討論和批評指正。如需轉載請註明出處。