select count(*)底层究竟干了啥么?
轉載自??select count(*)底層究竟干了啥么?
“SELECT COUNT( * ) FROM t” 是個再常見不過的 SQL 需求了。在 MySQL 的使用規范中,我們一般使用事務引擎 InnoDB 作為(一般業務)表的存儲引擎,在此前提下,COUNT( * )操作的時間復雜度為 O(N),其中 N 為表的行數。
而 MyISAM 表中可以快速取到表的行數。這些實踐經驗的背后是怎樣的機制,以及為什么需要/可以是這樣,就是此文想要探討的。
先來看一下概況: MySQL COUNT( * ) 在 2 種存儲引擎中的部分問題:
| 執行耗時 | O(N),其中 N 是表的行數,無論是否考慮 IO 因素 | O(1) |
| 執行過程 | 掃描全表,根據數據來計算行數 | 取到表級 meta 信息的 row_count 值 |
| 流程 | 執行過程如何?2 種存儲引擎在哪里開始分道揚鑣? | ? |
| 原因 | 為何必須要掃表計算 count,而不能存儲一個 row_count 變量 | 為何可以記錄 count 變量? |
| 問題 | Count 值如何計算?存在哪里? | Count 值如何獲取? |
下面就帶著這些問題,以 InnoDB 存儲引擎為主來進行討論。
?
一、InnoDB 全表 COUNT( * )
主要問題:
1. 執行框架 – 循環: 讀取 + 計數
1.1 基本結論
1.2 說明
簡單 SELELCT-SQL 的執行框架,類比 INSERT INTO … SELECT 是同樣的過程。
下面會逐步細化如何讀取與計數 ( count++ ) 。
2. 執行過程
引述: 執行過程部分,分為 4 個部分:
如果讀者希望直接看如何進行 COUNT( * ),那么也可以忽略 (1),而直接跳到 (2) 開始看。
2.1 COUNT( * ) 前置流程回憶 – 從 Client 端發 SQL 到 sub_select 函數
為了使看到的調用過程不太突兀,我們還是先回憶一下如何執行到 sub_select 函數這來的:
PS: 這里的 JOIN 結構,不僅僅是純語法結構,而是已經進行了語義處理,粗略地說,匯總了表的列表 ( table_list )、目標列的列表 ( target_list )、WHERE 條件、子查詢等語法結構。
在全表 COUNT( * )-case 中,table_list = [表“t”(別名也是“t”)],target_list = [目標列對象(列名為“COUNT( * )”)],當然這里沒有 WHERE 條件、子查詢等結構。
- join->optimize(),優化階段 (稍后 myisam 下全表 count( * ) 操作會涉及這里的一點內容)。
- join->exec(),執行階段 ( 重點 ),包含了 InnoDB 下全表count( * ) 操作的執行流程。
2.2 COUNT( * ) 流程 ( 于 sub_select 函數中 )
上層的流程與代碼是比較簡單的,集中在 sub_select 函數中,其中 2 類函數分別對應于前面”執行框架”部分所述的 2 個步驟 – 讀取、計數。先給出結論如下:
這里會涉及行鎖的獲取、MVCC 及行可見性的問題。當然對 于 SELECT COUNT( * ) 這類快照讀而言,只會涉及 MVCC 及其可見性,而不涉及行鎖。詳情可跳至“可見性與 row_search_mvcc 函數”部分。
計數一行: 代碼層面,將會在 evaluate_join_record 函數中對所讀取的行進行評估,看其是否應當計入 count 中 ( 即是否要 count++ )。
簡單來說,COUNT(arg) 本身為 MySQL 的函數操作,對于一行來說,若括號內的參數 arg ( 某列或整行 ) 的值若不是 NULL,則 count++,否則對該行不予計數。詳情可跳至“ Evaluate_join_record 與列是否為空”部分。
這兩個階段對 COUNT( * )結果的影響如下: (兩層過濾)
| 讀取一行 | 過濾1 | 過濾1 |
| row_search_mvcc函數 | 決定語句將要能看到的那一行 | 決定語句能看到多少行 |
| 處理一行 | 過濾2 | 過濾2 |
| evaluate_join_record函數 | 決定該行(NULL?)是否被計數 | 決定可見行中計數多少行,即最終結果是多少行 |
SQL 層流程框架相關代碼摘要如下:
1210 enum_nested_loop_state1211 sub_select(JOIN *join, QEP_TAB *const qep_tab,bool end_of_records)1212 {1213?? DBUG_ENTER("sub_select");... ... // 此處省略1000字1265?? while (rc == NESTED_LOOP_OK && join->return_tab >= qep_tab_idx)1266?? {1267???? int error;// 第一步,從存儲引擎中獲取一行;1268???? if (in_first_read)1269???? {1270?????? in_first_read= false;// 第一步,首次讀取,掃描第一個滿足條件的記錄;// 初始化cursor,從”頭”掃描到某個位置// 類似: SELECT id FROM t LIMIT 1;1271?????? error= (*qep_tab->read_first_record)(qep_tab);1272???? }1273???? else// 第一步,后續讀取,在前次掃描的位置上繼續遍歷,找到一個滿足條件的記錄;// 類似: SELECT id FROM t WHERE id > $last_id LIMIT 1;1274?????? error= info->read_record(info);... ... // 此處省略1000字// 第二步,處理剛剛取出的一行1291?????? rc= evaluate_join_record(join, qep_tab);... ... // 此處省略1000字1303?? DBUG_RETURN(rc);1304 }Q: 代碼層面,第一步驟(讀取一行)有 2 個分支,為什么?
A:從 InnoDB 接口層面考慮,分為 “讀第一行” 和 “讀下一行”,是 2 個不同的執行過程,讀第一行需要找到一個 ( cursor ) 位置并做一些初始化工作讓后續的過程可遞歸。
正如我們如果用腳本/程序來進行逐行的掃表操作,實現上就會涉及下面 2 個 SQL:
// SELECT id FROM t LIMIT 1; OR SELECT MIN(id)-1 FROM t; -> $last_id// SELECT id FROM t WHERE id > $last_id LIMIT 1;涉及到此例的代碼,SQL 層到存儲引擎層的調用關系,讀取階段的調用棧如下:(供參考)
sub_select 函數中從 SQL 層到 InnoDB 層的函數調用關系:(同顏色、同縮進 表示同一層)???(*qep_tab->read_first_record) ()| -- > join_read_first(tab)| -- > tab->read_record.read_record=join_read_next;| -- > table->file->ha_index_init()| -- > handler::ha_index_init(uint idx, bool sorted)| -- > ha_innobase::index_init()| -- > table->file->ha_index_first()| -- > handler::ha_index_first(uint idx, bool sorted)| -- > ha_innobase::index_first()| -- > ha_innobase::index_read()| -- > row_search_mvcc()初始化cursor并將其放到一個有效的初始位置上;???info->read_record (info)| -- > join_read_next(info)| -- > info->table->file->ha_index_next(info->record))| -- > handler::ha_index_next(uchar * buf)| -- > ha_innobase::index_next(uchar * buf)| -- > general_fetch(buf, ROW_SEL_NEXT, 0)| -- > row_search_mvcc()“向前”移動一次cursor;我們可以看到,無論是哪一個分支的讀取,最終都殊途同歸于 row_search_mvcc 函數。
以上是對 LOOP 中的代碼做一些簡要的說明,下面來看 row_search_mvcc 與 evaluate_join_record 如何輸出最終的 count 結果。
2.3 行可見性及 row_search_mvcc 函數
這里我們主要通過一組 case 和幾個問題來看行可見性對 COUNT( * ) 的影響。
?
Q:對于“SELECT COUNT( * ) FROM t”或者“SELECT MIN(id) FROM t”操作,第一次的讀行操作讀到的是表 t 中 ( B+ 樹最左葉節點 page 內 ) 的最小記錄嗎?( ha_index_first 為何也調用 row_search_mvcc 來獲取最小 key 值?)
A:不一定。即使是 MIN ( id ) 也不一定就讀取的是 id 最小的那一行,因為也同樣有行可見性的問題,實際上 index_read 取到的是 當前事務內語句可見的最小 index 記錄。這也反映了前面提到的 join_read_first 與 join_read_next “殊途同歸”到 row_search_mvcc 是理所應當的。
Q:針對圖中最后一問,如果事務 X 是 RU ( Read-Uncommitted ) 隔離級別,且 C-Insert ( 100 ) 的完成是在 X-count( * ) 執行過程中 ( 僅掃描到 5 或 10 這條記錄 ) 完成的,那么 X-count( * ) 在事務 C-Insert ( 100 ) 完成后,能否在之后的讀取過程中看到 100 這條記錄呢?
A:MySQL 采取”讀到什么就是什么”的策略,即 X-count( * ) 在后面可以讀到 100 這條記錄。
2.4 evaluate_join_record 與列是否為空
Q:某一行如何計入 count?
A:兩種情況會將所讀的行計入 count:
- e.g. SELECT COUNT(col_name) FROM t
- col_name 可以是主鍵、唯一鍵、非唯一鍵、非索引字段
?
- e.g-1. SELECT COUNT(*) FROM t
- e.g-2. SELECT COUNT(B.*) FROM A LEFT JOIN B ON A.id = B.id
Q: 特別地,對于 SELECT COUNT(id) FROM t,其中 id 字段是表 t 的主鍵,則如何?
A:效果上等價于 COUNT( * )。因為無論是 COUNT( * ),還是 COUNT ( pk_col ) 都是因為有主鍵從而充分斷定索取數據不為 NULL,這類 COUNT 表達式可以用于獲取當前可見的表行數。
Q: 用戶層面對 InnoDB COUNT( * ) 的優化操作問題
A:這個問題是業界熟悉的一個問題,掃描非空唯一鍵可得到表行數,但所涉及的字節數可能會少很多(在表的行長與主鍵、唯一鍵的長度相差較多時),相對的 IO 代價小很多。
相關調用棧參考如下:
參考一:evaluate_join_record()| -- > rc= (*qep_tab->next_select)(join, qep_tab+1, 0);| -- > end_send_group(...)| -- > init_sum_functions(join->sum_funcs, join->sum_funcs_end[idx+1]))| -- > (*func_ptr)->reset_and_add()| -- > Item_sum::aggregator_clear()| -- > Item_sum::aggregator_add()| -- > update_sum_func(Item_sum **func_ptr)| -- > (*func_ptr)->add()| -- > Item_sum::aggregator_add()參考二: (Item_sum::aggregator_add)((Item_sum *) (*func_ptr))->aggregator_add()| -- > (Item_sum *)this->aggr->add()| -- > ((Aggregator_simple *) aggr)->item_sum->add()| -- > if (! aggr->arg_is_null(false))| ------ > ((Item_sum_count *)aggr->item_sum)->count++;?
二、數據結構:
Q:count 值存儲在哪個內存變量里?
**A: **SQL 解析后,存儲于表達 COUNT( * ) 這一項中,((Item_sum_count*)item_sum)->count
如下圖所示回顧我們之前“COUNT( * )前置流程”部分提到的 JOIN 結構。
?
即 SQL 解析器為每個 SQL 語句進行結構化,將其放在一個 JOIN 對象 ( join ) 中來表達。在該對象中創建并填充了一個列表 result_field_list 用于存放結果列,列表中每個元素則是一個結果列的 ( Item_result_field* ) 對象 ( 指針 ) 。
在 COUNT( * )-case 中,結果列列表只包含一個元素,( Item_sum_count: public Item_result_field ) 類型對象 ( name = “COUNT( * )”),其中該類所特有的成員變量 count即為所求。
?
三、MyISAM 全表 COUNT( * )
由于 MyISAM 引擎并不常用于實際業務中,僅做簡要描述如下:
?
四、幾個問題
Q:MyISAM 與 InnoDB 在 COUNT( * ) 操作的執行過程在哪里開始分道揚鑣?
- 共性:共性存在于 SQL 層,即 SQL 解析之后的數據結構是一致的,count 變量都是存在于作為結果列的 Item_sum_count 類型對象中;返回給客戶端的過程也類似 – 對該 count 變量進行賦值并經由 MySQL 通信協議返回給客戶端。
- 區別:InnoDB 的 count 值計算是在 SQL 執行階段進行的;而 MyISAM 表本身在內存中有一份包含了表 row_count 值的 meta 信息,在 SQL 優化階段通過存儲引擎的標記給優化器一個 hint,表明該表所用的存儲引擎保存了精確行數,可以直接獲取到,無需再進入執行器。
?
Q:InnoDB 中為何無法向 MyISAM 一樣維護住一個 row_count 變量?
A:從 MVCC 機制與行可見性問題中可得到原因,每個事務所看到的行可能是不一樣的,其 count( * ) 結果也可能是不同的;反過來看,則是 MySQL-Server 端無法在同一時刻對所有用戶線程提供一個統一的讀視圖,也就無法提供一個統一的 count 值。
PS: 對于多個訪問 MySQL 的用戶線程 ( COUNT( * ) ) 而言,決定它們各自的結果的因素有幾個:
其中 1、2 對于 Server 而言都是全局或者說可控的,只有 3 是每個用戶線程中事務所獨有的屬性,這是 Server 端不可控的因素,因此 Server 端也就對每個 COUNT( * ) 結果不可控了。
Q:InnoDB-COUNT( * ) 屬 table scan 操作,是否會將現有 Buffer Pool 中其它用戶線程所需熱點頁從 LRU-list 中擠占掉,從而其它用戶線程還需從磁盤 load 一次,突然加重 IO 消耗,可能對現有請求造成阻塞?
A:MySQL 有這樣的優化策略,將掃表操作所 load 的 page 放在 LRU-list 的 oung/old 的交界處 ( LRU 尾部約 3/8 處 )。這樣用戶線程所需的熱點頁仍然在 LRU-list-young 區域,而掃表操作不斷 load 的頁則會不斷沖刷 old 區域的頁,這部分的頁本身就是被認為非熱點的頁,因此也相對符合邏輯。
PS: 個人認為還有一種類似的優化思路,是限定掃描操作所使用的 Buffer Pool 的大小為 O(1) 級別,但這樣做需要付出額外的內存管理成本。
Q:InnoDB-COUNT( * ) 是否會像 SELECT * FROM t 那樣讀取存儲大字段的溢出頁(如果存在)?
A:否。因為 InnoDB-COUNT( * ) 只需要數行數,而每一行的主鍵肯定不是 NULL,因此只需要讀主鍵索引頁內的行數據,而無需讀取額外的溢出頁。
本文作者:賈春生
總結
以上是生活随笔為你收集整理的select count(*)底层究竟干了啥么?的全部內容,希望文章能夠幫你解決所遇到的問題。
- 上一篇: 而且电脑有很大的噪音而且电脑有很大的噪音
- 下一篇: 26个字母vs上万个汉字,中国人的打字机