openGauss執行器技術

Gauss松鼠會發表於2022-11-09

openGauss執行器在資料庫的整個體系結構中起承上(最佳化器)啟下(儲存)的作用。本文首先介紹 openGauss執行器的基本框架,然後引申介紹執行引擎中的一些關鍵技術。透過本文的閱讀,讀者能對 openGauss執行器有個基本的認識。

一、 openGauss執行器概述

從客戶端發出一條SQL語句到結果返回給客戶端的整體執行流程如圖1所示,從中可以看到執行器所處的位置。

在這裡插入圖片描述

圖1 客戶端發出SQL語句的執行流程示意圖

如果把資料庫看成一個組織,最佳化器位於組織的最上層,是這個組織的首腦,是發號施令下達指令的機構,執行器位於組織的中間,聽從最佳化器的指揮,嚴格執行最佳化器給予的計劃,將從儲存空間中讀取的資料進行加工處理最終返回給客戶端。

關係是元組(表中的每行,即資料庫中每條記錄)的集合,而關係代數是集合上的一系列操作。

執行器接收到的指令就是由最佳化器應對SQL查詢而翻譯出來的關係代數運算子所組成的執行樹。一棵形象的執行樹如圖2所示。

在這裡插入圖片描述

圖2 執行樹示意圖

圖中的每一個方塊代表一個具體的關係代數運算子,稱其為運算元,而兩種箭頭代表流(藍色箭頭為①,紅色箭頭為②)。其中,標註為①的流代表資料流,可以看到資料從葉節點流到根節點;標註為②的流代表控制流,從根節點向下驅動(指上層節點呼叫下層節點函式的資料傳送函式,從下層節點請求資料)。

執行器的整體目標就是在每一個由最佳化器構建出來的執行樹上,透過控制流驅動資料流在執行樹上高效流動,其流動的速度決定了執行器的處理效率。

二、openGauss執行引擎

下面具體介紹openGauss的執行引擎。

(一)執行流程

執行器的整體執行流程如圖3所示。

在這裡插入圖片描述

圖3 執行器整體執行流程圖

上文openGauss執行器概述中描述了執行器在整個資料庫架構中所處的位置,執行引擎的執行流程非常清晰,分成3個階段。

  • 初始化階段。在這個階段執行器會完成一些初始化工作,通常的做法是遍歷整個執行樹,根據每個運算元的不同特徵進行初始化執行。比如 HashJoin這個運算元,在這個階段會進行 Hash表的初始化,主要是記憶體的分配。
  • 執行階段。這個階段是執行器最重要的部分。在這個階段,執行器完成對於執行樹的迭代(Pipeline)遍歷,透過從磁碟讀取資料,根據執行樹的具體邏輯完成查詢語義。
  • 清理階段。因為執行器在初始化階段向系統申請了資源,所以在這個階段要完成對資源的清理。比如在 HashJoin初始化時對 Hash表記憶體申請的釋放。

(二) 執行運算元

openGauss執行器概述中提到表達一個SQL語句需要很多不同的代數運算子進行組合。openGauss為了完成這些代數運算子的功能,引入了運算元(Operator)。運算元是執行樹的最基本的運算單元。按照不同的功能,運算元劃分為如下幾種。

1.控制運算元

控制運算元並不對映代數運算子,而是為使執行器完成一些特殊的流程所引入的,其主要型別及描述見表1。

表1 控制運算元
運算元型別 描述
Result 處理僅需要一次計算的條件表示式或insert中的value子句
Append 處理大於或者等於2的子樹流程
BitmapAnd 需要對兩個或以上點陣圖進行並操作的流程
BitmapOr 需要對兩個或以上點陣圖進行或操作的流程
RecursiveUnion 用於處理with recursive遞迴查詢
Limit 用於處理下層資料的limit操作
VecToRow 用於普通執行器和向量化執行器之間資料傳輸的轉換

2.掃描運算元

掃描運算元負責從底層資料來源抽取資料,資料來源可能來自檔案系統,也可能來自網路(分散式查詢)。掃描節點(運算元在執行樹上稱為節點)都位於執行樹的葉子節點,作為執行數的資料輸入來源。掃描運算元的型別及描述見表2。

表2 掃描運算元
運算元型別 描述
Seqscan 順序掃描行存
CstoreScan 順序掃描列存
DfsScan 順序掃描HDFS類檔案系統
Stream 順序掃描來自網路的資料流,資料流一般來自其他子樹執行分發到網路中的資料
BitmapHeapScan 透過bitmap結構獲取元組
BitmapIndexScan 利用索引獲取滿足條件的bitmap結構
TidScan 透過事先得到的Tid來掃描heap上的資料
SubQueryScan 從子查詢的輸出來掃描資料
ValueScan 掃描Values子句產生的資料來源
CteScan 掃描cte表示式
WorkTableScan 掃描RecursiveUnion產生的迭代資料
FunctionScan 掃描Function產生的批次資料
IndexScan 掃描索引得到Tid,然後從heap上掃描資料
IndexOnlyScan 在某些情況下,可以只用掃描索引就能得到查詢想要的資料,因此不需要掃描heap
ForgeinScan 從使用者定義的外表資料來源掃描資料

3.物化運算元

物化運算元指運算元的處理無法全部在記憶體中完成,需要進行下盤(即寫入磁碟)操作。因為物化運算元演算法要求,在做物化運算元邏輯處理的時候,要求把下層的資料進行快取處理。因為對於下層運算元返回的資料量不可提前預知,所以需要在物化運算元演算法上考慮資料無法全部放置到記憶體的情況。物化運算元的型別及描述見表3。

表3 物化運算元
運算元型別 描述
Sort 對下層資料進行排序,例如快速排序
Group 對下層已經排序的資料進行分組
Agg 對下層資料進行分組(無序)
Unique 對下層資料進行去重操作
Hash 對下層資料進行快取,儲存到一個hash表裡
SetOp 對下層資料進行快取,用於處理intersect等集合操作

4.連線運算元

連線運算元是為了應對資料庫中最常見的連線操作,根據處理演算法和資料輸入源的不同,連線運算元分成以下幾種型別,如表4所示。

表4 連線運算元
運算元型別 描述
Nestloop 對下層兩股資料流實現迴圈巢狀連線操作
MergeJoin 對下層兩股排序資料流實現歸併連線操作
HashJoin 對下層兩股資料流實現雜湊連線操作

同時為了應對不同的連線操作,openGauss定義瞭如下的連線運算元的連線型別。定義兩股資料流,一股為S1(左),一股為S2(右),連線運算元的連線型別如表5所示。

表5 連線運算元的連線型別
運算元型別 描述
Join運算元連線型別 描述
Inner Join 內連線,對於S1和S2上滿足條件的資料進行連線操作。
Left Join 左連線,對於S1沒有匹配S2的資料,進行補空輸出。
Right Join 右連線,對於S2沒有匹配S1的資料,進行補空輸出。
Full Join 全連線,除了Inner Join的輸出部分,對於S1,S2沒有匹配的部分,進行各自補空輸出 。
Semi Join 半連線,當S1能夠在S2中找到一個匹配的,單獨輸出S1 。
Anti Join 反連線,當S1能夠在S2中找不到一個匹配的,單獨輸出S1。

表4中的3個連線運算元都已經支援表5中6種不同的連線型別。

NestLoop運算元: 對於左表中的每一行,掃描一次右表。演算法簡單,但非常耗時(計算笛卡兒乘積),如果可以用索引掃描右表,則可能是一個不錯的策略。可以將左表的當前行中的值用作右索引掃描的鍵。

MergeJoin: 在連線開始前,先對每個表按照連線屬性(Join Attributes)進行排序,然後並行掃描兩個表,組合匹配的行形成連線行。MergeJoin只需掃描一次表。排序可以透過排序演算法或使用連線鍵上的索引來實現。

HashJoin: 先掃描內表,並根據其連線屬性計算雜湊值作為雜湊鍵(Hash Key,也稱雜湊鍵)存 入 哈 希 表 中。然後掃描 表,計算雜湊鍵,在雜湊表中找到匹配的行。

對於連線的表無序的情況,MergeJoin操作需要將兩個表掃描並進行排序,複雜度會達到O(nlogn),而 NestLoop操作是一種巢狀迴圈的查詢方式,複雜度達到O(n2)。而 HashJoin操作藉助雜湊表來加速查詢,複雜度基本在O(n)。

不過,HashJoin操作只適用於等值連線,對於>、<、<=、>=這樣的連線還需要 NestLoop這種通用的連線方式來處理。如果連線鍵是索引列本來就有序,或者 SQL 本身需要排序,那麼用 MergeJoin操作的代價會比 HashJoin操作更小。

下面簡單介紹 HashJoin操作的執行流程。

HashJoin,顧名思義,就是利用雜湊表進行連線查詢,雜湊表的資料結構組織形式如圖4所示。

在這裡插入圖片描述

圖4 雜湊表

可以看到,雜湊表根據雜湊值分成多個桶,相同的雜湊鍵值的元組用連結串列的方式串聯在一起,因為雜湊演算法的高效和雜湊表的唯一指向性,HashJoin操作的匹配效率非常高,但是 HashJoin操作只能支援等值查詢。

HashJoin節點有兩棵子樹:一棵稱為外表; 另一棵稱為內表。內表輸出的資料用於生成雜湊表,而外表生成的資料則在雜湊表上進行探查並 返回連線結果。

在內、外表的選擇上,最佳化器一般根據這兩棵子樹的代價進行分析選擇。因為雜湊表需要申請記憶體進行存放,因此最佳化器傾向於輸出行數少的子樹作為內表,這樣資料能夠被記憶體存放的機率比較大,如果存放不下,則需要進行下盤操作。

HashJoin操作的主要執行流程如下:

  • 掃描內表元組,根據連線鍵計算雜湊值,並插入到雜湊表中根據雜湊值計算出來的槽位上。在這個步驟中,系統會反覆讀取內表元組直到把內表讀取完,並將雜湊表構建出來。
  • 掃描外表元組,根據連線鍵計算雜湊值,直接查詢雜湊表進行連線操作,並將 結果輸出,在這個步驟中,系統會反覆讀取外表直到外表讀取完畢,這個時候連線的結 果也將全部輸出。
    上面提到,如果當前的內表元組無法全部放在記憶體裡,會進行下盤(寫入磁碟)操作,HashJoin對於下盤支援的設計思想非常精妙,採用了典型的分而治之的演算法。
  • 根據內表和外表的鍵值的雜湊值,對內表和外表進行分割槽,經過分割槽之後,內表和外表被劃分成很多小的內、外表,這裡的劃分原則是以相同的雜湊值分割槽之後資料要劃分到相同下標的內、外表中,同時內表的資料要能夠存放在記憶體裡。
  • 取相同下標的內、外表,重複步驟(1)和(2)中的演算法進行元組輸出。
  • 重複上一步驟的操作,直到處理完所有的經過分割槽後的內、外表。

(三)表示式計算

除了運算元,為了代數運算子的完備性,還需要有表示式的計算。根據SQL語句的不同,表示式的計算可能產生在每個運算元上,用於進一步處理運算元上的資料流。表示式的計算主要有以下兩個功能。

  • 過濾:根據表示式的邏輯,過濾掉不符合規則的資料。
  • 投影:根據表示式的邏輯,對資料流進行表示式變換,產生新的資料。表示式計算的核心是對錶達式樹的遍歷和計算,前面說到運算元也是用樹來表達執行計劃。樹這個基礎的資料結構在執行器的流程中扮演了非常重要的角色。

看下面這個SQL語句:

SQL2:select w_id from warehouse where 2*w_tax + 0.9 > 1 and w_city != ‘Beijing’;

SQL語句中 where條件後面的就是SQL表示式,如果以樹的形式展現,如圖5所示。

在這裡插入圖片描述

圖5 SQL語句表示式樹

表示式計算對運算元上的資料流進行計算,透過遍歷表示式計算樹完成整體的表示式計算(為了便於說明,我們對上述表示式樹中每個節點進行了編號,見節點前的數字),可以看到上面的圖中有些節點中標註的是 Const,這代表這個節點是一個定值節點,儲存了一個定值,有些節點中標註的是 ExpOp,這代表這個節點是一個計算節點,根據表示式的不同有不同的計算方法,有些節點標註的是 Col,代表從表中的某個列中讀取的資料。上述的表示式計算的詳細的流程如下:

  • 根節點11 代表一個 AND 運算子,AND 邏輯是隻要有一個子樹的結果為false,則提前終止運算,否則進行下一個子樹運算。下面有兩個子表示式,先處理節點9,首先遞迴遍歷到其子節點3。
  • 節點3代表了一個乘法,有兩個子節點1、2,從節點1列中取得w_tax的值,從節點2中取得定值2,然後進行乘法運算,計算資料儲存到節點3引擎的暫存空間中。
  • 節點5代表一個加法運算,有兩個子節點3、4,因此從節點4上取定值0.9,表示式3的結果剛才在第(2)步中已經計算了,只需要讀取出來,運算結果儲存到節點5的暫存空間裡。
  • 節點9代表一個比較運算,其有兩個子節點5、6,因此將節點5儲存的資料和節點6上的定值資料1進行大於比較,如果結果為false,則提前終止當前的表示式運算, 跳入下一行,重新從步驟(1)開始計算,如果為true,則進行下一個子表示式的計算。
  • 節點9已經處理完畢,接著處理節點10。
  • 節點10代表字串不等於比較運算,有兩個子節點7、8,從節點7中取得 w_city值,同時從節點8中取得定值字串“Beijing”,然後進行不等於字串比較運算,如果為true,輸出元組(Tuple),否則重新從步驟(1)開始計算。

由此可見,透過遍歷整個表示式樹,根據表示式樹的不同節點的型別做出相應的動作,有些是對資料的讀取,有些是進行函式計算。表示式樹中葉子節點都來自資料流中的資料或者棧上的定值,而非葉子節點都是計算函式。

三、openGauss執行器的高階特性介紹

本文將介紹openGauss執行器的幾個高階特性,在介紹高階特性之前,先簡單介紹當前 CPU 體系架構中影響效能的幾個關鍵因素。這些關鍵因素和其對應的技術構成了執行器中的兩個高階特性:編譯執行和向量化引擎。影響效能的關鍵因素如下:

  • 函式呼叫:函式呼叫過程中需要維護引數和返回地址在棧幀的管理,處理完成之後還要返回到之前的棧幀,因此在使用者的函式呼叫過程中,CPU 要消耗額外的指令進行函式呼叫上下文的維護。
  • 分支預測:指令在現代 CPU 中以流水線執行,當處理器遇到分支條件跳轉指令時,通常不能確定執行哪個分支,因此處理器採用分支預測來預測每條跳轉指令是否會執行。如果猜測準確,那麼流水線中就會充滿指令;如果對跳轉猜測錯誤,那麼就要求處理器丟掉它這個跳轉指令後的所有已做的操作,然後再開始用從正確位置起始的指令去填充流水線。可以看到,這種預測錯誤會導致很嚴重的效能懲罰,即會導致20~40個時鐘週期的浪費,從而導致 CPU 效能嚴重下降。提速方式有兩種:一種是更準確的智慧預測,但是無論多麼準確,總會存在誤判;另一種就是從根本上消除分支。
  • CPU 存取資料:CPU 對於資料的存取存在鮮明的層次關係,CPU 在暫存器、CPU 快取記憶體(CACHE)、記憶體中的存取速度依次越來越慢,所承載的容量卻越來越大。同時,CPU 在訪問資料的時候也會遵循從快到慢的原則,比如快取中找不到的資料才會從記憶體中找,而這兩者的訪問速度差距在兩個數量級。如果 CPU 的訪問模式是線性的(比如訪問陣列),CPU 會主動將後續的記憶體地址預載入到快取,這就是 CPU的資料預取。因此,如果程式能夠充分利用這個特徵,將大大提高程式的效能。
  • SIMD(單指令多資料流):對於計算密集型程式來說,可能經常需要對大量不同的資料進行同樣的運算。SIMD引入之前,執行流程為同樣的指令重複執行,每次取一條資料進行運算。而SIMD可以一條指令執行多個位寬資料的計算。比如當前最新的體系結構已經支援512位寬的SIMD指令,那麼對於16位整型的加法,可以並行執行32個整型對的加法。

(一)編譯執行

上文介紹了基於遍歷樹的表示式計算框架。這種框架的好處是清晰明瞭,但在效能上卻不是最優的,主要有以下幾個原因:

  • 表示式計算框架的通用性決定了其執行模式要適配各種不同的運算子和資料型別,因此在執行時要根據表示式遍歷的具體結果來確定執行的函式和型別,對這些型別的判斷要引入非常多的分支判斷。
  • 表示式計算在整體的執行過程中要進行多次的函式呼叫,其呼叫的深度取決於表示式樹的深度,這也有著非常大的開銷。

除了上述兩個主要原因,分支判斷和函式呼叫在執行運算元中也是影響效能的關鍵因素。為了提升表示式計算的執行速度,openGauss引入了業界著名的開源編譯框架———LLVM(LowLevelVirtualMachine)。

LLVM 是一個通用的編譯框架,能夠支援不同的計算平臺。LLVM 提升整體表示式計算執行速度的核心要點如下。

  • openGauss內建的 LLVM 編譯框架透過為每一個計算單元(表示式或者執行運算元裡面的熱點函式)生成一段獨特的執行程式碼,由於在編譯的時候提前知道了表示式涉及的操作和資料型別,可將表示式生成的執行程式碼中所有的邏輯內聯,完全去除函式呼叫。
    比如對於上文提到的表示式計算過程,openGauss內建的 LLVM 編譯為這個表示式生成了下面這樣一段特殊程式碼,其中已經沒有任何函式呼叫,所有的函式都已經被內聯在一起,同時去掉了關於資料型別的分支判斷。
Bool qual(){    bool qual1res = 2 * w_tax + 0.9 > 1;
    bool qual2res = w_city !=’Beijing’;
    Return qual1res && qual2res;}
  • LLVM 編譯框架利用編譯技術最大限度地讓生成的程式碼將中間結果的資料儲存在 CPU 暫存器裡,以加快資料讀取的速度。

(二) 向量化引擎

上文提到了執行器的資料流動模式:控制流向下、資料流向上。傳統的執行引擎資料流遵循一次一元組的傳輸模式,而向量化引擎將這個模型改成一次一批元組的模式,這種看似簡單的修改卻帶來巨大的效能提升。單個元組與向量化元組的對比如圖6所示。

在這裡插入圖片描述

圖6 單個元組與向量化元組對比

其中的主要原因如下,這也與前面介紹的 CPU 架構中影響效能的幾個關鍵因素對應。

  • 一次一元組的函式模型在控制流的調動下,每次都需要進行函式呼叫,呼叫次數隨著資料的增長而增長,而一次一批元組的模式則大大降低了執行節點的函式呼叫開銷,如果設定一次一批元組的數量為1000,則函式呼叫相對於一次一元組能減少3個數量級。
  • 一次一批元組的模式在內部實現上是透過陣列來表達的,CPU 對陣列的存取非常友好,能夠讓陣列在後續的資料處理過程中,大機率能夠在快取中被命中。比如下面這個簡單計算兩個整型資料加法的函式(其程式碼僅為了展示,不代表真實實現),展示了一次一元組和一次一批元組的兩種編寫程式碼方法。
    一次一元組的整型資料加法:
int int4addint4(int4 a, int b){    Return a+b;

一次一批元組的整型資料加法:

void int4addint4(int4 a[], int b[], int res[]){ 
      for(int i = 0; i < N; i++) 
      res[i] = a[i] + b[i];}

一次一批元組的這個計算函式,因為 CPU 快取記憶體的區域性性原理,資料和指令的快取命中率會非常好,可極大提升處理效能。

  • 一次一批元組的資料陣列化的組織方式為利用SIMD特性帶來了非常好的機會,使SIMD能夠大大提升在元組上的計算效能。還是以上述整型資料加法的例子講解,可以重寫上述的函式如下。
void int4addint4SIMD(int4 a[], int b[], int res[]){    for(int i = 0; i < N/SIMDLEN; i++)   
  res[i..i+SIMDLEN] = SIMDADD(a[i..i+SIMDLEN], b[i..i+ SIMDLEN];}

可以看到,由於SIMD可以一次處理一批資料,使迴圈的次數衰減,因此效能可得到進一步提升。

四、小結

本文描述了openGauss執行引擎的基本構成和一些技術特點,執行器作為資料庫查詢的最 終 執 行 單 元,其 架 構 和 技 術 決 定 了 數 據 庫 執 行 查 詢 的 整 體 運 行 效 率,openGauss執行引擎採用了諸如向量化、編譯執行等多種現代軟體技術,並充分結合硬體技術的特徵進行高效執行。


來自 “ ITPUB部落格 ” ,連結:http://blog.itpub.net/69997967/viewspace-2922533/,如需轉載,請註明出處,否則將追究法律責任。

相關文章