解析 Greenplum 數據庫的排序算法
本文是直播的文字稿,錄播地址:https://www.bilibili.com/video/BV11Y4y1U799
Sort 節點概覽
排序的樸素含義是將一個數據集按照某種特定的排序方式進行排列的算法,最常見的排列方式是數值順序和字典序。
排序算法的應用非常廣泛,主要分爲了兩類:
-
內排序:在內存中完成的排序,常見的有插入排序、快速排序、堆排序、基數排序等
-
外排序:數據集過大,內存中無法全部存放,需要藉助外存的排序,常見的有歸併排序的各種變形
gpdb 的排序節點會根據查詢計劃中的排序鍵對指定的元組進行排序,根據排序的數據量和其他的一些性質,gpdb 會選擇不同的排序算法:
-
如果排序節點的工作內存可以容納所有的元組時,排序節點使用快速排序或者堆排序
-
堆排序主要用於 TopK 查詢,即只需要輸出排序後元組的前 K 個,例如 Sort 節點之上還存在 Limit 節點
如果工作內存無法容納所有的元組,則使用基於歸併排序的外排序算法。
排序節點除了本身對元組排序的功能外,在 gpdb 中的應用也廣泛,查詢優化器還會根據代價選擇基於排序的聚集節點 Group Agg 和連接節點 Merge Join。
此外,Group By,Distinct 等 sql 關鍵字也和排序息息相關。
TupleSort
TupleSort 是 gpdb 各種排序功能的底層實現,各種需要排序的模塊都會使用調用 TupleSort 對元組進行排序。
TupleSort 使用的排序算法如下所示:
其中快速排序和堆排序都是標準的內存排序算法。
快速排序
快速排序(Quick Sort)是最常見的內存排序算法,由 Tony Hoare 在 1959 年發明。
快速排序的三個步驟:
挑選基準值,從數據集中挑選出一個基準元素,一般稱爲 Pivot
分割:將所有比 pivot 小的數據放到 pivot 之前,將所有比 pivot 大的數據放到 pivot 之後
遞歸子序列:遞歸的將小於 pivot 的子序列大於 pivot 的子序列分別進行排序
gpdb 中對於快速排序的實現如下:
代碼位置:https://github.com/greenplum-db/gpdb/blob/main/src/backend/utils/sort/gen_qsort_tuple.pl
堆排序
堆排序也是內存中一種常用的排序算法,堆是一種完全二叉樹
最大堆:對於每個節點,其值大於左右子節點的值
最小堆:對於每個節點,其值小於左右子節點的值
堆排序算法:
建立最大堆,數組中的最大元素在堆頂
取出堆頂元素,插入到數組中,更新堆
重複第二步,直到堆大小爲 0
原始的數組的排列:
開始建堆:
進行排序:
gpdb 中也有對堆排序的實現:
代碼位置:https://github.com/greenplum-db/gpdb/blob/main/src/backend/utils/sort/tuplesort.c#L3525
外部歸併排序
基於外存的歸併排序主要分爲了兩個階段:
-
分割階段:將原始待排序數據分成若干個順串
-
合併階段:將所有的小順串合併爲包含所有數據的大順串
順串的定義:由於要排序的數據集過大,無法全部在內存中排序,因此只能選擇部分數據在內存中排序,將排好序的部分數據稱爲順串
替換選擇算法
分割階段可以線性掃描一遍數據,當達到內存大小閾值的時候,在內存中排序,生成一個順串。然後再重複的取出原始數據到內存中排序,生成順串,直到原始數據被取完。
這樣生成的順串大小,實際上不會超過內存的大小。如果順串越小,在合併的時候,讀取外存的次數就越多,我們的排序算法的效率就越低。
所以,如何在分割階段 ,儘量生成儘可能大於內存容量的順串,減少合併階段讀取外存的數量?
可以使用替換選擇算法,替換選擇算法借鑑的是掃雪機模型。
想象有一個環形的跑道,跑道上有積雪,假設最開始時積雪的高度爲 h,掃雪機不停地向前剷雪,同時也有新的雪落在跑道上,新的雪一部分落在了掃雪機的前面,一部分落在了掃雪機的後面。假設雪下的速度和掃雪機剷雪的速度一致,掃雪機掃了一圈之後,掃雪機前面的高度仍然爲 h,後面的高度是 0,這樣就達到了一個動態的平衡。
掃雪機前方和後面的積雪就是一個從 0 - h 的斜坡,也就是說路面積雪量就是下圖中直角三角形的面積,並且可以計算出掃雪機剷雪的量就是這個三角形的兩倍。
類比掃雪機模型,跑道上的積雪就是替換選擇算法使用的堆,積雪的量就是內存的大小。
輸出當前最小值,生成順串的過程就是剷雪的過程。順串的大小就是剷雪量。
新落下的雪就是新的輸入數據,由於輸入隨機,如果輸入大於等於剛輸出的元素,則被加入到堆中,即被掃雪車清除。如果輸入小於剛輸出的元素,則相當於新雪下在了掃雪車的後方,本次剷雪 (順串) 不包含該元素。
因此,順串的長度就是剷雪量,也就是內存大小 (跑道上的積雪) 的兩倍。
基於此,替換選擇算法的大致過程如下:
-
初始化階段,將元組讀取到內存中,並根據排序鍵建立最小堆
-
取出堆頂元組,寫到順串文件的緩衝區,並記錄這個元組的排序鍵是 lastkey
-
讀取新的元組,如果排序鍵大於 lastkey,則插入到堆中,重新調整堆的順序
-
如果新元組的排序鍵小於 lastkey,則插入到堆的末尾,並將堆的大小減一
-
重複第二步,直至堆的大小變爲 0
-
然後重新建堆,再取出新的元組,重複第二步,生成下一個順串
順串合併
假設順串分佈在 K 個文件中,如何高效的比較 K 個文件中的最小值,並將其輸出到外部文件中?
敗者樹算法
輸入每個順串的第一個記錄作爲敗者樹的葉子節點,建立初始化敗者樹。
兩兩相比較,父親節點存儲了兩個子節點比較的敗者(節點較大的值);勝利者 (較小者)可以參與更高層的比賽。這樣樹的頂端就是當次比較的冠軍(最小者)
調整敗者樹,當我們把最小者輸入到輸出文件以後,需要從相應的順串取出 一個記錄補上去。補回來的時候,我們就需要調整敗者樹,我們只需要沿着當前 節點的父親節點一直比較到頂端。比較的規則是與父親節點比較,勝者可以參與更高層的比較,一直向上,直到根節點。失敗者留在當前節點。
第一次比較:
第二次比較:
合併階段如何減少磁盤讀取次數
多路歸併
兩路歸併,使用兩個輸入文件和兩個輸出文件,每次歸併,順串的長度翻倍,並存儲到輸出文件中。下次歸併,輸出緩衝區和輸出緩衝區的位置互換。
下面是一個兩路歸併的例子,每個輸入文件在初始狀態下有 32 個順串,每次歸併,順串的長度翻倍。
這樣歸併之後,IO 次數是 64 * 6 = 384 次,每個順串移動了 6 次,有沒有什麼更好的辦法,可以使順串的移動次數更少?
多相歸併
Knuth 5.4.2 D 多相歸併排序算法。
初始化階段,N+1 個緩衝區,其中 N 個輸入緩衝區,1 個輸出緩衝區,每一個輸入緩衝區包含若干個順串。
從每個輸入緩衝區選取開頭的順串,組成 N 個順串,並對其進行歸併排序,排序結果寫入輸出緩衝區。此時每個輸入緩衝區順串數減 1,輸出緩衝區順串數加 1。
如果任何一個輸入緩衝區的順串數都大於 0,重複第二步
如果所有緩衝區的順串數和大於 1,選擇順串數爲 0 的輸入緩衝區作爲新的輸出緩衝區,重複第二步
如果所有緩衝區的順串數和爲 1,那麼這個順串就是排序好的數據集,算法結束
TupleSort 代碼邏輯
TupleSort 是排序節點的核心,算法主要分爲了四個階段:
第一階段
初始化 TupleSort,調用函數 tuplesort_begin_common,生成 Tuplesortstate,Tuplesortstate 用於描述排序的狀態等信息。
其中 status 字段表示當前狀態機的信息
狀態轉換圖:
TupleSortstate 中其他的一些重要字段:
第二階段
插入元組,每次調用函數 puttuple_common,根據當前 TupleSortstate 的狀態,將元組插入到不同的位置
-
對於 TSS_INITIAL 狀態,會將元組存儲到內存的 memtuples 中,如果滿足 TopK 的排序條件,會轉爲堆排序算法,狀態切換爲 TSS_BOUNDED
-
TSS_BOUNDED 狀態:插入到堆中
-
TSS_BUILDRUNS 狀態:外排序算法,基於替換選擇算法,如果元組大於等於堆頂元組,插入當前元組到堆,否則是其他的順串,將其放到 memtuples 末尾
第三階段
調用 tuplesort_performsort 執行實際的排序操作,仍然根據狀態機,選擇不同的排序策略。
-
TSS_INITIAL:所有數據都在內存中,直接執行快速排序,結束後將狀態設置爲 TSS_SORTEDINMEM
-
TSS_BOUNDED:所有數據仍然在內存中,執行堆排序,結束後將狀態設置爲 TSS_SORTEDINMEM
-
TSS_BUILDRUNS:執行多相歸併排序,函數 mergeruns 負責對順串進行歸併
第四階段
負責輸出排序後的元組,在排序完成後,每次調用 tuplesort_gettuple_common 獲取排序後的元組。
還是會根據不同的狀態選擇不同的策略。
-
TSS_SORTEDINMEM:元組是在內存中排序的,元組本身也在內存中,直接從 memtuples 中獲取即可
-
TSS_SORTEDONTAPE:元組通過歸併排序完成,存儲在外部文件中,因此元組需要從文件中讀取
-
TSS_FINALMERGE:元組存儲在文件中,每個文件有且僅有一個順串,在輸出元組的時候需要進行合併
單鍵排序
gpdb 的排序支持單鍵和多鍵排序兩種,其中單鍵排序基於 TupleSort 接口,多鍵排序基於 TupleSort_mk 接口,排序節點也是標準的執行器三部曲 ExecInitSort、ExecSort、ExecEndSort,但是由於 TupleSort 和 TupleSort_mk 已經封裝了完善的排序邏輯,因此三部曲的邏輯就比較簡單了。
ExecInitSort
初始化的時候,調用 ExecInitSort 方法,主要負責初始化 SortState 結構體。
ExecSort
ExecSort 負責傳遞元組給下層節點排序,並將排好序的數據返回給上層節點。
ExecSort 的第一次調用會讀取所有的元組並傳遞給 TupleSort 排序。
/*
* Scan the subplan and feed all the tuples to tuplesort.
*/
for (;;)
{
slot = ExecProcNode(outerNode);
if (TupIsNull(slot))
break;
tuplesort_puttupleslot(tuplesortstate, slot);
}
SIMPLE_FAULT_INJECTOR("execsort_before_sorting");
/*
* Complete the sort.
*/
tuplesort_performsort(tuplesortstate);
後續每次調用 ExecSort,都會返回排序後的元組。
SO1_printf("ExecSort: %s\n",
"retrieving tuple from tuplesort");
/*
* Get the first or next tuple from tuplesort. Returns NULL if no more
* tuples. Note that we only rely on slot tuple remaining valid until the
* next fetch from the tuplesort.
*/
slot = node->ss.ps.ps_ResultTupleSlot;
(void) tuplesort_gettupleslot(tuplesortstate,
ScanDirectionIsForward(dir),
false, slot, NULL);
ExecEndSort
ExecEndSort 的邏輯比較簡單,主要就是清理掃描和排序結果,以及清理外排序的臨時文件。
/* clean out the tuple table */
ExecClearTuple(node->ss.ss_ScanTupleSlot);
/* must drop pointer to sort result tuple */
ExecClearTuple(node->ss.ps.ps_ResultTupleSlot);
if (node->tuplesortstate != NULL)
{
/*
* Save stats like in ExecSortExplainEnd, so that we can display
* them later in EXPLAIN ANALYZE.
*/
tuplesort_finalize_stats(node->tuplesortstate,
&node->sortstats);
if (node->ss.ps.instrument)
{
node->ss.ps.instrument->workfileCreated = (node->sortstats.spaceType == SORT_SPACE_TYPE_DISK);
node->ss.ps.instrument->workmemused = node->sortstats.workmemused;
node->ss.ps.instrument->execmemused = node->sortstats.execmemused;
}
tuplesort_end((Tuplesortstate *) node->tuplesortstate);
node->tuplesortstate = NULL;
}
多鍵排序
gpdb 中特有的排序方式,針對具有相同前綴的字符串排序的優化。
多鍵排序算法又被稱爲三路基數排序,融合了快速排序和基數排序的排序算法,主要的優勢在於對具有相同前綴的字符串進行更高效的排序。
多鍵排序的流程和單鍵排序的三部曲類似,但底層基於 TupleSort_mk 接口。
標準快速排序在處理字符串的時候,平均時間複雜度是 N*logN,當字符串擁有相同的前綴時,快速排序仍然需要花費大量的時間去比較這些字符串的相同前綴,而多鍵排序避免了對前綴的重複比較,只使用必要的非前綴字符確定排序。
在現實世界中,具有相同前綴的字符串的場景還是很多的,例如很多的 URL 都以 http:// 開頭,每個具體的站點都有自己特定的前綴,例如 https://www.baidu.com。
下面是一個多鍵排序的示例:
注意:從 postgres12 開始,已經自帶了多鍵排序,因此目前 gpdb 當中已經刪除了對應的 tuplesort_mk 的邏輯。
本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源:https://mp.weixin.qq.com/s/J76V5fjXhBYnV2B75IiYpQ