一文讀懂MySQL的索引結構及查詢優化

行無際發表於2020-09-21

回顧前文: 一文學會MySQL的explain工具

(同時再次強調,這幾篇關於MySQL的探究都是基於5.7版本,相關總結與結論不一定適用於其他版本)

MySQL官方文件中(https://dev.mysql.com/doc/refman/5.7/en/optimization-indexes.html)有這樣一段描述:

The best way to improve the performance of SELECT operations is to create indexes on one or more of the columns that are tested in the query. But unnecessary indexes waste space and waste time for MySQL to determine which indexes to use. Indexes also add to the cost of inserts, updates, and deletes because each index must be updated. You must find the right balance to achieve fast queries using the optimal set of indexes.

就是說提高查詢效能最直接有效的方法就是建立索引,但是不必要的索引會浪費空間,同時也增加了額外的時間成本去判斷應該走哪個索引,此外,索引還會增加插入、更新、刪除資料的成本,因為做這些操作的同時還要去維護(更新)索引樹。因此,應該學會使用最佳索引集來優化查詢。

索引結構

參考:

  1. 《MySQL索引背後的資料結構及演算法原理》http://blog.codinglabs.org/articles/theory-of-mysql-index.html

  2. 《Mysql BTree和B+Tree詳解》https://www.cnblogs.com/Transkai/p/11595405.html

  3. 《為什麼MySQL使用B+樹》https://draveness.me/whys-the-design-mysql-b-plus-tree/

  4. 《淺入淺出MySQL和InnoDB》https://draveness.me/mysql-innodb/

  5. 《漫畫:什麼是B樹?》https://mp.weixin.qq.com/s/rDCEFzoKHIjyHfI_bsz5Rw

什麼是索引

在MySQL中,索引(Index)是幫助高效獲取資料的資料結構。這種資料結構MySQL中最常用的就是B+樹(B+Tree)。

Indexes are used to find rows with specific column values quickly. Without an index, MySQL must begin with the first row and then read through the entire table to find the relevant rows.

就好比給你一本書和一篇文章標題,如果沒有目錄,讓你找此標題對應的文章,可能需要從第一頁翻到最後一頁;如果有目錄大綱,你可能只需要在目錄頁尋找此標題,然後迅速定位文章。

這裡我們可以把書(book)看成是MySQL中的table,把文章(article)看成是table中的一行記錄,即row文章標題(title)看成row中的一列column目錄自然就是對title列建立的索引index了,這樣根據文章標題從書中檢索文章就對應sql語句select * from book where title = ?,相應的,書中每增加一篇文章(即insert into book (title, ...) values ('華山論劍', ...)),都需要維護一下目錄,這樣才能從目錄中找到新增的文章華山論劍,這一操作對應的是MySQL中每插入(insert)一條記錄需要維護title列的索引樹(B+Tree)。

為什麼使用B+Tree

首先需要澄清的一點是,MySQL跟B+樹沒有直接的關係,真正與B+樹有關係的是MySQL的預設儲存引擎InnoDB,MySQL中儲存引擎的主要作用是負責資料的儲存和提取,除了InnoDB之外,MySQL中也支援比如MyISAM等其他儲存引擎(詳情見https://dev.mysql.com/doc/refman/5.7/en/storage-engine-setting.html)作為表的底層儲存引擎。

mysql> show engines;
+--------------------+---------+----------------------------------------------------------------+--------------+------+------------+
| Engine             | Support | Comment                                                        | Transactions | XA   | Savepoints |
+--------------------+---------+----------------------------------------------------------------+--------------+------+------------+
| MRG_MYISAM         | YES     | Collection of identical MyISAM tables                          | NO           | NO   | NO         |
| CSV                | YES     | CSV storage engine                                             | NO           | NO   | NO         |
| PERFORMANCE_SCHEMA | YES     | Performance Schema                                             | NO           | NO   | NO         |
| BLACKHOLE          | YES     | /dev/null storage engine (anything you write to it disappears) | NO           | NO   | NO         |
| InnoDB             | DEFAULT | Supports transactions, row-level locking, and foreign keys     | YES          | YES  | YES        |
| MyISAM             | YES     | MyISAM storage engine                                          | NO           | NO   | NO         |
| ARCHIVE            | YES     | Archive storage engine                                         | NO           | NO   | NO         |
| MEMORY             | YES     | Hash based, stored in memory, useful for temporary tables      | NO           | NO   | NO         |
| FEDERATED          | NO      | Federated MySQL storage engine                                 | NULL         | NULL | NULL       |
+--------------------+---------+----------------------------------------------------------------+--------------+------+------------+

提到索引,我們可能會立馬想到下面幾種資料結構來實現。

(1) 雜湊表
雜湊雖然能夠提供O(1)的單資料行的查詢效能,但是對於範圍查詢排序卻無法很好支援,需全表掃描。

(2) 紅黑樹
紅黑樹(Red Black Tree)是一種自平衡二叉查詢樹,在進行插入和刪除操作時通過特定操作保持二叉查詢樹的平衡,從而獲得較高的查詢效能。

一般來說,索引本身也很大,往往不可能全部儲存在記憶體中,因此索引往往以索引檔案的形式儲存的磁碟上。這樣的話,索引查詢過程中就要產生磁碟I/O消耗,相對於記憶體存取,I/O存取的消耗遠遠高於記憶體,所以評價一個資料結構作為索引的優劣最重要的指標就是查詢過程中磁碟I/O次數。換句話說,索引的結構組織要儘量減少查詢過程中磁碟I/O的次數。

在這裡,磁碟I/O的次數取決於樹的高度,所以,在資料量較大時,紅黑樹會因樹的高度較大而造成磁碟IO較多,從而影響查詢效率。

(3) B-Tree
B樹中的B代表平衡(Balance),而不是二叉(Binary),B樹是從平衡二叉樹演化而來的。

為了降低樹的高度(也就是減少磁碟I/O次數),把原來瘦高的樹結構變得矮胖,B樹會在每個節點儲存多個元素(紅黑樹每個節點只會儲存一個元素),並且節點中的元素從左到右遞增排列。如下圖所示:

B-Tree結構圖

B-Tree在查詢的時候比較次數其實不比二叉查詢樹少,但在記憶體中的大小比較、二分查詢的耗時相比磁碟IO耗時幾乎可以忽略。 B-Tree大大降低了樹的高度,所以也就極大地提升了查詢效能。

(4) B+Tree
B+Tree是在B-Tree基礎上進一步優化,使其更適合實現儲存索引結構。InnoDB儲存引擎就是用B+Tree實現其索引結構。

B-Tree結構圖中可以看到每個節點中不僅包含資料的key值,還有data值。而每一個節點的儲存空間是有限的,如果data值較大時將會導致每個節點能儲存的key的數量很小,這樣會導致B-Tree的高度變大,增加了查詢時的磁碟I/O次數,進而影響查詢效能。在B+Tree中,所有data值都是按照鍵值大小順序存放在同一層的葉子節點上,而非葉子節點上只儲存key值資訊,這樣可以增大每個非葉子節點儲存的key值數量,降低B+Tree的高度,提高效率。

B+Tree結構圖

這裡補充一點相關知識 在計算機中,磁碟往往不是嚴格按需讀取,而是每次都會預讀,即使只需要一個位元組,磁碟也會從這個位置開始,順序向後讀取一定長度的資料放入記憶體。這樣做的理論依據是電腦科學中著名的區域性性原理

當一個資料被用到時,其附近的資料也通常會馬上被使用。

由於磁碟順序讀取的效率很高(不需要尋道時間,只需很少的旋轉時間),因此對於具有區域性性的程式來說,預讀可以提高I/O效率。預讀的長度一般為頁(page)的整數倍。

是計算機管理儲存器的邏輯塊,硬體及作業系統往往將主存和磁碟儲存區分割為連續的大小相等的塊,每個儲存塊稱為一頁(許多作業系統的頁預設大小為4KB),主存和磁碟以頁為單位交換資料。當程式要讀取的資料不在主存中時,會觸發一個缺頁異常,此時作業系統會向磁碟發出讀盤訊號,磁碟會找到資料的起始位置並向後連續讀取一頁或幾頁載入記憶體中,然後異常返回,程式繼續執行。(如下命令可以檢視作業系統的預設頁大小)

$ getconf PAGE_SIZE
4096

資料庫系統的設計者巧妙利用了磁碟預讀原理,將一個節點的大小設為作業系統的頁大小的整數倍,這樣每個節點只需要一次I/O就可以完全載入。

InnoDB儲存引擎中也有頁(Page)的概念,頁是其磁碟管理的最小單位。InnoDB儲存引擎中預設每個頁的大小為16KB。

mysql> show variables like 'innodb_page_size';
+------------------+-------+
| Variable_name    | Value |
+------------------+-------+
| innodb_page_size | 16384 |
+------------------+-------+
1 row in set (0.01 sec)

一般表的主鍵型別為INT(佔4個位元組)或BIGINT(佔8個位元組),指標型別也一般為4或8個位元組,也就是說一個頁(B+Tree中的一個節點)中大概儲存16KB/(8B+8B)=1K個鍵值(因為是估值,為方便計算,這裡的K取值為10^3)。也就是說一個深度為3的B+Tree索引可以維護10^3 * 10^3 * 10^3 = 10億條記錄。

B+Tree的高度一般都在2到4層。mysql的InnoDB儲存引擎在設計時是將根節點常駐記憶體的,也就是說查詢某一鍵值的行記錄時最多隻需要1到3次磁碟I/O操作。

隨機I/O對於MySQL的查詢效能影響會非常大,而順序讀取磁碟中的資料會很快,由此我們也應該儘量減少隨機I/O的次數,這樣才能提高效能。在B-Tree中由於所有的節點都可能包含目標資料,我們總是要從根節點向下遍歷子樹查詢滿足條件的資料行,這會帶來大量的隨機I/O,而B+Tree所有的資料行都儲存在葉子節點中,而這些葉子節點通過雙向連結串列依次按順序連線,當我們在B+樹遍歷資料(比如說範圍查詢)時可以直接在多個葉子節點之間進行跳轉,保證順序倒序遍歷的效能。

另外,對以上提到的資料結構不熟悉的朋友,這裡推薦一個線上資料結構視覺化演示工具,有助於快速理解這些資料結構的機制:https://www.cs.usfca.edu/~galles/visualization/Algorithms.html

主鍵索引

上面也有提及,在MySQL中,索引屬於儲存引擎級別的概念。不同儲存引擎對索引的實現方式是不同的,這裡主要看下MyISAMInnoDB兩種儲存引擎的索引實現方式。

MyISAM索引實現

MyISAM引擎使用B+Tree作為索引結構時葉子節點的data域存放的是資料記錄的地址。如下圖所示:

MyISAM主鍵索引原理圖

由上圖可以看出:MyISAM索引檔案和資料檔案是分離的,索引檔案僅儲存資料記錄的地址,因此MyISAM的索引方式也叫做非聚集的,之所以這麼稱呼是為了與InnoDB的聚集索引區分。

InnoDB索引實現

InnoDB主鍵索引也使用B+Tree作為索引結構時的實現方式卻與MyISAM截然不同。InnoDB的資料檔案本身就是索引檔案。在InnoDB中,表資料檔案本身就是按B+Tree組織的一個索引結構,這棵樹的葉子節點data域儲存了完整的資料記錄,這個索引的key是資料表的主鍵,因此InnoDB表資料檔案本身就是主索引。

InnoDB主鍵索引原理圖

InnoDB儲存引擎中的主鍵索引(primary key)又叫做聚集索引(clustered index)。因為InnoDB的資料檔案本身要按主鍵聚集,所以InnoDB要求表必須有主鍵(MyISAM可以沒有),如果沒有顯式指定,則MySQL系統會自動選擇一個可以唯一標識資料記錄的列作為主鍵,如果不存在這種列,則MySQL自動為InnoDB表生成一個隱含欄位作為主鍵,這個欄位長度為6個位元組,型別為長整形。(詳情見官方文件:https://dev.mysql.com/doc/refman/5.7/en/innodb-index-types.html)

聚集索引這種實現方式使得按主鍵搜尋十分高效,直接能查出整行資料。

在InnoDB中,用非單調遞增的欄位作為主鍵不是個好主意,因為InnoDB資料檔案本身是一棵B+Tree,非單增的主鍵會造成在插入新記錄時資料檔案為了維持B+Tree的特性而頻繁的分裂調整,十分低效,因而使用遞增欄位作為主鍵則是一個很好的選擇。

非主鍵索引

MyISAM索引實現

MyISAM中,主鍵索引和非主鍵索引(Secondary key,也有人叫做輔助索引)在結構上沒有任何區別,只是主鍵索引要求key是唯一的,而輔助索引的key可以重複。這裡不再多加敘述。

InnoDB索引實現

InnoDB的非主鍵索引data域儲存相應記錄主鍵的值。換句話說,InnoDB的所有非主鍵索引都引用主鍵的值作為data域。如下圖所示:

InnoDB非主鍵索引原理圖

由上圖可知:使用非主鍵索引搜尋時需要檢索兩遍索引,首先檢索非主鍵索引獲得主鍵(primary key),然後用主鍵到主鍵索引樹中檢索獲得完整記錄。

那麼為什麼非主鍵索引結構葉子節點儲存的是主鍵值,而不像主鍵索引那樣直接儲存完整的一行資料,這樣就能避免回表二次檢索?顯然,這樣做一方面節省了大量的儲存空間,另一方面多份冗餘資料,更新資料的效率肯定低下,另外保證資料的一致性是個麻煩事。

到了這裡,也很容易明白為什麼不建議使用過長的欄位作為主鍵,因為所有的非主鍵索引都引用主鍵值,過長的主鍵值會讓非主鍵索引變得過大。

聯合索引

官方文件:https://dev.mysql.com/doc/refman/5.7/en/multiple-column-indexes.html

比如INDEX idx_book_id_hero_name (book_id, hero_name) USING BTREE,即對book_id, hero_name兩列建立了一個聯合索引。

A multiple-column index can be considered a sorted array, the rows of which contain values that are created by concatenating the values of the indexed columns.

聯合索引是多列按照次序一列一列比較大小,拿idx_book_id_hero_name這個聯合索引來說,先比較book_id,book_id小的排在左邊,book_id大的排在右邊,book_id相同時再比較hero_name。如下圖所示:

InnoDB聯合索引原理圖

瞭解了聯合索引的結構,就能引入最左字首法則

If the table has a multiple-column index, any leftmost prefix of the index can be used by the optimizer to look up rows. For example, if you have a three-column index on (col1, col2, col3), you have indexed search capabilities on (col1), (col1, col2), and (col1, col2, col3).

就是說聯合索引中的多列是按照列的次序排列的,如果查詢的時候不能滿足列的次序,比如說where條件中缺少col1 = ?,直接就是col2 = ? and col3 = ?,那麼就走不了聯合索引,從上面聯合索引的結構圖應該能明顯看出,只有col2列無法通過索引樹檢索符合條件的資料。

根據最左字首法則,我們知道對INDEX idx_book_id_hero_name (book_id, hero_name)來說,where book_id = ? and hero_name = ?的查詢來說,肯定可以走索引,但是如果是where hero_name = ? and book_id = ?呢,表面上看起來不符合最左字首法則啊,但MySQL優化器會根據已有的索引,調整查詢條件中這兩列的順序,讓它符合最左字首法則,走索引,這裡也就回答了上篇《一文學會MySQL的explain工具》中為什麼用show warnings命令檢視時,where中的兩個過濾條件hero_namebook_id先後順序被調換了。

至於對聯合索引中的列進行範圍查詢等各種情況,都可以先想聯合索引的結構是如何建立出來的,然後看過濾條件是否滿足最左字首法則。比如說範圍查詢時,範圍列可以用到索引(必須是最左字首),但是範圍列後面的列無法用到索引。同時,索引最多用於一個範圍列,因此如果查詢條件中有兩個範圍列則無法全用到索引。

優化建議

主鍵的選擇

在使用InnoDB儲存引擎時,如果沒有特別的需要,儘量使用一個與業務無關的遞增欄位作為主鍵,主鍵欄位不宜過長。原因上面在講索引結構時已提過。比如說常用雪花演算法生成64bit大小的整數(佔8個位元組,用BIGINT型別)作為主鍵就是一個不錯的選擇。

索引的選擇

(1) 表記錄比較少的時候,比如說只有幾百條記錄的表,對一些列建立索引的意義可能並不大,所以表記錄不大時酌情考慮索引。但是業務上具有唯一特性的欄位,即使是多個欄位的組合,也建議使用唯一索引(UNIQUE KEY)。

(2) 當索引的選擇性非常低時,索引的意義可能也不大。所謂索引的選擇性(Selectivity),是指不重複的索引值(也叫基數Cardinality)與表記錄數的比值,即count(distinct 列名)/count(*),常見的場景就是有一列status標識資料行的狀態,可能status非0即1,總資料100萬行有50萬行status為0,50萬行status為1,那麼是否有必要對這一列單獨建立索引呢?

An index is best used when you need to select a small number of rows in comparison to the total rows.

這句話我摘自stackoverflow上《MySQL: low selectivity columns = how to index?》下面一個人的回答。(詳情見:https://stackoverflow.com/questions/2386852/mysql-low-cardinality-selectivity-columns-how-to-index)

對於上面說的status非0即1,而且這兩種情況分佈比較均勻的情況,索引可能並沒有實際意義,實際查詢時,MySQL優化器在計算全表掃描和索引樹掃描代價後,可能會放棄走索引,因為先從status索引樹中遍歷出來主鍵值,再去主鍵索引樹中查最終資料,代價可能比全表掃描還高。

但是如果對於status為1的資料只有1萬行,其他99萬行資料status為0的情況呢,你怎麼看?歡迎有興趣的朋友在文章下面留言討論!

補充: 關於MySQL如何選擇走不走索引或者選擇走哪個最佳索引,可以使用MySQL自帶的trace工具一探究竟。具體使用見下面的官方文件。
https://dev.mysql.com/doc/internals/en/optimizer-tracing.html
https://dev.mysql.com/doc/refman/5.7/en/information-schema-optimizer-trace-table.html

使用方法:

mysql> set session optimizer_trace="enabled=on",end_markers_in_json=on;
mysql> select * from tb_hero where hero_id = 1;
mysql> SELECT * FROM information_schema.OPTIMIZER_TRACE;

注意:開啟trace工具會影響MySQL效能,所以只能臨時分析sql使用,用完之後應當立即關閉

mysql> set session optimizer_trace="enabled=off";

(3) 在varchar型別欄位上建立索引時,建議指定索引長度,有些時候可能沒必要對全欄位建立索引,根據實際文字區分度決定索引長度即可【說明:索引的長度與區分度是一對矛盾體,一般對字串型別資料,長度為20的索引,區分度會高達90%以上,可以使用count(distinct left(列名, 索引長度))/count(*)來確定區分度】。

這種指定索引長度的索引叫做字首索引(詳情見https://dev.mysql.com/doc/refman/5.7/en/column-indexes.html#column-indexes-prefix)。

With col_name(N) syntax in an index specification for a string column, you can create an index that uses only the first N characters of the column. Indexing only a prefix of column values in this way can make the index file much smaller. When you index a BLOB or TEXT column, you must specify a prefix length for the index.

字首索引語法如下:

mysql> alter table tb_hero add index idx_hero_name_skill2 (hero_name, skill(2));

字首索引兼顧索引大小和查詢速度,但是其缺點是不能用於group byorder by操作,也不能用於covering index(即當索引本身包含查詢所需全部資料時,不再訪問資料檔案本身)。

(4) 當查詢語句的where條件或group byorder by含多列時,可根據實際情況優先考慮聯合索引(multiple-column index),這樣可以減少單列索引(single-column index)的個數,有助於高效查詢。

If you specify the columns in the right order in the index definition, a single composite index can speed up several kinds of queries on the same table.

建立聯合索引時要特別注意column的次序,應結合上面提到的最左字首法則以及實際的過濾、分組、排序需求。區分度最高的建議放最左邊

說明:

  • order by的欄位可以作為聯合索引的一部分,並且放在最後,避免出現file_sort的情況,影響查詢效能。正例:where a=? and b=? order by c會走索引idx_a_b_c,但是WHERE a>10 order by b卻無法完全使用上索引idx_a_b,只會使用上聯合索引的第一列a

  • 存在非等號和等號混合時,在建聯合索引時,應該把等號條件的列前置。如:where c>? and d=?那麼即使c的區分度更高,也應該把d放在索引的最前列,即索引idx_d_c

  • 如果where a=? and b=?,如果a列的幾乎接近於唯一值,那麼只需要建立單列索引idx_a即可

order by與group by

儘量在索引列上完成分組、排序,遵循索引最左字首法則,如果order by的條件不在索引列上,就會產生Using filesort,降低查詢效能。

分頁查詢

MySQL分頁查詢大多數寫法可能如下:

mysql> select * from tb_hero limit offset,N;

MySQL並不是跳過offset行,而是取offset+N行,然後返回放棄前offset行,返回N行,那當offset特別大的時候,效率就非常的低下。

可以對超過特定閾值的頁數進行SQL改寫如下:

先快速定位需要獲取的id段,然後再關聯

mysql> select a.* from tb_hero a, (select hero_id from tb_hero where 條件 limit 100000,20 ) b where a.hero_id = b.hero_id;

或者這種寫法

mysql> select a.* from tb_hero a inner join (select hero_id from tb_hero where 條件 limit 100000,20) b on a.hero_id = b.hero_id;

多表join

(1) 需要join的欄位,資料型別必須絕對一致;
(2) 多表join時,保證被關聯的欄位有索引

覆蓋索引

利用覆蓋索引(covering index)來進行查詢操作,避免回表,從而增加磁碟I/O。換句話說就是,儘可能避免select *語句,只選擇必要的列,去除無用的列。

An index that includes all the columns retrieved by a query. Instead of using the index values as pointers to find the full table rows, the query returns values from the index structure, saving disk I/O. InnoDB can apply this optimization technique to more indexes than MyISAM can, because InnoDB secondary indexes also include the primary key columns. InnoDB cannot apply this technique for queries against tables modified by a transaction, until that transaction ends.

Any column index or composite index could act as a covering index, given the right query. Design your indexes and queries to take advantage of this optimization technique wherever possible.

當索引本身包含查詢所需全部列時,無需回表查詢完整的行記錄。對於InnoDB來說,非主鍵索引中包含了所有的索引列以及主鍵值,查詢的時候儘量用這種特性避免回表操作,資料量很大時,查詢效能提升很明顯。

in和exsits

原則:小表驅動大表,即小的資料集驅動大的資料集

(1) 當A表的資料集大於B表的資料集時,in優於exists

mysql> select * from A where id in (select id from B)

(2) 當A表的資料集小於B表的資料集時,exists優於in

mysql> select * from A where exists (select 1 from B where B.id = A.id)

like

索引檔案具有B+Tree最左字首匹配特性,如果左邊的值未確定,那麼無法使用索引,所以應儘量避免左模糊(即%xxx)或者全模糊(即%xxx%)。

mysql> select * from tb_hero where hero_name like '%無%';
+---------+-----------+--------------+---------+
| hero_id | hero_name | skill        | book_id |
+---------+-----------+--------------+---------+
|       3 | 張無忌    | 九陽神功     |       3 |
|       5 | 花無缺    | 移花接玉     |       5 |
+---------+-----------+--------------+---------+
2 rows in set (0.00 sec)

mysql> explain select * from tb_hero where hero_name like '%無%';
+----+-------------+---------+------------+------+---------------+------+---------+------+------+----------+-------------+
| id | select_type | table   | partitions | type | possible_keys | key  | key_len | ref  | rows | filtered | Extra       |
+----+-------------+---------+------------+------+---------------+------+---------+------+------+----------+-------------+
|  1 | SIMPLE      | tb_hero | NULL       | ALL  | NULL          | NULL | NULL    | NULL |    6 |    16.67 | Using where |
+----+-------------+---------+------------+------+---------------+------+---------+------+------+----------+-------------+
1 row in set, 1 warning (0.00 sec)

可以看出全模糊查詢時全表掃了,這個時候使用覆蓋索引的特性,只選擇索引欄位可以有所優化。如下:

mysql> explain select book_id, hero_name from tb_hero where hero_name like '%無%';
+----+-------------+---------+------------+-------+---------------+-----------------------+---------+------+------+----------+--------------------------+
| id | select_type | table   | partitions | type  | possible_keys | key                   | key_len | ref  | rows | filtered | Extra                    |
+----+-------------+---------+------------+-------+---------------+-----------------------+---------+------+------+----------+--------------------------+
|  1 | SIMPLE      | tb_hero | NULL       | index | NULL          | idx_book_id_hero_name | 136     | NULL |    6 |    16.67 | Using where; Using index |
+----+-------------+---------+------------+-------+---------------+-----------------------+---------+------+------+----------+--------------------------+
1 row in set, 1 warning (0.00 sec)

count(*)

阿里巴巴Java開發手冊中有這樣的規約:

不要使用count(列名)count(常量)來替代count(*)count(*)是SQL92定義的標準統計行數的語法,跟資料庫無關,跟NULL非NULL無關【說明:count(*)會統計值為NULL的行,而count(列名)不會統計此列為NULL值的行】。
count(distinct col)計算該列除NULL之外的不重複行數,注意count(distinct col1, col2)如果其中一列全為NULL,那麼即使另一列有不同的值,也返回為0

擷取一段官方文件對count的描述(具體見:https://dev.mysql.com/doc/refman/5.7/en/aggregate-functions.html#function_count)

COUNT(expr): Returns a count of the number of non-NULL values of expr in the rows.The result is a BIGINT value.If there are no matching rows, COUNT(expr) returns 0.

COUNT(*) is somewhat different in that it returns a count of the number of rows, whether or not they contain NULL values.

Prior to MySQL 5.7.18, InnoDB processes SELECT COUNT(*) statements by scanning the clustered index. As of MySQL 5.7.18, InnoDB processes SELECT COUNT(*) statements by traversing the smallest available secondary index unless an index or optimizer hint directs the optimizer to use a different index. If a secondary index is not present, the clustered index is scanned.

可見5.7.18之前,MySQL處理count(*)會掃描主鍵索引,5.7.18之後從非主鍵索引中選擇較小的合適的索引掃描。可以用explain看下執行計劃。

mysql> select version();
+-----------+
| version() |
+-----------+
| 5.7.18    |
+-----------+
1 row in set (0.00 sec)

mysql> explain select count(*) from tb_hero;
+----+-------------+---------+------------+-------+---------------+-----------+---------+------+------+----------+-------------+
| id | select_type | table   | partitions | type  | possible_keys | key       | key_len | ref  | rows | filtered | Extra       |
+----+-------------+---------+------------+-------+---------------+-----------+---------+------+------+----------+-------------+
|  1 | SIMPLE      | tb_hero | NULL       | index | NULL          | idx_skill | 15      | NULL |    6 |   100.00 | Using index |
+----+-------------+---------+------------+-------+---------------+-----------+---------+------+------+----------+-------------+
1 row in set, 1 warning (0.00 sec)

mysql> explain select count(1) from tb_hero;
+----+-------------+---------+------------+-------+---------------+-----------+---------+------+------+----------+-------------+
| id | select_type | table   | partitions | type  | possible_keys | key       | key_len | ref  | rows | filtered | Extra       |
+----+-------------+---------+------------+-------+---------------+-----------+---------+------+------+----------+-------------+
|  1 | SIMPLE      | tb_hero | NULL       | index | NULL          | idx_skill | 15      | NULL |    6 |   100.00 | Using index |
+----+-------------+---------+------------+-------+---------------+-----------+---------+------+------+----------+-------------+
1 row in set, 1 warning (0.00 sec)

有人糾結count(*)count(1)到底哪種寫法更高效,從上面的執行計劃來看都一樣,如果你還不放心的話,官方文件中也明確指明瞭InnoDBcount(*)count(1)的處理完全一致。

InnoDB handles SELECT COUNT(*) and SELECT COUNT(1) operations in the same way. There is no performance difference.

其他

索引列上做任何操作(表示式函式計算型別轉換等)時無法使用索引會導致全表掃描

實戰

前幾周測試同事對公司的某產品進行壓測,某單表寫入了近2億條資料,過程中發現配的報表有幾個資料查詢時間太長,所以重點看了幾個慢查詢SQL。避免敏感資訊,這裡對其提取簡化做個記錄。

mysql> select count(*) from tb_alert;
+-----------+
| count(*)  |
+-----------+
| 198101877 |
+-----------+

表join慢

表join後,取前10條資料就花了15秒,看了下SQL執行計劃,如下:

mysql> select * from tb_alert left join tb_situation_alert on tb_alert.alert_id = tb_situation_alert.alert_id limit 10;
10 rows in set (15.46 sec)

mysql> explain select * from tb_alert left join tb_situation_alert on tb_alert.alert_id = tb_situation_alert.alert_id limit 10;
+----+-------------+--------------------+------------+------+---------------+------+---------+------+-----------+----------+----------------------------------------------------+
| id | select_type | table              | partitions | type | possible_keys | key  | key_len | ref  | rows      | filtered | Extra                                              |
+----+-------------+--------------------+------------+------+---------------+------+---------+------+-----------+----------+----------------------------------------------------+
|  1 | SIMPLE      | tb_alert           | NULL       | ALL  | NULL          | NULL | NULL    | NULL | 190097118 |   100.00 | NULL                                               |
|  1 | SIMPLE      | tb_situation_alert | NULL       | ALL  | NULL          | NULL | NULL    | NULL |   8026988 |   100.00 | Using where; Using join buffer (Block Nested Loop) |
+----+-------------+--------------------+------------+------+---------------+------+---------+------+-----------+----------+----------------------------------------------------+
2 rows in set, 1 warning (0.00 sec)

可以看出join的時候沒有用上索引,tb_situation_alert表上聯合主鍵是這樣的PRIMARY KEY (situation_id, alert_id),參與表join欄位是alert_id,原來是不符合聯合索引的最左字首法則,僅從這條sql看,解決方案有兩種,一種是對tb_situation_alert表上的alert_id單獨建立索引,另外一種是調換聯合主鍵的列的次序,改為PRIMARY KEY (alert_id, situation_id)。當然不能因為多配一張報表,就改其他產線的表的主鍵索引,這並不合理。在這裡,應該對alert_id列單獨建立索引。

mysql> create index idx_alert_id on tb_situation_alert (alert_id);

mysql> select * from tb_alert left join tb_situation_alert on tb_alert.alert_id = tb_situation_alert.alert_id limit 100;
100 rows in set (0.01 sec)

mysql> explain select * from tb_alert left join tb_situation_alert on tb_alert.alert_id = tb_situation_alert.alert_id limit 100;
+----+-------------+--------------------+------------+------+---------------+--------------+---------+---------------------------------+-----------+----------+-------+
| id | select_type | table              | partitions | type | possible_keys | key          | key_len | ref                             | rows      | filtered | Extra |
+----+-------------+--------------------+------------+------+---------------+--------------+---------+---------------------------------+-----------+----------+-------+
|  1 | SIMPLE      | tb_alert           | NULL       | ALL  | NULL          | NULL         | NULL    | NULL                            | 190097118 |   100.00 | NULL  |
|  1 | SIMPLE      | tb_situation_alert | NULL       | ref  | idx_alert_id  | idx_alert_id | 8       | tb_alert.alert_id |         2 |   100.00 | NULL  |
+----+-------------+--------------------+------------+------+---------------+--------------+---------+---------------------------------+-----------+----------+-------+
2 rows in set, 1 warning (0.00 sec)

優化後,執行計劃可以看出join的時候走了索引,查詢前100條0.01秒,和之前的取前10條資料就花了15秒天壤之別。

分頁查詢慢

從第10000000條資料往後翻頁時,25秒才能出結果,這裡就能使用上面的分頁查詢優化技巧了。上面講優化建議時,沒看執行計劃,這裡正好看一下。

mysql> select * from tb_alert limit 10000000, 10;
10 rows in set (25.23 sec)

mysql> explain select * from tb_alert limit 10000000, 10;
+----+-------------+----------+------------+------+---------------+------+---------+------+-----------+----------+-------+
| id | select_type | table    | partitions | type | possible_keys | key  | key_len | ref  | rows      | filtered | Extra |
+----+-------------+----------+------------+------+---------------+------+---------+------+-----------+----------+-------+
|  1 | SIMPLE      | tb_alert | NULL       | ALL  | NULL          | NULL | NULL    | NULL | 190097118 |   100.00 | NULL  |
+----+-------------+----------+------------+------+---------------+------+---------+------+-----------+----------+-------+
1 row in set, 1 warning (0.00 sec)

再看下使用上分頁查詢優化技巧的sql的執行計劃

mysql> select * from tb_alert a inner join (select alert_id from tb_alert limit 10000000, 10) b on a.alert_id = b.alert_id;
10 rows in set (2.29 sec)

mysql> explain select * from tb_alert a inner join (select alert_id from tb_alert a2 limit 10000000, 10) b on a.alert_id = b.alert_id;
+----+-------------+------------+------------+--------+---------------+---------------+---------+-----------+-----------+----------+-------------+
| id | select_type | table      | partitions | type   | possible_keys | key           | key_len | ref       | rows      | filtered | Extra       |
+----+-------------+------------+------------+--------+---------------+---------------+---------+-----------+-----------+----------+-------------+
|  1 | PRIMARY     | <derived2> | NULL       | ALL    | NULL          | NULL          | NULL    | NULL      |  10000010 |   100.00 | NULL        |
|  1 | PRIMARY     | a          | NULL       | eq_ref | PRIMARY       | PRIMARY       | 8       | b.alert_id |         1 |   100.00 | NULL        |
|  2 | DERIVED     | a2         | NULL       | index  | NULL          | idx_processed | 5       | NULL      | 190097118 |   100.00 | Using index |
+----+-------------+------------+------------+--------+---------------+---------------+---------+-----------+-----------+----------+-------------+
3 rows in set, 1 warning (0.00 sec)

分組聚合慢

分析SQL後,發現根本上並非分組聚合慢,而是掃描聯合索引後,回表導致效能低下,去除不必要的欄位,使用覆蓋索引。

這裡避免敏感資訊,只演示分組聚合前的簡化SQL,主要問題也是在這。
表上有聯合索引KEY idx_alert_start_host_template_id ( alert_start, alert_host, template_id),優化前的sql為

mysql> select alert_start, alert_host, template_id, alert_service from tb_alert where alert_start > {ts '2019-06-05 00:00:10.0'} limit 10000;
10000 rows in set (1 min 5.22 sec)

使用覆蓋索引,去掉template_id列,就能避免回表,查詢時間從1min多變為0.03秒,如下:

mysql> select alert_start, alert_host, template_id from tb_alert where alert_start > {ts '2019-06-05 00:00:10.0'} limit 10000;
10000 rows in set (0.03 sec)

mysql> explain select alert_start, alert_host, template_id from tb_alert where alert_start > {ts '2019-06-05 00:00:10.0'} limit 10000;
+----+-------------+----------+------------+-------+------------------------------------+------------------------------------+---------+------+----------+----------+--------------------------+
| id | select_type | table    | partitions | type  | possible_keys                      | key                                | key_len | ref  | rows     | filtered | Extra                    |
+----+-------------+----------+------------+-------+------------------------------------+------------------------------------+---------+------+----------+----------+--------------------------+
|  1 | SIMPLE      | tb_alert | NULL       | range | idx_alert_start_host_template_id   | idx_alert_start_host_template_id   | 9       | NULL | 95048559 |   100.00 | Using where; Using index |
+----+-------------+----------+------------+-------+------------------------------------+------------------------------------+---------+------+----------+----------+--------------------------+
1 row in set, 1 warning (0.01 sec)

總結

任何不考慮應用場景的設計都不是最好的設計,就比如說表結構的設計、索引的建立,都應該權衡資料量大小、查詢需求、資料更新頻率等。
另外正如《阿里巴巴java開發手冊》中提到的索引規約(詳情見:《Java開發手冊》之"異常處理、MySQL 資料庫"): 建立索引時避免有如下極端誤解:

1)寧濫勿缺。認為一個查詢就需要建一個索引
2)寧缺勿濫。認為索引會消耗空間、嚴重拖慢記錄的更新以及行的新增速度

相關文章