阿里面試官:MySQL如何設計索引更高效?

有情懷,有乾貨,微信搜索【 三太子敖丙】關注這個不同的程序員。

本文 GitHub https://github.com/JavaFamily 已收錄,有一線大廠面試完整考點、資料以及個人系列文章。java

前言

數據庫系列更新到如今我想你們對全部的概念都已有個大概認識了,這周我在看評論的時候我發現有個網友的提問我以爲頗有意思:帥丙如何設計一個索引?大家都是怎麼設計索引的?怎麼設計更高效?mysql

我一想索引我寫過不少了呀,沒道理讀者還不會啊,可是我一回頭看完,那確實,我就寫了索引的概念,優劣勢,沒提到怎麼設計,那這篇文章又這樣應運而生了。git

本文仍是會有不少以前寫過的重複概念,可是也是爲了你們能更好的理解MySQL中幾種索引設計的原理。程序員

正文

咱們知道,索引是一個基於鏈表實現的樹狀Tree結構,可以快速的檢索數據,目前幾乎所RDBMS數據庫都實現了索引特性,好比MySQL的B+Tree索引,MongoDB的BTree索引等。github

在業務開發過程當中,索引設計高效與否決定了接口對應SQL的執行效率,高效的索引能夠下降接口的Response Time,同時還能夠下降成本,咱們要現實的目標是:索引設計->下降接口響應時間->下降服務器配置->下降成本,最終要落實到成本上來,由於老闆最關心的是成本面試

今天就跟你們聊聊MySQL中的索引以及如何設計索引,使用索引才能提下降接口的RT,提升用戶體檢。sql

MySQL中的索引

MySQL中的InnoDB引擎使用B+Tree結構來存儲索引,能夠儘可能減小數據查詢時磁盤IO次數,同時樹的高度直接影響了查詢的性能,通常樹的高度維持在 3~4 層。shell

B+Tree由三部分組成:根root、枝branch以及Leaf葉子,其中root和branch不存儲數據,只存儲指針地址,數據所有存儲在Leaf Node,同時Leaf Node之間用雙向鏈表連接,結構以下:數據庫

從上面能夠看到,每一個Leaf Node是三部分組成的,即前驅指針p_prev,數據data以及後繼指針p_next,同時數據data是有序的,默認是升序ASC,分佈在B+tree右邊的鍵值老是大於左邊的,同時從root到每一個Leaf的距離是相等的,也就是訪問任何一個Leaf Node須要的IO是同樣的,即索引樹的高度Level + 1次IO操做。json

咱們能夠將MySQL中的索引能夠當作一張小表,佔用磁盤空間,建立索引的過程其實就是按照索引列排序的過程,先在sort_buffer_size進行排序,若是排序的數據量大,sort_buffer_size容量不下,就須要經過臨時文件來排序,最重要的是經過索引能夠避免排序操做(distinct,group by,order by)。

彙集索引

MySQL中的表是IOT(Index Organization Table,索引組織表),數據按照主鍵id順序存儲(邏輯上是連續,物理上不連續),並且主鍵id是彙集索引(clustered index),存儲着整行數據,若是沒有顯示的指定主鍵,MySQL會將全部的列組合起來構造一個row_id做爲primary key,例如表users(id, user_id, user_name, phone, primary key(id)),id是彙集索引,存儲了id, user_id, user_name, phone整行的數據。

輔助索引

輔助索引也稱爲二級索引,索引中除了存儲索引列外,還存儲了主鍵id,對於user_name的索引idx_user_name(user_name)而言,其實等價於idx_user_name(user_name, id),MySQL會自動在輔助索引的最後添加上主鍵id,熟悉Oracle數據庫的都知道,索引裏除了索引列還存儲了row_id(表明數據的物理位置,由四部分組成:對象編號+數據文件號+數據塊號+數據行號),咱們在建立輔助索引也能夠顯示添加主鍵id。

-- 建立user_name列上的索引
mysql> create index idx_user_name on users(user_name);
-- 顯示添加主鍵id建立索引
mysql> create index idx_user_name_id on users(user_name,id);
-- 對比兩個索引的統計數據
mysql> select a.space as tbl_spaceid, a.table_id, a.name as table_name, row_format, space_type,  b.index_id , b.name as index_name, n_fields, page_no, b.type as index_type  from information_schema.INNODB_TABLES a left join information_schema.INNODB_INDEXES b  on a.table_id =b.table_id where a.name = 'test/users';
+-------------+----------+------------+------------+------------+----------+------------------+----------+------
| tbl_spaceid | table_id | table_name | row_format | space_type | index_id | index_name       | n_fields | page_no | index_type |
+-------------+----------+------------+------------+------------+----------+------------------+----------+------
|         518 |     1586 | test/users | Dynamic    | Single     |     1254 | PRIMARY          |        9 |       4 |          3 |
|         518 |     1586 | test/users | Dynamic    | Single     |     4003 | idx_user_name    |        2 |       5 |          0 |
|         518 |     1586 | test/users | Dynamic    | Single     |     4004 | idx_user_name_id |        2 |      45 |          0 |
mysql> select index_name, last_update, stat_name, stat_value, stat_description from mysql.innodb_index_stats where index_name in ('idx_user_name','idx_user_name_id');
+------------------+---------------------+--------------+------------+-----------------------------------+
| index_name       | last_update         | stat_name    | stat_value | stat_description                  |
+------------------+---------------------+--------------+------------+-----------------------------------+   
| idx_user_name    | 2021-01-02 17:14:48 | n_leaf_pages |       1358 | Number of leaf pages in the index |
| idx_user_name    | 2021-01-02 17:14:48 | size         |       1572 | Number of pages in the index      |
| idx_user_name_id | 2021-01-02 17:14:48 | n_leaf_pages |       1358 | Number of leaf pages in the index |
| idx_user_name_id | 2021-01-02 17:14:48 | size         |       1572 | Number of pages in the index      |

對比一下兩個索引的結果,n_fields表示索引中的列數,n_leaf_pages表示索引中的葉子頁數,size表示索引中的總頁數,經過數據比對就能夠看到,輔助索引中確實包含了主鍵id,也說明了這兩個索引時徹底一致。

Index_name n_fields n_leaf_pages size
idx_user_name 2 1358 1572
idx_user_name_id 2 1358 1572

索引回表

上面證實了輔助索引包含主鍵id,若是經過輔助索引列去過濾數據有可能須要回表,舉個例子:業務須要經過用戶名user_name去查詢用戶表users的信息,業務接口對應的SQL:

select  user_id, user_name, phone from users where user_name = 'Laaa';

咱們知道,對於索引idx_user_name而言,其實就是一個小表idx_user_name(user_name, id),若是隻查詢索引中的列,只須要掃描索引就能獲取到所需數據,是不須要回表的,以下SQL語句:

SQL 1: select id, user_name from users where user_name = 'Laaa';

SQL 2: select id from users where user_name = 'Laaa';

mysql> explain select id, name from users where name = 'Laaa';
+----+-------------+-------+------------+------+---------------+---------------+---------+-------+------+-------
| id | select_type | table | partitions | type | possible_keys | key           | key_len | ref   | rows | filtered | Extra       |
+----+-------------+-------+------------+------+---------------+---------------+---------+-------+------+-------
|  1 | SIMPLE      | users | NULL       | ref  | idx_user_name | idx_user_name | 82      | const |    1 |   100.00 | Using index |
mysql> explain select id from users where name = 'Laaa';
+----+-------------+-------+------------+------+---------------+---------------+---------+-------+------+-------
| id | select_type | table | partitions | type | possible_keys | key           | key_len | ref   | rows | filtered | Extra       |
+----+-------------+-------+------------+------+---------------+---------------+---------+-------+------+-------
|  1 | SIMPLE      | users | NULL       | ref  | idx_user_name | idx_user_name | 82      | const |    1 |   100.00 | Using index |

SQL 1和SQL 2的執行計劃中的Extra=Using index 表示使用覆蓋索引掃描,不須要回表,再來看上面的業務SQL:

select user_id, user_name, phone from users where user_name = 'Laaa';

能夠看到select後面的user_id,phone列不在索引idx_user_name中,就須要經過主鍵id進行回表查找,MySQL內部分以下兩個階段處理:

Section 1select **id** from users where user_name = 'Laaa' //id = 100101

Section 2: select user_id, user_name, phone from users where id = 100101;

Section 2的操做稱爲回表,即經過輔助索引中的主鍵id去原表中查找數據。

索引高度

MySQL的索引時B+tree結構,即便表裏有上億條數據,索引的高度都不會很高,一般維持在3-4層左右,我來計算下索引idx_name的高度,從上面知道索引信息:index_id = 4003, page_no = 5,它的偏移量offset就是page_no x innodo_page_size + 64 = 81984,經過hexdump進行查看

$hexdump -s 81984 -n 10 /usr/local/var/mysql/test/users.ibd
0014040 00 02 00 00 00 00 00 00 0f a3                  
001404a

其中索引的PAGE_LEVEL爲00,即idx_user_name索引高度爲1,0f a3 表明索引編號,轉換爲十進制是4003,正是index_id。

數據掃描方式

全表掃描

從左到右依次掃描整個B+Tree獲取數據,掃描整個表數據,IO開銷大,速度慢,鎖等嚴重,影響MySQL的併發。

對於OLAP的業務場景,須要掃描返回大量數據,這時候全表掃描的順序IO效率更高。

索引掃描

一般來說索引比表小,掃描的數據量小,消耗的IO少,執行速度塊,幾乎沒有鎖等,可以提升MySQL的併發。

對於OLTP系統,但願全部的SQL都能命中合適的索引老是美好的。

主要區別就是掃描數據量大小以及IO的操做,全表掃描是順序IO,索引掃描是隨機IO,MySQL對此作了優化,增長了change buffer特性來提升IO性能。

索引優化案例

分頁查詢優化

業務要根據時間範圍查詢交易記錄,接口原始的SQL以下:

select  * from trade_info where status = 0 and create_time >= '2020-10-01 00:00:00' and create_time <= '2020-10-07 23:59:59' order by id desc limit 102120, 20;

表trade_info上有索引idx_status_create_time(status,create_time),經過上面分析知道,等價於索引(status,create_time,id),對於典型的分頁limit m, n來講,越日後翻頁越慢,也就是m越大會越慢,由於要定位m位置須要掃描的數據愈來愈多,致使IO開銷比較大,這裏能夠利用輔助索引的覆蓋掃描來進行優化,先獲取id,這一步就是索引覆蓋掃描,不須要回表,而後經過id跟原表trade_info進行關聯,改寫後的SQL以下:

select * from trade_info a ,

(select  id from trade_info where status = 0 and create_time >= '2020-10-01 00:00:00' and create_time <= '2020-10-07 23:59:59' order by id desc limit 102120, 20) as b   //這一步走的是索引覆蓋掃描,不須要回表
 where a.id = b.id;

不少同窗只知道這樣寫效率高,可是未必知道爲何要這樣改寫,理解索引特性對編寫高質量的SQL尤其重要。

分而治之老是不錯的

營銷系統有一批過時的優惠卷要失效,核心SQL以下:

-- 須要更新的數據量500w
update coupons set status = 1 where status =0 and create_time >= '2020-10-01 00:00:00' and create_time <= '2020-10-07 23:59:59';

在Oracle裏更新500w數據是很快,由於能夠利用多個cpu core去執行,可是MySQL就須要注意了,一個SQL只能使用一個cpu core去處理,若是SQL很複雜或執行很慢,就會阻塞後面的SQL請求,形成活動鏈接數暴增,MySQL CPU 100%,相應的接口Timeout,同時對於主從複製架構,並且作了業務讀寫分離,更新500w數據須要5分鐘,Master上執行了5分鐘,binlog傳到了slave也須要執行5分鐘,那就是Slave延遲5分鐘,在這期間會形成業務髒數據,好比重複下單等。

優化思路:先獲取where條件中的最小id和最大id,而後分批次去更新,每一個批次1000條,這樣既能快速完成更新,又能保證主從複製不會出現延遲。

優化以下:

  1. 先獲取要更新的數據範圍內的最小id和最大id(表沒有物理delete,因此id是連續的)
mysql> explain select min(id) min_id, max(id) max_id from coupons where status =0 and create_time >= '2020-10-01 00:00:00' and create_time <= '2020-10-07 23:59:59'; 
+----+-------------+-------+------------+-------+------------------------+------------------------+---------+---
| id | select_type | table | partitions | type  | possible_keys          | key                    | key_len | ref  | rows   | filtered | Extra                    |
+----+-------------+-------+------------+-------+------------------------+------------------------+---------+---
|  1 | SIMPLE      | users | NULL       | range | idx_status_create_time | idx_status_create_time | 6       | NULL | 180300 |   100.00 | Using where; Using index |

​ Extra=Using where; Using index使用了索引idx_status_create_time,同時須要的數據都在索引中能找到,因此不須要回表查詢數據。

  1. 以每次1000條commit一次進行循環update,主要代碼以下:
current_id = min_id;
for  current_id < max_id do
  update coupons set status = 1 where id >=current_id and id <= current_id + 1000;  //經過主鍵id更新1000條很快
commit;
current_id += 1000;
done

這兩個案例告訴咱們,要充分利用輔助索引包含主鍵id的特性,先經過索引獲取主鍵id走覆蓋索引掃描,不須要回表,而後再經過id去關聯操做是高效的,同時根據MySQL的特性使用分而治之的思想既能高效完成操做,又能避免主從複製延遲產生的業務數據混亂。

MySQL索引設計

熟悉了索引的特性以後,就能夠在業務開發過程當中設計高質量的索引,下降接口的響應時間。

前綴索引

對於使用REDUNDANT或者COMPACT格式的InnoDB表,索引鍵前綴長度限制爲767字節。若是TEXT或VARCHAR列的列前綴索引超過191個字符,則可能會達到此限制,假定爲utf8mb4字符集,每一個字符最多4個字節。

能夠經過設置參數innodb_large_prefix來開啓或禁用索引前綴長度的限制,便是設置爲OFF,索引雖然能夠建立成功,也會有一個警告,主要是由於index size會很大,效率大量的IO的操做,即便MySQL優化器命中了該索引,效率也不會很高。

-- 設置innodb_large_prefix=OFF禁用索引前綴限制,雖然能夠建立成功,可是有警告。
mysql> create index idx_nickname on users(nickname);    // `nickname` varchar(255)
Records: 0  Duplicates: 0  Warnings: 1
mysql> show warnings;
+---------+------+---------------------------------------------------------+
| Level   | Code | Message                                                 |
+---------+------+---------------------------------------------------------+
| Warning | 1071 | Specified key was too long; max key length is 767 bytes |

業務發展初期,爲了快速實現功能,對一些數據表字段的長度定義都比較寬鬆,好比用戶表users的暱稱nickname定義爲varchar(128),並且有業務接口須要經過nickname查詢,系統運行了一段時間以後,查詢users表最大的nickname長度爲30,這個時候就能夠建立前綴索引來減少索引的長度提高性能。

-- `nickname` varchar(128) DEFAULT NULL定義的執行計劃
mysql> explain select * from users where nickname = 'Laaa';
+----+-------------+-------+------------+------+---------------+--------------+---------+-------+------+--------
| id | select_type | table | partitions | type | possible_keys | key          | key_len | ref   | rows | filtered | Extra |
+----+-------------+-------+------------+------+---------------+--------------+---------+-------+------+--------
|  1 | SIMPLE      | users | NULL       | ref  | idx_nickname  | idx_nickname | 515     | const |    1 |   100.00 | NULL  |

key_len=515,因爲表和列都是utf8mb4字符集,每一個字符佔4個字節,變長數據類型+2Bytes,容許NULL額外+1Bytes,即128 x 4 + 2 + 1 = 515Bytes。建立前綴索引,前綴長度也能夠不是當前表的數據列最大值,應該是區分度最高的那部分長度,通常能達到90%以上便可,例如email字段存儲都是相似這樣的值xxxx@yyy.com,前綴索引的最大長度能夠是xxxx這部分的最大長度便可。

-- 建立前綴索引,前綴長度爲30
mysql> create index idx_nickname_part on users(nickname(30));
-- 查看執行計劃
mysql> explain select * from users where nickname = 'Laaa';
+----+-------------+-------+------------+------+--------------------------------+-------------------+---------+-
| id | select_type | table | partitions | type | possible_keys                  | key               | key_len | ref   | rows | filtered | Extra       |
+----+-------------+-------+------------+------+--------------------------------+-------------------+---------+-
|  1 | SIMPLE      | users | NULL       | ref  | idx_nickname_part,idx_nickname | idx_nickname_part | 123     | const |    1 |   100.00 | Using where |

能夠看到優化器選擇了前綴索引,索引長度爲123,即30 x 4 + 2 + 1 = 123 Bytes,大小不到原來的四分之。

前綴索引雖然能夠減少索引的大小,可是不能消除排序。

mysql> explain select gender,count(*) from users where nickname like 'User100%' group by nickname limit 10;
+----+-------------+-------+------------+-------+--------------------------------+--------------+---------+-----
| id | select_type | table | partitions | type  | possible_keys                  | key          | key_len | ref  | rows | filtered | Extra                 |
+----+-------------+-------+------------+-------+--------------------------------+--------------+---------+-----
|  1 | SIMPLE      | users | NULL       | range | idx_nickname_part,idx_nickname | idx_nickname | 515     | NULL |  899 |   100.00 | Using index condition |
--能夠看到Extra= Using index condition表示使用了索引,可是須要回表查詢數據,沒有發生排序操做。
mysql> explain select gender,count(*) from users where nickname like  'User100%' group by nickname limit 10;
+----+-------------+-------+------------+-------+-------------------+-------------------+---------+------+------
| id | select_type | table | partitions | type  | possible_keys     | key               | key_len | ref  | rows | filtered | Extra                        |
+----+-------------+-------+------------+-------+-------------------+-------------------+---------+------+------
|  1 | SIMPLE      | users | NULL       | range | idx_nickname_part | idx_nickname_part | 123     | NULL |  899 |   100.00 | Using where; Using temporary |
--能夠看到Extra= Using where; Using temporaryn表示在使用了索引的狀況下,須要回表去查詢所需的數據,同時發生了排序操做。

複合索引

在單列索引不能很好的過濾數據的時候,能夠結合where條件中其餘字段來建立複合索引,更好的去過濾數據,減小IO的掃描次數,舉個例子:業務須要按照時間段來查詢交易記錄,有以下的SQL:

select  * from trade_info where status = 1 and create_time >= '2020-10-01 00:00:00' and create_time <= '2020-10-07 23:59:59';

開發同窗根據以往復合索引的設計的經驗:惟一值多選擇性好的列做爲複合索引的前導列,因此建立複合索idx_create_time_status是高效的,由於create_time是一秒一個值,惟一值不少,選擇性很好,而status只有離散的6個值,因此認爲這樣建立是沒問題的,可是這個經驗只適合於等值條件過濾,不適合有範圍條件過濾的狀況,例如idx_user_id_status(user_id,status)這個是沒問題的,可是對於包含有create_time範圍的複合索引來講,就不適應了,咱們來看下這兩種不一樣索引順序的差別,即idx_status_create_time和idx_create_time_status。

-- 分別建立兩種不一樣的複合索引
mysql> create index idx_status_create_time on trade_info(status, create_time);
mysql> create index idx_create_time_status on trade_info(create_time,status);
-- 查看SQL的執行計劃
mysql> explain select * from users where status = 1 and create_time >='2021-10-01 00:00:00' and create_time <= '2021-10-07 23:59:59';
+----+-------------+-------+------------+-------+-----------------------------------------------+---------------
| id | select_type | table | partitions | type  | possible_keys                                 | key                    | key_len | ref  | rows  | filtered | Extra                 |
+----+-------------+-------+------------+-------+-----------------------------------------------+---------------
|  1 | SIMPLE      | trade_info | NULL       | range | idx_status_create_time,idx_create_time_status | idx_status_create_time | 6       | NULL | 98518 |   100.00 | Using index condition |

從執行計劃能夠看到,兩種不一樣順序的複合索引都存在的狀況,MySQL優化器選擇的是idx_status_create_time索引,那爲何不選擇idx_create_time_status,咱們經過optimizer_trace來跟蹤優化器的選擇。

-- 開啓optimizer_trace跟蹤
mysql> set session optimizer_trace="enabled=on",end_markers_in_json=on;
-- 執行SQL語句
mysql> select * from trade_info where status = 1 and create_time >='2021-10-01 00:00:00' and create_time <= '2021-10-07 23:59:59';
-- 查看跟蹤結果
mysql>SELECT trace FROM information_schema.OPTIMIZER_TRACE\G;

對比下兩個索引的統計數據,以下所示:

複合索引 Type Rows 參與過濾索引列 Chosen Cause
idx_status_create_time Index Range Scan 98518 status AND create_time True Cost低
idx_create_time_status Index Range Scan 98518 create_time False Cost高

MySQL優化器是基於Cost的,COST主要包括IO_COST和CPU_COST,MySQL的CBO(Cost-Based Optimizer基於成本的優化器)老是選擇Cost最小的做爲最終的執行計劃去執行,從上面的分析,CBO選擇的是複合索引idx_status_create_time,由於該索引中的status和create_time都能參與了數據過濾,成本較低;而idx_create_time_status只有create_time參數數據過濾,status被忽略了,其實CBO將其簡化爲單列索引idx_create_time,選擇性沒有複合索引idx_status_create_time好。

複合索引設計原則

  1. 將範圍查詢的列放在複合索引的最後面,例如idx_status_create_time。
  2. 列過濾的頻繁越高,選擇性越好,應該做爲複合索引的前導列,適用於等值查找,例如idx_user_id_status。

這兩個原則不是矛盾的,而是相輔相成的。

跳躍索引

通常狀況下,若是表users有複合索引idx_status_create_time,咱們都知道,單獨用create_time去查詢,MySQL優化器是不走索引,因此還須要再建立一個單列索引idx_create_time。用過Oracle的同窗都知道,是能夠走索引跳躍掃描(Index Skip Scan),在MySQL 8.0也實現Oracle相似的索引跳躍掃描,在優化器選項也能夠看到skip_scan=on。

| optimizer_switch             |use_invisible_indexes=off,skip_scan=on,hash_join=on |

適合複合索引前導列惟一值少,後導列惟一值多的狀況,若是前導列惟一值變多了,則MySQL CBO不會選擇索引跳躍掃描,取決於索引列的數據分表狀況。

mysql> explain select id, user_id,status, phone from users where create_time >='2021-01-02 23:01:00' and create_time <= '2021-01-03 23:01:00';
+----+-------------+-------+------------+------+---------------+------+---------+------+--------+----------+----
| id | select_type | table | partitions | type | possible_keys | key  | key_len | ref  | rows   | filtered | Extra       |
+----+-------------+-------+------------+------+---------------+------+---------+------+--------+----------+----
|  1 | SIMPLE      | users | NULL       | range  | idx_status_create_time          | idx_status_create_time | NULL    | NULL | 15636 |    11.11 | Using where; Using index for skip scan|

也能夠經過optimizer_switch='skip_scan=off'來關閉索引跳躍掃描特性。

總結

本位爲你們介紹了MySQL中的索引,包括彙集索引和輔助索引,輔助索引包含了主鍵id用於回表操做,同時利用覆蓋索引掃描能夠更好的優化SQL。

同時也介紹瞭如何更好作MySQL索引設計,包括前綴索引,複合索引的順序問題以及MySQL 8.0推出的索引跳躍掃描,咱們都知道,索引能夠加快數據的檢索,減小IO開銷,會佔用磁盤空間,是一種用空間換時間的優化手段,同時更新操做會致使索引頻繁的合併分裂,影響索引性能,在實際的業務開發中,如何根據業務場景去設計合適的索引是很是重要的,今天就聊這麼多,但願對你們有所幫助。

我是敖丙,你知道的越多,你不知道的越多,感謝各位的三連,咱們下期見。

絮叨

敖丙把本身的面試文章整理成了一本電子書,共 1630頁!

乾貨滿滿,字字精髓。目錄以下,還有我複習時總結的面試題以及簡歷模板,如今免費送給你們。

連接:https://pan.baidu.com/s/1ZQEKJBgtYle3v-1LimcSwg 密碼:wjk6

我是敖丙,你知道的越多,你不知道的越多,感謝各位人才的:點贊收藏評論,咱們下期見!


文章持續更新,能夠微信搜一搜「 三太子敖丙 」第一時間閱讀,回覆【 資料】有我準備的一線大廠面試資料和簡歷模板,本文 GitHub https://github.com/JavaFamily 已經收錄,有大廠面試完整考點,歡迎Star。
相關文章
相關標籤/搜索