1. 程式人生 > >MySQL索引演算法原理及慢查詢優化

MySQL索引演算法原理及慢查詢優化

前言

MySQL憑藉著出色的效能、低廉的成本、豐富的資源,已經成為絕大多數網際網路公司的首選關係型資料庫。雖然效能出色,但所謂“好馬配好鞍”,如何能夠更好的使用它,已經成為開發工程師的必修課,我們經常會從職位描述上看到諸如“精通MySQL”、“SQL語句優化”、“瞭解資料庫原理”等要求。我們知道一般的應用系統,讀寫比例在10:1左右,而且插入操作和一般的更新操作很少出現效能問題,遇到最多的,也是最容易出問題的,還是一些複雜的查詢操作,所以查詢語句的優化顯然是重中之重。
本人從11年7月份起,一直在聯想研究院核心業務系統部做慢查詢的優化工作,共計7,8個系統,累計解決和積累了上百個慢查詢案例。隨著業務的複雜性提升,遇到的問題千奇百怪,五花八門,匪夷所思。本文旨在以開發工程師的角度來解釋資料庫索引的原理和如何優化慢查詢。

一 一個慢查詢引發的思考

select
   count(*) 
from
   task 
where
   status=2 
   and operator_id=20839 
   and operate_time>1371169729 
   and operate_time<1371174603 
   and type=2;

系統使用者反應有一個功能越來越慢,於是工程師找到了上面的SQL。
並且興致沖沖的找到了我,“這個SQL需要優化,給我把每個欄位都加上索引”
我很驚訝,問道“為什麼需要每個欄位都加上索引?”
“把查詢的欄位都加上索引會更快”工程師信心滿滿
“這種情況完全可以建一個聯合索引,因為是最左字首匹配,所以operate_time需要放到最後,而且還需要把其他相關的查詢都拿來,需要做一個綜合評估。”
“聯合索引?最左字首匹配?綜合評估?”工程師不禁陷入了沉思。
多數情況下,我們知道索引能夠提高查詢效率,但應該如何建立索引?索引的順序如何?許多人卻只知道大概。其實理解這些概念並不難,而且索引的原理遠沒有想象的那麼複雜。

二 MySQL索引原理

索引
索引在於提高查詢效率,可以類比字典,如果要查“mysql”這個單詞,我們肯定需要定位到m字母,然後從下往下找到y字母,再找到剩下的sql。如果沒有索引,那麼你可能需要把所有單詞看一遍才能找到你想要的,如果我想找到m開頭的單詞呢?或者ze開頭的單詞呢?是不是覺得如果沒有索引,這個事情根本無法完成?

索引原理
除了詞典,生活中隨處可見索引的例子,如火車站的車次表、圖書的目錄等。它們的原理都是一樣的,通過不斷的縮小想要獲得資料的範圍來篩選出最終想要的結果,同時把隨機的事件變成順序的事件,也就是我們總是通過同一種查詢方式來鎖定資料。
資料庫也是一樣,但顯然要複雜許多,因為不僅面臨著等值查詢,還有範圍查詢(>、<、between、in)、模糊查詢(like)、並集查詢(or)等等。資料庫應該選擇怎麼樣的方式來應對所有的問題呢?我們回想字典的例子,能不能把資料分成段,然後分段查詢呢?最簡單的如果1000條資料,1到100分成第一段,101到200分成第二段,201到300分成第三段......這樣查第250條資料,只要找第三段就可以了,一下子去除了90%的無效資料。但如果是1千萬的記錄呢,分成幾段比較好?稍有演算法基礎的同學會想到搜尋樹,其平均複雜度是lgN,具有不錯的查詢效能。但這裡我們忽略了一個關鍵的問題,複雜度模型是基於每次相同的操作成本來考慮的,資料庫實現比較複雜,資料儲存在磁碟上,而為了提高效能,每次又可以把部分資料讀入記憶體來計算,因為我們知道訪問磁碟的成本大概是訪問記憶體的十萬倍左右,所以簡單的搜尋樹難以滿足複雜的應用場景。

磁碟IO與預讀
前面提到了訪問磁碟,那麼這裡先簡單介紹一下磁碟IO和預讀,磁碟讀取資料靠的是機械運動,每次讀取資料花費的時間可以分為尋道時間、旋轉延遲、傳輸時間三個部分,尋道時間指的是磁臂移動到指定磁軌所需要的時間,主流磁碟一般在5ms以下;旋轉延遲就是我們經常聽說的磁碟轉速,比如一個磁碟5400,7200轉,表示每分鐘能轉5400,7200次,對7200次的轉速來說1秒鐘能轉120次,旋轉延遲就是1/120/2 = 4.17ms;傳輸時間指的是從磁碟讀出或將資料寫入磁碟的時間,一般在零點幾毫秒,相對於前兩個時間可以忽略不計。那麼訪問一次磁碟的時間,即一次磁碟IO的時間約等於5+4.17 = 9ms左右,聽起來還挺不錯的,但要知道一臺500 -MIPS的機器每秒可以執行5億條指令,因為指令依靠的是電的性質,換句話說執行一次IO的時間可以執行40萬條指令,資料庫動輒十萬百萬乃至千萬級資料,每次9毫秒的時間,顯然是個災難。下圖是計算機硬體延遲的對比圖,供大家參考:
various-system-software-hardware-latencies
考慮到磁碟IO是非常高昂的操作,計算機作業系統做了一些優化,當一次IO時,不光把當前磁碟地址的資料,而是把相鄰的資料也都讀取到記憶體緩衝區內,因為區域性預讀性原理告訴我們,當計算機訪問一個地址的資料的時候,與其相鄰的資料也會很快被訪問到。每一次IO讀取的資料我們稱之為一頁(page)。具體一頁有多大資料跟作業系統有關,一般為4k或8k,也就是我們讀取一頁內的資料時候,實際上才發生了一次IO,這個理論對於索引的資料結構設計非常有幫助。

索引的資料結構
前面講了生活中索引的例子,索引的基本原理,資料庫的複雜性,又講了作業系統的相關知識,目的就是讓大家瞭解,任何一種資料結構都不是憑空產生的,一定會有它的背景和使用場景,我們現在總結一下,我們需要這種資料結構能夠做些什麼,其實很簡單,那就是:每次查詢資料時把磁碟IO次數控制在一個很小的數量級,最好是常數數量級。那麼我們就想到如果一個高度可控的多路搜尋樹是否能滿足需求呢?就這樣,b+樹應運而生。

詳解b+樹
b+樹
如上圖,是一顆b+樹,關於b+樹的定義可以參見底部B+樹詳解,這裡只說一些重點,淺藍色的塊我們稱之為一個磁碟塊,可以看到每個磁碟塊包含幾個資料項(深藍色所示)和指標(黃色所示),如磁碟塊1包含資料項17和35,包含指標P1、P2、P3,P1表示小於17的磁碟塊,P2表示在17和35之間的磁碟塊,P3表示大於35的磁碟塊。真實的資料存在於葉子節點即3、5、9、10、13、15、28、29、36、60、75、79、90、99。非葉子節點只不儲存真實的資料,只儲存指引搜尋方向的資料項,如17、35並不真實存在於資料表中。

b+樹的查詢過程
如圖所示,如果要查詢資料項29,那麼首先會把磁碟塊1由磁碟載入到記憶體,此時發生一次IO,在記憶體中用二分查詢確定29在17和35之間,鎖定磁碟塊1的P2指標,記憶體時間因為非常短(相比磁碟的IO)可以忽略不計,通過磁碟塊1的P2指標的磁碟地址把磁碟塊3由磁碟載入到記憶體,發生第二次IO,29在26和30之間,鎖定磁碟塊3的P2指標,通過指標載入磁碟塊8到記憶體,發生第三次IO,同時記憶體中做二分查詢找到29,結束查詢,總計三次IO。真實的情況是,3層的b+樹可以表示上百萬的資料,如果上百萬的資料查詢只需要三次IO,效能提高將是巨大的,如果沒有索引,每個資料項都要發生一次IO,那麼總共需要百萬次的IO,顯然成本非常非常高。

b+樹性質
1.通過上面的分析,我們知道IO次數取決於b+數的高度h,假設當前資料表的資料為N,每個磁碟塊的資料項的數量是m,則有h=㏒(m+1)N,當資料量N一定的情況下,m越大,h越小;而m = 磁碟塊的大小 / 資料項的大小,磁碟塊的大小也就是一個數據頁的大小,是固定的,如果資料項佔的空間越小,資料項的數量越多,樹的高度越低。這就是為什麼每個資料項,即索引欄位要儘量的小,比如int佔4位元組,要比bigint8位元組少一半。這也是為什麼b+樹要求把真實的資料放到葉子節點而不是內層節點,一旦放到內層節點,磁碟塊的資料項會大幅度下降,導致樹增高。當資料項等於1時將會退化成線性表。
2.當b+樹的資料項是複合的資料結構,比如(name,age,sex)的時候,b+數是按照從左到右的順序來建立搜尋樹的,比如當(張三,20,F)這樣的資料來檢索的時候,b+樹會優先比較name來確定下一步的所搜方向,如果name相同再依次比較age和sex,最後得到檢索的資料;但當(20,F)這樣的沒有name的資料來的時候,b+樹就不知道下一步該查哪個節點,因為建立搜尋樹的時候name就是第一個比較因子,必須要先根據name來搜尋才能知道下一步去哪裡查詢。比如當(張三,F)這樣的資料來檢索時,b+樹可以用name來指定搜尋方向,但下一個欄位age的缺失,所以只能把名字等於張三的資料都找到,然後再匹配性別是F的資料了, 這個是非常重要的性質,即索引的最左匹配特性。

三 慢查詢優化

關於MySQL索引原理是比較枯燥的東西,大家只需要有一個感性的認識,並不需要理解得非常透徹和深入。我們回頭來看看一開始我們說的慢查詢,瞭解完索引原理之後,大家是不是有什麼想法呢?先總結一下索引的幾大基本原則

1 建索引的幾大原則

1.最左字首匹配原則,非常重要的原則,mysql會一直向右匹配直到遇到範圍查詢(>、<、between、like)就停止匹配,比如a = 1 and b = 2 and c > 3 and d = 4 如果建立(a,b,c,d)順序的索引,d是用不到索引的,如果建立(a,b,d,c)的索引則都可以用到,a,b,d的順序可以任意調整。
2.=和in可以亂序,比如a = 1 and b = 2 and c = 3 建立(a,b,c)索引可以任意順序,mysql的查詢優化器會幫你優化成索引可以識別的形式
3.儘量選擇區分度高的列作為索引,區分度的公式是count(distinct col)/count(*),表示欄位不重複的比例,比例越大我們掃描的記錄數越少,唯一鍵的區分度是1,而一些狀態、性別欄位可能在大資料面前區分度就是0,那可能有人會問,這個比例有什麼經驗值嗎?使用場景不同,這個值也很難確定,一般需要join的欄位我們都要求是0.1以上,即平均1條掃描10條記錄
4.索引列不能參與計算,保持列“乾淨”,比如from_unixtime(create_time) = ’2014-05-29’就不能使用到索引,原因很簡單,b+樹中存的都是資料表中的欄位值,但進行檢索時,需要把所有元素都應用函式才能比較,顯然成本太大。所以語句應該寫成create_time = unix_timestamp(’2014-05-29’);
5.儘量的擴充套件索引,不要新建索引。比如表中已經有a的索引,現在要加(a,b)的索引,那麼只需要修改原來的索引即可

2 回到開始的慢查詢

根據最左匹配原則,最開始的sql語句的索引應該是status、operator_id、type、operate_time的聯合索引;其中status、operator_id、type的順序可以顛倒,所以我才會說,把這個表的所有相關查詢都找到,會綜合分析;
比如還有如下查詢

select * from task where status = 0 and type = 12 limit 10;
select count(*) from task where status = 0 ;

那麼索引建立成(status,type,operator_id,operate_time)就是非常正確的,因為可以覆蓋到所有情況。這個就是利用了索引的最左匹配的原則

3 查詢優化神器 - explain命令

關於explain命令相信大家並不陌生,具體用法和欄位含義可以參考官網explain-output,這裡需要強調rows是核心指標,絕大部分rows小的語句執行一定很快(有例外,下面會講到)。所以優化語句基本上都是在優化rows。

四 慢查詢優化基本步驟

0.先執行看看是否真的很慢,注意設定SQL_NO_CACHE
1.where條件單表查,鎖定最小返回記錄表。這句話的意思是把查詢語句的where都應用到表中返回的記錄數最小的表開始查起,單表每個欄位分別查詢,看哪個欄位的區分度最高
2.explain檢視執行計劃,是否與1預期一致(從鎖定記錄較少的表開始查詢)
3.order by limit 形式的sql語句讓排序的表優先查
4.瞭解業務方使用場景
5.加索引時參照建索引的幾大原則
6.觀察結果,不符合預期繼續從0分析

1 幾個慢查詢案例

下面幾個例子詳細解釋瞭如何分析和優化慢查詢

2 複雜語句寫法

很多情況下,我們寫SQL只是為了實現功能,這只是第一步,不同的語句書寫方式對於效率往往有本質的差別,這要求我們對mysql的執行計劃和索引原則有非常清楚的認識,請看下面的語句

select
   distinct cert.emp_id 
from
   cm_log cl 
inner join
   (
      select
         emp.id as emp_id,
         emp_cert.id as cert_id 
      from
         employee emp 
      left join
         emp_certificate emp_cert 
            on emp.id = emp_cert.emp_id 
      where
         emp.is_deleted=0
   ) cert 
      on (
         cl.ref_table='Employee' 
         and cl.ref_oid= cert.emp_id
      ) 
      or (
         cl.ref_table='EmpCertificate' 
         and cl.ref_oid= cert.cert_id
      ) 
where
   cl.last_upd_date >='2013-11-07 15:03:00' 
   and cl.last_upd_date<='2013-11-08 16:00:00';

0.先執行一下,53條記錄 1.87秒,又沒有用聚合語句,比較慢

53 rows in set (1.87 sec)

1.explain

+----+-------------+------------+-------+---------------------------------+-----------------------+---------+-------------------+-------+--------------------------------+
| id | select_type | table      | type  | possible_keys                   | key                   | key_len | ref               | rows  | Extra                          |
+----+-------------+------------+-------+---------------------------------+-----------------------+---------+-------------------+-------+--------------------------------+
|  1 | PRIMARY     | cl         | range | cm_log_cls_id,idx_last_upd_date | idx_last_upd_date     | 8       | NULL              |   379 | Using where; Using temporary   |
|  1 | PRIMARY     | <derived2> | ALL   | NULL                            | NULL                  | NULL    | NULL              | 63727 | Using where; Using join buffer |
|  2 | DERIVED     | emp        | ALL   | NULL                            | NULL                  | NULL    | NULL              | 13317 | Using where                    |
|  2 | DERIVED     | emp_cert   | ref   | emp_certificate_empid           | emp_certificate_empid | 4       | meituanorg.emp.id |     1 | Using index                    |
+----+-------------+------------+-------+---------------------------------+-----------------------+---------+-------------------+-------+--------------------------------+

簡述一下執行計劃,首先mysql根據idx_last_upd_date索引掃描cm_log表獲得379條記錄;然後查表掃描了63727條記錄,分為兩部分,derived表示構造表,也就是不存在的表,可以簡單理解成是一個語句形成的結果集,後面的數字表示語句的ID。derived2表示的是ID = 2的查詢構造了虛擬表,並且返回了63727條記錄。我們再來看看ID = 2的語句究竟做了寫什麼返回了這麼大量的資料,首先全表掃描employee表13317條記錄,然後根據索引emp_certificate_empid關聯emp_certificate表,rows = 1表示,每個關聯都只鎖定了一條記錄,效率比較高。獲得後,再和cm_log的379條記錄根據規則關聯。從執行過程上可以看出返回了太多的資料,返回的資料絕大部分cm_log都用不到,因為cm_log只鎖定了379條記錄。
如何優化呢?可以看到我們在執行完後還是要和cm_log做join,那麼我們能不能之前和cm_log做join呢?仔細分析語句不難發現,其基本思想是如果cm_log的ref_table是EmpCertificate就關聯emp_certificate表,如果ref_table是Employee就關聯employee表,我們完全可以拆成兩部分,並用union連線起來,注意這裡用union,而不用union all是因為原語句有“distinct”來得到唯一的記錄,而union恰好具備了這種功能。如果原語句中沒有distinct不需要去重,我們就可以直接使用union all了,因為使用union需要去重的動作,會影響SQL效能。
優化過的語句如下

select
   emp.id 
from
   cm_log cl 
inner join
   employee emp 
      on cl.ref_table = 'Employee' 
      and cl.ref_oid = emp.id  
where
   cl.last_upd_date >='2013-11-07 15:03:00' 
   and cl.last_upd_date<='2013-11-08 16:00:00' 
   and emp.is_deleted = 0  
union
select
   emp.id 
from
   cm_log cl 
inner join
   emp_certificate ec 
      on cl.ref_table = 'EmpCertificate' 
      and cl.ref_oid = ec.id  
inner join
   employee emp 
      on emp.id = ec.emp_id  
where
   cl.last_upd_date >='2013-11-07 15:03:00' 
   and cl.last_upd_date<='2013-11-08 16:00:00' 
   and emp.is_deleted = 0

4.不需要了解業務場景,只需要改造的語句和改造之前的語句保持結果一致

5.現有索引可以滿足,不需要建索引

6.用改造後的語句實驗一下,只需要10ms 降低了近200倍!

+----+--------------+------------+--------+---------------------------------+-------------------+---------+-----------------------+------+-------------+
| id | select_type  | table      | type   | possible_keys                   | key               | key_len | ref                   | rows | Extra       |
+----+--------------+------------+--------+---------------------------------+-------------------+---------+-----------------------+------+-------------+
|  1 | PRIMARY      | cl         | range  | cm_log_cls_id,idx_last_upd_date | idx_last_upd_date | 8       | NULL                  |  379 | Using where |
|  1 | PRIMARY      | emp        | eq_ref | PRIMARY                         | PRIMARY           | 4       | meituanorg.cl.ref_oid |    1 | Using where |
|  2 | UNION        | cl         | range  | cm_log_cls_id,idx_last_upd_date | idx_last_upd_date | 8       | NULL                  |  379 | Using where |
|  2 | UNION        | ec         | eq_ref | PRIMARY,emp_certificate_empid   | PRIMARY           | 4       | meituanorg.cl.ref_oid |    1 |             |
|  2 | UNION        | emp        | eq_ref | PRIMARY                         | PRIMARY           | 4       | meituanorg.ec.emp_id  |    1 | Using where |
| NULL | UNION RESULT | <union1,2> | ALL    | NULL                            | NULL              | NULL    | NULL                  | NULL |             |
+----+--------------+------------+--------+---------------------------------+-------------------+---------+-----------------------+------+-------------+
53 rows in set (0.01 sec)

3 明確應用場景

舉這個例子的目的在於顛覆我們對列的區分度的認知,一般上我們認為區分度越高的列,越容易鎖定更少的記錄,但在一些特殊的情況下,這種理論是有侷限性的

select
   * 
from
   stage_poi sp 
where
   sp.accurate_result=1 
   and (
      sp.sync_status=0 
      or sp.sync_status=2 
      or sp.sync_status=4
   );

0.先看看執行多長時間,951條資料6.22秒,真的很慢

951 rows in set (6.22 sec)

1.先explain,rows達到了361萬,type = ALL表明是全表掃描

+----+-------------+-------+------+---------------+------+---------+------+---------+-------------+
| id | select_type | table | type | possible_keys | key  | key_len | ref  | rows    | Extra       |
+----+-------------+-------+------+---------------+------+---------+------+---------+-------------+
|  1 | SIMPLE      | sp    | ALL  | NULL          | NULL | NULL    | NULL | 3613155 | Using where |
+----+-------------+-------+------+---------------+------+---------+------+---------+-------------+

2.所有欄位都應用查詢返回記錄數,因為是單表查詢 0已經做過了951條

3.讓explain的rows 儘量逼近951

看一下accurate_result = 1的記錄數

select count(*),accurate_result from stage_poi  group by accurate_result;
+----------+-----------------+
| count(*) | accurate_result |
+----------+-----------------+
|     1023 |              -1 |
|  2114655 |               0 |
|   972815 |               1 |
+----------+-----------------+

我們看到accurate_result這個欄位的區分度非常低,整個表只有-1,0,1三個值,加上索引也無法鎖定特別少量的資料

再看一下sync_status欄位的情況

select count(*),sync_status from stage_poi  group by sync_status;
+----------+-------------+
| count(*) | sync_status |
+----------+-------------+
|     3080 |           0 |
|  3085413 |           3 |
+----------+-------------+

同樣的區分度也很低,根據理論,也不適合建立索引

問題分析到這,好像得出了這個表無法優化的結論,兩個列的區分度都很低,即便加上索引也只能適應這種情況,很難做普遍性的優化,比如當sync_status 0、3分佈的很平均,那麼鎖定記錄也是百萬級別的

4.找業務方去溝通,看看使用場景。業務方是這麼來使用這個SQL語句的,每隔五分鐘會掃描符合條件的資料,處理完成後把sync_status這個欄位變成1,五分鐘符合條件的記錄數並不會太多,1000個左右。瞭解了業務方的使用場景後,優化這個SQL就變得簡單了,因為業務方保證了資料的不平衡,如果加上索引可以過濾掉絕大部分不需要的資料

5.根據建立索引規則,使用如下語句建立索引

alter table stage_poi add index idx_acc_status(accurate_result,sync_status);

6.觀察預期結果,發現只需要200ms,快了30多倍。

952 rows in set (0.20 sec)

我們再來回顧一下分析問題的過程,單表查詢相對來說比較好優化,大部分時候只需要把where條件裡面的欄位依照規則加上索引就好,如果只是這種“無腦”優化的話,顯然一些區分度非常低的列,不應該加索引的列也會被加上索引,這樣會對插入、更新效能造成嚴重的影響,同時也有可能影響其它的查詢語句。所以我們第4步調差SQL的使用場景非常關鍵,我們只有知道這個業務場景,才能更好地輔助我們更好的分析和優化查詢語句。

4 無法優化的語句

select
   c.id,
   c.name,
   c.position,
   c.sex,
   c.phone,
   c.office_phone,
   c.feature_info,
   c.birthday,
   c.creator_id,
   c.is_keyperson,
   c.giveup_reason,
   c.status,
   c.data_source,
   from_unixtime(c.created_time) as created_time,
   from_unixtime(c.last_modified) as last_modified,
   c.last_modified_user_id  
from
   contact c  
inner join
   contact_branch cb 
      on  c.id = cb.contact_id  
inner join
   branch_user bu 
      on  cb.branch_id = bu.branch_id 
      and bu.status in (
         1,
      2)  
   inner join
      org_emp_info oei 
         on  oei.data_id = bu.user_id 
         and oei.node_left >= 2875 
         and oei.node_right <= 10802 
         and oei.org_category = - 1  
   order by
      c.created_time desc  limit 0 ,
      10;

還是幾個步驟
0.先看語句執行多長時間,10條記錄用了13秒,已經不可忍受

10 rows in set (13.06 sec)

1.explain

+----+-------------+-------+--------+-------------------------------------+-------------------------+---------+--------------------------+------+----------------------------------------------+
| id | select_type | table | type   | possible_keys                       | key                     | key_len | ref                      | rows | Extra                                        |
+----+-------------+-------+--------+-------------------------------------+-------------------------+---------+--------------------------+------+----------------------------------------------+
|  1 | SIMPLE      | oei   | ref    | idx_category_left_right,idx_data_id | idx_category_left_right | 5       | const                    | 8849 | Using where; Using temporary; Using filesort |
|  1 | SIMPLE      | bu    | ref    | PRIMARY,idx_userid_status           | idx_userid_status       | 4       | meituancrm.oei.data_id   |   76 | Using where; Using index                     |
|  1 | SIMPLE      | cb    | ref    | idx_branch_id,idx_contact_branch_id | idx_branch_id           | 4       | meituancrm.bu.branch_id  |    1 |                                              |
|  1 | SIMPLE      | c     | eq_ref | PRIMARY                             | PRIMARY                 | 108     | meituancrm.cb.contact_id |    1 |                                              |
+----+-------------+-------+--------+-------------------------------------+-------------------------+---------+--------------------------+------+----------------------------------------------+

從執行計劃上看,mysql先查org_emp_info表掃描8849記錄,再用索引idx_userid_status關聯branch_user表,再用索引idx_branch_id關聯contact_branch表,最後主鍵關聯contact表。
rows返回的都非常少,看不到有什麼異常情況。我們在看一下語句,發現後面有order by + limit組合,會不會是排序量太大搞的?於是我們簡化SQL,去掉後面的order by 和 limit,看看到底用了多少記錄來排序

select
  count(*)
from
   contact c  
inner join
   contact_branch cb 
      on  c.id = cb.contact_id  
inner join
   branch_user bu 
      on  cb.branch_id = bu.branch_id 
      and bu.status in (
         1,
      2)  
   inner join
      org_emp_info oei 
         on  oei.data_id = bu.user_id 
         and oei.node_left >= 2875 
         and oei.node_right <= 10802 
         and oei.org_category = - 1  
+----------+
| count(*) |
+----------+
|   778878 |
+----------+
1 row in set (5.19 sec)

發現排序之前居然鎖定了778878條記錄,如果針對70萬的結果集排序,將是災難性的,怪不得這麼慢,那我們能不能換個思路,先根據contact的created_time排序,再來join會不會比較快呢?
於是改造成下面的語句,也可以用straight_join來優化
select
c.id,
c.name,
c.position,
c.sex,
c.phone,
c.office_phone,
c.feature_info,
c.birthday,
c.creator_id,
c.is_keyperson,
c.giveup_reason,
c.status,
c.data_source,
from_unixtime(c.created_time) as created_time,
from_unixtime(c.last_modified) as last_modified,
c.last_modified_user_id
from
contact c
where
exists (
select
1
from
contact_branch cb
inner join
branch_user bu
on cb.branch_id = bu.branch_id
and bu.status in (
1,
2)
inner join
org_emp_info oei
on oei.data_id = bu.user_id
and oei.node_left >= 2875
and oei.node_right <= 10802
and oei.org_category = - 1
where
c.id = cb.contact_id
)
order by
c.created_time desc limit 0 ,
10;

驗證一下效果 預計在1ms內,提升了13000多倍!
```sql
10 rows in set (0.00 sec)

本以為至此大工告成,但我們在前面的分析中漏了一個細節,先排序再join和先join再排序理論上開銷是一樣的,為何提升這麼多是因為有一個limit!大致執行過程是:mysql先按索引排序得到前10條記錄,然後再去join過濾,當發現不夠10條的時候,再次去10條,再次join,這顯然在內層join過濾的資料非常多的時候,將是災難的,極端情況,內層一條資料都找不到,mysql還傻乎乎的每次取10條,幾乎遍歷了這個資料表!
用不同引數的SQL試驗下

select
   sql_no_cache   c.id,
   c.name,
   c.position,
   c.sex,
   c.phone,
   c.office_phone,
   c.feature_info,
   c.birthday,
   c.creator_id,
   c.is_keyperson,
   c.giveup_reason,
   c.status,
   c.data_source,
   from_unixtime(c.created_time) as created_time,
   from_unixtime(c.last_modified) as last_modified,
   c.last_modified_user_id    
from
   contact c   
where
   exists (
      select
         1        
      from
         contact_branch cb         
      inner join
         branch_user bu                     
            on  cb.branch_id = bu.branch_id                     
            and bu.status in (
               1,
            2)                
         inner join
            org_emp_info oei                           
               on  oei.data_id = bu.user_id                           
               and oei.node_left >= 2875                           
               and oei.node_right <= 2875                           
               and oei.org_category = - 1                
         where
            c.id = cb.contact_id           
      )        
   order by
      c.created_time desc  limit 0 ,
      10;
Empty set (2 min 18.99 sec)

2 min 18.99 sec!比之前的情況還糟糕很多。由於mysql的nested loop機制,遇到這種情況,基本是無法優化的。這條語句最終也只能交給應用系統去優化自己的邏輯了。
通過這個例子我們可以看到,並不是所有語句都能優化,而往往我們優化時,由於SQL用例迴歸時落掉一些極端情況,會造成比原來還嚴重的後果。所以,第一:不要指望所有語句都能通過SQL優化,第二:不要過於自信,只針對具體case來優化,而忽略了更復雜的情況。

慢查詢的案例就分析到這兒,以上只是一些比較典型的案例。我們在優化過程中遇到過超過1000行,涉及到16個表join的“垃圾SQL”,也遇到過線上線下資料庫差異導致應用直接被慢查詢拖死,也遇到過varchar等值比較沒有寫單引號,還遇到過笛卡爾積查詢直接把從庫搞死。再多的案例其實也只是一些經驗的積累,如果我們熟悉查詢優化器、索引的內部原理,那麼分析這些案例就變得特別簡單了。

B樹是為實現高效的磁碟存取而設計的多叉平衡搜尋樹。這個概念在檔案系統,資料庫系統中非常重要。當然,有關於B樹的產生,發展,結構等等方面的介紹已經非常詳細,所以本文只是介紹有關於B樹和B+樹最核心的知識點,也算是我本人的學習筆記。至於詳細的資料,因為畢竟有著太多,所以不再贅述。可以向大家推薦一篇部落格:從B樹、B+樹、B*樹談到R 樹,這篇文章中,作者對於B樹系列資料結構的講解非常詳細,我的這篇部落格,也是大量參考了人家的很多例子和描述。

五 B樹

1基本原理

首先,簡單說一下B樹產生的原因。B樹是一種查詢樹,我們知道,這一類樹(比如二叉查詢樹,紅黑樹等等)最初生成的目的都是為了解決某種系統中,查詢效率低的問題。B樹也是如此,它最初啟發於二叉查詢樹,二叉查詢樹的特點是每個非葉節點都只有兩個孩子節點。然而這種做法會導致當資料量非常大時,二叉查詢樹的深度過深,搜尋演算法自根節點向下搜尋時,需要訪問的節點也就變的相當多。如果這些節點儲存在外儲存器中,每訪問一個節點,相當於就是進行了一次I/O操作,隨著樹高度的增加,頻繁的I/O操作一定會降低查詢的效率。

這裡有一個基本的概念,就是說我們從外儲存器中讀取資訊的步驟,簡單來分,大致有兩步:

  1. 找到儲存這個資料所對應的磁碟頁面,這個過程是機械化的過程,需要依靠磁臂的轉動,找到對應磁軌,所以耗時長。
  2. 讀取資料進記憶體,並實施運算,這是電子化的過程,相當快。

綜上,對於外儲存器的資訊讀取最大的時間消耗在於尋找磁碟頁面。那麼一個基本的想法就是能不能減少這種讀取的次數,在一個磁碟頁面上,多儲存一些索引資訊。B樹的基本邏輯就是這個思路,它要改二叉為多叉,每個節點儲存更多的指標資訊,以降低I/O運算元。

2 基本結構

1. B樹的定義

有關於B樹概念的定義,不同的資料在表述上有所差別。我在這裡採用《算導》中的定義,用最小度tt tt來定義B樹。一棵最小度為tt tt的B樹是滿足如下四個條件的平衡多叉樹:

  • 每個節點最多包含2t−12t−1 2t - 12t−1個關鍵字;除根節點外的每個節點至少有t−1t−1 t - 1t−1個關鍵字(t≤2t≤2 t \leq 2t≤2),根節點至少有一個關鍵字;

  • 一個節點uu uu中的關鍵字按非降序排列:u.key1≤u.key2≤…u.keynu.key1≤u.key2≤…u.keyn u.key_1 \leq u.key_2 \leq \dots u.key_nu.key1​≤u.key2​≤…u.keyn​;

  • 每個節點的關鍵字對其子樹的範圍分割。設節點uu uu有n+1n+1 n + 1n+1個指標,指向其n+1n+1 n + 1n+1棵子樹,指標為u.p1,…u.pnu.p1,…u.pn u.p_1, \dots u.p_nu.p1​,…u.pn​,關鍵字kiki k_iki​為u.piu.pi u.p_iu.pi​所指的子樹中的關鍵字,有k1≤u.key1≤k2≤u.key2…k1≤u.key1≤k2≤u.key2… k_1 \leq u.key_1 \leq k_2 \leq u.key_2 \dotsk1​≤u.key1​≤k2​≤u.key2​…成立;

  • 所有葉子節點具有相同的深度,即樹的高度hh hh。這表明B樹是平衡的。平衡性其實正是B樹名字的來源,B表示的正是單詞Balanced;

一個標準的B樹如下圖:

2. B樹的高度

我直接給出結論了:對於一個包含nn nn個關鍵字(n≥1n≥1 n \geq 1n≥1),最小度數t≥2t≥2 t \geq 2t≥2的B樹T,其高度hh hh滿足如下規律:

h≤logtn+12h≤logt⁡n+12

在搜尋B樹時,很明顯,訪問節點(即讀取磁碟)的次數與樹的高度呈正比,而B樹與紅黑樹和普通的二叉查詢樹相比,雖然高度都是對數數量級,但是顯然B樹中loglog loglog函式的底可以比2更大,因此,和二叉樹相比,極大地減少了磁碟讀取的次數。

3 搜尋演算法

這裡,我直接用部落格從B樹、B+樹、B*樹談到R 樹中的例子(因為這個例子非常好,也有現成的圖示,就直接拿來用,不再自己班門弄斧了),一棵已經建立好的B樹如下圖所示,我們的目的是查詢關鍵字為29的檔案:

b+樹

先簡單對上圖說明一下:

  • 圖中的小紅方塊表示對應關鍵字所代表的檔案的儲存位置,實際上可以看做是一個地址,比如根節點中17旁邊的小紅塊表示的就是關鍵字17所對應的檔案在硬碟中的儲存地址。

  • P是指標,不用多說了,需要注意的是:指標,關鍵字,以及關鍵字所代表的檔案地址這三樣東西合起來構成了B樹的一個節點,這個節點儲存在一個磁碟塊上

下面,看看搜尋關鍵字的29的檔案的過程:

  1. 從根節點開始,讀取根節點資訊,根節點有2個關鍵字:17和35。因為17 < 29 < 35,所以找到指標P2指向的子樹,也就是磁碟塊3(1次I/0操作)

  2. 讀取當前節點資訊,當前節點有2個關鍵字:26和30。26 < 29 < 30,找到指標P2指向的子樹,也就是磁碟塊8(2次I/0操作)

  3. 讀取當前節點資訊,當前節點有2個關鍵字:28和29。找到了!(3次I/0操作)

由上面的過程可見,同樣的操作,如果使用平衡二叉樹,那麼需要至少4次I/O操作,B樹比之二叉樹的這種優勢,還會隨著節點數的增加而增加。另外,因為B樹節點中的關鍵字都是排序好的,所以,在節點中的資訊被讀入記憶體之後,可以採用二分查詢這種快速的查詢方式,更進一步減少了讀入記憶體之後的計算時間,由此更能說明對於外存資料結構來說,I/O次數是其查詢資訊中最大的時間消耗,而我們要做的所有努力就是儘量在搜尋過程中減少I/O操作的次數。

4 向B樹插入關鍵字

向B樹種插入關鍵字的過程與向二叉查詢樹中插入關鍵字的過程類似,但是要稍微複雜一點,因為根據上面B樹的定義,我們可以看出,B樹每個節點中關鍵字的個數是有範圍要求的,同時,B樹是平衡的,所以,如果像二叉查詢樹那樣,直接找到相關的葉子,插入關鍵字,有可能會導致B樹的結構發生變化而這種變化會使得B樹不再是B樹。

所以,我們這樣來設計B樹種對新關鍵字的插入:首先找到要插入的關鍵字應該插入的葉子節點(為方便描述,設這個葉子節點為u),如果u是滿的(恰好有2t−1個關鍵字),那麼由於不能將一個關鍵字插入滿的節點,我們需要對u按其當前排在中間關鍵字u.keyt​進行分裂,分裂成兩個節點u1,u2;同時,作為分裂標準的關鍵字u.keyt會被上移到u的父節點中,在u.keyt插入前,如果u的父節點未滿,則直接插入即可;如果u的父節點已滿,則按照上面的方法對u的父節點分裂,這個過程如果一直不停止的話,最終會導致B樹的根節點分裂,B樹的高度增加一層。

我用《算導》中的一個題目展示一下這種插入關鍵字的過程:

現在我們要將關鍵字序列:F, S, Q, K, C, L, H, T, V, W, M, R, N, P, A, B, X, Y依次插入一棵最小度為2的B樹中。也就是說,這棵樹的節點中,最多有3個關鍵字,最少有1個關鍵字。

第1步,F, S, Q可以被插入一個節點(也就是根節點)

第2步,插入關鍵字K,因為節點已滿,所以在插入前,發生分裂,中間關鍵字Q上移,建立了一個新的根節點:

第3步,插入關鍵字C:

第4步,插入關鍵字L,L應該被插入到根節點的左側的孩子中,因為此時該節點已滿,所以在插入前,發生分裂:

第5步,插入關鍵字H, T, V,這個過程沒有發生節點的分裂:

第6步,插入關鍵字W,W應該被插入到根節點的最右側的孩子中,因為此時該節點已滿,所以在插入前,關鍵字T上移,最右端的葉子節點發生分裂:

第7步,插入關鍵字M,M應該被插入到根節點的左起第2個孩子中,因為此時該節點已滿,所以在插入前,發生分裂,分裂之後,中間關鍵字K上移,導致根節點發生分裂,樹高增加1:

第8步,同樣的道理,插入關鍵字R, N, P, A, B, X, Y:最終得到的B樹如下:

5 從B樹刪除關鍵字

刪除操作的基本思想和插入操作是一樣的,都是不能因為關鍵字的改變而改變B樹的結構。插入操作主要防止的是某個節點中關鍵字的個數太多,所以採用了分裂;刪除則是要防止某個節點中,因刪除了關鍵字而導致這個節點的關鍵字個數太少,所以採用了合併操作。

下面分三種情況來討論下刪除操作是如何工作的,這個過程的順序是自根節點起向下遍歷B樹

**Case - 1:**如果要刪除的關鍵字k在節點u中,而且u是葉子節點,那麼直接刪除k

**Case - 2:**如果要刪除的關鍵字k在節點u中,而且u是內部節點,那麼分以下3種情況討論:

(1) 如果u中前於k的子節點u1中至少含有t個關鍵字,則找出k在以u1​為根的子樹中的前驅k′ (前驅的意思是u1中比k小的關鍵字中最大的),然後在以u1為根的子樹中刪除k′,並在u中以k′替代k

(2) 如果上面的條件(1)不成立,也就是說,前於k的子節點中關鍵字的個數小於t了,那麼就去找後於k的子節點,記為u2。若u2中至少含有t個關鍵字,則找出k在以u2為根的子樹中的後繼k′(大於k的關鍵字中最小的),然後在以u2為根的子樹中刪除k′,並在u中以k′替代k。可以看出(2)是(1)的一個對稱過程

(3) 如果u1,u2中的關鍵字個數都是t−1,則將k和u2合併後併入u1,這樣u就失去了k和指向u2的指標,最後遞迴地從u1中刪除k

**Case - 3:**如果要刪除的關鍵字k不在當前節點u中,而且u是內部節點(如果自上而下掃描到葉子都沒有這個關鍵字的話,那就說明要刪除的關鍵字根本就不存在,所以此處只考慮u是內部節點的情況),則首先確定包含k的u的子樹,我們這裡設為u.pi。如果u.pi中至少含有t個關鍵字,那麼繼續掃描,尋找下一個要被掃描的子樹;如果u.pi中只含有t−1個關鍵字,則需要分下面兩種情況進行操作:

(1) 如果u.pi至少有一個相鄰的兄弟比較“豐滿”(即這個兄弟至少有t個關鍵字)。則將u中的一個關鍵字降至u.pi​,同時令u.pi的最“豐滿”的兄弟中升一個關鍵至u。然後繼續掃描B樹,尋找k

(2) 如果u.pi的兩個相鄰的兄弟都不“豐滿”(都只有t−1個關鍵字)。則令u.pi和其一個兄弟合併,再將u的一個關鍵字降至新合併的節點。使之成為該節點的中間關鍵字。

舉個例子,就可以清晰看到上面說的這幾種刪除的情況。拿下圖所示的最小度為3的B樹為例(即樹中除根和葉子之外的節點只能有2,3,4,5四種情況的關鍵字個數):

Step 1: 刪除上圖中的關鍵字F,過程如下:先掃描根節點(含P),再掃描其左孩子(含CGM),發現豐滿,繼續掃描到左起第二個葉子,然後就是符合Case - 1的情況了。結果如下圖所示:

Step 2: 再刪除M,此時遇到**Case - 2(1)**的情況,結果如下圖所示:

Step 3: 再刪除G,G的前驅、後驅都是不豐滿的。也就是**Case - 2(3)**的情況,結果如下圖所示:

Step 4: 再刪除D,掃描至含CL的節點後,發現它不豐滿,且他的兄弟也不豐滿。則將節點CL和TX合併,並降關鍵字P至新合併的節點。也就是**Case - 3(2)**的情況,結果如下圖所示,此時,樹高減1:

Step 5: 再刪除B,也就是**Case - 3(1)**的情況,結果如下圖所示:

下面總結一下B樹的刪除原理:

  1. 基本原則是不能破壞關鍵字個數的限制;
  2. 如果在當前節點中,找到了要刪的關鍵字,且當前節點為內部節點。那麼,如果有比較豐滿的前驅或後繼,借一個上來,再把要刪的關鍵字降下去,在子樹中遞迴刪除;如果沒有比較豐滿的前驅或後繼,則令前驅與後繼合併,把要刪的關鍵字降下去,遞迴刪除;
  3. 如果在當前節點中,還未找到要刪的關鍵字,且當前節點為內部節點。那麼去找下一步應該掃描的孩子,並判斷這個孩子是否豐滿,如果豐滿,繼續掃描;如果不豐滿,則看其有無豐滿的兄弟,有的話,從父親那裡接一個,父親再找其最豐滿的兄弟借一個;如果沒有豐滿的兄弟,則合併,再令父親下降,以保證B樹的結構。

五 B+樹

1 B+樹的定義

B+樹是B樹的一種變形,它更適合實際應用中作業系統的檔案索引和資料庫索引。定義如下:(為和大多資料保持一致,這裡使用階數m來定義B+樹,而不像之前的B樹中,使用的是最小度t來定義)

  1. 除根節點外的內部節點,每個節點最多有m個關鍵字,最少有⌈m/2⌉個關鍵字。其中每個關鍵字對應一個子樹(也就是最多有mm mm棵子樹,最少有⌈m/2⌉棵子樹);
  2. 根節點要麼沒有子樹,要麼至少有2棵子樹;
  3. 所有的葉子節點包含了全部的關鍵字以及這些關鍵字指向檔案的指標,並且:
  • 所有葉子節點中的關鍵字按大小順序排列
  • 相鄰的葉子節點順序連結(相當於是構成了一個順序連結串列)
  • 所有葉子節點在同一層
  1. 所有分支節點的關鍵字都是對應子樹中關鍵字的最大值

比如,下圖就是一個非常典型的B+樹的例子。

B+樹和B樹相比,主要的不同點在以下3項:

  • 內部節點中,關鍵字的個數與其子樹的個數相同,不像B樹種,子樹的個數總比關鍵字個數多1個
  • 所有指向檔案的關鍵字及其指標都在葉子節點中,不像B樹,有的指向檔案的關鍵字是在內部節點中。換句話說,B+樹中,內部節點僅僅起到索引的作用,
  • 在搜尋過程中,如果查詢和內部節點的關鍵字一致,那麼搜尋過程不停止,而是繼續向下搜尋這個分支。

根據B+樹的結構,我們可以發現B+樹相比於B樹,在檔案系統,資料庫系統當中,更有優勢,原因如下:

  1. B+樹的磁碟讀寫代價更低
    B+樹的內部結點並沒有指向關鍵字具體資訊的指標。因此其內部結點相對B樹更小。如果把所有同一內部結點的關鍵字存放在同一盤塊中,那麼盤塊所能容納的關鍵字數量也越多。一次性讀入記憶體中的需要查詢的關鍵字也就越多。相對來說I/O讀寫次數也就降低了。

  2. B+樹的查詢效率更加穩定
    由於內部結點並不是最終指向檔案內容的結點,而只是葉子結點中關鍵字的索引。所以任何關鍵字的查詢必須走一條從根結點到葉子結點的路。所有關鍵字查詢的路徑長度相同,導致每一個數據的查詢效率相當。

  3. B+樹更有利於對資料庫的掃描
    B樹在提高了磁碟IO效能的同時並沒有解決元素遍歷的效率低下的問題,而B+樹只需要遍歷葉子節點就可以解決對全部關鍵字資訊的掃描,所以對於資料庫中頻繁使用的range query,B+樹有著更高的效能。

總結

本文以一個慢查詢案例引入了MySQL索引原理、優化慢查詢的一些方法論;並針對遇到的典型案例做了詳細的分析,並且詳細介紹了B+樹。其實做了這麼長時間的語句優化後才發現,任何資料庫層面的優化都抵不上應用系統的優化,同樣是MySQL,可以用來支撐Google/FaceBook/Taobao應用,但可能連你的個人網站都撐不住。套用最近比較流行的話:“查詢容易,優化不易,且寫且珍惜!”

參考文獻

參考文獻如下

1. <<高效能MySQL>>

2. <<資料結構與演算法>>

5. Organization and Maintenance of Large Ordered Indices

6. the ubiquitous B tree