Hive原始碼解析

哥不是小萝莉發表於2024-08-29

1.概述

Apache Hive是一款建立在Hadoop之上的資料倉儲工具,它提供了類似於SQL的查詢語言,使得使用者能夠透過簡單的SQL語句來處理和分析大規模的資料。本文將深入分析Apache Hive的原始碼,探討其關鍵元件和工作原理,以便更好地理解其在大資料處理中的角色。

2.內容

在開始原始碼分析之前,讓我們先了解一下Hive的整體架構。Hive採用了類似於傳統資料庫的表結構,但底層資料儲存在Hadoop分散式檔案系統(HDFS)中。其架構主要包括後設資料儲存、查詢編譯器、執行引擎等關鍵元件。如圖所示。

2.1 瞭解Hive整體架構組成

瞭解Hive整體架構組成是深入分析Hive原始碼的基礎。透過仔細閱讀Hive官方文件以及深入研究原始碼結構,我們能夠揭示Hive系統的基本構成。整體而言,Hive架構主要由使用者介面、後設資料儲存、查詢處理流程以及資料儲存與計算等關鍵元件組成。這些元件相互協作,構建了一個強大而靈活的大資料處理框架,使得使用者可以以SQL的方式便捷地操作分散式儲存在HDFS中的龐大資料集。

1.使用者介面

Hive提供了三種主要的使用者介面,分別是命令列、JDBC/ODBC 客戶端和 Web UI介面。其中,命令列是最為常用的,為使用者提供了便捷的命令列介面。JDBC/ODBC 客戶端則是Hive的Java客戶端,透過類似於傳統資料庫JDBC的方式連線至Hive Server。而Web UI介面則透過瀏覽器訪問,提供了更直觀的圖形化操作介面。

2.後設資料儲存

Hive的後設資料(MetaStore)儲存在關係型資料庫中,比如MySQL或Derby。這些後設資料包括表的名稱、表的列和分割槽屬性、表的特性(例如是否為外部表)、以及表的資料所在目錄等資訊。

3.查詢處理流程

Hive的查詢處理包括直譯器、編譯器、最佳化器等模組,負責對Hive SQL查詢語句進行詞法分析、語法分析、編譯、最佳化以及生成查詢計劃。生成的查詢計劃儲存在Hadoop分散式檔案系統(HDFS)中,並在隨後透過MapReduce任務的排程來執行。

4.資料儲存與計算

Hive的資料儲存在HDFS中,而大部分的查詢和計算任務由MapReduce完成。值得注意的是,一些查詢可能不會生成MapReduce任務,例如,對於類似於 SELECT * FROM stu 的查詢,Hive能夠執行一種高效的讀取操作。在這種情況下,Hive直接掃描與表 stu 相關聯的儲存目錄下的檔案,然後將查詢結果輸出。但大多數涉及資料的計算操作都會透過MapReduce實現。
透過上述架構分析,我們能夠更清晰地瞭解Hive在資料處理過程中的工作流程,包括使用者介面的選擇、後設資料的管理、查詢語句的處理,以及資料的儲存和計算方式。這有助於開發者更好地理解和最佳化Hive在大資料環境中的效能和可擴充套件性。

2.2 深度分析Hive後設資料儲存機制

關於資料儲存,Hive提供了極大的靈活性,不設定專門的資料儲存格式或索引。使用者能夠自由組織Hive中的表,只需在建立表時指定資料的列分隔符和行分隔符,Hive即可解析資料。所有的資料都儲存在Hadoop分散式檔案系統(HDFS)中,儲存結構主要包括資料庫、檔案、表和檢視。Hive的資料模型涵蓋了Table(內部表)、External Table(外部表)、Partition(分割槽)和Bucket(桶)。Hive預設支援直接載入文字檔案,同時還提供了對各種壓縮檔案的支援,比GZIP、ZLIB、SNAPPY等。
此外,Hive將後設資料儲存在關係型資料庫管理系統(RDBMS)中,使用者可以透過三種不同的模式連線到資料庫。這種設計使得Hive能夠靈活地與多種關係型資料庫整合,提供了後設資料管理的可擴充套件性和可定製性。

1.內嵌模式

這種模式連線到一個本地內嵌的資料庫Derby,通常用於單元測試。內嵌的Derby資料庫每次只能訪問一個資料檔案,這意味著它不支援多會話連線。這種配置適用於輕量級的測試場景,其中每個測試可以在相對獨立的資料庫環境中執行,確保測試之間的隔離性和可重複性。如圖所示。

2.本地模式

這種模式本質上將Hive預設的後設資料儲存介質從內建的Derby資料庫切換至MySQL資料庫。透過這種配置,不論以何種方式或在何處啟動Hive,只要連線到同一臺Hive服務,所有節點都能訪問一致的後設資料資訊,實現後設資料的共享。如圖所示。

3.遠端模式

在遠端模式下,MetaStore服務在其自己的獨立JVM上執行,而不是在HiveServer的JVM中。其他程序若要與MetaStore伺服器通訊,則可以使用Thrift協議連線至MetaStore服務進行後設資料庫訪問。在生產環境中,強烈建議配置Hive MetaStore為遠端模式。在這種配置下,其他依賴於Hive的軟體能夠透過MetaStore訪問Hive。由於這種模式下還可以完全遮蔽資料庫層,因此帶來更好的可管理性和安全性。如圖所示。

請注意,在遠端模式下,我們需要配置hive.metastore.uris引數,明確指定MetaStore服務執行的機器IP和埠。此外,還需要手動單獨啟動Metastore服務。

2.3 深入解析Hive的工作原理

深度解析Hive的工作原理,不僅僅是對其內部機制進行透徹理解,更是對大資料處理正規化進行深刻認知的探索。本節將引領讀者從使用者查詢到底層資料儲存,從後設資料管理到分散式計算引擎,深入剖析Hive的工作原理技術內幕。
Hive的內部核心元件主要包含:後設資料儲存、查詢編譯器、執行引擎和資料儲存與計算。後設資料儲存負責管理關於表結構、分割槽資訊和其他後設資料的資訊,而查詢編譯器將Hive SQL語句翻譯成MapReduce任務,最終由執行引擎在Hadoop叢集上排程和執行。

1.MetaStore(後設資料儲存)

負責儲存和管理Hive的後設資料,它使用關聯式資料庫來持久儲存後設資料資訊,其中包括有關表結構、分割槽資訊等的關鍵資訊。

2.直譯器和編譯器

這一部分負責將使用者提交的SQL語句轉換成語法樹,然後生成DAG(有向無環圖)形式的Job鏈,形成邏輯計劃。這個過程確保了SQL查詢的合法性和最佳化的可行性。

3.最佳化器

Hive的最佳化器提供了基於規則的最佳化,其中包括列過濾、行過濾、謂詞下推以及不同的Join方式。列過濾透過去除查詢中不需要的列,行過濾在TableScan階段進行,利用Partition資訊只讀取符合條件的Partition。謂詞下推有助於減少後續處理的資料量。對於Join,Hive支援Map端Join、Shuffle Join、Sort Merge Join等多種方式,以適應不同的資料分佈和處理需求。

4.執行器

執行器負責將最佳化後的DAG轉換為MapReduce任務,按順序執行其中的所有Job。在沒有依賴關係的情況下,執行器採用併發方式執行,以提高整體執行效率。這個階段將邏輯計劃轉化為實際的MapReduce任務,並執行相應的資料處理操作。
這些核心元件協同工作,構成了Hive在大資料環境中進行資料處理的完整流程。從後設資料管理到SQL查詢的解析和最佳化,再到最終的執行,這一系列的步驟清晰地展現了Hive在分散式環境中的強大功能。如圖所示。

3.深度分析Hive Driver工作機制

在閱讀一個框架的原始碼時,通常的做法是從程式的入口開始,只關注核心部分,跳過校驗和異常等次要細節。在深入研究Hive的原始碼時,我們的起點往往是執行指令碼。
在深入分析Hive執行指令碼之前,我們首先來了解一下Hive的原始碼目錄結構,如圖所示。

首先,我們可以觀察程式碼以確定Hive的客戶端模式,是Cli還是Beeline。程式碼如下:

# 檢查SERVICE變數是否為空
if [ "$SERVICE" = "" ] ; then
  # 如果SERVICE為空,再檢查HELP變數是否為"_help"
  if [ "$HELP" = "_help" ] ; then
    # 如果HELP是"_help",將SERVICE設定為"help"
    SERVICE="help"
  else
    # 如果HELP不是"_help",將SERVICE設定為"cli"
    SERVICE="cli"
  fi
fi

# 檢查SERVICE變數是否為"cli"且USE_BEELINE_FOR_HIVE_CLI變數是否為"true"
if [[ "$SERVICE" == "cli" && "$USE_BEELINE_FOR_HIVE_CLI" == "true" ]] ; then
  # 如果兩個條件都滿足,將SERVICE設定為"beeline"
  SERVICE="beeline"
fi

在bin/hive目錄下,存在一個名為cli.sh的指令碼,它包含了啟動Hive Cli或Beeline的邏輯實現。程式碼如下:

以下是給定Hive指令碼的中文註釋版本:

shell
# 設定THISSERVICE變數為"cli"
THISSERVICE=cli
# 將THISSERVICE新增到SERVICE_LIST環境變數中,後面新增一個空格
export SERVICE_LIST="${SERVICE_LIST}${THISSERVICE} "

# 設定舊版CLI為預設客戶端
# 如果USE_DEPRECATED_CLI未設定或不等於false,則使用舊版CLI
if [ -z "$USE_DEPRECATED_CLI" ] || [ "$USE_DEPRECATED_CLI" != "false" ]; then
  USE_DEPRECATED_CLI="true"
fi

# 定義updateCli函式,用於更新CLI配置
updateCli() {
  # 如果USE_DEPRECATED_CLI等於"true",則配置舊版CLI
  if [ "$USE_DEPRECATED_CLI" == "true" ]; then
    export HADOOP_CLIENT_OPTS=" -Dproc_hivecli $HADOOP_CLIENT_OPTS " # 新增配置選項
    CLASS=org.apache.hadoop.hive.cli.CliDriver # 設定類為舊版CLI驅動
    JAR=hive-cli-*.jar # 設定jar包為舊版CLI的jar包
  else
    # 如果USE_DEPRECATED_CLI不等於"true",則配置新版CLI(Beeline)
    export HADOOP_CLIENT_OPTS=" -Dproc_beeline $HADOOP_CLIENT_OPTS -Dlog4j.configurationFile=beeline-log4j2.properties" # 新增配置選項
    CLASS=org.apache.hive.beeline.cli.HiveCli # 設定類為新版CLI(Beeline)驅動
    JAR=hive-beeline-*.jar # 設定jar包為新版CLI(Beeline)的jar包
  fi
}

# 定義cli函式,用於執行Hive命令
cli () {
  updateCli # 呼叫updateCli函式更新配置
  execHiveCmd $CLASS $JAR "$@" # 執行Hive命令
}

# 定義cli_help函式,用於顯示Hive命令的幫助資訊
cli_help () {
  updateCli # 呼叫updateCli函式更新配置
  execHiveCmd $CLASS $JAR "--help" # 執行幫助命令
}

從實現指令碼里面可以看到,如果啟動的是Hive Cli,則會載入hive-cli-*.jar依賴,然後從對應的org.apache.hadoop.hive.cli.CliDriver類的main方法中啟動。如果啟動的是Beeline,則會載入hive-beeline-*.jar依賴中org.apache.hive.beeline.cli.HiveCli類中的main方法。下面,我們以CliDriver類為例子來進行原始碼入口分析。

1.main方法

在CliDriver類中,找到對應的main方法:

 public static void main(String[] args) throws Exception {
    // Hive Cli 啟動入口
    int ret = new CliDriver().run(args);
    // 退出虛擬機器
    System.exit(ret);
  }

在上述程式碼中,關鍵在於理解ret這個返回引數,它在整個流程中將起著非常重要的作用。在後續,可以總結ret的各種返回值,這樣可以根據退出程式碼大致判斷出錯誤的型別。例如,0 表示正常退出。

2.run方法

在CliDriver類中,找到對應的run方法:

public  int run(String[] args) throws Exception {

    OptionsProcessor oproc = new OptionsProcessor();
    // 解析引數
    if (!oproc.process_stage1(args)) {
      // 解析失敗,返回錯誤碼
      return 1;
}
// 省略其它程式碼
}

透過process_stage1方法進行引數校驗是整個流程的關鍵步驟。此方法主要用於驗證系統級別的引數,例如hiveconf、hive.root.logger、hivevar等。如果這類引數出現異常,將導致方法返回引數ret = 1,可能會影響後續過程的正常執行。因此,在執行Hive命令前,確保這些引數的正確性對於保證程式的順利執行非常重要。
接著,將會進行日誌類的初始化,儘管這部分不是關注的重點。初始化日誌類是為了在後續的執行過程中記錄重要資訊,幫助進行除錯、錯誤追蹤以及日誌記錄。這一步驟為後續執行階段提供了詳盡的執行時資訊,確保了程式的順利執行和問題排查的可行性。

// 初始化日誌,這裡會重新初始化log4j,這樣就可以在hive的其他核心類載入之前初始化log4j
boolean logInitFailed = false;
String logInitDetailMessage;
try {
  logInitDetailMessage = LogUtils.initHiveLog4j();
} catch (LogInitializationException e) {
  logInitFailed = true;
  logInitDetailMessage = e.getMessage();
}

// 這裡會初始化一些session的配置
CliSessionState ss = new CliSessionState(new HiveConf(SessionState.class));
// 設定一些輸入流
ss.in = System.in;
try {
  // 設定輸出流
  ss.out = new PrintStream(System.out, true, "UTF-8");
  // 設定資訊流
  ss.info = new PrintStream(System.err, true, "UTF-8");
  // 設定錯誤流
  ss.err = new CachingPrintStream(System.err, true, "UTF-8");
} catch (UnsupportedEncodingException e) {
  // 返回錯誤碼
  return 3;
}

在此部分,首先建立了客戶端會話類 CliSessionState,這個類承載著重要的資料,例如使用者輸入的 SQL 和 SQL 執行的結果,都會被封裝在其中。隨後,在這個類的基礎上,進行了標準輸入、輸出和錯誤流的初始化。值得注意的是,如果環境不支援 UTF-8 字元編碼,將導致方法返回值 ret = 3。這一步是非常關鍵的,因為環境對字元編碼的支援直接影響了後續對於字元處理和輸出結果的準確性。
在process_stage2階段,再次進行引數校驗,值得注意的是該階段的入參有所不同。在process_stage1中,入參是args,實際上在process_stage1階段,OptionsProcessor會儲存所有的args,並在process_stage2根據引數的鍵(key)賦值給CliSessionState物件 ss (雖然這個過程比較細節,但在整體理解中並非十分關鍵)。process_stage2負責解析使用者的引數,比如`-e`、`-f`、`-v`、`-database`等。當這類引數異常時,方法將返回值 ret = 2。這一步是對使用者引數的解析,其中出現異常可能導致後續的執行流程受阻。這個階段的關鍵在於理解和解析使用者輸入的引數,為後續的處理提供準確的指令和方向。

// 解析引數
if (!oproc.process_stage2(ss)) {
  // 引數解析失敗,返回錯誤碼
  return 2;
}

HiveConf是Hive的配置類,用於管理Hive的各種配置項。在命令列中,透過set命令可以修改當前會話的配置,這就是透過HiveConf物件實現的。prompt是互動頁面中的終端命令,可以透過配置進行修改。在這個階段,確保啟動引數級別沒有問題意味著即將進入互動式頁面,進入互動式頁面代表著使用者可以開始使用 Hive 進行互動式查詢和操作。這個階段的重要性在於保證Hive環境的配置準確性,以確保使用者能夠順利地開始互動式會話。

// 設定所有透過命令列指定的屬性
HiveConf conf = ss.getConf();
for (Map.Entry<Object, Object> item : ss.cmdProperties.entrySet()) {
  conf.set((String) item.getKey(), (String) item.getValue());
  ss.getOverriddenConfigurations().put((String) item.getKey(), 
  (String) item.getValue());
}

// 讀取提示配置並替換變數
prompt = conf.getVar(HiveConf.ConfVars.CLIPROMPT);
prompt = new VariableSubstitution(new HiveVariableSource() {
  @Override
  public Map<String, String> getHiveVariable() {
    return SessionState.get().getHiveVariables();
  }
}).substitute(conf, prompt);
prompt2 = spacesForString(prompt);

最後,我們進入核心程式碼部分,透過CliSessionState(會話資訊)、HiveConf(配置資訊)、OptionsProcessor(引數資訊),系統即將執行下一步操作。這個階段是整個執行流程的核心,將使用者的會話資訊、Hive的配置資訊以及使用者提供的引數資訊結合起來,為後續的操作提供了基礎和指導。這個過程負責將使用者的操作環境、配置以及指令引數整合,為即將開始的操作奠定基礎。

// 執行cli driver的工作
try {
  return executeDriver(ss, conf, oproc);
} finally {
  ss.resetThreadName();
  ss.close();
}

3.executeDriver方法

這部分主要涉及一些初始化工作。在啟動Hive時,如果我們指定了資料庫,處理過程將交由 processSelectDatabase 方法來處理。該方法的核心在於執行 processLine("use " + database + ";"),這意味著執行use命令切換到指定的資料庫。這個步驟在初始化過程中具有重要作用,因為它允許使用者在啟動Hive時直接定位到指定的資料庫,而不是使用預設資料庫。

CliDriver cli = new CliDriver();
cli.setHiveVariables(oproc.getHiveVariables());

// 如果指定了資料庫,則使用指定的資料庫
cli.processSelectDatabase(ss);

4.processLine方法

在processLine方法中,有一個引數可以控制退出。其中,引數true代表允許中斷,也就是允許使用者透過Ctrl + C進行中斷操作。因此,processLine最初處理的是中斷的邏輯。這類操作的本質是註冊一個JVM的鉤子(Hook)程式,它檢測訊號量並在JVM退出時執行一段特定的退出邏輯。這樣的實現能夠允許使用者在Hive會話中透過Ctrl + C中斷當前操作,並執行相應的清理或退出邏輯,確保資源得到正確釋放。我們可以手動編寫類似的程式來理解這種訊號量處理的機制和實現。

public static void main(String[] args) {
        // 註冊鉤子函式
        Runtime.getRuntime().addShutdownHook(new Thread(() -> {
            System.out.println("程式異常終止,執行清理工作");
        }, "Hook模擬執行緒"));

        while (true) {
            System.out.println(new Date().toString() + ": 邏輯處理");
            try {
                // 休眠10秒
                TimeUnit.SECONDS.sleep(10);
            } catch (InterruptedException e) {
                throw new RuntimeException(e);
            }
        }
    }

5.processCmd方法

在這部分核心程式碼中,透過SessionState,同時對 SQL 進行一些格式上的處理,比如去除前後空格,按空格分割成一個個 token 等操作。隨後對 SQL 或這些 token 進行一些判斷。這些操作可能包括語法檢查、語義驗證等,以確保使用者輸入的 SQL 符合預期的格式和規範,同時對輸入進行必要的正確性檢查。這些處理確保了系統能夠正確地執行使用者輸入的SQL語句。

CliSessionState ss = (CliSessionState) SessionState.get();
ss.setLastCommand(cmd);

ss.updateThreadName();
// 重新整理輸出流,這樣就不會包含上一條命令的輸出
ss.err.flush();
String cmd_trimmed = HiveStringUtils.removeComments(cmd).trim();
// 將 SQL 語句按照空格分割
String[] tokens = tokenizeCmd(cmd_trimmed);
int ret = 0;

6.processLocalCmd方法

這個方法實際上涉及整個流程的全域性處理,從 SQL 的解析開始,然後執行 SQL,最後將結果列印輸出。這一系列操作包括解析使用者輸入的 SQL 語句,將其轉換為可執行的任務,執行這些任務,並最終將執行結果呈現給使用者。在這個階段,系統處理了SQL的語法解析、邏輯執行、物理執行和結果輸出等環節。這個方法在Hive系統中屬於關鍵的處理步驟,完成了整個 SQL 查詢執行的主要流程。

int processLocalCmd(String cmd, CommandProcessor proc, CliSessionState ss) {
  boolean escapeCRLF = HiveConf.getBoolVar(conf, 
  HiveConf.ConfVars.HIVE_CLI_PRINT_ESCAPE_CRLF);
  int ret = 0;

  if (proc != null) {
    if (proc instanceof IDriver) {
      IDriver qp = (IDriver) proc;
      PrintStream out = ss.out;
      long start = System.currentTimeMillis();
      if (ss.getIsVerbose()) {
        out.println(cmd);
      }

      ret = qp.run(cmd).getResponseCode();
      if (ret != 0) {
        qp.close();
        return ret;
      }

      // 執行查詢,計算時間
      long end = System.currentTimeMillis();
      double timeTaken = (end - start) / 1000.0;

      ArrayList<String> res = new ArrayList<String>();

      printHeader(qp, out);

      // 列印輸出結果
      int counter = 0;
      try {
        if (out instanceof FetchConverter) {
          ((FetchConverter) out).fetchStarted();
        }
        while (qp.getResults(res)) {
          for (String r : res) {
                if (escapeCRLF) {
                  r = EscapeCRLFHelper.escapeCRLF(r);
                }
            out.println(r);
          }
          counter += res.size();
          res.clear();
          if (out.checkError()) {
            break;
          }
        }
      } catch (IOException e) {
        console.printError("Failed with exception " 
        + e.getClass().getName() + ":" + e.getMessage(),
            "\n" + org.apache.hadoop.util.StringUtils.stringifyException(e));
        ret = 1;
      }

      qp.close();

      if (out instanceof FetchConverter) {
        ((FetchConverter) out).fetchFinished();
      }

      console.printInfo(
          "Time taken: " + timeTaken + " seconds" 
          + (counter == 0 ? "" : ", Fetched: " + counter + " row(s)"));
    } else {
      String firstToken = tokenizeCmd(cmd.trim())[0];
      String cmd_1 = getFirstCmd(cmd.trim(), firstToken.length());

      if (ss.getIsVerbose()) {
        ss.out.println(firstToken + " " + cmd_1);
      }
      CommandProcessorResponse res = proc.run(cmd_1);
      if (res.getResponseCode() != 0) {
        ss.out
            .println("Query returned non-zero code: " 
            + res.getResponseCode() + ", cause: " + res.getErrorMessage());
      }
      if (res.getConsoleMessages() != null) {
        for (String consoleMsg : res.getConsoleMessages()) {
          console.printInfo(consoleMsg);
        }
      }
      ret = res.getResponseCode();
    }
  }

  return ret;
}

在CliDriver中,類方法的呼叫關係呈現為一個複雜的結構,主要涉及啟動CLI會話、處理命令列輸入、執行SQL語句等多個重要步驟。這些方法之間存在相互呼叫和依賴關係,形成了完整的執行流程。其中,一些核心方法包括引數處理、會話狀態管理、SQL解析、執行計劃生成和結果輸出等。這些方法之間的協調與互動構成了Hive CLI的完整工作流程。理解這些方法之間的呼叫關係有助於深入掌握Hive CLI的工作原理和內部機制。如圖所示。

4.總結

本章聚焦於Hive原始碼分析,深入探討了Hive查詢處理的核心流程。透過對原始碼的分析,讓大家逐步瞭解了Hive如何處理SQL查詢,詳細討論了每個處理階段的關鍵細節和功能。

5.結束語

這篇部落格就和大家分享到這裡,如果大家在研究學習的過程當中有什麼問題,可以加群進行討論或傳送郵件給我,我會盡我所能為您解答,與君共勉!

另外,博主出新書了《深入理解Hive》、同時已出版的《Kafka並不難學》和《Hadoop大資料探勘從入門到進階實戰》也可以和新書配套使用,喜歡的朋友或同學, 可以在公告欄那裡點選購買連結購買博主的書進行學習,在此感謝大家的支援。關注下面公眾號,根據提示,可免費獲取書籍的教學影片。

相關文章