select count(*) 底層究竟做了什麼?
閱讀本文大概需要 6.6 分鐘。
SELECT COUNT( * ) FROM t
是個再常見不過的 SQL 需求了。在 MySQL 的使用規範中,我們一般使用事務引擎 InnoDB 作為(一般業務)表的儲存引擎,在此前提下, COUNT( * )
操作的時間複雜度為 O(N)
,其中 N 為表的行數。
而 MyISAM
表中可以快速取到表的行數。這些實踐經驗的背後是怎樣的機制,以及為什麼需要/可以是這樣,就是此文想要探討的。
先來看一下概況: MySQL COUNT( * )
在 2 種儲存引擎中的部分問題:
下面就帶著這些問題,以 InnoDB
儲存引擎為主來進行討論。
一、InnoDB 全表 COUNT( * )
主要問題:
-
執行過程是怎樣的?
-
如何計算
count
?影響count
結果的因素有哪些? -
count
值存在哪裡?涉及的資料結構是怎樣的? -
為什麼
InnoDB
只能通過掃表來實現count( * )
?(見本文最後的問題) -
全表
COUNT( * )
作為table scan
型別操作的一個case
,有什麼風險? -
COUNT(* )
操作是否會像SELECT *
一樣可能讀取大欄位涉及的溢位頁?
1. 執行框架 – 迴圈: 讀取 + 計數
1.1 基本結論
-
全表掃描,一個迴圈解決問題。
-
迴圈內: 先讀取一行,再決定該行是否計入
count
。 -
迴圈內是一行一行進行計數處理的。
1.2 說明
簡單 SELELCT-SQL
的執行框架,類比 INSERT INTO … SELECT
是同樣的過程。
下面會逐步細化如何讀取與計數 ( count++
) 。
2. 執行過程
引述: 執行過程部分,分為 4 個部分:
-
COUNT( * )
前置流程: 從Client
端發 SQL 語句,到MySQL-Server
端執行SELECT
之前,為後面的一些闡述做一鋪墊。 -
COUNT( * )
流程: 簡要給出程式碼層面的流程框架及 2 個核心步驟的重點呼叫棧部分。 -
讀取一行: 可見性及
row_search_mvcc
函式,介紹可見性如何影響COUNT( * )
結果。 -
計數一行:
Evaluate_join_record
與列是否為空,介紹計數過程如何影響COUNT( * )
結果。
如果讀者希望直接看如何進行 COUNT( * )
,那麼也可以忽略 (1),而直接跳到 (2) 開始看。
2.1 COUNT( * ) 前置流程回憶 – 從 Client 端發 SQL 到 sub_select 函式
為了使看到的呼叫過程不太突兀,我們還是先回憶一下如何執行到 sub_select
函式這來的:
-
MySQL-Client
端傳送 SQL 語句,根據 MySQL 通訊協議封包傳送。 -
Mysql-Server
端接收資料包,由協議解析出command
型別 (QUERY
) 及 SQL 語句 ( 字串 ) 。 -
SQL 語句經過解析器解析輸出為
JOIN
類的物件,用於結構化地表達該 SQL 語句。
PS: 這裡的 JOIN
結構,不僅僅是純語法結構,而是已經進行了語義處理,粗略地說,彙總了表的列表 ( table_list
)、目標列的列表 ( target_list
)、 WHERE
條件、子查詢等語法結構。
在全表 COUNT( * )-case
中, table_list = [表“t”(別名也是“t”)]
, target_list = [目標列物件(列名為“COUNT( * )”)]
,當然這裡沒有 WHERE
條件、子查詢等結構。
-
JOIN
物件有 2 個重要的方法:JOIN::optimize()
,JOIN::exec()
,分別用於進行查詢語句的優化 和 查詢語句的執行。
join->optimize(),優化階段 (稍後 myisam
下全表 count( * )
操作會涉及這裡的一點內容)。
join->exec(),執行階段 ( 重點 ),包含了 InnoDB
下全表 count( * )
操作的執行流程。
-
join->exec() 經過若干呼叫,將呼叫到
sub_select
函式來執行簡單 SQL,包括COUNT( * )
。 -
END of sub_select
。
2.2 COUNT( * ) 流程 ( 於 sub_select 函式中 )
上層的流程與程式碼是比較簡單的,集中在 sub_select
函式中,其中 2 類函式分別對應於前面”執行框架”部分所述的 2 個步驟 – 讀取、計數。先給出結論如下:
-
讀取一行:從相對頂層的
sub_select
函式經過一番呼叫,最終所有分支將呼叫到row_search_mvcc
函式中,該函式就是用於從InnoDB
儲存引擎所儲存的B+-tree
結構中讀取一行到記憶體中的一個buf (uchar * )
中,待後續處理使用。這裡會涉及行鎖的獲取、MVCC 及行可見性的問題。當然對 於
SELECT COUNT( * )
這類快照讀而言,只會涉及 MVCC 及其可見性,而不涉及行鎖。詳情可跳至“可見性與row_search_mvcc
函式”部分。 -
計數一行: 程式碼層面,將會在
evaluate_join_record
函式中對所讀取的行進行評估,看其是否應當計入count
中 ( 即是否要count++
)。
簡單來說, COUNT(arg)
本身為 MySQL 的函式操作,對於一行來說,若括號內的引數 arg ( 某列或整行 )
的值若不是 NULL,則 count++
,否則對該行不予計數。詳情可跳至“ Evaluate_join_record 與列是否為空”部分。
這兩個階段對 COUNT( * )
結果的影響如下: (兩層過濾)
SQL 層流程框架相關程式碼摘要如下:
1210 enum_nested_loop_state1211 sub_select(JOIN *join, QEP_TAB *const qep_tab,bool end_of_records)1212 {1213DBUG_ENTER("sub_select");... ... // 此處省略1000字1265while (rc == NESTED_LOOP_OK && join->return_tab >= qep_tab_idx)1266{1267int error;// 第一步,從儲存引擎中獲取一行;1268if (in_first_read)1269{1270in_first_read= false;// 第一步,首次讀取,掃描第一個滿足條件的記錄;// 初始化cursor,從”頭”掃描到某個位置// 類似: SELECT id FROM t LIMIT 1;1271error= (*qep_tab->read_first_record)(qep_tab);1272}1273else// 第一步,後續讀取,在前次掃描的位置上繼續遍歷,找到一個滿足條件的記錄;// 類似: SELECT id FROM t WHERE id > $last_id LIMIT 1;1274error= info->read_record(info);... ... // 此處省略1000字// 第二步,處理剛剛取出的一行1291rc= evaluate_join_record(join, qep_tab);... ... // 此處省略1000字1303DBUG_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:
1、如果 COUNT 函式中的引數是某列,則會判斷所讀行中該列定義是否 Nullable
以及該列的值是否為 NULL
;若兩者均為是,則不會計入 count,否則將計入 count。
-
e.g.
SELECT COUNT(col_name) FROM t
-
col_name
可以是主鍵、唯一鍵、非唯一鍵、非索引欄位
2、如果 COUNT 中帶有 * ,則會判斷這部分的整行是否為 NULL,如果判斷引數為 NULL,則忽略該行,否則 count++
。
-
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
引擎並不常用於實際業務中,僅做簡要描述如下:
-
MyISAM-COUNT( * )
操作是O(1)
時間複雜度的操作。 -
每張
MyISAM
表中存放了一個meta
資訊-count 值,在記憶體中與檔案中各有一份,記憶體中的 count 變數值通過讀取檔案中的 count 值來進行初始化。 -
SELECT COUNT( * ) FROM t
會直接讀取記憶體中的表 t 對應的 count 變數值。 -
記憶體中的 count 值與檔案中的 count 值由寫操作來進行更新,其一致性由表級鎖來保證。
-
表級鎖保證的寫入序列化使得,同一時刻所有使用者執行緒的讀操作要麼被鎖,要麼只會看到一種資料狀態。
四、幾個問題
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
,因此只需要讀主鍵索引頁內的行資料,而無需讀取額外的溢位頁。
據說,帥的人已經將這個公眾號設為星標
·END·
程式設計師的成長之路
路雖遠,行則必至
微信ID:cxydczzl