MySQL實戰45講
第八節: 事務到底是隔離的還是不隔離的?
示例:
CREATE TABLE `t` ( `id` int(11) NOT NULL, `k` int(11) DEFAULT NULL, PRIMARY KEY (`id`) ) ENGINE=InnoDB; insert into t(id, k) values(1,1),(2,2);
begin/start transaction 命令並不是一個事務的起點,在執行到它們之后的第一個操作InnoDB表 的語句,事務才真正啟動。如果你想要馬上啟動一個事務,可以使用start transaction with consistent snapshot 這個命令。
整個專欄里面,都是默認 autocommit=1。
事務C沒有顯式地使用begin/commit,表示這個update語句本身就是一個事務, 語句完成的時候會自動提交。事務B在更新了行之后查詢; 事務A在一個只讀事務中查詢,並且時間順序上是在事務B的查詢之后。
事務B查到的k的值是3,而事務A查到的k的值是1,
-
在MySQL里,有兩個“視圖”的概念:
- 一個是view。它是一個用查詢語句定義的虛擬表,在調用的時候執行查詢語句並生成結果。 創建視圖的語法是create view…,而它的查詢方法與表一樣。
- 另一個是InnoDB在實現MVCC時用到的一致性讀視圖,即consistent read view,用於支持 RC(Read Committed,讀提交)和RR(Repeatable Read,可重復讀)隔離級別的實現
它沒有物理結構,作用是事務執行期間用來定義“我能看到什么數據”。
-
“快照”在MVCC里是怎么工作的?
在可重復讀隔離級別下,事務在啟動的時候就“拍了個快照”。注意,這個快照是基於整庫的。
-
這個快照是怎么實現的
InnoDB里面每個事務有一個唯一的事務ID,叫作transaction id。它是在事務開始的時候向 InnoDB的事務系統申請的,是按申請順序嚴格遞增的。
而每行數據也都是有多個版本的。每次事務更新數據的時候,都會生成一個新的數據版本,並且 把transaction id賦值給這個數據版本的事務ID,記為rowtrx_id。同時,舊的數據版本要保留, 並且在新的數據版本中,能夠有信息可以直接拿到它。
也就是說,數據表中的一行記錄,其實可能有多個版本(row),每個版本有自己的rowtrx_id。
如圖2所示,就是一個記錄被多個事務連續更新后的狀態。
圖中虛線框里是同一行數據的4個版本,當前最新版本是V4,k的值是22,它是被transaction id 為25的事務更新的,因此它的rowtrx_id也是25。
-
語句更新會生成undo log(回滾日志),那么,undo log 在哪呢?
圖2中的三個虛線箭頭,就是undo log;而V1、V2、V3並不是物理上真實存在的,而 是每次需要的時候根據當前版本和undo log計算出來的。比如,需要V2的時候,就是通過V4依 次執行U3、U2算出來。
-
InnoDB是怎么定義快照 的?
按照可重復讀的定義,一個事務啟動的時候,能夠看到所有已經提交的事務結果。但是之后,這 個事務執行期間,其他事務的更新對它不可見。
因此,一個事務只需要在啟動的時候聲明說,“以我啟動的時刻為准,如果一個數據版本是在我 啟動之前生成的,就認;如果是我啟動以后才生成的,我就不認,我必須要找到它的上一個版本”。
當然,如果“上一個版本”也不可見,那就得繼續往前找。還有,如果是這個事務自己更新的數 據,它自己還是要認的。
在實現上, InnoDB為每個事務構造了一個數組,用來保存這個事務啟動瞬間,當前正在“活 躍”的所有事務ID。“活躍”指的就是,啟動了但還沒提交。
數組里面事務ID的最小值記為低水位,當前系統里面已經創建過的事務ID的最大值加1記為高水 位。
這個視圖數組和高水位,就組成了當前事務的一致性視圖(read-view)。
而數據版本的可見性規則,就是基於數據的rowtrx_id和這個一致性視圖的對比結果得到的。 這個視圖數組把所有的rowtrx_id 分成了幾種不同的情況。
這樣,對於當前事務的啟動瞬間來說,一個數據版本的rowtrx_id,有以下幾種可能:
- 如果落在綠色部分,表示這個版本是已提交的事務或者是當前事務自己生成的,這個數據是 可見的;
- 如果落在紅色部分,表示這個版本是由將來啟動的事務生成的,是肯定不可見的;
- 如果落在黃色部分,那就包括兩種情況 a. 若 rowtrx_id在數組中,表示這個版本是由還沒提交的事務生成的,不可見; b. 若 rowtrx_id不在數組中,表示這個版本是已經提交了的事務生成的,可見。
比如,對於圖2中的數據來說,如果有一個事務,它的低水位是18,那么當它訪問這一行數據 時,就會從V4通過U3計算出V3,所以在它看來,這一行的值是11。
有了這個聲明后,系統里面隨后發生的更新,是不是就跟這個事務看到的內容無關了呢? 因為之后的更新,生成的版本一定屬於上面的2或者3(a)的情況,而對它來說,這些新的數據版 本是不存在的,所以這個事務的快照,就是“靜態”的了。
InnoDB利用了“所有數據都有多個版本”的這個特性,實現了“秒級創建 快照”的能力。
-
-
我們繼續看一下圖1中的三個事務,分析下事務A的語句返回的結果,為什么是k=1。
這里,我們不妨做如下假設:
- 事務A開始前,系統里面只有一個活躍事務ID是99;
- 事務A、B、C的版本號分別是100、101、102,且當前系統里只有這四個事務;
- 三個事務開始前,(1,1)這一行數據的rowtrx_id是90。
這樣,事務A的視圖數組就是[99,100], 事務B的視圖數組是[99,100,101], 事務C的視圖數組是 [99,100,101,102]。
為了簡化分析,我先把其他干擾語句去掉,只畫出跟事務A查詢邏輯有關的操作:
從圖中可以看到,第一個有效更新是事務C,把數據從(1,1)改成了(1,2)。這時候,這個數據的最 新版本的rowtrx_id是102,而90這個版本已經成為了歷史版本。
第二個有效更新是事務B,把數據從(1,2)改成了(1,3)。這時候,這個數據的最新版本(即row trx_id)是101,而102又成為了歷史版本。
在事務A查詢的時候,其實事務B還沒有提交,但是它生成的(1,3)這個版本已 經變成當前版本了。但這個版本對事務A必須是不可見的,否則就變成臟讀了。
好,現在事務A要來讀數據了,它的視圖數組是[99,100]。當然了,讀數據都是從當前版本讀起 的。所以,事務A查詢語句的讀數據流程是這樣的:
- 找到(1,3)的時候,判斷出rowtrx_id=101,比高水位大,處於紅色區域,不可見;
- 接着,找到上一個歷史版本,一看rowtrx_id=102,比高水位大,處於紅色區域,不可見;
- 再往前找,終於找到了(1,1),它的rowtrx_id=90,比低水位小,處於綠色區域,可見。
這樣執行下來,雖然期間這一行數據被修改過,但是事務A不論在什么時候查詢,看到這行數據 的結果都是一致的,所以我們稱之為一致性讀。
一個數據版本,對於一個事務視圖來說,除了自己的更新總是可見以外,有三種情況:
- 版本未提交,不可見;
- 版本已提交,但是是在視圖創建后提交的,不可見;
- 版本已提交,而且是在視圖創建前提交的,可見。
現在,我們用這個規則來判斷圖4中的查詢結果,事務A的查詢語句的視圖數組是在事務A啟動的 時候生成的,這時候:
- (1,3)還沒提交,屬於情況1,不可見;
- (1,2)雖然提交了,但是是在視圖數組創建之后提交的,屬於情況2,不可見;
- (1,1)是在視圖數組創建之前提交的,可見。
-
更新邏輯
事務B的update語句,如果按照一致性讀,好像結果不對哦?
圖5中,事務B的視圖數組是先生成的,之后事務C才提交,不是應該看不見(1,2)嗎,怎么能 算出(1,3)來?
是的,如果事務B在更新之前查詢一次數據,這個查詢返回的k的值確實是1。
但是,當它要去更新數據的時候,就不能再在歷史版本上更新了,否則事務C的更新就丟失了。 因此,事務B此時的set k=k+1是在(1,2)的基礎上進行的操作。
所以,這里就用到了這樣一條規則:更新數據都是先讀后寫的,而這個讀,只能讀當前的 值,稱為“當前讀”(current read)。
因此,在更新的時候,當前讀拿到的數據是(1,2),更新后生成了新版本的數據(1,3),這個新版本 的rowtrx_id是101。
所以,在執行事務B查詢語句的時候,一看自己的版本號是101,最新數據的版本號也是101,是 自己的更新,可以直接使用,所以查詢得到的k的值是3。
這里我們提到了一個概念,叫作當前讀。其實,除了update語句外,select語句如果加鎖,也是 當前讀。
所以,如果把事務A的查詢語句select *fromt where id=1修改一下,加上lock in share mode 或 for update,也都可以讀到版本號是101的數據,返回的k的值是3。下面這兩個select語句,就是 分別加了讀鎖(S鎖,共享鎖)和寫鎖(X鎖,排他鎖)。
select k from t where id=1 lock in share mode; select k from t where id=1 for update;
假設事務C不是馬上提交的,而是變成了下面的事務C’,會怎么樣呢?
事務C’的不同是,更新后並沒有馬上提交,在它提交前,事務B的更新語句先發起了。前面說過 了,雖然事務C’還沒提交,但是(1,2)這個版本也已經生成了,並且是當前的最新版本。那么,事 務B的更新語句會怎么處理呢?
這時候,我們在上一篇文章中提到的“兩階段鎖協議”就要上場了。事務C’沒提交,也就是說(1,2) 這個版本上的寫鎖還沒釋放。而事務B是當前讀,必須要讀最新版本,而且必須加鎖,因此就被 鎖住了,必須等到事務C’釋放這個鎖,才能繼續它的當前讀。
到這里,我們把一致性讀、當前讀和行鎖就串起來了。
-
事務的可重復讀的能力是怎么實現的?
可重復讀的核心就是一致性讀(consistent read);而事務更新數據的時候,只能用當前讀。如 果當前的記錄的行鎖被其他事務占用的話,就需要進入鎖等待。
而讀提交的邏輯和可重復讀的邏輯類似,它們最主要的區別是:
- 在可重復讀隔離級別下,只需要在事務開始的時候創建一致性視圖,之后事務里的其他查詢 都共用這個一致性視圖;
- 在讀提交隔離級別下,每一個語句執行前都會重新算出一個新的視圖。
那么,我們再看一下,在讀提交隔離級別下,事務A和事務B的查詢語句查到的k,分別應該是多 少呢?
“start transaction with consistent snapshot; ”的意思是從這個語句開始,創 建一個持續整個事務的一致性快照。所以,在讀提交隔離級別下,這個用法就沒意義了,等效於 普通的start transaction
下面是讀提交時的狀態圖,可以看到這兩個查詢語句的創建視圖數組的時機發生了變化,就是圖 中的read view框。(注意:這里,我們用的還是事務C的邏輯直接提交,而不是事務C’)
!
這時,事務A的查詢語句的視圖數組是在執行這個語句的時候創建的,時序上(1,2)、(1,3)的生成 時間都在創建這個視圖數組的時刻之前。但是,在這個時刻:
- (1,3)還沒提交,屬於情況1,不可見;
- (1,2)提交了,屬於情況3,可見。
所以,這時候事務A查詢語句返回的是k=2。 顯然地,事務B查詢結果k=3。
-
小結
InnoDB的行數據有多個版本,每個數據版本有自己的rowtrx_id,每個事務或者語句有自己的一 致性視圖。普通查詢語句是一致性讀,一致性讀會根據rowtrx_id和一致性視圖確定數據版本的 可見性。
- 對於可重復讀,查詢只承認在事務啟動前就已經提交完成的數據;
- 對於讀提交,查詢只承認在語句啟動前就已經提交完成的數據;
而當前讀,總是讀取已經提交完成的最新版本。
為什么表結構不支持“可重復讀”?這是因為表結構沒有對應的行數據,也沒有 rowtrx_id,因此只能遵循當前讀的邏輯。
MySQL 8.0已經可以把表結構放在InnoDB字典里了,也許以后會支持表結構的可重復 讀。
第九節: 普通索引和唯一索引,應該怎么選擇?
從性能的角度考慮,你選擇唯一索引還是普通索引呢?選擇的依據是什么 呢?
還是用第四節: 深入淺出索引(上)中的例子來說明,假設字段 k 上的值 都不重復。
接下來,我們就從這兩種索引對查詢語句和更新語句的性能影響來進行分析。
-
查詢過程
假設,執行查詢的語句是 select id fromTwhere k=5。這個查詢語句在索引樹上查找的過程,先 是通過B+樹從樹根開始,按層搜索到葉子節點,也就是圖中右下角的這個數據頁,然后可以認 為數據頁內部通過二分法來定位記錄。
- 對於普通索引來說,查找到滿足條件的第一個記錄(5,500)后,需要查找下一個記錄,直到碰 到第一個不滿足k=5條件的記錄。
- 對於唯一索引來說,由於索引定義了唯一性,查找到第一個滿足條件的記錄后,就會停止繼 續檢索。
這個不同帶來的性能差距是微乎其微的。
InnoDB的數據是按數據頁為單位來讀寫的。也就是說,當需要讀一條記錄的時候, 並不是將這個記錄本身從磁盤讀出來,而是以頁為單位,將其整體讀入內存。在InnoDB中,每 個數據頁的大小默認是16KB。
因為引擎是按頁讀寫的,所以說,當找到k=5的記錄的時候,它所在的數據頁就都在內存里了。 那么,對於普通索引來說,要多做的那一次“查找和判斷下一條記錄”的操作,就只需要一次指針 尋找和一次計算。
當然,如果k=5這個記錄剛好是這個數據頁的最后一個記錄,那么要取下一個記錄,必須讀取下 一個數據頁,這個操作會稍微復雜一些。 但是,我們之前計算過,對於整型字段,一個數據頁可以放近千個key,因此出現這種情況的概 率會很低。所以,我們計算平均性能差異時,仍可以認為這個操作成本對於現在的CPU來說可以 忽略不計。
-
更新過程
change buffer
當需要更新一個數據頁時,如果數據頁在內存中就直接更新,而如果這個數據頁還沒有在內存中 的話,在不影響數據一致性的前提下,InooDB會將這些更新操作緩存在change buffer中,這樣 就不需要從磁盤中讀入這個數據頁了。在下次查詢需要訪問這個數據頁的時候,將數據頁讀入內 存,然后執行change buffer中與這個頁有關的操作。通過這種方式就能保證這個數據邏輯的正 確性。
雖然名字叫作change buffer,實際上它是可以持久化的數據。也就是 說,change buffer在內存中有拷貝,也會被寫入到磁盤上。
將change buffer中的操作應用到原數據頁,得到最新結果的過程稱為merge。除了訪問這個數據 頁會觸發merge外,系統有后台線程會定期merge。在數據庫正常關閉(shutdown)的過程中, 也會執行merge操作。
顯然,如果能夠將更新操作先記錄在change buffer,減少讀磁盤,語句的執行速度會得到明顯 的提升。而且,數據讀入內存是需要占用buffer pool的,所以這種方式還能夠避免占用內存,提 高內存利用率。
-
什么條件下可以使用change buffer呢?
對於唯一索引來說,所有的更新操作都要先判斷這個操作是否違反唯一性約束。比如,要插入 (4,400)這個記錄,就要先判斷現在表中是否已經存在k=4的記錄,而這必須要將數據頁讀入內存 才能判斷。如果都已經讀入到內存了,那直接更新內存會更快,就沒必要使用change buffer 了。
因此,唯一索引的更新就不能使用change buffer,實際上也只有普通索引可以使用。
change buffer用的是buffer pool里的內存,因此不能無限增大。change buffer的大小,可以通 過參數innodb_change_buffer_max_size來動態設置。這個參數設置為50的時候,表示change buffer的大小最多只能占用buffer pool的50%。
如果要在這張表中插入一個 新記錄(4,400)的話,InnoDB的處理流程是怎樣的?
第一種情況是,這個記錄要更新的目標頁在內存中。這時,InnoDB的處理流程如下:
- 對於唯一索引來說,找到3和5之間的位置,判斷到沒有沖突,插入這個值,語句執行結束;
- 對於普通索引來說,找到3和5之間的位置,插入這個值,語句執行結束。
這樣看來,普通索引和唯一索引對更新語句性能影響的差別,只是一個判斷,只會耗費微小的 CPU時間。
第二種情況是,這個記錄要更新的目標頁不在內存中。這時,InnoDB的處理流程如下:
- 對於唯一索引來說,需要將數據頁讀入內存,判斷到沒有沖突,插入這個值,語句執行結 束;
- 對於普通索引來說,則是將更新記錄在change buffer,語句執行就結束了。
將數據從磁盤讀入內存涉及隨機IO的訪問,是數據庫里面成本最高的操作之一。change buffer 因為減少了隨機磁盤訪問,所以對更新性能的提升是會很明顯的。
-
-
change buffer的使用場景
用change buffer對更新過程的加速作用,change buffer只限於用在普通索引的場景下,而不適用於唯一索引。
普通 索引的所有場景,使用change buffer都可以起到加速作用嗎?
因為merge的時候是真正進行數據更新的時刻,而change buffer的主要目的就是將記錄的變更動 作緩存下來,所以在一個數據頁做merge之前,change buffer記錄的變更越多(也就是這個頁面 上要更新的次數越多),收益就越大。
因此,對於寫多讀少的業務來說,頁面在寫完以后馬上被訪問到的概率比較小,此時change buffer的使用效果最好。這種業務模型常見的就是賬單類、日志類的系統。
反過來,假設一個業務的更新模式是寫入之后馬上會做查詢,那么即使滿足了條件,將更新先記 錄在change buffer,但之后由於馬上要訪問這個數據頁,會立即觸發merge過程。這樣隨機訪問 IO的次數不會減少,反而增加了change buffer的維護代價。所以,對於這種業務模式來 說,change buffer反而起到了副作用。
-
索引選擇和實踐
普通索引和唯一索引應該怎么選擇。其實,這兩類索引在查詢能力上 是沒差別的,主要考慮的是對更新性能的影響。所以,我建議你盡量選擇普通索引。
如果所有的更新后面,都馬上伴隨着對這個記錄的查詢,那么你應該關閉change buffer。而在 其他情況下,change buffer都能提升更新性能。
特別地,在使用機械硬盤時,change buffer這個機制的收效是非常顯著的。所以,當你有一個 類似“歷史數據”的庫,並且出於成本考慮用的是機械硬盤時,那你應該特別關注這些表里的索 引,盡量使用普通索引,然后把change buffer 盡量開大,以確保這個“歷史數據”表的數據寫入 速度。
-
change buffer 和 redo log
redo log見第二節:日志系統:一條SQL更新語句是如何執行的?
現在,我們要在表上執行這個插入語句:
insert into t(id,k) values(id1,k1),(id2,k2);
們假設當前k索引樹的狀態,查找到位置后,k1所在的數據頁在內存(InnoDB buffer pool)中,k2所在的數據頁不在內存中。如圖2所示是帶change buffer的更新狀態圖。
分析這條更新語句,你會發現它涉及了四個部分:內存、redo log(ib_log_fileX)、 數據表空間 (t.ibd)、系統表空間(ibdata1)。
這條更新語句做了如下的操作(按照圖中的數字順序):
- Page 1在內存中,直接更新內存;
- Page 2沒有在內存中,就在內存的change buffer區域,記錄下“我要往Page 2插入一行”這個 信息;
- 將上述兩個動作記入redo log中(圖中3和4)。
做完上面這些,事務就可以完成了。所以,你會看到,執行這條更新語句的成本很低,就是寫了 兩處內存,然后寫了一處磁盤(兩次操作合在一起寫了一次磁盤),而且還是順序寫的。
同時,圖中的兩個虛線箭頭,是后台操作,不影響更新的響應時間。
那在這之后的讀請求,要怎么處理呢?
比如,我們現在要執行 select *fromt where k in (k1, k2)。這里,畫了這兩個讀請求的流程 圖。
如果讀語句發生在更新語句后不久,內存中的數據都還在,那么此時的這兩個讀操作就與系統表 空間(ibdata1)和 redo log(ib_log_fileX)無關了。所以,在圖中就沒畫出這兩部分。
從圖中可以看到:
- 讀Page 1的時候,直接從內存返回。有幾位同學在前面文章的評論中問到,WAL之后如果 讀數據,是不是一定要讀盤,是不是一定要從redo log里面把數據更新以后才可以返回?其 實是不用的。你可以看一下圖3的這個狀態,雖然磁盤上還是之前的數據,但是這里直接從 內存返回結果,結果是正確的。
- 要讀Page 2的時候,需要把Page 2從磁盤讀入內存中,然后應用change buffer里面的操作 日志,生成一個正確的版本並返回結果。
可以看到,直到需要讀Page 2的時候,這個數據頁才會被讀入內存。
redo log 主要節省的是隨 機寫磁盤的IO消耗(轉成順序寫),而change buffer主要節省的則是隨機讀磁盤的IO消 耗。
第十節: MySQL為什么有時候會選錯索引?
我本地操作和課程中的結果不一致,本節內容就沿用課程中的吧
示例:
先建一個簡單的表,表里有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;
使用存儲過程往表t中插入10萬行記錄,取值按整數遞增,即:(1,1,1),(2,2,2),(3,3,3) 直到 (100000,100000,100000)。
delimiter ;; create procedure idata() begin declare i int; set i=1; while(i<=100000)do insert into t values(i, i, i); set i=i+1; end while; end;; delimiter ; call idata();
分析一條SQL語句:
select * from t where a between 10000 and 20000;
這條查詢語句的執行也確實符合預期,key這個字段值是’a’,表示優化器選擇了索 引a。
再執行下面的操作
這時候,session B的查詢語句select *fromt where a between 10000 and 20000就不會再選擇 索引a了。我們可以通過慢查詢日志(slowlog)來查看一下具體的執行情況。
下面的三條SQL語句,就是這個實驗過程。
set long_query_time=0; select * from t where a between 10000 and 20000; /*Q1*/ select * from t force index(a) where a between 10000 and 20000;/*Q2*//*使用force index(a)來讓優化器強制使用索引a*/
- 第一句,是將慢查詢日志的閾值設置為0,表示這個線程接下來的語句都會被記錄入慢查詢日 志中;
- 第二句,Q1是session B原來的查詢;
- 第三句,Q2是加了force index(a)來和session B原來的查詢語句執行情況對比。
如圖3所示是這三條SQL語句執行完成后的慢查詢日志。
我本地是版本是8.0.25,慢查詢日志在mysql.slow_log這個表中
可以看到,Q1掃描了10萬行,顯然是走了全表掃描,執行時間是14毫秒。Q2掃描了10001行, 執行了10毫秒。也就是說,我們在沒有使用force index的時候,MySQL用錯了索引,導致了更長的執行時間。
-
優化器的邏輯
優化器選擇索引的目的,是找到一個最優的執行方案,並用最小的代價去執行語句。在數據庫 里面,掃描行數是影響執行代價的因素之一。掃描的行數越少,意味着訪問磁盤數據的次數越 少,消耗的CPU資源越少。
掃描行數並不是唯一的判斷標准,優化器還會結合是否使用臨時表、是否排序等因素進行 綜合判斷。
我們這個簡單的查詢語句並沒有涉及到臨時表和排序,所以MySQL選錯索引肯定是在判斷掃描 行數的時候出問題了。
-
掃描行數是怎么判斷的?
MySQL在真正開始執行語句之前,並不能精確地知道滿足這個條件的記錄有多少條,而只能根 據統計信息來估算記錄數。
這個統計信息就是索引的“區分度”。顯然,一個索引上不同的值越多,這個索引的區分度就越 好。而一個索引上不同的值的個數,我們稱之為“基數”(cardinality)。也就是說,這個基數越 大,索引的區分度越好。
我們可以使用show index方法,看到一個索引的基數。如圖4所示,就是表t的show index的結果 。雖然這個表的每一行的三個字段值都是一樣的,但是在統計信息中,這三個索引的基數值並不 同,而且其實都不准確。
-
MySQL是怎樣得到索引的基數的呢?
MySQL采樣統計的方 法:
- 為什么要采樣統計呢?因為把整張表取出來一行行統計,雖然可以得到精確的結果,但是代價太 高了,所以只能選擇“采樣統計”。
- 采樣統計的時候,InnoDB默認會選擇N個數據頁,統計這些頁面上的不同值,得到一個平均 值,然后乘以這個索引的頁面數,就得到了這個索引的基數。
- 而數據表是會持續更新的,索引統計信息也不會固定不變。所以,當變更的數據行數超過1/M的 時候,會自動觸發重新做一次索引統計。
在MySQL中,有兩種存儲索引統計的方式,可以通過設置參數innodb_stats_persistent的值來選 擇:
- 設置為on的時候,表示統計信息會持久化存儲。這時,默認的N是20,M是10。
- 設置為off的時候,表示統計信息只存儲在內存中。這時,默認的N是8,M是16。
由於是采樣統計,所以不管N是20還是8,這個基數都是很容易不准的。
從圖4中看到,這次的索引統計值(cardinality列)雖然不夠精確,但大體上還是差不多 的,選錯索引一定還有別的原因。
其實索引統計只是一個輸入,對於一個具體的語句來說,優化器還要判斷,執行這個語句本身要 掃描多少行。
接下來,我們再一起看看優化器預估的,這兩個語句的掃描行數是多少。
rows這個字段表示的是預計掃描行數。
其中,Q1的結果還是符合預期的,rows的值是104620;但是Q2的rows值是37116,偏差就大 了。而圖1中我們用explain命令看到的rows是只有10001行,是這個偏差誤導了優化器的判斷。
這是因為,如果使用索引a,每次從索引a上拿到一個值,都要回到主鍵索引上查出整行數據, 這個代價優化器也要算進去的。
而如果選擇掃描10萬行,是直接在主鍵索引上掃描的,沒有額外的代價。
優化器會估算這兩個選擇的代價,從結果看來,優化器認為直接掃描主鍵索引更快。當然,從執 行時間看來,這個選擇並不是最優的。
使用普通索引需要把回表的代價算進去,在圖1執行explain的時候,也考慮了這個策略的代價 , 但圖1的選擇是對的。也就是說,這個策略並沒有問題。
所以MySQL選錯索引,這件事兒還得歸咎到沒能准確地判斷出掃描行數。
analyze table t 命令,可以用來重新統計索引信息。
發現explain的結果預估的rows值跟實際情況差距比較大,可以采用
analyze table 表名
這個 方法來處理。其實,如果只是索引統計不准確,通過analyze命令可以解決很多問題,但是前面我們說了,優 化器可不止是看掃描行數。
依然是基於這個表t,我們看看另外一個語句:
select * from t where (a between 1 and 1000) and (b between 50000 and 100000) order by b limit 100)alias limit 1;
從條件上看,這個查詢沒有符合條件的記錄,因此會返回空集合。
我們先來看一下a、b這兩個索引的結構圖。
如果使用索引a進行查詢,那么就是掃描索引a的前1000個值,然后取到對應的id,再到主鍵索 引上去查出每一行,然后根據字段b來過濾。顯然這樣需要掃描1000行。
如果使用索引b進行查詢,那么就是掃描索引b的最后50001個值,與上面的執行過程相同,也是 需要回到主鍵索引上取值再判斷,所以需要掃描50001行。
所以你一定會想,如果使用索引a的話,執行速度明顯會快很多。那么,下面我們就來看看到底 是不是這么一回事兒。
圖8是執行explain的結果。
explain select * from t where (a between 1 and 1000) and (b between 50000 and 100000) order by b limit 1;
可以看到,返回結果中key字段顯示,這次優化器選擇了索引b,而rows字段顯示需要掃描的行 數是50198。
從這個結果中,你可以得到兩個結論:
- 掃描行數的估計值依然不准確;
- . 這個例子里MySQL又選錯了索引。
-
-
索引選擇異常和處理
一種方法是,像我們第一個例子一樣,采用force index強行選擇一個索引。
MySQL會根據 詞法解析的結果分析出可能可以使用的索引作為候選項,然后在候選列表中依次判斷每個索引需 要掃描多少行。如果force index指定的索引在候選索引列表中,就直接選擇這個索引,不再評估 其他索引的執行代價。
第二種方法就是,我們可以考慮修改 語句,引導MySQL使用我們期望的索引。
比如,在這個例子里,顯然把“order by b limit 1” 改 成 “order by b,a limit 1” ,語義的邏輯是相同的。
之前優化器選擇使用索引b,是因為它認為使用索引b可以避免排序(b本身是索引,已經是有序 的了,如果選擇索引b的話,不需要再做排序,只需要遍歷),所以即使掃描行數多,也判定為 代價更小。
現在order by b,a 這種寫法,要求按照b,a排序,就意味着使用這兩個索引都需要排序。因此,掃 描行數成了影響決策的主要條件,於是此時優化器選了只需要掃描1000行的索引a。
當然,這種修改並不是通用的優化手段,只是剛好在這個語句里面有limit 1,因此如果有滿足條 件的記錄, order byb limit 1和order byb,a limit 1 都會返回b是最小的那一行,邏輯上一致,才 可以這么做。
這里還有一種改法
select * from (select * from t where (a between 1 and 1000) and (b between 50000 and 100000) order by b limit 100)alias limit 1;
在這個例子里,我們用limit 100讓優化器意識到,使用b索引代價是很高的。其實是我們根據數 mysql> select * from (select * from t where (a between 1 and 1000) and (b between 50000 and 100000) order by b limit 100)alias limit 1; 據特征誘導了一下優化器,也不具備通用性。
**第三種方法是,在有些場景下,我們可以新建一個更合適的索引,來提供給優化器做選 擇,或刪掉誤用的索引。**
3. ##### 上期問題
+ 如果某次寫入使用了change buffer機制,之后主機異常 重啟,是否會丟失change buffer和數據?
答案是不會丟失。雖然是只更新內存,但是在事務提 交的時候,我們把change buffer的操作也記錄到redo log里了,所以崩潰恢復的時候,change buffer也能找回來。
+ merge的過程是否會把數據直接寫回磁盤?
merge的執行流程是這樣的:
1. 從磁盤讀入數據頁到內存(老版本的數據頁);
2. 從change buffer里找出這個數據頁的change buffer 記錄(可能有多個),依次應用,得到新 版數據頁;
3. 寫redo log。這個redo log包含了數據的變更和change buffer的變更。
到這里merge過程就結束了。這時候,數據頁和內存中change buffer對應的磁盤位置都還沒有修 改,屬於臟頁,之后各自刷回自己的物理數據,就是另外一個過程了。
### 第十一節:怎么給字符串字段加索引?
MySQL是支持前綴索引的,也就是說,你可以定義字符串的一部分作為索引。默認地,如果你創建索引的語句不指定前綴長度,那么索引就會包含整個字符串。
```sql
create table SUser(
ID bigint unsigned primary key,
email varchar(64),
)engine=innodb;
比如,這兩個在email字段上創建索引的語句:
alter table SUser add index index1(email);
alter table SUser add index index2(email(6));
第一個語句創建的index1索引里面,包含了每個記錄的整個字符串;而第二個語句創建的index2 索引里面,對於每個記錄都是只取前6個字節。
!
從圖中你可以看到,由於email(6)這個索引結構中每個郵箱字段都只取前6個字節(即: zhangs),所以占用的空間會更小,這就是使用前綴索引的優勢。
但這同時帶來的損失是,可能會增加額外的記錄掃描次數。
接下來,我們再看看下面這個語句,在這兩個索引定義下分別是怎么執行的。
select id,name,email from SUser where email='zhangssxyz@xxx.com';
如果使用的是index1(即email整個字符串的索引結構),執行順序是這樣的:
- 從index1索引樹找到滿足索引值是'zhangssxyz@xxx.com’的這條記錄,取得ID2的值;
- 到主鍵上查到主鍵值是ID2的行,判斷email的值是正確的,將這行記錄加入結果集;
- 取index1索引樹上剛剛查到的位置的下一條記錄,發現已經不滿足 email='zhangssxyz@xxx.com’的條件了,循環結束。
這個過程中,只需要回主鍵索引取一次數據,所以系統認為只掃描了一行。
如果使用的是index2(即email(6)索引結構),執行順序是這樣的:
- 從index2索引樹找到滿足索引值是’zhangs’的記錄,找到的第一個是ID1;
- 到主鍵上查到主鍵值是ID1的行,判斷出email的值不是’zhangssxyz@xxx.com’,這行記錄丟 棄;
- 取index2上剛剛查到的位置的下一條記錄,發現仍然是’zhangs’,取出ID2,再到ID索引上取 整行然后判斷,這次值對了,將這行記錄加入結果集;
- 重復上一步,直到在idxe2上取到的值不是’zhangs’時,循環結束。
在這個過程中,要回主鍵索引取4次數據,也就是掃描了4行。
通過這個對比,你很容易就可以發現,使用前綴索引后,可能會導致查詢語句讀數據的次數變 多。
但是,對於這個查詢語句來說,如果你定義的index2不是email(6)而是email(7),也就是說取 email字段的前7個字節來構建索引的話,即滿足前綴’zhangss’的記錄只有一個,也能夠直接查到 ID2,只掃描一行就結束了。
使用前綴索引,定義好長度,就可以做到既節省空間,又不用額外增加太多的查 詢成本。
當要給字符串創建前綴索引時,有什么方法能夠確定我應該使用多長的前綴呢?
實際上,我們在建立索引時關注的是區分度,區分度越高越好。因為區分度越高,意味着重復的 鍵值越少。因此,我們可以通過統計索引上有多少個不同的值來判斷要使用多長的前綴。
首先,你可以使用下面這個語句,算出這個列上有多少個不同的值:
select count(distinct email) as L from SUser;
然后,依次選取不同長度的前綴來看這個值,比如我們要看一下4~7個字節的前綴索引,可以用 這個語句:
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;
當然,使用前綴索引很可能會損失區分度,所以你需要預先設定一個可以接受的損失比例,比如 5%。然后,在返回的L4~L7中,找出不小於 L * 95%的值,假設這里L6、L7都滿足,你就可以 選擇前綴長度為6。
前綴索引對覆蓋索引的影響
前面我們說了使用前綴索引可能會增加掃描行數,這會影響到性能。其實,前綴索引的影響不止 如此,我們再看一下另外一個場景。
你先來看看這個SQL語句:
select id,email from SUser where email='zhangssxyz@xxx.com';
與前面例子中的SQL語句
select id,name,email from SUser where email='zhangssxyz@xxx.com';
相比,這個語句只要求返回id和email字段。
所以,如果使用index1(即email整個字符串的索引結構)的話,可以利用覆蓋索引,從index1查 到結果后直接就返回了,不需要回到ID索引再去查一次。而如果使用index2(即email(6)索引結 構)的話,就不得不回到ID索引再去判斷email字段的值。
即使你將index2的定義修改為email(18)的前綴索引,這時候雖然index2已經包含了所有的信息, 但InnoDB還是要回到id索引再查一下,因為系統並不確定前綴索引的定義是否截斷了完整信 息。
也就是說,使用前綴索引就用不上覆蓋索引對查詢性能的優化了,這也是你在選擇是否使用前綴 索引時需要考慮的一個因素。
其他方式
第一種方式是使用倒序存儲。
select field_list from t where id_card = reverse('input_id_card_string');
第二種方式是使用hash字段。
alter table t add id_card_crc int unsigned, add index(id_card_crc);
然后每次插入新記錄的時候,都同時用crc32()這個函數得到校驗碼填到這個新字段。由於校驗碼 可能存在沖突,也就是說兩個不同的身份證號通過crc32()函 數得到的結果可能是相同的,所以你 的查詢語句where部分要判斷id_card的值是否精確相同。
select field_list from t where id_card_crc=crc32('input_id_card_string') and id_card='input_id_card_string';
使用倒序存儲和使用hash字段這兩種方法的異同點。
首先,它們的相同點是,都不支持范圍查詢。倒序存儲的字段上創建的索引是按照倒序字符串的 方式排序的,已經沒有辦法利用索引方式查出身份證號碼在 [ID_X, ID_Y]的所有市民了。同樣 地,hash字段的方式也只能支持等值查詢。
它們的區別,主要體現在以下三個方面:
1. 從占用的額外空間來看,倒序存儲方式在主鍵索引上,不會消耗額外的存儲空間,而hash字 段方法需要增加一個字段。當然,倒序存儲方式使用4個字節的前綴長度應該是不夠的,如 果再長一點,這個消耗跟額外這個hash字段也差不多抵消了。
2. 在CPU消耗方面,倒序方式每次寫和讀的時候,都需要額外調用一次reverse函數,而hash 字段的方式需要額外調用一次crc32()函數。如果只從這兩個函數的計算復雜度來看的 話,reverse函數額外消耗的CPU資源會更小些。
3. 從查詢效率上看,使用hash字段方式的查詢性能相對更穩定一些。因為crc32算出來的值雖 然有沖突的概率,但是概率非常小,可以認為每次查詢的平均掃描行數接近1。而倒序存儲 方式畢竟還是用的前綴索引的方式,也就是說還是會增加掃描行數。
小結
字符串字段創建索引的場景,可以使用的 方式有:
- 直接創建完整索引,這樣可能比較占用空間;
- 創建前綴索引,節省空間,但會增加查詢掃描次數,並且不能使用覆蓋索引;
- 倒序存儲,再創建前綴索引,用於繞過字符串本身前綴的區分度不夠的問題;
- 創建hash字段索引,查詢性能穩定,有額外的存儲和計算消耗,跟第三種方式一樣,都不支 持范圍掃描。
第十二節:為什么我的MySQL會“抖”一下?
-
你的SQL語句為什么變“慢”了
InnoDB在處理更新語句的時候,只做了寫日志這一個磁盤操作。這個日志 叫作redo log(重做日志),在更新內存寫 完redo log后,就返回給客戶端,本次更新成功。
把內存里的數據寫入磁盤的過程,術語就是 flush。
當內存數據頁跟磁盤數據頁內容不一致的時候,我們稱這個內存頁為“臟頁”。內存數據寫 入到磁盤后,內存和磁盤上的數據頁的內容就一致了,稱為“干凈頁”。
平時執行很快的更新操作,其實就是在寫內存和日志,而 MySQL偶爾“抖”一下的那個瞬間,可能就是在刷臟頁(flush)。
什么情況會引發數據庫的flush過程呢?
-
第一種場景是,InnoDB的redo log寫滿了。這時候系統會停止所有更新操作,把 checkpoint往前推進,redo log留出空間可以繼續寫。
checkpoint可不是隨便往前修改一下位置就可以的。比如圖2中,把checkpoint位置從CP推進到 CP’,就需要將兩個點之間的日志(淺綠色部分),對應的所有臟頁都flush到磁盤上。之后,圖 中從write pos到CP’之間就是可以再寫入的redo log的區域。
-
第二種場景是,系統內存不足。當需要新的內存頁,而內存不夠用的時候,就要淘汰 一些數據頁,空出內存給別的數據頁使用。如果淘汰的是“臟頁”,就要先將臟頁寫到磁盤。難道不能直接把內存淘汰掉,下次需要請求的時候,從磁盤讀入數據 頁,然后拿redo log出來應用不就行了?這里其實是從性能考慮的。如果刷臟頁一定會寫盤, 就保證了每個數據頁有兩種狀態:
- 一種是內存里存在,內存里就肯定是正確的結果,直接返回;
- 另一種是內存里沒有數據,就可以肯定數據文件上是正確的結果,讀入內存后返回。 這樣的效率最高。
-
第三種場景是,MySQL認為系統“空閑”的時候。刷“臟頁”。
-
第四種場景是,MySQL正常關閉的情況。這時候,MySQL會把內存的臟頁都flush到磁 盤上,這樣下次MySQL啟動的時候,就可以直接從磁盤上讀數據,啟動速度會很快。
上面四種場景對性能的影響:
- 第三種情況是屬於MySQL空閑時的操作,這時系統沒什么壓力,而第四種場景是數據庫 本來就要關閉了。這兩種情況下,你不會太關注“性能”問題。
- 第一種是“redo log寫滿了,要flush臟頁”,這種情況是InnoDB要盡量避免的。因為出現這種情況 的時候,整個系統就不能再接受更新了,所有的更新都必須堵住。如果你從監控上看,這時候更 新數會跌為0。
- 第二種是“內存不夠用了,要先將臟頁寫到磁盤”,這種情況其實是常態。InnoDB用緩沖池 (buffer pool)管理內存,緩沖池中的內存頁有三種狀態:
- 第一種是,還沒有使用的;
- 第二種是,使用了並且是干凈頁;
- 第三種是,使用了並且是臟頁。
InnoDB的策略是盡量使用內存,因此對於一個長時間運行的庫來說,未被使用的頁面很少。
而當要讀入的數據頁沒有在內存的時候,就必須到緩沖池中申請一個數據頁。這時候只能把最久 不使用的數據頁從內存中淘汰掉:如果要淘汰的是一個干凈頁,就直接釋放出來復用;但如果是 臟頁呢,就必須將臟頁先刷到磁盤,變成干凈頁后才能復用。
所以,刷臟頁雖然是常態,但是出現以下這兩種情況,都是會明顯影響性能的:
- 一個查詢要淘汰的臟頁個數太多,會導致查詢的響應時間明顯變長;
- 日志寫滿,更新全部堵住,寫性能跌為0,這種情況對敏感業務來說,是不能接受的。
所以,InnoDB需要有控制臟頁比例的機制,來盡量避免上面的這兩種情況。
InnoDB刷臟頁的控制策略
首先,你要正確地告訴InnoDB所在主機的IO能力,這樣InnoDB才能知道需要全力刷臟頁的時 候,可以刷多快。
這就要用到innodb_io_capacity這個參數了,它會告訴InnoDB你的磁盤能力。這個值我建議你設 置成磁盤的IOPS。磁盤的IOPS可以通過fio這個工具來測試。
下面的語句是用來測試磁盤隨機 讀寫的命令:
fio -filename=$filename -direct=1 -iodepth 1 -thread -rw=randrw -ioengine=psync -bs=16k -size=500M -numjobs=10 -runtime=10 -group_reporting -name=mytest
雖然我們現在已經定義了“全力刷臟頁”的行為,但平時總不能一直是全力刷吧?畢竟磁盤能力不 能只用來刷臟頁,還需要服務用戶請求。所以接下來,我們就一起看看InnoDB怎么控制引擎按 照“全力”的百分比來刷臟頁。
如果你來設計策略控制刷臟頁的速度,會參考哪些因 素呢?
如果刷太慢,會出現什么情況?首先是內存臟頁太多,其次是redo log寫 滿。
InnoDB的刷盤速度就是要參考這兩個因素:一個是臟頁比例,一個是redo log寫盤速度。
InnoDB會在后台刷臟頁,而刷臟頁的過程是要將內存頁寫入磁盤。所以,無論 是你的查詢語句在需要內存的時候可能要求淘汰一個臟頁,還是由於刷臟頁的邏輯會占用IO資源 並可能影響到了你的更新語句,都可能是造成你從業務端感知到MySQL“抖”了一下的原因
要盡量避免這種情況,你就要合理地設置innodb_io_capacity的值,並且平時要多關注臟頁比 例,不要讓它經常接近75%。
其中,臟頁比例是通過Innodb_buffer_pool_pages_dirty/Innodb_buffer_pool_pages_total得到 的,具體的命令參考下面的代碼:
select VARIABLE_VALUE into @a from global_status where VARIABLE_NAME = 'Innodb_buffer_pool_pages_dirty'; select VARIABLE_VALUE into @b from global_status where VARIABLE_NAME = 'Innodb_buffer_pool_pages_total'; select @a/@b;
再看一個有趣的策略。
一旦一個查詢請求需要在執行過程中先flush掉一個臟頁時,這個查詢就可能要比平時慢了。而 MySQL中的一個機制,可能讓你的查詢會更慢:在准備刷一個臟頁的時候,如果這個數據頁旁 邊的數據頁剛好是臟頁,就會把這個“鄰居”也帶着一起刷掉;而且這個把“鄰居”拖下水的邏輯還 可以繼續蔓延,也就是對於每個鄰居數據頁,如果跟它相鄰的數據頁也還是臟頁的話,也會被放 到一起刷。
在InnoDB中,innodb_flush_neighbors 參數就是用來控制這個行為的,值為1的時候會有上述 的“連坐”機制,值為0時表示不找鄰居,自己刷自己的。
找“鄰居”這個優化在機械硬盤時代是很有意義的,可以減少很多隨機IO。機械硬盤的隨機IOPS 一般只有幾百,相同的邏輯操作減少隨機IO就意味着系統性能的大幅度提升。
而如果使用的是SSD這類IOPS比較高的設備的話,建議你把innodb_flush_neighbors的值 設置成0。因為這時候IOPS往往不是瓶頸,而“只刷自己”,就能更快地執行完必要的刷臟頁操 作,減少SQL語句響應時間。
在MySQL 8.0中,innodb_flush_neighbors參數的默認值已經是0了。
-
第十三節:為什么表數據刪掉一半,表文件大小不變?
一個InnoDB表包含兩部 分,即:表結構定義和數據。在MySQL 8.0版本以前,表結構是存在以.frm為后綴的文件里。而 MySQL 8.0版本,則已經允許把表結 構定義放在系統數據表中了。因為表結構定義占用的空間很 小,所以我們今天主要討論的是表數據。
-
參數innodb_file_per_table
表數據既可以存在共享表空間里,也可以是單獨的文件。這個行為是由參數 innodb_file_per_table控制的:
- 這個參數設置為OFF表示的是,表的數據放在系統共享表空間,也就是跟數據字典放在一 起;
- 這個參數設置為ON表示的是,每個InnoDB表數據存儲在一個以 .ibd為后綴的文件中。
從MySQL 5.6.6版本開始,它的默認值就是ON了。
建議不論使用MySQL的哪個版本,都將這個值設置為ON。因為,一個表單獨存儲為一個文 件更容易管理,而且在你不需要這個表的時候,通過drop table命令,系統就會直接刪除這個文 件。而如果是放在共享表空間中,即使表刪掉了,空間也是不會回收的。
將innodb_file_per_table設置為ON,是推薦做法,我們接下來的討論都是基於這個 設置展開的。
在刪除整個表的時候,可以使用drop table命令回收表空間。
-
數據刪除流程
先再來看一下InnoDB中一個索引的示意圖。在前面提到過,InnoDB里的數據都是用B+樹的結構組織的。
假設,我們要刪掉R4這個記錄,InnoDB引擎只會把R4這個記錄標記為刪除。如果之后要再插入 一個ID在300和600之間的記錄時,可能會復用這個位置。但是,磁盤文件的大小並不會縮小。
現在,你已經知道了InnoDB的數據是按頁存儲的,那么如果我們刪掉了一個數據頁上的所有記 錄,會怎么樣?整個數據頁就可以被復用了。
數據頁的復用跟記錄的復用是不同的。
記錄的復用,只限於符合范圍條件的數據。比如上面的這個例子,R4這條記錄被刪除后,如果 插入一個ID是400的行,可以直接復用這個空間。但如果插入的是一個ID是800的行,就不能復 用這個位置了。
而當整個頁從B+樹里面摘掉以后,可以復用到任何位置。以圖1為例,如果將數據頁page A上的 所有記錄刪除以后,page A會被標記為可復用。這時候如果要插入一條ID=50的記錄需要使用新 頁的時候,page A是可以被復用的。
如果相鄰的兩個數據頁利用率都很小,系統就會把這兩個頁上的數據合到其中一個頁上,另外一 個數據頁就被標記為可復用。
進一步地,如果我們用delete命令把整個表的數據刪除呢?結果就是,所有的數據頁都會被標記 為可復用。但是磁盤上,文件不會變小。
delete命令其實只是把記錄的位置,或者數據頁標記為了“可復用”,但磁盤文件 的大小是不會變的。也就是說,通過delete命令是不能回收表空間的。這些可以復用,而沒有被 使用的空間,看起來就像是“空洞”。
不止是刪除數據會造成空洞,插入數據也會。
如果數據是按照索引遞增順序插入的,那么索引是緊湊的。但如果數據是隨機插入的,就可能造 成索引的數據頁分裂。
假設圖1中page A已經滿了,這時我要再插入一行數據,會怎樣呢?
可以看到,由於page A滿了,再插入一個ID是550的數據時,就不得不再申請一個新的頁面 page B來保存數據了。頁分裂完成后,page A的末尾就留下了空洞(注意:實際上,可能不止1 個記錄的位置是空洞)。
另外,更新索引上的值,可以理解為刪除一個舊的值,再插入一個新值。不難理解,這也是會造 成空洞的。
也就是說,經過大量增刪改的表,都是可能是存在空洞的。所以,如果能夠把這些空洞去掉,就 能達到收縮表空間的目的。
而重建表,就可以達到這樣的目的。
-
重建表
在重建表的時候,InnoDB不會把整 張表占滿,每個頁留了1/16給后續的更新用。也就是說,其實重建表之后不是“最”緊湊的。
可以新建一個與表A結構相同的表B,然后按照主鍵ID遞增的順序,把數據一行一行地從表A 里讀出來再插入到表B中。
由於表B是新建的表,所以表A主鍵索引上的空洞,在表B中就都不存在了。顯然地,表B的主鍵 索引更緊湊,數據頁的利用率也更高。如果我們把表B作為臨時表,數據從表A導入表B的操作完 成后,用表B替換A,從效果上看,就起到了收縮表A空間的作用。
使用alter table A engine=InnoDB命令來重建表。在MySQL 5.5版本之前,這個命 令的執行流程跟我們前面描述的差不多,區別只是這個臨時表B不需要你自己創建,MySQL會自 動完成轉存數據、交換表名、刪除舊表的操作。
顯然,花時間最多的步驟是往臨時表插入數據的過程,如果在這個過程中,有新的數據要寫入到 表A的話,就會造成數據丟失。因此,在整個DDL過程中,表A中不能有更新。也就是說,這個 DDL不是Online的。
而在MySQL 5.6版本開始引入的Online DDL,對這個操作流程做了優化。
簡單描述一下引入了Online DDL之后,重建表的流程:
- 建立一個臨時文件,掃描表A主鍵的所有數據頁;
- 用數據頁中表A的記錄生成B+樹,存儲到臨時文件中;
- 生成臨時文件的過程中,將所有對A的操作記錄在一個日志文件(rowlog)中,對應的是圖 中state2的狀態;
- 臨時文件生成后,將日志文件中的操作應用到臨時文件,得到一個邏輯數據上與表A相同的 數據文件,對應的就是圖中state3的狀態;
- 用臨時文件替換表A的數據文件。
可以看到,與圖3過程的不同之處在於,由於日志文件記錄和重放操作這個功能的存在,這個方 案在重建表的過程中,允許對表A做增刪改操作。這也就是 Online DDL名字的來源。
圖4的流程中,alter語句在啟動的時候需要獲取MDL寫鎖,但是這個寫鎖在真正拷貝數據 之前就退化成讀鎖了。 為什么要退化呢?為了實現Online,MDL讀 鎖不會阻塞增刪改操作。 那為什么不干脆直接解鎖呢?為了保護自己,禁止其他線程對這個表同時做DDL。 而對於一個大表來說,Online DDL最耗時的過 程就是拷貝數據到臨時表的過程,這個步驟的執 行期間可以接受增刪改操作。所以,相對於整個DDL過程來說,鎖的時間非常短。對業務來說, 就可以認 為是Online的。
上述的這些重建方法都會掃描原表數據和構建臨時文件。對於很大的表來 說,這個操作是很消耗IO和CPU資源的。因此,如果是線上服務,你要很小心地控 制操作時 間。如果想要比較安全的操作的話,我推薦你使用GitHub開源的gh-ost來做。
-
Online 和 inplace
在圖3中,我們把表A中的數據導出來的存放位置叫作tmp_table。這是一個臨 時表,是在server層創建的。
在圖4中,根據表A重建出來的數據是放在“tmp_file”里的,這個臨時文件是InnoDB在內部創建出 來的。整個DDL過程都在InnoDB內部完成。對於server層來說,沒有把數據挪動到臨時表,是 一個“原地”操作,這就是“inplace”名稱的來源。
重建表的這個語句alter table t engine=InnoDB,其實隱含的意思是:
alter table t engine=innodb,ALGORITHM=inplace;
跟inplace對應的就是拷貝表的方式了,用法是:
alter table t engine=innodb,ALGORITHM=copy;
當你使用ALGORITHM=copy的時候,表示的是強制拷貝表,對應的流程就是圖3的操作過程。
inplace跟Online是不是就是一個意思?
其實不是的,只是在重建表這個邏輯中剛好是這樣而已。
比如,如果我要給InnoDB表的一個字段加全文索引,寫法是:
alter table t add FULLTEXT(field_name)
這個過程是inplace的,但會阻塞增刪改操作,是非Online的。
如果說這兩個邏輯之間的關系是什么的話,可以概括為:
- DDL過程如果是Online的,就一定是inplace的;
- 反過來未必,也就是說inplace的DDL,有可能不是Online的。截止到MySQL 8.0,添加全文 索引(FULLTEXTindex)和空間索引(SPATIAL index)就屬於這種情況。
optimize table、analyze table和alter table這三種方式重建表的區別
- 從MySQL 5.6版本開始,alter table t engine = InnoDB(也就是recreate)默認的就是上面圖4 的流程了;
- analyze table t 其實不是重建表,只是對表的索引信息做重新統計,沒有修改數據,這個過程 中加了MDL讀鎖;
- optimize table t 等於recreate+analyze。
第十四節:count(*)這么慢,我該怎么辦?
-
count(*)的實現方式
在不同的MySQL引擎中,count(*)有不同的實現方式。
- MyISAM引擎把一個表的總行數存在了磁盤上,因此執行count(*)的時候會直接返回這個數, 效率很高;
- 而InnoDB引擎就麻煩了,它執行count(*)的時候,需要把數據一行一行地從引擎里面讀出 來,然后累積計數。
我們在這篇文章里討論的是沒有過濾條件的count(*),如果加了where 條件 的話,MyISAM表也是不能返回得這么快的。
為什么InnoDB不跟MyISAM一樣,也把數字存起來呢?
這是因為即使是在同一個時刻的多個查詢,由於多版本並發控制(MVCC)的原因,InnoDB 表“應該返回多少行”也是不確定的。這里,我用一個算count(*)的例子來為你解釋一下。
假設表t中現在有10000條記錄,我們設計了三個用戶並行的會話。
- 會話A先啟動事務並查詢一次表的總行數;
- 會話B啟動事務,插入一行后記錄后,查詢表的總行數;
- 會話C先啟動一個單獨的語句,插入一行記錄后,查詢表的總行數。
我們假設從上到下是按照時間順序執行的,同一行語句是在同一時刻執行的。
你會看到,在最后一個時刻,三個會話A、B、C會同時查詢表t的總行數,但拿到的結果卻不同
這和InnoDB的事務設計有關系,可重復讀是它默認的隔離級別,在代碼上就是通過多版本並發 控制,也就是MVCC來實現的。每一行記錄都要判斷自己是否對這個會話可見,因此對於 count(*)請求來說,InnoDB只好把數據一行一行地讀出依次判斷,可見的行才能夠用於計算“基 於這個查詢”的表的總行數。
當然,現在這個看上去笨笨的MySQL,在執行count(*)操作的時候還是做了優化的。
InnoDB是索引組織表,主鍵索引樹的葉子節點是數據,而普通索引樹的葉子節點是 主鍵值。所以,普通索引樹比主鍵索引樹小很多。對於count(*)這樣的操作,遍歷哪個索引樹得 到的結果邏輯上都是一樣的。因此,MySQL優化器會找到最小的那棵樹來遍歷。在保證邏輯正 確的前提下,盡量減少掃描的數據量,是數據庫系統設計的通用法則之一。
如果你用過showtable status 命令的話,就會發現這個命令的輸出結果里面也有一個 TABLE_ROWS用於顯示這個表當前有多少行,這個命令執行挺快的,那這個TABLE_ROWS能 代替count(*)嗎?
實際上,TABLE_ROWS就是從這個采樣估算得來的,因此它也很不准。 有多不准呢,官方文檔說誤差可能達到40%到50%。所以,show table status命令顯示的行 數也不能直接使用。
小結一下:
- MyISAM表雖然count(*)很快,但是不支持事務;
- show table status命令雖然返回很快,但是不准確;
- InnoDB表直接count(*)會遍歷全表,雖然結果准確,但會導致性能問題。
-
不同的count用法(基於InnoDB引擎)
首先你要弄清楚count()的語義。count()是一個聚合函數,對於返回的結果集,一行行地 判斷,如果count函數的參數不是NULL,累計值就加1,否則不加。最后返回累計值。
所以,count(*)、count(主鍵id)和count(1) 都表示返回滿足條件的結果集的總行數;而count(字 段),則表示返回滿足條件的數據行里面,參數“字段”不為NULL的總個數。
至於分析性能差別的時候,你可以記住這么幾個原則:
- server層要什么就給什么;
- InnoDB只給必要的值;
- 現在的優化器只優化了count(*)的語義為“取行數”,其他“顯而易見”的優化並沒有做。
對於count(主鍵id)來說,InnoDB引擎會遍歷整張表,把每一行的id值都取出來,返回給server 層。server層拿到id后,判斷是不可能為空的,就按行累加。
對於count(1)來說,InnoDB引擎遍歷整張表,但不取值。server層對於返回的每一行,放一個 數字“1”進去,判斷是不可能為空的,按行累加。
單看這兩個用法的差別的話,你能對比出來,count(1)執行得要比count(主鍵id)快。因為從引擎 返回id會涉及到解析數據行,以及拷貝字段值的操作。
對於count(字段)來說:
- 如果這個“字段”是定義為not null的話,一行行地從記錄里面讀出這個字段,判斷不能為 null,按行累加;
- 如果這個“字段”定義允許為null,那么執行的時候,判斷到有可能是null,還要把值取出來再 判斷一下,不是null才累加。
但是count(*)是例外,並不會把全部字段取出來,而是專門做了優化,不取值。count(*)肯定不 是null,按行累加。
看到這里,你一定會說,優化器就不能自己判斷一下嗎,主鍵id肯定非空啊,為什么不能按照 count(*)來處理,多么簡單的優化啊。
當然,MySQL專門針對這個語句進行優化,也不是不可以。但是這種需要專門優化的情況太多 了,而且MySQL已經優化過count(*)了,你直接使用這種用法就可以了。
所以結論是:按照效率排序的話,count(字段)<count(主鍵id)<count(1)≈count(*),所以建議,盡量使用count(*)。
第十五節: 答疑文章(一):日志和索引相關問題
-
日志相關問題
在第二節:日志系統:一條SQL更新語句是如何執行的?講到binlog(歸檔日 志)和redo log(重做日志)配合崩潰恢復的時候,用的是反證法,說明了如果沒有兩階段提 交,會導致MySQL出現主備數據不一致等問題。
有同學問,在兩階段提交的不同瞬間,MySQL如果發生異常重啟,是怎 么保證數據完整性的?
再放一次兩階段提交的圖,方便你學習下面的內容。
這個例子里面,沒有顯式地開啟事務,因此這個update語句自己就是一個事務,在執行 完成后提交事務時,就會用到這個“commit步驟“。
在兩階段提交的不同時刻,MySQL異常重啟會出現什么現象?
如果在圖中時刻A的地方,也就是寫入redo log 處於prepare階段之后、寫binlog之前,發生了崩 潰(crash),由於此時binlog還沒寫,redo log也還沒提交,所以崩潰恢復的時候,這個事務會 回滾。這時候,binlog還沒寫,所以也不會傳到備庫。
如果在圖中在時刻B,也就是binlog寫完,redo log還沒commit前發生 crash,那崩潰恢復的時候MySQL會怎么處理?
我們先來看一下崩潰恢復時的判斷規則。
-
如果redo log里面的事務是完整的,也就是已經有了commit標識,則直接提交;
-
如果redo log里面的事務只有完整的prepare,則判斷對應的事務binlog是否存在並完整:
a. 如果是,則提交事務;
b. 否則,回滾事務。
這里,時刻B發生crash對應的就是2(a)的情況,崩潰恢復過程中事務會被提交。
追問1:MySQL怎么知道binlog是完整的?
回答:一個事務的binlog是有完整格式的:
- statement格式的binlog,最后會有COMMIT;
- row格式的binlog,最后會有一個XID event。
另外,在MySQL 5.6.2版本以后,還引入了binlog-checksum參數,用來驗證binlog內容的正確 性。對於binlog日志由於磁盤原因,可能會在日志中間出錯的情況,MySQL可以通過校驗 checksum的結果來發現。所以,MySQL還是有辦法驗證事務binlog的完整性的。
追問2:redo log 和 binlog是怎么關聯起來的?
回答:它們有一個共同的數據字段,叫XID。崩潰恢復的時候,會按順序掃描redo log:
- 如果碰到既有prepare、又有commit的redo log,就直接提交;
- 如果碰到只有parepare、而沒有commit的redo log,就拿着XID去binlog找對應的事務。
追問3:處於prepare階段的redo log加上完整binlog,重啟就能恢 復,MySQL為什么要這么設計?
回答:其實,這個問題還是跟我們在反證法中說到的數據與備份的一致性有關。在時刻B,也就 是binlog寫完以后MySQL發生崩潰,這時候binlog已經寫入了,之后就會被從庫(或者用這個 binlog恢復出來的庫)使用。
所以,在主庫上也要提交這個事務。采用這個策略,主庫和備庫的數據就保證了一致性。
追問4:如果這樣的話,為什么還要兩階段提交呢?干脆先redo log寫完,再寫 binlog。崩潰恢復的時候,必須得兩個日志都完整才可以。是不是一樣的邏輯?
回答:其實,兩階段提交是經典的分布式系統問題,並不是MySQL獨有的。
如果必須要舉一個場景,來說明這么做的必要性的話,那就是事務的持久性問題。
對於InnoDB引擎來說,如果redo log提交完成了,事務就不能回滾(如果這還允許回滾,就可能 覆蓋掉別的事務的更新)。而如果redo log直接提交,然后binlog寫入的時候失敗,InnoDB又回 滾不了,數據和binlog日志又不一致了。
追問5:不引入兩個日志,也就沒有兩階段提交的必要了。只用binlog來支持崩 潰恢復,又能支持歸檔,不就可以了?
回答:這位同學的意思是,只保留binlog,然后可以把提交流程改成這樣:…-> “數據更新到內 存” -> “寫 binlog” -> “提交事務”,是不是也可以提供崩潰恢復的能力?意思是,只保留binlog,然后可以把提交流程改成這樣:…-> “數據更新到內 存” -> “寫 binlog” -> “提交事務”,是不是也可以提供崩潰恢復的能力?
答案是不可以。
如果說歷史原因的話,那就是InnoDB並不是MySQL的原生存儲引擎。MySQL的原生引擎是 MyISAM,設計之初就有沒有支持崩潰恢復。
InnoDB在作為MySQL的插件加入MySQL引擎家族之前,就已經是一個提供了崩潰恢復和事務支 持的引擎了。
InnoDB接入了MySQL后,發現既然binlog沒有崩潰恢復的能力,那就用InnoDB原有的redo log 好了。
追問6:那能不能反過來,只用redo log,不要binlog?
回答:如果只從崩潰恢復的角度來講是可以的。你可以把binlog關掉,這樣就沒有兩階段提交 了,但系統依然是crash-safe的。
但是,如果你了解一下業界各個公司的使用場景的話,就會發現在正式的生產庫上,binlog都是 開着的。因為binlog有着redo log無法替代的功能。
一個是歸檔。redo log是循環寫,寫到末尾是要回到開頭繼續寫的。這樣歷史日志沒法保 留,redo log也就起不到歸檔的作用。
一個就是MySQL系統依賴於binlog。binlog作為MySQL一開始就有的功能,被用在了很多地方。 其中,MySQL系統高可用的基礎,就是binlog復制。
還有很多公司有異構系統(比如一些數據分析系統),這些系統就靠消費MySQL的binlog來更新 自己的數據。關掉binlog的話,這些下游系統就沒法輸入了。
總之,由於現在包括MySQL高可用在內的很多系統機制都依賴於binlog,所以“鳩占鵲巢”redo log還做不到。
追問7:redo log一般設置多大?
回答:redo log太小的話,會導致很快就被寫滿,然后不得不強行刷redo log,這樣WAL機制的 能力就發揮不出來了。
如果磁盤夠大,建議將redo log設置為4個文 件、每個文件1GB。
追問8:正常運行中的實例,數據寫入后的最終落盤,是從redo log更新過來的 還是從buffer pool更新過來的呢?
回答:redo log里面到底是什么?
實際上,redo log並沒有記錄數據頁的完整數據,所以它並沒有能力自己去更新磁盤數據頁,也 就不存在“數據最終落盤,是由redo log更新過去”的情況。
- 如果是正常運行的實例的話,數據頁被修改以后,跟磁盤的數據頁不一致,稱為臟頁。最終 數據落盤,就是把內存中的數據頁寫盤。這個過程,甚至與redo log毫無關系。
- 在崩潰恢復場景中,InnoDB如果判斷到一個數據頁可能在崩潰恢復的時候丟失了更新,就 會將它讀到內存,然后讓redo log更新內存內容。更新完成后,內存頁變成臟頁,就回到了 第一種情況的狀態。
追問9: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文件(文件名是 ib_logfile+數字),是在執行commit語句的時候 做的。
這里說的是事務執行過程中不會“主動去刷盤”,以減少不必要的IO消耗。但是可能會出現“被 動寫入磁盤”,比如內存不夠、其他事務提交等情況。
單獨執行一個更新語句的時候,InnoDB會自己啟動一個事務,在語句執行完成的時候提交。過 程跟上面是一樣的,只不過是“壓縮”到了一個語句里面完成。
-