【GreatSQL最佳化器-04】貪婪搜尋演算法淺析

GreatSQL發表於2024-11-22

【GreatSQL最佳化器-04】貪婪搜尋演算法淺析

一、貪婪搜尋(greedy_search)介紹

GreatSQL的最佳化器用greedy_search方法來列舉所有的表連線場景,然後從中根據最小cost來決定最佳連線順序。這裡面就涉及每種場景的cost計算方法,不同計算方法會導致不同的排序結果。

因為列舉所有join場景,當表數量很大的時候就有可能無窮無盡消耗系統資源,因此GreatSQL執行greedy_search的時候使用search_depthprune_level變數(分別對應GreatSQL中的 optimizer_search_depthoptimizer_prune_level 系統變數),防止無窮無盡的分析各種連線順序的成本,如果連線表的個數小於search_depth,那麼就繼續窮舉分析每一種連線順序的cost,否則只對與optimizer_search_depth 值相同數量的表進行窮舉分析。該值越大,成本分析的越精確,越容易得到好的執行計劃,但是消耗的時間也就越長,否則得到的可能不是最好的執行計劃,但可以省掉很多分析連線成本的時間。設定合適的 prune_level 值可以裁剪掉一些不必要的深度,直接跟後面訪問數量最少的表進行連線計算。

執行greedy_search複雜度可能是O(N*N^search_depth/search_depth)。如果search_depth > N 那麼演算法的複雜度就是 O(N!)。通常最佳化器分析的複雜度都是 O(N!)

名稱 場景
search_depth thd->variables.optimizer_search_depth>0 (預設62) thd->variables.optimizer_search_depth
thd->variables.optimizer_search_depth=0 表數量<=7,值為表數量+1
表數量>7,值為7
prune_level 在設定了引數prune_level(預設設定)後,不再使用窮舉的方式擴充套件執行計劃,而是在剩餘表中直接選取訪問最少紀錄數的表計算。如果未設定,則繼續跟後面的表連線計算cost獲取"最優"的執行計劃。因此設定為on的時候執行復雜度小更快。 thd->variables.optimizer_prune_level 值為0和1,預設值為1

下面用一個簡單的例子來說明greedy_search是什麼。

CREATE TABLE t1 (c1 INT PRIMARY KEY, c2 INT,date1 DATETIME);
INSERT INTO t1 VALUES (1,10,'2021-03-25 16:44:00.123456'),(2,1,'2022-03-26 16:44:00.123456'),(3,4,'2023-03-27 16:44:00.123456'),(5,5,'2024-03-25 16:44:00.123456');
CREATE TABLE t2 (cc1 INT PRIMARY KEY, cc2 INT);
INSERT INTO t2 VALUES (1,3),(2,1),(3,2),(4,3),(5,15);
CREATE TABLE t3 (ccc1 INT, ccc2 varchar(100));
INSERT INTO t3 VALUES (1,'aa1'),(2,'bb1'),(3,'cc1'),(4,'dd1'),(null,'ee');
CREATE INDEX idx1 ON t1(c2);
CREATE INDEX idx2 ON t1(c2,date1);
CREATE INDEX idx2_1 ON t2(cc2);
CREATE INDEX idx3_1 ON t3(ccc1);

-- 這裡做了一個6張表的連線,這裡面就涉及了greedy_search方法來決定哪張表先執行哪張表後執行
-- 看下面最終結果是按照t1,t4,t2,t5,t3,t4順序來執行連線的
greatsql> EXPLAIN SELECT * FROM t3,t1,t2,t1 AS t4,t2 AS t5,t3 AS t6 WHERE t1.c1=t3.ccc1 and t2.cc1=t3.ccc1 and t1.c1=t4.c1 and t1.c1=t5.cc1 and t1.c1=t6.ccc1;
+----+-------------+-------+------------+--------+---------------+---------+---------+-----------+------+----------+-------------+
| id | select_type | table | partitions | type   | possible_keys | key     | key_len | ref       | rows | filtered | Extra       |
+----+-------------+-------+------------+--------+---------------+---------+---------+-----------+------+----------+-------------+
|  1 | SIMPLE      | t1    | NULL       | index  | PRIMARY       | idx2    | 11      | NULL      |    4 |   100.00 | Using index |
|  1 | SIMPLE      | t4    | NULL       | eq_ref | PRIMARY       | PRIMARY | 4       | db1.t1.c1 |    1 |   100.00 | NULL        |
|  1 | SIMPLE      | t2    | NULL       | eq_ref | PRIMARY       | PRIMARY | 4       | db1.t1.c1 |    1 |   100.00 | NULL        |
|  1 | SIMPLE      | t5    | NULL       | eq_ref | PRIMARY       | PRIMARY | 4       | db1.t1.c1 |    1 |   100.00 | NULL        |
|  1 | SIMPLE      | t3    | NULL       | ref    | idx3_1        | idx3_1  | 5       | db1.t1.c1 |    1 |   100.00 | NULL        |
|  1 | SIMPLE      | t6    | NULL       | ref    | idx3_1        | idx3_1  | 5       | db1.t1.c1 |    1 |   100.00 | NULL        |
+----+-------------+-------+------------+--------+---------------+---------+---------+-----------+------+----------+-------------+

二、greedy_search程式碼解釋

bool JOIN::make_join_plan() {
  // Choose the table order based on analysis done so far.
  if (Optimize_table_order(thd, this, nullptr).choose_table_order())
    return true;
}

bool Optimize_table_order::choose_table_order() {
  // 先對所有表做一個排序,小表放前面
  merge_sort();
  if (straight_join)
    optimize_straight_join(join_tables);
  else {
    if (greedy_search(join_tables)) return true;
  }
}

bool Optimize_table_order::greedy_search(table_map remaining_tables) {
  do {
    if (best_extension_by_limited_search(remaining_tables, idx, search_depth))
      return true;
    // 如果層數沒有超過規定大小,說明已經執行完列舉可以退出。如果超過了,說明不需要執行列舉連線,按照單獨連線方式繼續執行。
    if (size_remain <= search_depth || use_best_so_far) {
      return false;
    }
    --size_remain;
    ++idx;
  }
}

// 用current_search_depth來計數,每進行一次深度連線計算就減一,用來控制join連線列舉分析的層數。
bool Optimize_table_order::best_extension_by_limited_search(
    table_map remaining_tables, uint idx, uint current_search_depth) {
  for (JOIN_TAB **pos = join->best_ref + idx; *pos && !use_best_so_far; pos++) {
      /* Find the best access method from 's' to the current partial plan */
      // 該表之後的每個表遍歷一遍,計算每種連線的cost
      best_access_path(s, remaining_tables, idx, false,
                       idx ? (position - 1)->prefix_rowcount : 1.0, position);
      // 如果算完左連線cost比之前的還大,就不繼續算下一張表,直接開始下一次連線計算
      if (position->prefix_cost >= join->best_read &&
          found_plan_with_allowed_sj) {
        trace_one_table.add("pruned_by_cost", true);
        continue;
      }
      // 開啟裁剪模式的話
      if (prune_level == 1) {
        // 如果當前計算出來的rowcount小於之前儲存下來的best_rowcount,那麼就替換best_rowcount值
        if (best_rowcount > position->prefix_rowcount ||
            best_cost > position->prefix_cost ||
            (idx == join->const_tables &&  // 's' is the first table in the QEP
             s->table() == join->sort_by_table)) {
            best_rowcount = position->prefix_rowcount;
            best_cost = position->prefix_cost;
         } else // 否則就跳過剩下表的窮舉搜尋計算,直接用下一次連線繼續計算
           continue;
      }
      // current_search_depth還在層數允許範圍內,遞迴繼續進行下一次最佳化查詢
      if ((current_search_depth > 1) && remaining_tables_after) {
        if (prune_level == 1 &&             // 1)
            position->key != nullptr &&     // 2)
            position->rows_fetched <= 1.0)  // 3)
        {
          // 如果連線左表cost比之前的大,那麼會被裁剪
          // 如果滿足(EQ_)REF key的join方式並且本次找到的行數只有一行,那麼就執行EQ_REF-joined連線計算
          eq_ref_extension_by_limited_search();
        } else
          // 繼續執行下一張連線表的連線cost計算
          best_extension_by_limited_search(remaining_tables_after, idx + 1,
                                             current_search_depth - 1));
      } else  // if ((current_search_depth > 1) && ... // 層數還有剩,後面沒有表要連線了,那就儲存當次計算結果以便下次做比較
      {  // 將這次計算出來的rowcount和read_cost存入prefix_rowcount和prefix_read_cost,方便下一次連線比較cost
        // 如果此次join的cost更小,那麼儲存到join->best_read 和 join->best_rowcount
        if (consider_plan(idx, &trace_one_table)) return true;
      }
  }
}

void Optimize_table_order::best_access_path(JOIN_TAB *tab,
                                            const table_map remaining_tables,
                                            const uint idx, bool disable_jbuf,
                                            const double prefix_rowcount,
                                            POSITION *pos) {
  // 如果根據前面的結果keyuse_array陣列有值的話,那麼根據find_best_ref()函式先找出最優索引,按照索引的方式計算cost
  if (tab->keyuse() != nullptr &&
      (table->file->ha_table_flags() & HA_NO_INDEX_ACCESS) == 0)
    best_ref =
        find_best_ref(tab, remaining_tables, idx, prefix_rowcount,
                      &found_condition, &ref_depend_map, &used_key_parts);
  // 最主要計算下面3個值
  pos->filter_effect = filter_effect = std::min(1.0, tab->found_records * calculate_condition_filter() / rows_after_filtering);
  pos->rows_fetched = rows_fetched = rows_after_filtering;
  pos->read_cost = scan_read_cost = calculate_scan_cost();    
}

最佳化器內部有一些最佳化可以根據需要自己重新定義行為,這就涉及到optimizer_switch開關的設定。詳細可以設定的optimizer_switch如下表所示,以後每個專門開一期細講。

附表:最佳化器涉及的OPTIMIZER_SWITCH表

OPTIMIZER_SWITCH 預設 說明
OPTIMIZER_SWITCH_INDEX_MERGE ON 可以在多個索引上進行查詢,並將結果合併返回。
OPTIMIZER_SWITCH_INDEX_MERGE_UNION OFF 會在使用到的多個索引上同時進行掃描,並取這些掃描結果的並集作為最終結果集。
OPTIMIZER_SWITCH_INDEX_MERGE_SORT_UNION ON 比單純的Union索引合併多了一步對二級索引記錄的主鍵id排序的過程。
OPTIMIZER_SWITCH_INDEX_MERGE_INTERSECT ON 會在使用到的多個索引上同時進行掃描,並取這些掃描結果的交集作為最終結果集。
OPTIMIZER_SWITCH_ENGINE_CONDITION_PUSHDOWN ON 只用於NDB引擎,開啟後時按照WHERE條件過濾後的資料傳送到SQL節點來處理,不開啟所有資料節點的資料都傳送到SQL節點來處理
OPTIMIZER_SWITCH_INDEX_CONDITION_PUSHDOWN ON 當ICP開啟時,用於二級索引的range、 ref、 eq_ref或ref_or_null掃描,如果部分where條件能使用索引的欄位,server會把這部分下推到引擎層,可以利用index過濾的where條件在儲存引擎層進行資料過濾。
OPTIMIZER_SWITCH_MRR ON 針對多列索引,也叫組合索引來做基本掃描,然後對匹配的記錄按照主鍵排序,這樣按照有序的主鍵順序從磁碟上掃描需要的全部記錄。根本功能是把對磁碟的隨機掃描轉化為順序掃描。
OPTIMIZER_SWITCH_MRR_COST_BASED ON cost base的方式還選擇啟用MRR最佳化,當發現最佳化後的代價過高時就會不使用該項最佳化
OPTIMIZER_SWITCH_BNL ON 塊巢狀迴圈(Block Nested Loop, BNL),將驅動表的資料先快取在join buffer中,一次讀取被驅動表的資料,可以和驅動表的多條記錄進行join,這樣就可以減少全表掃描的次數。
OPTIMIZER_SWITCH_BKA OFF 批次索引訪問(Batched Key Access),主要適用於join的表上有索引可利用,無索引只能使用BNL.多表join語句,被join的表/非驅動表必須有索引可用,才能利用BKA.對於多表join語句,當MySQL使用索引訪問第二個join表的時候,使用一個join buffer來收集第一個操作物件生成的相關列值
OPTIMIZER_SWITCH_SEMIJOIN ON 是否啟用semijoin
OPTIMIZER_SWITCH_MATERIALIZATION ON OPTIMIZER_SWITCH_SEMIJOIN=ON,把內表去重然後生成有對應索引的臨時表(有點類似其他資料中的物化檢視),然後透過外表的對應鍵值遍歷這張臨時表。
OPTIMIZER_SWITCH_LOOSE_SCAN ON OPTIMIZER_SWITCH_SEMIJOIN=ON,把inner-table資料基於索引進行分組,取每組第一條資料進行匹配。
OPTIMIZER_SWITCH_FIRSTMATCH ON OPTIMIZER_SWITCH_SEMIJOIN=ON,只選用內部表的第1條與外表匹配的記錄。
OPTIMIZER_SWITCH_DUPSWEEDOUT ON OPTIMIZER_SWITCH_SEMIJOIN=ON,使用臨時表對semi-join產生的結果集去重
OPTIMIZER_SWITCH_SUBQ_MAT_COST_BASED ON OPTIMIZER_SWITCH_MATERIALIZATION=ON的時候,如果此項為ON選擇CANDIDATE_FOR_IN2EXISTS_OR_MAT策略,否則選擇SUBQ_MATERIALIZATION策略
OPTIMIZER_SWITCH_USE_INDEX_EXTENSIONS ON 索引擴充套件使用,主要用於INNODB的第二索引,也就是普通的索引,把索引中包含的主鍵值利用到。比如主鍵為(a,b),索引為(c). 如果用到了索引c,那麼把索引變成(c,a,b) 這樣,就可以用到新的組合索引了。不過這種場合用的也比較少,一般是根據組合主鍵中的第一個欄位和普通索引一起來做檢索的時候。
OPTIMIZER_SWITCH_COND_FANOUT_FILTER ON join時候是否使用條件過濾百分比
OPTIMIZER_SWITCH_DERIVED_MERGE ON 派生表合併,類似Oracle的檢視合併,當派生SQL中存在以下操作是無法展開:UNION 、GROUP 、DISTINCT、LIMIT及聚合操作
OPTIMIZER_SWITCH_USE_INVISIBLE_INDEXES OFF 是否使用不可見索引
OPTIMIZER_SKIP_SCAN ON 使用skip scan方式進行範圍掃描,當要查詢的列都在索引中時,即使where中的條件不是索引的第一部分,也可以使用索引。
OPTIMIZER_SWITCH_HASH_JOIN ON 使用hash join方法,導致開銷更大,已經棄用
OPTIMIZER_SWITCH_SUBQUERY_TO_DERIVED OFF 子查詢轉換為派生表
OPTIMIZER_SWITCH_PREFER_ORDERING_INDEX ON 對於 limit N 帶有 group by ,order by 的 SQL 語句 (order by 和 group by 的欄位有索引可以使用),執行計劃選擇 where 條件中的索引查詢過濾資料,而不是根據order by id 的索引進而導致全表掃描
OPTIMIZER_SWITCH_HYPERGRAPH_OPTIMIZER OFF 使用超圖理論(Hypergraph Theory)來最佳化查詢計劃
OPTIMIZER_SWITCH_DERIVED_CONDITION_PUSHDOWN ON 對於派生表(DERIVED)的條件下推,即在執行派生表之前應用條件,減少在派生表中處理的資料量。
OPTIMIZER_SWITCH_FAVOR_RANGE_SCAN OFF 當index dive不能被跳過的時候,如果此項=ON,那麼cost值*0.1
OPTIMIZER_SWITCH_REMOVE_USELESS_OUTERJOIN OFF 用於控制最佳化器在處理不必要的外連線時的行為。不必要的外連線指的是那些永遠不會返回任何內連線行的連線。這些外連線在查詢執行時會被最佳化器移除。

三、實際例子說明

接下來看幾個例子來說明上面的程式碼。

greatsql> EXPLAIN SELECT * FROM t3,t1,t2,t1 AS t4,t2 AS t5,t3 AS t6 WHERE t1.c1=t3.ccc1 and t2.cc1=t3.ccc1 AND t1.c1=t4.c1 AND t1.c1=t5.cc1 AND t1.c1=t6.ccc1;
+----+-------------+-------+------------+--------+---------------+---------+---------+-----------+------+----------+-------------+
| id | select_type | table | partitions | type   | possible_keys | key     | key_len | ref       | rows | filtered | Extra       |
+----+-------------+-------+------------+--------+---------------+---------+---------+-----------+------+----------+-------------+
|  1 | SIMPLE      | t1    | NULL       | index  | PRIMARY       | idx2    | 11      | NULL      |    4 |   100.00 | Using index |
|  1 | SIMPLE      | t4    | NULL       | eq_ref | PRIMARY       | PRIMARY | 4       | db1.t1.c1 |    1 |   100.00 | NULL        |
|  1 | SIMPLE      | t2    | NULL       | eq_ref | PRIMARY       | PRIMARY | 4       | db1.t1.c1 |    1 |   100.00 | NULL        |
|  1 | SIMPLE      | t5    | NULL       | eq_ref | PRIMARY       | PRIMARY | 4       | db1.t1.c1 |    1 |   100.00 | NULL        |
|  1 | SIMPLE      | t3    | NULL       | ref    | idx3_1        | idx3_1  | 5       | db1.t1.c1 |    1 |   100.00 | NULL        |
|  1 | SIMPLE      | t6    | NULL       | ref    | idx3_1        | idx3_1  | 5       | db1.t1.c1 |    1 |   100.00 | NULL        |
+----+-------------+-------+------------+--------+---------------+---------+---------+-----------+------+----------+-------------+

-- 看一下列舉順序:
"`t1`" -> `t1` `t4` -> `t3`
                    -> `t2` -> `t3`
                            -> `t2` `t5` -> `t3`
                                         -> `t3` `t6` -- cost太大不繼續計算下一張表,直接開始下一次連線計算
                                         -> `t3` -> `t3` `t6`
       -> `t3`   
       -> `t3` `t6`  
`t1` `t4` -> `t1` -> `t3`
                  -> `t2` -> `t3`
                          -> `t2` `t5` -> `t3` 
                                       -> `t3` `t6` cost太大不繼續計算下一張表,直接開始下一次連線計算
                                       -> `t3` -> `t3` `t6` cost太大不繼續計算下一張表,直接開始下一次連線計算
後面類似,因為太多不寫出來了。


-- 層數改為1,看看效果。
greatsql> set optimizer_search_depth=1;

-- 發現表的連線順序跟之前不一樣了。
greatsql> explain SELECT * FROM t3,t1,t2,t1 as t4,t2 as t5,t3 as t6 where t1.c1=t3.ccc1 and t2.cc1=t3.ccc1 and t1.c1=t4.c1 and t1.c1=t5.cc1 and t1.c1=t6.ccc1;
+----+-------------+-------+------------+--------+---------------+---------+---------+-----------+------+----------+-------------+
| id | select_type | table | partitions | type   | possible_keys | key     | key_len | ref       | rows | filtered | Extra       |
+----+-------------+-------+------------+--------+---------------+---------+---------+-----------+------+----------+-------------+
|  1 | SIMPLE      | t1    | NULL       | index  | PRIMARY       | idx2    | 11      | NULL      |    4 |   100.00 | Using index |
|  1 | SIMPLE      | t4    | NULL       | eq_ref | PRIMARY       | PRIMARY | 4       | db1.t1.c1 |    1 |   100.00 | NULL        |
|  1 | SIMPLE      | t3    | NULL       | ref    | idx3_1        | idx3_1  | 5       | db1.t1.c1 |    1 |   100.00 | NULL        |
|  1 | SIMPLE      | t2    | NULL       | eq_ref | PRIMARY       | PRIMARY | 4       | db1.t1.c1 |    1 |   100.00 | NULL        |
|  1 | SIMPLE      | t5    | NULL       | eq_ref | PRIMARY       | PRIMARY | 4       | db1.t1.c1 |    1 |   100.00 | NULL        |
|  1 | SIMPLE      | t6    | NULL       | ref    | idx3_1        | idx3_1  | 5       | db1.t1.c1 |    1 |   100.00 | NULL        |
+----+-------------+-------+------------+--------+---------------+---------+---------+-----------+------+----------+-------------+

`t1` cost最小,選這個繼續走
`t1` `t4`
`t3`
`t2`
`t2` `t5`
`t3` `t6`
`t1` -> `t1` `t4` -> `t3` -> `t2` -> `t2` `t5` -> `t3` `t6` cost最小,選這個繼續走
                                  -> `t3` `t6`
                          -> `t2` `t5`
                          -> `t3` `t6`
                  -> `t2` 
                  -> `t2` `t5`
                  -> `t3` `t6`
     -> `t3`
     -> `t2`
     -> `t2` `t5`
     -> `t3` `t6`

四、總結

從上面最佳化器的步驟我們認識了貪婪演算法的過程,知道了2個基本引數search_depth和prune_level,這兩個引數可以自定義設定值用來簡化演算法的步驟,減少資源的消耗,但是也會相應的導致最後結果不精確,所以還是要按照個人需求進行設定。


Enjoy GreatSQL 😃

關於 GreatSQL

GreatSQL是適用於金融級應用的國內自主開源資料庫,具備高效能、高可靠、高易用性、高安全等多個核心特性,可以作為MySQL或Percona Server的可選替換,用於線上生產環境,且完全免費併相容MySQL或Percona Server。

相關連結: GreatSQL社群 Gitee GitHub Bilibili

GreatSQL社群:

社群部落格有獎徵稿詳情:https://greatsql.cn/thread-100-1-1.html

image-20230105161905827

技術交流群:

微信:掃碼新增GreatSQL社群助手微信好友,傳送驗證資訊加群

image-20221030163217640

相關文章