源碼解讀:semi join 如何避免拉取大表數據?(一)

簡介

背景

Hash join是解決複雜join的一個重要手段,但其無法避免拉取左右兩端的數據到計算層進行計算,導致某些場景下執行效率不高。作爲一種補充,bka join則可以利用OLTP數據庫中的索引,通過join構造inner表的predicate命中表索引,在某些場景下有比較好的join效率。PolarDB-X是面向HTAP設計的分佈式數據庫,在複雜查詢時也會重點考慮利用數據庫的索引信息來提升join的查詢效率,因此有了本文的semi bka join。

要解決的問題

在這篇源碼解讀中,我們要解決的是類似這樣一條SQL的執行效率問題,select * from t1 where c1 in (select c1 from t2);其中t2爲一張大表,t2表的c2列上有索引,t1爲一張小表,且匹配後數據量很小。

Q:這個問題重要麼?

A:其實還挺重要的,這代表了一類經典的TP型SQL,客戶用的也比較多。

經典執行模式的不足

比如hash join,sort merge join,nested loop join,都需要把兩張表的數據都拉取到計算節點,而t2是一張大表,導致執行效率不高。

解決方案

避免拉取t2這張大表的數據,引入semi bka join的執行模式,後面會詳細展開。

講述方式

爲了避免大家在閱讀源碼時迷失在大量的細節之中,我們會用一種重構的形式來逐步構建semi bka join。同時,有些內容對於理解該部分設計,可能並沒有太大的作用,比如代碼中對於新分區表與老分區表的if eles判斷,因此在重構中刪除了該部分;最後,我們對於一些邏輯進行了重構,以便能夠更加清晰的進行講述。 綜上,感興趣的朋友可以參考我在https://github.com/wcf2333/galaxysql.git的代碼提交記錄,結合本篇文章閱讀,體驗會更好。

在分支refactor_semi_join分支上,移除了對於全局二級索引和新分區表的支持,精簡了執行器的一些處理;

在wcf2333_build_semi_bka_join分支上,基於refactor_semi_join分支,首先移除了semi bka join的實現,然後逐步進行了豐富和實現,步驟可見提交記錄,如下。

remove semi bka join and materialized semi join

add the simplest optimizer rule for semi bka join

add the simplest executor for semi bka join

support multi column in lookup predicate

enhance optimizer

support stream lookup join

support dynamic pruning

support single sharding key when table rule is not simple

support lookup predicate with multi-column when pruning

注:本文中bka(batch key access)和lookup表達相同的含義

目標

在寫這篇文章時,是期望大家可以在學到一些東西之後,能夠在一個暫時沒有實現該功能的系統上真正實現一個semi bka join。它足夠魯棒,包含大量的細節,理想情況下足以在生產環境中使用。這也是爲什麼會花挺大力氣採用重構的方式來寫這篇文章的原因。 顯然,一篇文章顯然是不足以實現上述目標的,所以不出意外的話陸陸續續還會有幾篇,我們期望回答所有重要的細節。因此,如果大家有什麼問題的話,歡迎提問,無論是留言還是直接聯繫我,我們會在後續的文章中把大家的問題都囊括其中。 提醒:我們會在本篇中介紹一些細節,有些細節不進行debug可能不太容易理解,所以感興趣的朋友可以搭建debug環境邊閱讀邊調試。

前置知識

在理解semi bka join的詳細設計和實現時,不可避免的要對其相關組件進行介紹,從中我們挑了兩個重要的來展開介紹一下,hash join的核心設計與如何接入異步執行框架。

HashJoin的核心設計

我們之所以要講解hash join的核心設計,是因爲bka join與hash join有很多非常像的地方。理解了hash join,有助於我們更好的理解bka join。同時,這樣的對比學習,可能會讓大家有更多的收穫。

hash join執行流程

hash join的左邊是Outer端(探測hash table的一端),右邊是inner端(構建hash table的一端),執行流程如下所示。

流程中的幾個核心問題

1.保存在hash table中的是什麼?

public class ConcurrentRawHashTable implements Hash {

    public static final int NOT_EXISTS = -1;

    /**
     * The array of keys (buckets)
     */
    private final AtomicIntegerArray keys;

    /**
     * The mask for wrapping a position counter
     */
    private final int mask;

    /**
     * The current table size.
     */
    private final int n;
}

該行在緩存數據中的位置(keys中保存的int值),而非join key的值或者記錄本身(所以爲了輸出完整數據,我們需要緩存build端的數據,即ChunksIndex buildChunks)

2.如何解決哈希碰撞?

拉鍊的形式來解決哈希碰撞(所以你會看到positionLinks和hashTable總是如影隨形)。根據保存在哈希表中的位置,獲取build端的相應行,進而檢測記錄是否真正匹配。

ConcurrentRawHashTable hashTable;

int[] positionLinks;
int matchedPosition = hashTable.get(hashCode);
while (matchedPosition != LIST_END) {
  if (buildKeyChunks.equals(matchedPosition, keyChunk, position)) {
    break;
  }
  matchedPosition = positionLinks[matchedPosition];
}

3.如何匹配結果並輸出?

build端構建hash table之後,probe端開始流式的探測並輸出,可見AbstractBufferedJoinExec的doNextChunk和nextRow方法,整體流程如下:

遍歷probe端的結果,拿到probe端join key的hash code,首先去hash table中找到對應的位置(matchInit方法)
判斷position是否有效(matchValid方法),並檢測join條件是否匹配(checkJoinCondition)
若匹配,則輸出相應結果(buildJoinRow方法)
隨後一直從鏈表中獲取同一hash code對應的build chunk中的位置(matchNext方法),跳轉至步驟2直至拿到的位置不合法
注:在wcf2333_build_semi_bka_join分支上對AbstractBufferedJoinExec進行了部分重構,邏輯可能會更清晰一些,有興趣的朋友可以參考。

matchedPosition = matchInit(probeJoinKeyChunk, probeKeyHashCode, probePosition);
for (; matchValid(matchedPosition);
  matchedPosition = matchNext(matchedPosition, probeJoinKeyChunk, probePosition)) 
{
    if (!checkJoinCondition(buildChunks, probeChunk, probePosition, matchedPosition)) {
     continue;
  }
    buildJoinRow();
}

細節的強化:我們現在對上述流程中的步驟三(輸出結果)進行審視,原因是join的類型並非只有inner,還有outer join,semi join與anti join,其各自的特點分別如下:

outer join:如果進行probe的這行無法匹配右表,則右表中相應字段填充爲null

semi join:只輸出左表內容,且一旦匹配,則無需繼續探測鏈表中餘下的記錄是否能夠繼續匹配

anti join:同semi join,但需要注意的是condition中不包含anti的語義,因此一旦condition匹配,則anti join一定不匹配,但是condition不匹配時,anti join未必匹配,此時需要進一步判斷condition的結果是否爲null。例如where a not in (select b from t2),則condition爲a = b,null = 3的結果爲null,這會導致condition不匹配,但是卻不應該輸出。此外,還需要注意not exists與not in轉換而來的anti join的區別,整體來講anti join的這部分並不太容易理解,非必要可暫時不對該部分進行理解,下次文章期望能夠對該部分進行重構。

4. 對於semi和anti時的一點優化,即doSpecialCheckForSemiJoin方法 如果構建哈希表的結果爲空,則semi join的輸出結果爲空,anti join的輸出結果爲probe端。 如果anti join中爲一列且build chunk中該列含有null值時,則anti join的輸出結果爲空。

擴展:如果需要支持多列時anti join的pass nothing優化,可以參考如下進行擴展,即檢查該行中參與構建哈希表的所有字段是否均爲null。

protected static boolean checkNullRecord(Chunk chunk) {
  int blockCount = chunk.getBlockCount();
  int position = chunk.getBlock(0).getPositionCount();
  boolean hasNullRecord = IntStream.range(0, position).anyMatch(
      pos -> IntStream.range(0, blockCount).allMatch(t -> chunk.getBlock(t).isNull(pos)));
  return hasNullRecord;
}

對於null safe equal的處理? null safe equal:用<=>表示,用於判斷join中null = null是否成立。 null safe equal時將build端的null值放入哈希表中,否則構建哈希表時過濾掉null值,如下:

boolean[] nullPos = getNullPos(keyChunk, ignoreNullBlocks);
for (int offset = 0; offset < keyChunk.getPositionCount(); offset++, position++) {
  if (!nullPos[offset]) {
    int next = hashTable.put(position, hashes[offset]);
    positionLinks[position] = next;
  }
}

異步框架下算子狀態的切換

大致思路:通常情況下,如果我們能夠拿到數據進行處理,則該算子一定不會結束,狀態爲非阻塞狀態;反之,如果拿不到數據,則應進一步判斷爲block狀態還是finish狀態(當然,實際上我們會有Producer和Consumer,將來可以寫一篇文章來聊聊這其中的幾個接口與狀態切換,非本文重點,按下不表)

Semi BKA join的設計與實現

好了,現在我們要正式開始一步步的構建semi bka join了。

添加整個框架

我們首先實現semi bka join優化器部分,其次是執行器部分,即將LogicalSemiJoin轉換爲SemiBkaJoin,涉及的commit如下。

add the simplest optimizer rule for semi bka join

add the simplest executor for semi bka join

support multi column in lookup predicate

enhance optimizer

優化器部分

1.我們需要一個新的RelNode類型,SemiBkaJoin,派生自LogicalSemiJoin。

2.我們需要一個LogicalSemiJointoSemiBkaJoin的規則,以便將下述的RelNode樹進行轉換。


tips:在一條規則中放一個開關,總不是一件太壞的事情,這樣如果該條規則有問題,可以更快的禁掉。

3.在RuleToUse中添加該規則,使得優化器的RBO階段可以使用該規則。

執行器部分

我們先來對比一下hash join和lookup join的執行流程。

hash join

lookup join

通過對上圖的執行過程進行分析,我們可以將bka join的執行過程分爲如下幾個階段。原來的代碼中這幾個階段不是非常清晰,我在之前提到的代碼分支上對這部分代碼進行了重構,感興趣的同學可以參考。

protected enum LookupJoinStatus {
  CONSUMING_OUTER,
  INIT_INNER_LOOKUP,
  CACHE_INNER_RESULT,
  BUILD_HASH_TABLE,
  PROBE_AND_OUTPUT
}

接下來我們對這幾個關鍵狀態進行一些解釋

CONSUMING_OUTER:拉取outer端數據並進行緩存
INIT_INNER_LOOKUP:根據outer端數據構建lookup predicate並下發物理SQL,拉取過濾後的數據
CACHE_INNER_RESULT:不停的獲取inner端數據並緩存
BUILD_HASH_TABLE:根據緩存的inner端數據構建哈希表
PROBE_AND_OUTPUT:根據緩存的outer端數據,探測並輸出

Q:現在我們來想一下如何接入異步框架,也就是算子何時會處於阻塞,非阻塞以及最終的結束狀態。

A:首先,只有在PROBE_AND_OUTPUT階段或者outer端無數據時,該算子纔可能是結束狀態;其次,只有CONSUMING_OUTER和CACHE_INNER_RESULT可能會出現block狀態,block狀態主要是爲了避免獲取DN數據時阻塞,整體流程圖如下。

Q:lookup join時下發的物理SQL只有在執行時才真正確定,這與通常情況很不同,因爲通常下發的物理SQL在拿到執行計劃時就已經確定了,如何解決?

A:第一步,生成物理SQL時,檢查是否帶有lookup的標,如果有,則在where條件中添加一個'bka_magic' = 'bka_magic'的佔位符;第二步,執行時用lookup condition對佔位符'bka_magic' = 'bka_magic'進行替換。

一個思考:有興趣的朋友可以看一下,LogicalView中的isMGetEnabled用於控制執行時生成lookup所需的物理SQL,expandView用於控制lookup執行時對於DN數據的掃描,是否真的有必要使用兩個開關?

支持多列in查詢

例如,select * from t1 where (c1,c2) in (select (c1,c3) from t2); 可以參考LookupConditionBuilder.buildMultiCondition方法,比較簡單,不再贅述。

優化器增強:限制使用Semi BKA join的場景

在這裏我們需要考慮的一個核心問題是,哪些場景不能使用Semi BKA join?

比如select * from t1 where c1 in (select md5(c2) from t2),調用相應的接口我們會發現md5(c2)並非是t2中的列,因此這種情況我們不再使用BKA join。

腦洞一下:這裏面其實有個可以思考的問題,下發select xx_func(c2) from t2 where xx_func(c2) in (...)這樣的SQL,在某些情況下應該也是會有比較好的性能的,比如存儲節點上具有函數索引。但這需要對優化器和執行器進行更爲精細的設計,同時還需要考慮收益和可維護性的問題,但不管怎麼樣,我覺得這是個有意思的問題。

優化Semi BKA join

其實至此,我們已經拿到了一個可以用的Semi BKA join了,但它的性能在某些場景下會有一定的優化空間,因此接下來我們對其進行一些優化,涉及如下commit。

support stream lookup join

support dynamic pruning

support single sharding key when table rule is not simple

support lookup predicate with multi-column when pruning

增強執行器:增加分批處理的能力

如上所述,我們需要先拉取outer端的所有數據,然後構造predicate獲取inner端數據,獲取完所有的inner端數據並使用其構建了哈希表之後,纔可以使用緩存的outer端數據進行探測並向上層流式的吐數據。

Q:這會帶來一定的問題,比如,這使得相比於hash join,吐數據的時間被延後了;再比如,這樣構造出來的predicate中的in值一般會非常多,存儲節點會更傾向於全表掃描而非索引掃描。

A:既然我們覺得outer端完全阻塞住了不太好,那就讓他流式起來好了,即outer端的數據量一旦超過一個閾值,我們就先拿這部分的數據走一個完整的join流程並對外輸出結果。 於是我們涉及到如下這樣一個略微複雜一點的流程:

思考一下:相比於把batchSize做成BufferQueue的字段,調用BufferQueue.pop()拉取batchSize行數據,我們還有一種方式,BufferQueue中不包含batchSize字段,使用BufferQueue.pop(batchSize)的方式拉取batchSize行數據。爲什麼要提這個呢,因爲這樣可以讓BufferQueue更加純粹,且爲執行時batchSize的動態調整留下了空間,雖然這種自適應的調整不一定很靠譜:((這是另一個有意思的問題了)。

支持動態裁剪
上述方案中,執行時predicate中in列表中的值會下發到每一個分片,比如outer端查出來的數據爲1,2,拼成的物理SQL爲select c1 from t2_physical_table_name where c1 in (1,2),且需要下發到所有分片。

我們進一步假設t2有八個分片,標號爲t2_{00-08},c1爲分片鍵,c1值爲1的記錄只可能出現在t2_01分片,c1值爲2的記錄只可能出現在t2_02分片,則理想情況下我們只需要下發兩條物理SQL。

即一條至t2_01分片的select c1 from t2_01 where c1 in (1),一條至t2_02分片的select c1 from t2_02 where c1 in (2)。也就是我們可以做分片裁剪和物理SQL中in值的裁剪。

篇幅有限,我們決定把對這部分設計的詳細介紹放到下次,有興趣的朋友可以先自行結合以下三個commit和給出的SQL例子進行對照理解。

support dynamic pruning

support single sharding key when table rule is not simple

support lookup predicate with multi-column when pruning
/*+TDDL: SEMI_BKA_JOIN(t1, t2)*/ select * from t1 where c1 in (select c2 from t2);
/*+TDDL: SEMI_BKA_JOIN(t1, t2)*/ select * from t1 where c1 in (select c2 from t3);
/*+TDDL: SEMI_BKA_JOIN(t1, t2)*/ select * from t1 where (c1,c3) in (select c2,c3 from t3);
CREATE TABLE `t1` (
    `c1` int(11) DEFAULT NULL,
    `c2` int(11) DEFAULT NULL,
    `c3` int(11) DEFAULT NULL,
    KEY `auto_shard_key_c1` USING BTREE (`c1`),
    KEY `auto_shard_key_c2` USING BTREE (`c2`)
) ENGINE = InnoDB dbpartition by hash(`c1`) tbpartition by hash(`c2`) tbpartitions 2;

CREATE TABLE `t2` (
    `c1` int(11) DEFAULT NULL,
    `c2` int(11) DEFAULT NULL,
    `c3` int(11) DEFAULT NULL,
    KEY `auto_shard_key_c2` USING BTREE (`c2`)
) ENGINE = InnoDB dbpartition by hash(`c2`) tbpartition by hash(`c2`) tbpartitions 2;

CREATE TABLE `t3` (
    `c1` int(11) DEFAULT NULL,
    `c2` int(11) DEFAULT NULL,
    `c3` int(11) DEFAULT NULL,
    KEY `auto_shard_key_c2` USING BTREE (`c2`),
    KEY `auto_shard_key_c3` USING BTREE (`c3`)
) ENGINE = InnoDB dbpartition by hash(`c2`) tbpartition by hash(`c3`) tbpartitions 2;

insert into t1 values (1,1,1), (2,2,2), (null, null, null);

insert into t2 values (1,1,1), (1,1,1), (null, null, null);

insert into t3 values (1,1,1), (null, null, null);

如何讓CBO選擇BKA join?

Q:首先,爲什麼相比於hash join,BKA join會更快,以及在哪些場景下會快?

A:相比於hash join,bka join在某些情況下可以避免拉取大量的數據,本質上在於hash join無法避免拉取兩張表的數據,唯一能決定的是使用小表構建哈希表,大表流式探測;而BKA join可以通過構造predicate的形式,只拉取小表的全量數據,同時只拉取大表中匹配的數據。但是分批之後,bka join會導致網絡交互次數增多,同時需要評估下發的物理SQL在存儲節點上的執行效率。

Q:其次,semi bka join相比於semi hash join,爲什麼會快?

A:看起來這個問題和上面是相同的,其實有一些差異。因爲semi join時輸出的一定是左端,在現有的實現下,semi hash join時一定會使用右端構建哈希表,當右端數據量大時,代價會很大。

思考:Semi hash join如何實現小表構建哈希表,而非永遠使用子查詢中的表構建哈希表? 在我們現在的執行器模式之下,這是無法實現的,大家有興趣的可以debug並思考一下原因,以及如何支持這種執行模式。

如何測試

測試還是一個蠻重要的東西,我覺得可能至少應該包括如下場景:

  1. 執行模式:tp_local, ap_local, mpp, cursor下推
  2. 左/右表數據:空表,三行普通值,三行null值,三行普通值+三行null值,隨機數據(包含隨機比例的重複值+null值)
  3. 列的個數:單列(sharding列),單列(非sharding列),兩列(全部爲sharding列),兩列(一列sharding列+一列非sharding列)
  4. 子查詢的類型,in, exists, some, any, all, not in, not exists, scalar_query
  5. 子查詢中列的對齊情況,對齊,不對齊,主要測試涉及到下推場景時是否正確
  6. 子查詢中列是否嚴格非null,測試優化器對於一些場景的處理是否符合預期
  7. 子查詢中的條件,某些條件下可爲空,只有join key等值,join key等值+join key非等值,join key等值+普通condition
  8. 子查詢中涉及列的類型,全類型測試 9. 保護性case:比如> all轉成的anti join絕不允許使用物化semi join等

總結

在本文中,我們主要介紹了hash join的執行流程,並從近乎零開始構建了semi bka join的執行模式。同時,我們在文中提到了很多問題,有興趣的朋友可以進行思考和交流。當然,如文中所述,我們還遺留了一些內容,關於這部分內容,我們會在下次文章中填坑,同時會結合更多場景優化更多細節。

作者:越寒

原文鏈接

本文爲阿里雲原創內容,未經允許不得轉載。

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