在介紹 Index Lookup Join 以前,咱們首先看一下什麼是 Nested Loop Join(NLJ)。 NLJ 的具體定義能夠參考 Wikipedia。NLJ 是最爲簡單暴力的 Join 算法,其執行過程簡述以下:git
遍歷 Outer 表,取一條數據 r;github
遍歷 Inner 表,對於 Inner 表中的每條數據,與 r 進行 join 操做並輸出 join 結果;算法
重複步驟 1,2 直至遍歷完 Outer 表中的全部數據。sql
NLJ 算法實現很是簡單而且 join 結果的順序與 Outer 表的數據順序一致。express
可是存在性能上的問題:執行過程當中,對於每一條 OuterRow,咱們都須要對 Inner 表進行一次全表掃操做,這將消耗大量時間。bash
爲了減小對於 Inner 表的全表掃次數,咱們能夠將上述步驟 1 優化爲每次從 Outer 表中讀取一個 batch 的數據,優化後的算法即 Block Nested-Loop Join(BNJ),BNJ 的具體定義能夠參考 Wikipedia。網絡
對於 BNJ 算法,咱們注意到,對於 Outer 表中每一個 batch,咱們並無必要對 Inner 表都進行一次全表掃操做,不少時候能夠經過索引減小數據讀取的代價。Index Lookup Join(ILJ) 在 BNJ 基礎上進行了改進,其執行過程簡述以下:session
從 Outer 表中取一批數據,設爲 B;多線程
經過 Join Key 以及 B 中的數據構造 Inner 表取值範圍,只讀取對應取值範圍的數據,設爲 S;併發
對 B 中的每一行數據,與 S 中的每一條數據執行 Join 操做並輸出結果;
重複步驟 1,2,3,直至遍歷完 Outer 表中的全部數據。
TiDB 的 ILJ 算子是一個多線程的實現,主要的線程有: Main Thead,Outer Worker,和 Inner Worker:
Outer Worker 一個:
按 batch 遍歷 Outer 表,並封裝對應的 task
將 task 發送給 Inner Worker 和 Main Thread
Inner Worker N 個:
Main Thread 一個:
這個算子有以下特色:
Join 結果的順序與 Outer 表的數據順序一致,這樣對上一層算子能夠提供順序保證;
對於 Outer 表中的每一個 batch,只在 Inner 表中掃描部分數據,提高單個 batch 的處理效率;
Outer 表的讀數據操做,Inner 表的讀數據操做,及 Join 操做並行執行,總體上是一個並行+Pipeline 的方式,儘量提高執行效率。
TiDB 中 ILJ 的執行階段可劃分爲以下圖所示的 5 步:
1. 啓動 Outer Worker 及 Inner Workers
這部分工做由 startWorkers 函數完成。該函數會 啓動一個 Outer Worker 和多個 Inner Worker 和 多個 Inner Worker。Inner Woker 的數量能夠經過 tidb_index_lookup_concurrency
這個系統變量進行設置,默認爲 4。
2. 讀取 Outer 表數據
這部分工做由 buildTask 函數完成。此處主要注意兩點:
第一點,對於每次讀取的 batch 大小,若是將其設置爲固定值,則可能會出現以下問題:
若設置的 batch 值較大,但 Outer 表數據量較小時。各個 Inner Worker 所需處理的任務量可能會不均勻,出現數據傾斜的狀況,致使併發總體性能相對單線程提高有限。
若設置的 batch 值較小,但 Outer 表數據量較大時。Inner Worker 處理任務時間短,須要頻繁從管道中取任務,CPU 不能被持續高效利用,由此帶來大量的線程切換開銷。此外, 當 batch 值較小時,同一批 inner 表數據能會被反覆讀取屢次,帶來更大的網絡開銷,對總體性能產生極大影響。
所以,咱們經過指數遞增的方式動態控制 batch 的大小(由函數 increaseBatchSize 完成),以免上述問題,batch size 的最大值由 session 變量 tidb_index_join_batch_size
控制,默認是 25000。讀取到的 batch 存儲在 lookUpJoinTask.outerResult 中。
第二點,若是 Outer 表的過濾條件不爲空,咱們須要對 outerResult 中的數據進行過濾(由函數 VectorizedFilter 完成)。outerResult 是 Chunk 類型(Chunk 的介紹請參考 TiDB 源碼閱讀系列文章(十)),若是對知足過濾條件的行進行提取並從新構建對象進行存儲,會帶來沒必要要的時間和內存開銷。VectorizedFilter
函數經過一個長度與 outerResult 實際數據行數相等的 bool slice 記錄 outerResult 中的每一行是否知足過濾條件以免上述開銷。 該 bool slice 存儲在 lookUpJoinTask.outerMatch 中。
3. Outer Worker 將 task 發送給 Inner Worker 和 Main Thread
Inner Worker 須要根據 Outer 表每一個 batch 的數據,構建 Inner 表的數據掃描範圍並讀取數據,所以 Outer Worker 須要將 task 發送給 Inner Worker。
如前文所述,ILJ 多線程併發執行,且 Join 結果的順序與 Outer 表的數據順序一致。 爲了實現這一點,Outer Worker 經過管道將 task 發送給 Main Thread,Main Thread 從管道中按序讀取 task 並執行 Join 操做,這樣即可以實如今多線程併發執行的狀況下的保序需求。
4. Inner Worker 讀取 inner 表數據
這部分工做由 handleTask 這個函數完成。handleTask 有以下幾個步驟:
constructDatumLookupKeys 函數計算 Outer 表對應的 Join Keys 的值,咱們能夠根據 Join Keys 的值從 Inner 表中僅查詢所須要的數據便可,而不用對 Inner 表中的全部數據進行遍歷。爲了不對同一個 batch 中相同的 Join Keys 重複查詢 Inner 表中的數據,sortAndDedupDatumLookUpKeys 會在查詢前對前面計算出的 Join Keys 的值進行去重。
fetchInnerResult 函數利用去重後的 Join Keys 構造對 Inner 表進行查詢的執行器,並讀取數據存儲於 task.innerResult
中。
buildLookUpMap 函數對讀取的 Inner 數據按照對應的 Join Keys 構建哈希表,存儲於 task.lookupMap
中。
上述步驟完成後,Inner Worker 向 task.doneCh
中發送數據,以喚醒 Main Thread 進行接下來的工做。
5. Main Thread 執行 Join 操做
這部分工做由 prepareJoinResult 函數完成。prepareJoinResult 有以下幾個步驟:
getFinishedTask 從 resultCh 中讀取 task,並等待 task.doneCh 發送來的數據,若該 task 沒有完成,則阻塞住;
接下來的步驟與 Hash Join相似(參考 TiDB 源碼閱讀系列文章(九)),lookUpMatchedInners 取一行 OuterRow 對應的 Join Key,從 task.lookupMap 中 probe 對應的 Inner 表的數據;
主線程對該 OuterRow,與取出的對應的 InnerRows 執行 Join 操做,寫滿存儲結果的 chk 後返回。
CREATE TABLE `t` (
`a` int(11) DEFAULT NULL,
`pk` int(11) NOT NULL AUTO_INCREMENT,
PRIMARY KEY (`pk`)
);
CREATE TABLE `s` (
`a` int(11) DEFAULT NULL,
KEY `idx_s_a` (`a`)
);
insert into t(`a`) value(1),(1),(1),(4),(4),(5);
insert into s value(1),(2),(3),(4);
select /*+ TIDB_INLJ(t) */ * from t left join s on t.a = s.a;
複製代碼
在上例中, t
爲 Outer 表,s
爲 Inner 表。 /** TIDN_INLJ */ 可讓優化器儘量選擇 Index Lookup Join 算法。
設 Outer 表讀數據 batch 的初始大小爲 2 行,Inner Worker 數量爲 2。
查詢語句的一種可能的執行流程以下圖所示,其中由上往下箭頭表示時間線:
做者:徐懷宇