技術分享 | SQL 最佳化:ICP 的缺陷

碼農談IT發表於2022-12-06

作者:胡呈清


愛可生 DBA 團隊成員,擅長故障分析、效能最佳化,個人部落格:a95ec11f67a8,歡迎討論。
本文來源:原創投稿


*愛可生開源社群出品,原創內容未經授權不得隨意使用,轉載請聯絡小編並註明來源。



什麼是 ICP(Index Condition Pushdown)

ICP 全稱 Index Condition Pushdown,也就是常說的索引條件下推,在之前的一篇文章中介紹過它:explain 執行計劃詳解2--Extra
使用二級索引查詢資料時,where 子句中屬於索引的一部分但又無法使用索引的條件,MySQL 會把這部分條件下推到儲存引擎層,篩選之後再進行回表,這樣回表的次數就減少了。

比如有這樣一個索引idx_test(birth_date,first_name,hire_date)查詢語句select * from employees where birth_date >= '1957-05-23' and birth_date <='1960-06-01' and hire_date>'1998-03-22';的執行過程:

1. 根據 birth_date >= '1957-05-23' and birth_date <='1960-06-01' 這個條件從 idx_test 索引中查詢資料,假設返回資料 10萬行;

2. 查詢出來的10萬行資料包含 hire_date 欄位,MySQL 會把 hire_date >'1998-03-22' 這個條件下推到儲存引擎,進一步篩選資料,假設還剩1000行;

3. 由於要查詢所有欄位的值,而前面查到的 1000 行資料只包含 birth_date,first_name,hire_date 三個欄位,所以需要回表查出所有欄位的值。回表的過程就是將這 1000 行資料的主鍵值拿出來,一個一個到主鍵索引上去查詢(也可以開啟 mrr,拿一批主鍵值回表),回表次數是 1000。如果沒有ICP,則回表次數是 10 萬。
很顯然在執行階段 ICP 可以減少回表的次數,在基於代價的最佳化器中,也就是能減少執行的成本。但是,最佳化器在最佳化階段選擇最優的執行計劃時真的能考慮到 ICP 可以減少成本嗎?下面我們透過一個實驗來回答這個問題。

實驗

先準備一些資料,下載 Employees Sample Database 並匯入到 MySQL 中:https://dev.mysql.com/doc/employee/en/employees-installation.html

還是上面那個例子,建立一個組合索引:

alter table employees add index idx_test(birth_date,first_name,hire_date);

執行下面這個SQL:

SELECT *
FROM employees
WHERE birth_date >= '1957-05-23'
    AND birth_date <= '1960-06-01'
    AND hire_date > '1998-03-22';

執行計劃如下:

mysql [localhost:5735] {msandbox} (employees) > explain select * from employees where birth_date >= '1957-05-23' and birth_date <='1960-06-01' and hire_date>'1998-03-22';
+----+-------------+-----------+------------+------+---------------+------+---------+------+--------+----------+-------------+
| id | select_type | table     | partitions | type | possible_keys | key  | key_len | ref  | rows   | filtered | Extra       |
+----+-------------+-----------+------------+------+---------------+------+---------+------+--------+----------+-------------+
|  1 | SIMPLE      | employees | NULL       | ALL  | idx_test      | NULL | NULL    | NULL | 298980 |    15.74 | Using where |
+----+-------------+-----------+------------+------+---------------+------+---------+------+--------+----------+-------------+

可以看到並沒有使用 idx_test 索引,但如果加 hint 強制走 idx_test 索引,我們知道可以使用 ICP,執行計劃如下:

mysql [localhost:5735] {msandbox} (employees) > explain select * from employees force index(idx_test) where birth_date >= '1957-05-23' and birth_date <='1960-06-01' and hire_date>'1998-03-22';
+----+-------------+-----------+------------+-------+---------------+----------+---------+------+--------+----------+-----------------------+
| id | select_type | table     | partitions | type  | possible_keys | key      | key_len | ref  | rows   | filtered | Extra                 |
+----+-------------+-----------+------------+-------+---------------+----------+---------+------+--------+----------+-----------------------+
|  1 | SIMPLE      | employees | NULL       | range | idx_test      | idx_test | 3       | NULL | 141192 |    33.33 | Using index condition |
+----+-------------+-----------+------------+-------+---------------+----------+---------+------+--------+----------+-----------------------+
1 row in set, 1 warning (0.00 sec)
再讓我們開啟 slow log 看下真實的執行效率:
  • 全表掃描需要掃描 300024 行,執行時間 0.15 秒

  • 走 idx_test 索引需要掃描 141192 行(Rows_examined: 1065 是個 bug,這顯然不是掃描行數,掃描行數我們可以從執行計劃看出,在這個例子中執行計劃裡的 rows 是真實的掃描行數,不是估算值,這個知識點不影響理解本文)。因為沒有其他條件,從返回結果行數我們也能知道回表次數就是 1065,執行時間只要 0.037 秒

# Time: 2022-11-24T18:02:01.001734+08:00
# Query_time: 0.146939  Lock_time: 0.000850 Rows_sent: 1065  Rows_examined: 300024
SET timestamp=1669284095;
select * from employees where birth_date >= '1957-05-23' and birth_date <='1960-06-01' and hire_date>'1998-03-22';
# Time: 2022-11-24T18:01:09.001223+08:00
# Query_time: 0.037211  Lock_time: 0.001649 Rows_sent: 1065  Rows_examined: 1065
SET timestamp=1669284032;
select * from employees force index(idx_test) where birth_date >= '1957-05-23' and birth_date <='1960-06-01' and hire_date>'1998-03-22';

很顯然走 idx_test 索引比全表掃描效率更高,那為什麼最佳化器不選擇走 idx_test 索引呢?一個不會犯錯的說法是最佳化器有它的演算法,並不以人類認為的時間快慢為標準來進行選擇。這次我們打破砂鍋問到底,最佳化器的演算法是什麼?

答案是成本,最佳化器在選擇最優的執行計劃時會計算所有可用的執行計劃的成本,然後選擇成本最小的那個。而成本有明確的計算方法,也能透過 explain format=json 展示執行計劃的成本,因此我們用這一點來證明 ICP 能否影響執行計劃的成本。關於 explain format=json 的詳細輸出解釋可以參考:explain format=json 詳解,本文不過多展開。

成本計算

1. I/O成本
表的資料和索引都儲存到磁碟上,當我們想查詢表中的記錄時,需要先把資料或者索引載入到記憶體中然後再操作。這個從磁碟到記憶體這個載入的過程損耗的時間稱之為I/O成本。
2. CPU成本

讀取以及檢測記錄是否滿足對應的搜尋條件、對結果集進行排序等這些操作損耗的時間稱之為CPU成本。

3. 成本常數

對於InnoDB儲存引擎來說,頁是磁碟和記憶體之間互動的基本單位,MySQL5.7 中規定讀取一個頁面花費的成本預設是1.0,讀取以及檢測一條記錄是否符合搜尋條件的成本預設是0.2。1.0、0.2這些數字稱之為成本常數(不同版本可能不一樣,可以透過 mysql.server_cost、mysql.engine_cost 檢視)。

不加干涉時,最佳化器選擇全表掃描,總成本為 "query_cost": "60725.00",計算公式:
  • IO成本:929*1 = 929 (929 是主鍵索引的頁數,透過表的統計資訊中的 Data_length/pagesize 得到)

  • CPU 成本:298980*0.2 = 59796(298980是掃描行數,全表掃描時這是一個估算值,也就是表的統計資訊中的 Rows)
  • 總成本 = IO成本 + CPU 成本 = 929 + 59796 = 60725
mysql [localhost:5735] {msandbox} (employees) > explain format=json select * from employees  where birth_date >= '1957-05-23' and birth_date <='1960-06-01' and hire_date>'1998-03-22'\G
*************************** 1. row ***************************
EXPLAIN: {
  "query_block": {
    "select_id": 1,
    "cost_info": {
      "query_cost""60725.00"
    },
    "table": {
      "table_name""employees",
      "access_type""ALL",
      "possible_keys": [
        "idx_test"
      ],
      "rows_examined_per_scan": 298980,
      "rows_produced_per_join": 47059,
      "filtered""15.74",
      "cost_info": {
        "read_cost""51313.14",
        "eval_cost""9411.86",
        "prefix_cost""60725.00",
        "data_read_per_join""6M"
      },
      "used_columns": [
        "emp_no",
        "birth_date",
        "first_name",
        "last_name",
        "gender",
        "hire_date"
      ],
      "attached_condition""((`employees`.`employees`.`birth_date` >= '1957-05-23') and (`employees`.`employees`.`birth_date` <= '1960-06-01') and (`employees`.`employees`.`hire_date` > '1998-03-22'))"
    }
  }
}
1 row in set, 1 warning (0.00 sec)
hint 走 idx_test 索引時,總成本為 "query_cost": "197669.81",計算公式:
  • 訪問 idx_test 索引的成本:

    • IO 成本=1*1=1(最佳化器認為讀取索引的一個範圍區間的I/O成本和讀取一個頁面是相同的,而條件中只有 birth_date >= '1957-05-23' and birth_date <='1960-06-01' 這一個範圍)

    • CPU 成本 = 141192*0.2 = 28238.4(掃描行數 "rows_examined_per_scan": 141192)

  • 回表的成本(不會考慮索引條件下推的作用,因此回表次數等於索引掃描行數):

    • 回表 IO 成本 = 141192*1 = 141192
    • 回表 CPU 成本 = 141192*0.2 = 28238.4
  • 總成本:1+28238.4+141192+28238.4=197669.8
mysql [localhost:5735] {msandbox} (employees) > explain format=json select * from employees force index(idx_test) where birth_date >= '1957-05-23' and birth_date <='1960-06-01' and hire_date>'1998-03-22'\G
*************************** 1. row ***************************
EXPLAIN: {
  "query_block": {
    "select_id": 1,
    "cost_info": {
      "query_cost""197669.81"
    },
    "table": {
      "table_name""employees",
      "access_type""range",
      "possible_keys": [
        "idx_test"
      ],
      "key""idx_test",
      "used_key_parts": [
        "birth_date"
      ],
      "key_length""3",
      "rows_examined_per_scan": 141192,
      "rows_produced_per_join": 47059,
      "filtered""33.33",
      "index_condition""((`employees`.`employees`.`birth_date` >= '1957-05-23') and (`employees`.`employees`.`birth_date` <= '1960-06-01') and (`employees`.`employees`.`hire_date` > '1998-03-22'))",
      "cost_info": {
        "read_cost""188257.95",
        "eval_cost""9411.86",
        "prefix_cost""197669.81",
        "data_read_per_join""6M"
      },
      "used_columns": [
        "emp_no",
        "birth_date",
        "first_name",
        "last_name",
        "gender",
        "hire_date"
      ]
    }
  }
}
1 row in set, 1 warning (0.00 sec)

結論

從上一步的成本結果來看,全表掃描的成本是 60725,而走 idx_test 索引的成本是 197669.81,因此最佳化器選擇全表掃描。
實際上 ICP 可以減少回表次數,走 idx_test 索引時的真實回表次數是 1065,成本應該是:
  • IO成本:1065*1 = 1065

  • CPU成本:1065*0.2 = 213

但是最佳化器在計算回表成本時,顯然沒有考慮 ICP,直接將掃描索引的行數 141192 當作了回表的次數,所以得到的回表成本巨大,總成本遠遠大於全表掃描的成本。

因此,我們可以得到的結論是:ICP可以在執行階段提高執行效率,但是在最佳化階段並不能改善執行計劃。


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

相關文章