MySQL實戰(二)

九、普通索引和唯一索引,應該怎麼選擇?

1)、查詢過程

在這裏插入圖片描述

假設執行查詢的語句是select id from T where k=5。這個查詢語句在索引樹上查找的過程,先是通過B+樹從樹根開始,按層搜索到葉子節點,也就是上圖中右下角的這個數據頁, 然後可以認爲數據頁內部通過二分法來定位記錄

  • 對於普通索引來說,查找到滿足條件的第一個記錄(5,500)後,需要查找下一個記錄,直到碰到第一個不滿足k=5條件的記錄
  • 對於唯一索引來說,由於索引定義了唯一性,查找到第一個滿足條件的記錄後,就會停止繼續檢索

InnoDB的數據是按數據頁爲單位來讀寫的。也就是說,當需要讀一條記錄的時候,並不是將這個記錄本身從磁盤讀出來,而是以頁爲單位,將其整體讀入內存。在InnoDB中,每個數據頁的大小默認是16KB

因爲引擎是按頁讀寫的,所以當找到k=5的記錄的時候,它所在的數據頁就都在內存裏了。對於普通索引來說,要多做的那一次查找和判斷下一條記錄的操作,就只需要一次指針尋址和一次計算。如果k=5這個記錄剛好是這個數據頁的最後一個記錄,那麼要取下一條記錄,必須讀取下一個數據頁,這個操作會稍微複雜一些

總體來說,使用普通索引還是唯一索引對查詢語句影響不大

2)、更新過程

當需要更新一個數據頁時,如果數據頁在內存中就直接更新,而如果這個數據頁還沒有在內存中的話,在不影響數據一致性的前提下,InnoDB會將這些更新操作緩存在change buffer中,這樣就不需要從磁盤中讀入這個數據頁了。在下次查詢需要訪問這個數據頁的時候,將數據頁讀入內存,然後執行change buffer中與這個頁有關的操作。通過這種方式就能保證這個數據邏輯的正確性

change buffer是可以持久化的數據,在內存中有拷貝,也會被寫入到磁盤上

將change buffer中的操作應用到元數據頁,得到最新結果的過程稱爲merge。除了訪問這個數據頁會觸發merge外,系統有後臺線程會定期merge。在數據庫正常關閉的過程中,也會執行merge操作

如果能夠將更新操作先記錄在change buffer,減少讀磁盤,語句的執行速度會得到明顯的提升。而且,數據讀入內存是需要佔用buffer pool的,所以這種方式還能夠避免佔用內存,提高內存利用率

什麼條件下可以使用change buffer呢?

對於唯一索引來說,所有的更新操作都要先判斷這個操作是否違反唯一性約束。比如,要插入(4,400)這個記錄,就要先判斷現在表中是否已經存在k=4這個記錄,就要先判斷現在表中是否已經存在k=4的記錄,而這必須要將數據頁讀入內存才能判斷。如果都已經讀入到內存了,那直接更新內存會更快,就沒必要使用change buffer了

唯一索引的更新不能使用change buffer,只有普通索引可以使用

如果要在這張表中插入一個新紀錄(4,400)的話,InnoDB的處理流程是怎麼樣的?

1)如果這個記錄要更新的目標頁在內存中。這時,InnoDB的處理流程如下:

  • 對於唯一索引來說,找到3和5之間的位置,判斷到沒有衝突,插入這個值,語句執行結束
  • 對於普通索引來說,找到3和5之間的位置,插入這個值,語句執行結束

普通索引和唯一索引對更新語句性能影響的差別,只是一個判斷,差別不大

2)如果這個記錄要更新的目標也不在內存中。這時,InnoDB的處理流程如下:

  • 對於唯一索引來說,需要將數據頁讀入內存,判斷到沒有衝突,插入這個值,語句執行結束
  • 對於普通索引來說,則是將更新就在change buffer,語句執行就結束了

將數據從磁盤讀入內存涉及隨機IO的訪問,是數據庫裏面成本最高的操作之一。change buffer因爲減少了隨機磁盤訪問,所以對更新性能的提升是會很明顯的

3)、change buffer的使用場景

因爲merge的時候是真正進行數據更新的時刻,而change buffer的主要目的就是將記錄的變更動作緩存襲來,所以在一個數據頁做merge之前,change buffer記錄的變更越多,收益就越大

對於寫多讀少的業務來說,頁面在寫完以後馬上被訪問到的概率比較小,此時change buffer的使用小高最好

假設一個業務的更新模式是寫入之後馬上會做查詢,那麼即使滿足了條件,將更新先記錄在change buffer,但之後由於馬上要訪問這個數據頁,會立即觸發merge過程。這樣隨機訪問IO的次數不會減少,反而增加了change buffer的維護代價。所以,對於這種業務模式來說,change buffer反而起到了副作用

4)、索引選擇和實踐

普通索引和唯一索引在查詢negligible上沒差別的,主要考慮的是對更新性能的影響。建議儘量選擇普通索引

如果所有的更新後面都馬上伴隨着對這個記錄的查詢,那麼應該關閉change buffer。而在其他情況下,change buffer都能提升更新性能

5)、change buffer和redo log

我們要在表上執行這個插入語句:

mysql> insert into t(id,k) values(id1,k1),(id2,k2);

假設當前k索引樹的狀態,查找到位置後,k1所在的數據頁在內存中,k2所在的數據頁不再內存中。如下圖所示是帶change buffer的更新狀態圖

在這裏插入圖片描述

這條更新語句中涉及了四個部分:內存、redo log、數據表空間(t.ibd)、系統表空間(ibdata1)

這條更新語句做了如下的操作:

1)Page1在內存中,直接更新內存

2)Page2沒有在內存中,就在內存的change buffer區域記錄下我要往Page2插入一行這個信息

3)將上述兩個動作記入redo log中

做完上面這些,事務就可以完成了。執行這條更新語句的成本很低,就是寫了兩處內存,然後更新了一處磁盤(兩次操作合在一起寫了一次磁盤),而且還是順序寫的

圖中的兩個虛線箭頭是後臺操作,不影響更新的響應時間

那在這之後的讀請求,要怎麼處理呢?

比如,現在要執行select * from t where k in (k1,k2)。如果讀語句發生在更新語句後不久,內存中的數據都還在,那麼此時的這兩個讀操作就與系統表空間和redo log無關了

在這裏插入圖片描述

1)讀Page1的時候,直接從內存返回

2)讀Page2的時候,需要把Page2從磁盤讀入內存中,然後應用change buffer裏面的操作日誌,生成一個正確的版本並返回結果

直到需要讀Page2的時候,這個數據頁纔會被讀入內存

redo log主要節省的是隨機寫磁盤的IO消耗(轉成順序寫),而change buffer主要節省的是隨機讀磁盤的IO消耗

十、MySQL爲什麼有時候會選錯索引?

新建一個表t,表中有a、b兩個字段,分別建上索引:

CREATE TABLE `t` (
  `id` int(11) NOT NULL,
  `a` int(11) DEFAULT NULL,
  `b` int(11) DEFAULT NULL,
  PRIMARY KEY (`id`),
  KEY `a` (`a`),
  KEY `b` (`b`)
) ENGINE=InnoDB

1)、通過explain查看這條語句的執行情況

在這裏插入圖片描述

possible_keys的值是a,表示優化器選擇了索引a

2)、強制使用索引a

select * from t force index(a) where a between 10000 and 20000;

3)、慢查詢日誌設置

1)臨時開啓慢查詢日誌:

set global slow_query_log = on; 

關閉慢查詢日誌:

set global slow_query_log = off;

2)臨時設置慢查詢時間臨界點:(查詢時間高於這個臨界點的都會被記錄到慢查詢日誌中)

set long_query_time = 1;

3)、查詢慢查詢日誌的開啓狀態和慢查詢日誌儲存的位置

show variables like '%quer%';

參數說明:

slow_query_log:是否已經開啓慢查詢

slow_query_log_file:慢查詢日誌文件路徑

long_query_time:超過多少秒的查詢就寫入日誌

log_queries_not_using_indexes:如果值設置爲ON,則會記錄所有沒有利用索引的查詢(性能優化時開啓此項,平時不要開啓)

4)、使用慢查詢日誌示例

日誌如下:

# Time: 2019-05-08T00:38:23.167212Z
# User@Host: root[root] @  [IP地址]  Id:   840
# Query_time: 0.151983  Lock_time: 0.086337 Rows_sent: 6784  Rows_examined: 16783
SET timestamp=1557275903;
select * from t where a between 10000 and 20000;

查詢掃描了16783行,執行時間爲0.15秒

5)、優化器選擇索引的目的是找到一個最優的執行方案,並用最小的代價去執行語句。在數據裏面,掃描行數是影響執行代價的因素之一。掃描的行數越少,意味着訪問磁盤數據的次數越少,消耗的CPU資源越少。優化器還會結合是否使用臨時表、是否排序等因素進行綜合判斷

6)、掃描行數是怎麼判斷的?

MySQL在真正開始執行語句之前,並不能精確地知道滿足這個條件的記錄有多少條,而只能根據統計信息來估算記錄數。這個統計信息就是索引的區分度。一個索引上不同的值越多,這個索引的區分度就越好。而一個索引上不同的值的個數,稱之爲基數。基數越大,索引的區分度越好

使用show index方法查看索引的基數(Cardinality)

在這裏插入圖片描述

7)、MySQL是怎樣得到索引的基數的呢?

MySQL選擇採樣統計,採樣統計的時候,InnoDB默認會選擇N個數據頁,統計這些頁面上的不同值,得到一個平均值,然後乘以這個索引的頁面數,就得到了這個索引的基數

數據表是會持續更新的,索引統計信息也不會固定不變。所以,當變更的數據行數超過1/M的時候,會自動觸發重新做一次索引統計

在MySQL中,有兩種存儲索引統計的方式,可以通過設置innodb_stats_persistent的值來選擇:

  • 設置爲on的時候,表示統計信息會持久化存儲。這時,默認的N是20,M是10
  • 設置爲off的時候,表示統計信息值存儲在內存中。這時,默認的N是8,M是16

8)、優化器選擇索引還要判斷,執行這個語句本身要掃描多少行

在這裏插入圖片描述

rows是代表預計掃描行數

如果使用索引a,每次從索引a上拿到一個值,都要回到主鍵索引上查出整行數據,這個代價優化器也要算進去

如果直接在主鍵索引上掃描,沒有額外的代價

9)、使用analyze table t(表名)命令,來重新統計索引信息

mysql> analyze table t;
+--------------+---------+----------+----------+
| Table        | Op      | Msg_type | Msg_text |
+--------------+---------+----------+----------+
| mysql_test.t | analyze | status   | OK       |
+--------------+---------+----------+----------+
1 row in set (0.06 sec)

十一、怎麼給字符串字段加索引?

1)、現在爲維護一個支持郵箱登錄的系統,用戶表是這麼定義的:

mysql> create table SUser(
ID bigint unsigned primary key,
email varchar(64), 
... 
)engine=innodb; 

由於使用郵箱登錄,所以業務代碼中一定會出現類似於這樣的語句:

mysql> select f1, f2 from SUser where email='xxx';

如果email這個字段上沒有索引,那麼這個語句就只能做全表掃描。同時,MySQL是支持前綴索引的,可以定義字符串的一部分作爲索引。默認地,如果創建索引的語句不指定前綴長度,那麼索引就會包含整個字符串

mysql> alter table SUser add index index1(email);
或
mysql> alter table SUser add index index2(email(6));

在這裏插入圖片描述

在這裏插入圖片描述

email(6)這個索引結構中每個郵箱字段都只取前6個字節,所以佔用的空間會更小,這就是使用前綴索引的優勢。但同時會增加額外的掃描記錄次數

下面這個SQL語句在這兩個索引定義下分別是怎麼執行的?

select id,name,email from SUser where email='[email protected]';

如果使用的是index1(即email整個字符串的索引結構),執行順序是這樣的:

1)從index1索引樹找到滿足索引值是[email protected]的這條記錄,取得ID2的值

2)到主鍵上查到主鍵值是ID2的行,判斷email的值是正確的,將這行記錄加入結果集

3)取index1索引樹上剛剛查到的位置的下一條記錄,發現已經不滿足[email protected]的條件了,循環結束

這個過程中,只需要回主鍵索引取一次數據,所以系統認爲只掃描了一行

如果使用的是index2(即email(6)索引結構),執行順序是這樣的:

1)從index2索引樹找到滿足索引值是zhangs的記錄,找到的第一個是ID1

2)到主鍵查到主鍵值是ID1的行,判斷出email的值不是[email protected],這行記錄丟棄

3)取index2上剛剛查到的位置的下一條記錄,發現仍然是zhangs,取出ID2,再到ID索引上取整行然後判斷,這次值對了,將這行記錄加入結果集

4)重複上一步,直到在index2上取到的值不是zhangs時,循環結束

這個過程中,要回主鍵索引取4次數據,也是就掃描了4行

通過這個對比可以發現,使用前綴索引後,可能會導致查詢語句讀數據的次數變多

對於這個查詢語句來說,如果定義的index2不是email(6)而是email(7),只掃描一行就結束了

使用前綴索引,定義好長度,就可以做到既節省空間,又不用額外增加太多的查詢成本

2)、要給字符串創建前綴索引時,如何能夠確定應該使用多長的前綴呢?

在建立索引時關注的是區分度,區分度越高越好。因爲區分度越高,意味着重複的鍵值越少

使用下面這個語句,算出這個列上有多少個不同的值:

mysql> select count(distinct email) as L from SUser;

依次取不同長度的前綴來看這個值,比如看一下4-7個字節的前綴索引,可以用這個語句:

mysql> select 
  count(distinct left(email,4)as L4,
  count(distinct left(email,5)as L5,
  count(distinct left(email,6)as L6,
  count(distinct left(email,7)as L7,
from SUser;

使用前綴所有可能會損失區分度,所以需要預先設定一個可以接受的損失比例

3)、前綴索引對覆蓋索引的影響

select id,email from SUser where email='[email protected]';

上面這個SQL語句只要求返回id和email字段,如果使用email整個字符串的索引結構的話,可以利用覆蓋索引,查到結果後直接返回了。而如果使用email(6)索引結構的話,就不得不回到ID索引再去判斷email字段的值。即使修改爲email(18)的前綴索引,這時候雖然已經包含了所有的信息,但InnoDB還要回到id索引再查一下,因爲系統並不確定前綴索引的定義是否截斷了完整信息

使用前綴鎖因就用不上覆蓋索引對查詢性能的優化了

4)、其他方式

遇到前綴的區分度不夠好的情況時,我們要怎麼辦?

如果要給身份證號的字段上加索引,一共18位,需要創建長度爲12以上的前綴索引才能夠滿足區分度要求

索引選取的越長,佔用的磁盤空間就越大,相同的數據頁能放下的索引值就越少,搜索的效率也就會越低

1)使用倒序存儲

mysql> select field_list from t where id_card = reverse('input_id_card_string');

由於身份證號的最後6位沒有地址碼這樣的重複邏輯,所以最後這6位很可能就提供了足夠的區分度

2)使用hash字段

在表上在創建一個整數字段,來保存身份證的校驗碼,同時在這個字段上創建索引

mysql> alter table t add id_card_crc int unsigned, add index(id_card_crc);

每次插入新紀錄的時候,都同時用crc32()這個函數得到校驗碼填到這個新字段。由於校驗碼可能存在衝突,也就是說兩個不同的身份證號通過crc32()函數得到的結果可能是相同的,所以查詢語句where部分要判斷id_card的值是否精確相同

mysql> select field_list from t where id_card_crc=crc32('input_id_card_string') and id_card='input_id_card_string';

3)倒序存儲和hash字段的異同點

相同點:

都不支持範圍查詢,只能支持等值查詢

區別:

1)從佔用的額外空間來看,倒序存儲方式在主鍵索引上,不會消耗額外的存儲空間,而hash字段方法需要增加一個字段。當然,倒序存儲的比較長這個消耗跟額外這個hash字段也差不多抵消了

2)在CPU消耗方面,倒序方式每次寫和讀的時候,都需要額外調用一次reverse函數,而hash字段的方式需要額外調用一個crc32()函數。如果只從這兩個函數的計算複雜度來看的話,reverse函數額外消耗的CPU資源會更小一些

3)從查詢效率上看,使用hash字段方式的查詢性能相對更穩定一些。因爲crc32算出來的值雖然有衝突的概率,但是概率非常小,可以認爲每次查詢的平均掃描行數接近1。而倒序存儲方式畢竟還是用的前綴索引的方式,也就說還是會增加掃描行數

5)、覆蓋索引總結:

如果一個索引包含(或覆蓋)所有需要查詢的字段的值,稱爲覆蓋索引。即只需掃描索引而無須回表

掃描索引而無需回表的優點

  • 索引條目通常遠小於數據行大小,只需要讀取索引,則MySQL會極大地減少數據訪問量
  • 因爲索引是按照列值順序存儲的,所以對於IO密集的範圍查找會比隨機從磁盤讀取每一行數據的IO少很多
  • 一些存儲引擎如MyISAM在內存中只緩存索引,數據則依賴於操作系統來緩存,因此要訪問數據需要一次系統調用
  • InnoDB的聚簇索引,覆蓋索引對InnoDB表特別有用(InnoDB的二級索引在葉子節點中保存了行的主鍵值,所以如果二級主鍵能夠覆蓋查詢,則可以避免對主鍵索引的二次查詢)

十二、爲什麼我的MySQL會抖一下?

1)、InnoDB在處理更新語句的時候,只做了寫日誌這一個磁盤處理。這個日誌叫做redo log(重做日誌),也就是《孔乙己》裏咸亨酒店掌櫃用來記賬的粉板,在更新內存寫完redo log後,就返回給客戶端,本次更新成功。做一下類比,掌櫃記賬的賬本是數據文件,記賬用的粉板是日誌文件,掌櫃的記憶就是內存。掌櫃要找時間把賬本更新一下,對應的就是把內存裏的數據寫入磁盤的過程,術語就是flush。在這個flush操作執行之前,孔乙己的賒賬總額,其實跟掌櫃手中賬本里面的記錄是不一致的。因爲孔乙己今天的賒賬金額還只在粉板上,而賬本里的記錄是老的,還沒把今天的賒賬算進去

2)、當內存數據頁跟磁盤數據頁不一致的時候,我們成這個內存頁爲髒頁。內存數據寫入到磁盤後,內存和磁盤行的數據頁的內容就一致了,稱爲乾淨頁。不論是髒頁還是乾淨頁都在內存中。在這個例子裏,內存對應的就是掌櫃的記憶

3)、假設原來孔乙己賒賬欠賬10文,這次又要賒9文

在這裏插入圖片描述

MySQL偶爾抖一下的那個瞬間可能就是在刷髒頁,什麼情況會把粉板上的賒賬記錄改到賬本上?

  • 第一種場景是,InnoDB的redo log寫滿了,這時候系統會停止所有更新操作,把checkpoint往前推進,redo log留出空間可以繼續寫

在這裏插入圖片描述

checkpoint位置從CP推進到CP’,就需要將兩個點之間的日誌對應的所有髒頁都flush到磁盤上。之後,圖中從write pos到CP’之間就是可以再寫入的redo log的區域

  • 第二種場景是,系統內存不足。當需要新的內存頁,而內存不夠用的時候,就要淘汰一些數據頁,空出內存給別的數據頁使用。如果淘汰的是髒頁,就要先將髒頁寫到磁盤

這時候不能直接把內存淘汰掉,下次需要請求的時候,從磁盤讀入數據頁,然後拿redo log出來應用不就行了?

這裏是從性能考慮的。如果刷髒頁一定會寫盤,就保證了每個數據頁有兩種狀態:一種是內存裏存在,內存裏就肯定是正確的結果,直接返回;另一種是內存裏沒有數據,就可以肯定數據文件上是正確的結果,讀入內存後返回。這樣的效率最高

  • 第三種場景是,MySQL認爲系統空閒的時候刷髒頁,當然在系統忙的時候也要找時間刷一點髒頁
  • 第四種場景是,MySQL正常關閉的時候會把內存的髒頁都flush到磁盤上,這樣下次MySQL啓動的時候,就可以直接從磁盤上讀數據,啓動速度會很快

4)、redo log寫滿了,要flush髒頁,出現這種情況的時候,整個系統就不能再接受更新了,所有的更新都必須堵住

5)、內存不夠用了,要先將髒頁寫到磁盤,這種情況是常態。InnoDB用緩衝池管理內存,緩衝池中的內存頁有三種狀態:

  • 第一種是還沒有使用的
  • 第二種是使用了並且是乾淨頁
  • 第三種是使用了並且是髒頁

InnoDB的策略是儘量使用內存,因此對於一個長時間運行的庫來說,未被使用的頁面很少

當要讀入的數據頁沒有在內存的時候,就必須到緩衝池中申請一個數據頁。這時候只能把最久不使用的數據頁從內存中淘汰掉:如果要淘汰的是一個乾淨頁,就直接釋放出來複用;但如果是髒頁,即必須將髒頁先刷到磁盤,變成乾淨頁後才能複用

刷頁雖然是常態,但是出現以下兩種情況,都是會明顯影響性能的:

1)一個查詢要淘汰的髒頁個數太多,會導致查詢的響應時間明顯變長

2)日誌寫滿,更新全部堵住,寫性能跌爲0,這種情況對敏感業務來說,是不能接受的

6)、InnoDB刷髒頁的控制策略

首先,要正確地告訴InnoDB所在主機的IO能力,這樣InnoDB才能知道需要全力刷髒頁的時候,可以刷多快。參數爲innodb_io_capacity,建議設置成磁盤的IOPS

InnoDB的刷盤速度就是考慮髒頁比例和redo log寫盤速度。參數innodb_max_dirty_pages_pct是髒頁比例上限,默認值是75%。髒頁比例是通過Innodb_buffer_pool_pages_dirty/Innodb_buffer_pool_pages_total得到的,SQL語句如下:

mysql>  select VARIABLE_VALUE into @a from performance_schema.global_status where VARIABLE_NAME = 'Innodb_buffer_pool_pages_dirty';
select VARIABLE_VALUE into @b from performance_schema.global_status where VARIABLE_NAME = 'Innodb_buffer_pool_pages_total';
select @a/@b;

十三、爲什麼表數據刪掉一半,表文件大小不變?

1)、一個InnoDB表包含兩個部分,即:表結構定義和數據。在MySQL8.0版本以前,表結構是存在以.frm爲後綴的文件裏。而MySQL8.0版本,則已經允許把表結構定義放在系統數據表中

2)、參數innodb_file_per_table

表數據既可以存在共享表空間裏,也可以是單獨的文件。這個行爲是由參數innodb_file_per_table控制的:

1)這個參數設置爲OFF表示的是,表的數據放在系統共享表空間,也就是跟數據字典放在一起

2)這個參數設置爲ON表示的是,每個InnoDB表數據存儲在一個以.ibd爲後綴的文件中

MySQL5.6.6版本開始,它的默認值就是ON了。一個表單獨存儲爲一個文件更容易管理,而且在不需要這個表的時候,通過drop table命令,系統就會直接刪除這個文件。而如果是放在共享表空間中,即使表刪掉了,空間也是不會回收的,所以推薦將innodb_file_per_table設置爲ON

3)、數據刪除流程

InnoDB裏的數據都是用B+樹的結構組織的

在這裏插入圖片描述

假設要刪除R4這個記錄,InnoDB引擎只會把R4這個記錄標記爲刪除。如果之後要再插入一個ID在300和600之間的記錄時,可能會複用這個位置。但是,磁盤文件的大小並不會縮小。如果刪除一個數據頁上的所有記錄,整個數據頁就可以被複用了

但是,數據頁的複用跟記錄的複用是不同的

記錄的複用只限於符合範圍條件的記錄。比如上面的這個例子,R4這條記錄被刪除後,如果插入一個ID是400的行,可以直接複用這個空間。但如果插入的是一個ID是800的行,就不能複用這個位置了

當整個頁從B+樹裏面摘掉以後,可以複用到任何位置。以上圖爲例,如果將數據頁pageA上的所有記錄刪除以後,pageA會被標記爲可複用。這時候如果要插入一條ID=50的記錄需要使用新頁的時候,pageA是可以被複用的

如果相鄰的兩個數據頁利用率都很小,系統就會把這兩個也上的數據合到其中一個頁上,另外一個數據頁就被標記爲可複用

如果用delete命令把整個表的數據刪除,所有的數據頁都會被標記爲可複用,但是磁盤上文件不會變小。通過delete命令是不能回收表空間的,這些可以複用而沒有被使用的空間,看起來就像是空洞

不止刪除數據會造成空洞,插入、更新數據也會

如果數據是按照索引遞增順序插入的,那麼索引是緊湊的。但如果數據是隨機插入的,就可能造成索引的數據頁分裂

在這裏插入圖片描述

上圖中插入前,pageA已經滿了,再插入一個ID是550的數據時,就不得不再申請一個新的頁面pageB來保存數據了。頁分裂完成後,pageA的末尾就留下了空洞

更新索引上的值可以理解爲刪除一箇舊的值再插入一個新值也是會造成空洞的

4)、重建表

經過大量增刪改的表都可能是存在空洞的,所以如果能夠把這些空洞去掉,就能達到收縮表空間的目的。重建表就可以達到這樣的目的

可以使用alter table A engine=InnoDB命令來重建表。在MySQL5.5版本之前,這個命令的執行流程是新建一個臨時表,按照主鍵ID遞增的順序把數據一行一行地從表A裏讀出來再插入到臨時表中,數據導入臨時表操作完成後,用臨時表替換表A,然後刪除舊的表A

花時間最多的步驟是往臨時表插入數據的過程,如果在這個過程中,有新的數據要寫入到表A的話,就會造成數據丟失。因此,在整個DDL過程中,表A中不能有更新。也就是說,這個DDL不是Online的

MySQL5.6版本開始引入的Online DDL,對這個操作流程做了優化,Online DDL重建表的流程:

1)建立一個臨時文件,掃描表A主鍵的所有數據頁

2)用數據頁中表A的記錄生成B+樹,存儲到臨時文件中

3)生成臨時文件的過程中,將所有對A的操作記錄在一個日誌文件中

4)臨時文件生成後,將日誌文件中的操作應用到臨時文件,得到一個邏輯數據上與A相同的數據文件

5)用臨時文件替換表A的數據文件

在這個重建表的過程中,允許對錶A做增刪改操作,這就是Online DDL。alter語句在啓動的時候需要獲取MDL寫鎖,但是這個寫鎖在真正拷貝數據之前就退化成讀鎖了,退化是爲了實現Online,MDL讀鎖不會阻塞增刪改操作,這個讀鎖是爲了保護自己禁止其他線程對這個表同時做DDL

5)、Online和inplace

重建表需要創建臨時表,這個是在server層創建的。而根據表A重建出來的數據放在臨時文件裏,臨時文件是InnoDB在內部創建出來的。整個DDL過程都是在InnoDB內部完成。對於server層來說,沒有把數據挪動到臨時表,是一個原地操作,這就是inplace名稱的來源

如果有一個1TB的表,現在磁盤空間是1.2TB,不能做一個inplace的DDL,原因是臨時文件也是要佔用臨時空間的

重建表語句隱含的意思是:

alter table t engine=innodb,ALGORITHM=inplace;

跟inplace對應的就是拷貝表的方式了,用法是:

alter table t engine=innodb,ALGORITHM=copy;

1)DDL過程如果是Online的,就一定是inplace的

2)inplace的DDL有可能不是Online的。截止到MySQL8.0,添加全文索引和空間索引就屬於這種情況

6)、optimize table、analyze table和alter table這三種方式重建表的區別:

  • alter table t engine=InnoDB就是重建表t的過程(recreate)

  • analyze table t其實不是重建表,只是對錶的索引信息做重新統計,沒有修改數據,這個過程中加了MDL讀鎖

  • optimize table t等於recreate+analyze

十四、count(*)這麼慢,我該怎麼辦?

1)、count(*)的實現方式

在不同的MySQL引擎中,count(*)有不同的實現方式:

  • MyISAM引擎把一個表的總行數存在了磁盤上,因此執行count(*)的時候會直接返回這個數,效率很高
  • InnoDB引擎執行count(*)的時候,需要把數據一行一行地從引擎裏面讀出來,然後累計計數

如果加了where條件的話,MyISAM表也是不能返回得這麼快的

由於多版本併發控制的原因,InnoDB表應該返回多少行也是不確定的

2)、假設表t中現在有10000條記錄,設計了三個用戶並行的會話

  • 會話A先啓動事務並查詢一次表的總行數
  • 會話B啓動事務,插入一行記錄後,查詢表的總行數
  • 會話C先啓動一個單獨的語句,插入一行記錄後,查詢表的總行數

在這裏插入圖片描述

可重複讀是InnoDB默認的隔離級別,在代碼上通過多版本併發控制,也就是MVCC來實現的。每一行記錄都要判斷自己是否對這個會話可見,因此對於count(*)請求來說,InnoDB只好把數據一行一行地讀出依次判斷,可見的行才能夠用於計算基於這個查詢的表的總行數

3)、InnoDB是索引組織表,主鍵索引樹的葉子節點是數據,而普通索引樹的葉子節點是主鍵值。所以,普通索引樹比主鍵索引樹小很多。對於count(*)這樣的操作,遍歷哪個索引樹得到的結果邏輯上都是一樣的。因此,MySQL優化器會找到最小的那棵樹來遍歷。在保證邏輯正確的前提下, 儘量減少掃描的數據量,是數據庫系統設計的通用法則之一

4)、小結:

  • MyISAM表雖然count(*)很快,但是不支持事務
  • show table status命令雖然返回很快, 但是不準確
  • InnoDB表直接count(*)會遍歷全表,雖然結果準確,但會導致性能問題

5)、用緩存系統保存計數

將計數保存在緩存系統中的方式,即使Redis正常工作,這個值還是邏輯上不精確的

在這裏插入圖片描述

會話A是一個插入交易記錄的邏輯,往數據表裏插入一行R,然後Redis計數加1;會話B就是查詢頁面顯示時需要的數據

在這個時序裏,在T3時刻會話B來查詢的時候,會顯示出先插入的R這個記錄,但是Redis的計數還沒加1。這時候,數據不一致

如果先Redis計數加1,再往數據表裏插入一行R,結果是一樣的

在這裏插入圖片描述

6)、在數據庫保存計數

把這個計數直接放到數據庫裏單獨的一張技術表C中,解決了崩潰丟失的問題,InnoDB是支持崩潰恢復不丟數據的

在這裏插入圖片描述

雖然會話B的讀操作仍然是在T3執行的,但是因爲這時候更新事務還沒有提交,所以計數值加1這個操作對會話B還不可見

上圖的操作應該先插入新記錄再更新計數值,因爲更新計數表涉及到行鎖的競爭,先插入再更新能最大程度地減少了事務之間的鎖等待,提升了併發度

7)、不同count的用法

count()是一個聚合函數,對於返回的結果集,一行行地判斷,如果count函數的參數不是NULL,累計值就加1,否則不加。最後返回累計值

1)對於count(主鍵id)來說,InnoDB引擎會遍歷整張表,把每一行的id值都取出來,返回給server層。server層拿到id後,判斷是不可能爲空的,就按行累加

2)對於count(1)來說,InnoDB引擎遍歷整張表,但不取值。server層對於返回的每一行,放一個數字1進入,判斷是不可能爲空的,按行累加

3)對於count(字段)來說,如果這個字段是定義爲not null的話,一行行地從記錄裏面讀出這個字段,判斷不能爲null,按行累加;如果這個字段定義允許爲null的話,那麼執行的時候,判斷到有可能是null,還要把值取出來在判斷一下,不是null才累加

4)對於count(*)來說,並不會把全部字段取出來,而是專門做了優化。不取值,count(*)肯定不是null,按行累加

按照效率排序count()<count(id)<count(1)count()count(字段)<count(主鍵id)<count(1)≈count(*),所以儘量使用count(*)

十五、日誌和索引相關問題

1)、日誌相關問題

在這裏插入圖片描述

在兩階段提交的不同時刻,MySQL異常重啓會出現什麼現象

如果在圖中時刻A的地方,也就是寫入redo log處於prepare階段之後、寫binlog之前,發生了崩潰,由於此時binlog還沒寫,redo log也還沒提交,所以崩潰恢復的時候,這個事務會回滾。這時候,binlog還沒寫,所以也不會傳到備庫

如果在圖中時刻B的地方,也就是binlog寫完,redo log還沒commit前發生崩潰,那崩潰恢復的時候MySQL怎麼處理?

崩潰恢復時的判斷規則:

1)如果redo log裏面的事務是完整的,也就是已經有了commit標識,則直接提交

2)如果redo log裏面的事務只有完整的prepare,則判斷對應的事務binlog是否存在並完整

a.如果完整,則提交事務

b.否則,回滾事務

時刻B發生崩潰對應的就是2(a)的情況,崩潰恢復過程中事務會被提交

問題一:MySQL怎麼知道binlog是完整的?

一個事務的binlog是有完整格式的:

  • statement格式的binlog,最後會有COMMIT
  • row格式的binlog,最後會有一個XID event

問題二:redo log和binlog是怎麼關聯起來的?

它們有一個共同的數據字段,叫XID。崩潰恢復的時候,會按順序掃描redo log:

  • 如果碰到既有prepare、又有commit的redo log,就直接提交
  • 如果碰到只有prepare、而沒有commit的redo log,就拿着XID去binlog找對應的事務

問題三:redo log一般設置多大?

如果是現在常見的幾個TB的磁盤的話,redo log設置爲4個文件、每個文件1GB

問題四:正常運行中的實例,數據寫入後的最終落盤,是從redo log更新過來的還是從buffer pool更新過來的呢?

redo log並沒有記錄數據頁的完整數據,所以它並沒有能力自己去更新磁盤數據頁,也就不存在數據最終落盤是由redo log更新過去的情況

1)如果是正常運行的實例的話,數據頁被修改以後,跟磁盤的數據頁不一致,稱爲髒頁。最終數據落盤,就是把內存中的數據頁寫盤。這個過程,甚至與redo log毫無關係

2)在崩潰恢復場景中,InnoDB如果判斷到一個數據頁可能在崩潰恢復的時候丟失了更新,就會將它對到內存,然後讓redo log更新內存內容。更新完成後,內存頁變成髒頁,就回到了第一種情況的狀態

問題四:redo log buffer是什麼?是先修改內存,還是先寫redo log文件?

在一個事務的更新過程中,日誌是要寫多次的。比如下面這個事務:

begin;
insert into t1 ...
insert into t2 ...
commit;

這個事務要往兩個表中插入記錄,插入數據的過程中,生成的日誌都得先保存起來,但又不能在還沒commit的時候就直接寫到redo log文件裏

所有,redo log buffer就是一塊內存,用來先存redo日誌的。也就是說,在執行第一個insert的時候,數據的內存被修改了,redo log buffer也寫入了日誌。但是,真正把日誌寫到redo log文件,是在執行commit語句的時候做的

十六、order by是怎麼工作的?

1)、在市民表中,要查詢城市是杭州的所有人名字,並且按照姓名排序返回前1000個人的姓名、年齡

表定義如下:

CREATE TABLE `t2` (
  `id` int(11) NOT NULL,
  `city` varchar(16) NOT NULL,
  `name` varchar(16) NOT NULL,
  `age` int(11) NOT NULL,
  `addr` varchar(128) DEFAULT NULL,
  PRIMARY KEY (`id`),
  KEY `city` (`city`)
) ENGINE=InnoDB;

SQL語句如下:

select city,name,age from t2 where city='杭州' order by name limit 1000;

2)、全字段排序

爲了避免全表掃描,我們需要在city字段加上索引,使用explain命令查看這個語句的執行情況

在這裏插入圖片描述

Extra這個字段中的Using filesort表示的就是需要排序,MySQL會給每個線程分配一塊內存用於排序,稱爲sort_buffer

在這裏插入圖片描述

從上圖中可以看到,滿足city='杭州’條件的行,是從ID_X到ID_(X+N)的這些記錄:

這個語句執行流程如下:

1)初始化sort_buffer,確定放入name、city、age這三個字段

2)從索引city找到第一個滿足city='杭州’條件的主鍵id,也就是圖中的ID_X

3)到主鍵id索引取出整行,取name、city、age三個字段的值,存入sort_buffer

4)從索引city取下一個記錄的主鍵id

5)重複步驟3、4直到city的值不滿足查詢條件爲止,對應的主鍵id也就是圖中的ID_Y

6)對sort_buffer中的數據按照字段name做快速排序

7)按照排序結果取前1000行返回給客戶端

在這裏插入圖片描述

按name排序這個動作,可能在內存中完成,也可能需要使用外部排序,這取決於排序所需的內存和參數sort_buffer_size。sort_buffer_size就是MySQL爲排序開闢的內存的大小。如果要排序的數據量小於sort_buffer_size,排序就在內存中完成。但如果排序數據量太大,內存放不下,則不得不利用磁盤臨時文件輔助排序

使用以下方法確定一個排序語句是否使用了臨時文件:

/* 打開 optimizer_trace,只對本線程有效 */
SET optimizer_trace='enabled=on'; 

/* @a 保存 Innodb_rows_read 的初始值 */
select VARIABLE_VALUE into @a from  performance_schema.session_status where variable_name = 'Innodb_rows_read';

/* 執行語句 */
select city, name,age from t2 where city='杭州' order by name limit 1000; 

/* 查看 OPTIMIZER_TRACE 輸出 */
SELECT * FROM `information_schema`.`OPTIMIZER_TRACE`;

/* @b 保存 Innodb_rows_read 的當前值 */
select VARIABLE_VALUE into @b from performance_schema.session_status where variable_name = 'Innodb_rows_read';

/* 計算 Innodb_rows_read 差值 */
select @b-@a;

在這裏插入圖片描述

number_of_tmp_files表示的是,排序過程中使用的臨時文件數。外部排序一般使用歸併排序算法。MySQL將需要排序的數據分成12份,每一份單獨排序後存在這些臨時文件中。然後把這12個有序文件再合併成一個有序的大文件

如果sort_buffer_size超過了需要排序的數據量的大小,number_of_tmp_files就是0,表示排序可以直接在內存中完成,否則就需要放在臨時文件中排序。

3)、rowid排序

全字段排序算法有個問題,如果查詢要返回的字段很多的話,那麼sort_buffer裏面放的字段數太多,這樣你村裏能夠同時放下的行數很少,要分成很多個臨時文件,排序的性能會很差

SET max_length_for_sort_data = 16;

max_length_for_sort_data是MySQL中專門控制用於排序的行數據的長度的一個參數。它的意思是,如果單行的長度超過這個值,MySQL就認爲單行太大,要換一個算法

新的算法放入sort_buffer的字段,只有要排序的列和主鍵id

整個執行流程如下:

1)初始化sort_buffer,確定放入兩個字段,即name和id

2)從索引city找到第一個滿足city='杭州’條件的主鍵id,也就是ID_X

3)到主鍵id索引取出整行,取name、id這兩個字段,存入sort_buffer

4)從索引city去下一個記錄的主鍵id

5)重複3、4直到不滿足city='杭州’條件爲止,也就是ID_Y

6)對sort_buffer中的數據按照字段name進行排序

7)遍歷排序結果,取前1000行,並按照id的值回到原表中取出city、name和age三個字段返回個客戶端

在這裏插入圖片描述
在這裏插入圖片描述

這時sort_mode變成了<sort_key,rowid>,表示參與排序的只有name和id這兩個字段,number_of_tmp_files變成了10,參與排序的函數雖然仍然是4000行,但是每一行都變小了,因此需要排序的總數據量就變小了,需要的臨時文件也相應地變少了

4)、全字段排序 vs rowid排序

如果MySQL擔心排序內存太小,會影響排序效率,纔會採用rowid排序算法,這樣排序過程中一次可以排序更多行,但是需要再回到原表去取數據

如果MySQL認爲內存足夠大,會優先選擇全字段排序,把需要的字段都放在sort_buffer中,這樣排序後就會直接從內存裏面返回查詢結果了,不用再回到原表去取數據

MySQL的設計思想:如果內存夠,就要多利用內存,儘量減少磁盤訪問

對於InnoDB表來說,rowid排序會要求回表多造成磁盤讀,因此不會被優先選擇

5)、使用聯合索引

在這個市民表上創建一個city和name的聯合索引,對應的SQL語句如下:

alter table t add index city_user(city, name);

在這裏插入圖片描述

在這個索引裏面,依然可以用樹搜索的方式定位到第一個滿足city='杭州’的記錄,並且額外確保了,接下來按順序取下一條記錄的遍歷過程中,只要city的值是杭州,name的值就一定是有序的

整個查詢過程的流程就變成了:

1)從索引(city,name)找到第一個滿足city='杭州’條件的主鍵id

2)到主鍵id索引取出整行,取name、city、age三個字段的值,作爲結果集的一部分直接返回

3)從索引(city,name)取下一個記錄主鍵id

4)重複2、3,直到查到第1000條記錄,或者是不滿足city='杭州’條件時循環結束

在這裏插入圖片描述

這個查詢過程不需要臨時表,也不需要排序

6)、使用覆蓋索引

覆蓋索引是指索引上的信息足夠滿足查詢請求,不需要再回到主鍵索引上去取數據

創建一個city、name和age的聯合索引,對應的SQL語句如下:

alter table t add index city_user_age(city, name, age);

1)從索引(city, name, age)找到第一個滿足city='杭州’的記錄,取出其中的city、name和age這三個字段的值,作爲結果集的一部分直接返回

2)從索引(city, name, age)取下一個記錄,同樣取出這三個字段的值,作爲結果集的一部分直接返回

3)重複執行步驟2,直到查到第1000條記錄,或者是不滿足city='杭州’條件時循環結束

在這裏插入圖片描述

7)、如果表裏面已經有了city_name(city,name)這個聯合索引,然後要查詢杭州和蘇州兩個城市中所有的市民的姓名,並且按名字排序,顯示前100條記錄,SQL語句如下:

mysql> select * from t where city in ('杭州','蘇州') order by name limit 100;

這個過程中雖然有city_name(city,name)這個聯合索引,但對於單個city內部,name是遞增的。但是由於這條SQL語句是要同時查詢杭州和蘇州這兩個城市,一次你所有滿足條件的name就不是遞增的了。這條SQL語句需要排序

可以先執行select * from t where city=‘杭州’ order by name limit 100;,這個語句是不需要排序的,客戶端用一個長度爲100的內存數組A保存結果,再執行select * from t where city=‘蘇州’ order by name limit 100;,用同樣的方法保存到內存數組B,現在A和B是兩個有序數組,採用歸併排序的思想,得到name最小的前100值,就是最終需要的結果了

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章