Hiveserver2 OOM 問題排查記錄 問題現象 Heap dump 分析 原因分析 問題解決

問題現象

生產環境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中有一個叫做queryIdOperationConcurrentHashMap,佔用了大量的內存。這個報告給出了問題分析的方向。下面的分析圍繞着queryIdOpereation展開。

原因分析

我們查看源代碼,發現OperationManager類中的queryIdOperation爲私有變量。因此queryIdOperation只可能在OperationManager中操作。繼續尋找操作queryIdOperation的方法,發現有如下三個:

  • addOperation
  • removeOperation
  • getOperationByQueryId

其中前兩個方法分別爲向集合中添加和移除元素。我們接下來分析這兩個方法。

OperationManageraddOperation方法代碼如下:

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;
}

接下來我們需要順着向上層找,分別追蹤addOperationremoveOperation方法的調用鏈。

addOperation方法在OperationManagernewExecuteStatementOperation方法中調用,內容如下:

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;
}

追溯這個方法調用,我們來到HiveSessionImplexecuteStatementInternal方法,內容如下:

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).
        }
    }
}

再繼續向上追蹤,發現上面的方法在HiveSessionImplexecuteStatementexecuteStatementAsync方法中調用(忽略了重載方法)。這兩個方法分別爲阻塞方式執行SQL statement和異步執行SQL statement。如果繼續向上追蹤調用,我們能夠找到CLIService類。ThriftCLIService類又再次包裝了CLIService類,它擁有ExecuteStatement方法。這個方法是thrift RPC調用的endpoint,通過TExecuteStatementReq類傳遞調用參數。繼續追蹤調用端的話我們陸續跟蹤到HiveStatementrunAsyncOnServer方法->execute方法(具體邏輯不再分析,只分析調用鏈)。HiveStatementjava.sql.Statement的子類,因此再往上分析就是JDBC使用的範疇了。調用鏈分析到這裏爲止。我們得到的結論是Hive JDBC調用statementexecute方法,會在執行SQL前創建一個operation。一條SQL的執行對應着一個operation。

接下來我們轉到RemoveOperation的分析。它的調用位於HiveSessionImplclose方法。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);
    }
}

繼續追蹤調用,我們發現它的調用端也在CLIServicecloseOperation方法。繼續跟蹤到ThriftCLIServiceCloseOperation方法,它也是thrift RPC endpoint。通過TCloseOperationReq傳遞RPC調用參數。追蹤到RPC調用端,我們跟蹤到HiveStatementcloseStatementIfNeeded方法。在往上追蹤,調用鏈爲closeClientOperation -> close方法。其中close方法重寫了java.sql.Statement的同名方法。到這裏我們得到結論,close Hive的statement的時候,會調用removeOperation,從而將operation從queryIdOperation中移除。

按照JDBC標準使用方式,statement使用完畢之後是必須要close的。也就是說正常情況下addOperationremoveOperation必然是成對出現。我們先假設用戶使用問題,沒有及時close掉statement。

接着繼續分析還有哪些時機會調用removeOperation方法。我們找到HiveSessionImplclose方法 -> SessionManagercloseSession方法。除了正常關閉session外,SessionManager中還有一個startTimeoutChecker。這個方法週期運行,當session超時的時候會自動關閉session。從而關閉所有的statement。這些措施確保了removeOperation是一定會被調用到的。就算是用戶使用問題,沒有close掉statement,這些operation也是可以被清理掉的。

造成OOM的原因是某些operation始終不能夠被remove掉。查看日誌我們的確發現部分query id的確沒有被remove掉(removeOperationLOG.info("Removed queryId: {} corresponding to operation: {}", queryId, opHandle);這一行代碼會打印日誌,存在一些query id沒有這一行日誌)。問題可能在於OperationManagergetQueryId方法。無法通過operation獲取到它對應的query id。

我們回到OperationManagergetQueryId方法。發現query id並沒有存儲在operation中,而是存儲在HiveConf中。

private String getQueryId(Operation operation) {
    return operation.getParentSession().getHiveConf().getVar(ConfVars.HIVEQUERYID);
}

一路跟蹤operation的parentSession是什麼時候賦值進去的。最終找到了HiveSessionImplexecuteStatementInternal方法。下面只貼出關鍵的一行,其他無關部分省略。

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的調用,我們發現QueryStatebuild方法。

QueryStatebuild方法中分配新的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();
}

跟蹤這個構造函數,不難發現ExecuteStatementOperationOperation的子類。創建ExecuteStatementOperation的時候調用了這個方法。

public ExecuteStatementOperation(HiveSession parentSession, String statement,
                                 Map<String, String> confOverlay, boolean runInBackground) {
    super(parentSession, confOverlay, OperationType.EXECUTE_STATEMENT);
    this.statement = statement;
}

ExecuteStatementOperationHiveCommandOperation的父類。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-26530patch。這個patch合併的時間明顯晚於Hive 3.1.0發佈的時間,是一個hotfix。它對應的正是OperationManagergetQueryId方法的修改。這個patch將OperationManagergetQueryId方法從:

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版本的環境都存在此隱患,建議緊急修復此問題。

本博客爲作者原創,歡迎大家參與討論和批評指正。如需轉載請註明出處。

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章