本文概要
本文分兩部分,
第一部分概念介紹,重在理解。
第二部分通過MySQL Innodb中的具體實現,加深相關知識的印象。
本文的原意是一篇個人學習筆記,為了避免成為草草記錄一下的流水賬,嘗試從給人介紹的角度開寫。但在整理的過程中,發現小知識點太多了,很容易陷入枯燥冗長的小細節描述。幾番折騰,目前的版本不能算滿意,你讀起來有不順的地方還請見諒,歡迎反饋。
1. 概念與理解
Redo與Undo並非是相互的逆操作,而是能配合起來使用的兩種機制。
說是兩種機制,其實都是日志記錄,不同的是Redo記錄以順序附加的形式記錄新值,如某條記錄<T,X,V>,表示事物T將新值V存儲到數據庫元素X,新值可以保證重做;
而Undo記錄通常以隨機操作的形式記錄舊值,如某條記錄<T1,Y,9>,表示事物T1對Y進行了修改,修改前Y的值是9,舊值能用於撤銷,也能供其他事務讀取。
Redo用來保證事務的原子性和持久性,Undo能保證事務的一致性,兩者也是系統恢復的基礎前提。
1.1 Redo
一個事務從開始到結束,要么提交完成,要么中止,具有原子性。而反映在redo日志中可能需要若干條記錄來保證,如:
<T0 start>
<T0,A,500>
<T0,B,500>
<T0 commit>
這里的某條Redo記錄不是事務級別的,一般對應的是事務中的一些關鍵步驟。如Innodb執行事務時會拆分為很多小事務,每個小事務產生某條Redo記錄。
而通過幾個數據庫原語能更一般性的描述Redo記錄:
Input(X):將X值從存儲介質讀入緩沖區
Read(X,t):將X值從緩沖區讀入事務內的變量t,如果緩沖中不存在,則觸發Input
Write(X,t): 將事務內的t寫入到緩沖區X塊,如果緩沖中X不存在,則觸發Input(X),再完成write
Output(X):將緩沖區X寫入到存儲中
所以上面的Redo記錄用原語表示如下:
很明顯,現實中的Redo日志大多不是這樣孤立的,更多的是多個事務交織在一起的,錯誤也隨時能發生,從小到數據格式錯誤到機房被導彈炸了。
下面通過3個Redo日志來討論:
(a) 在日志中只有部分記錄,可能事務在執行時系統發生了崩潰,這時需要根據日志重做。
(b) 日志中T0已經提交了,必須要對T0 進行Redo,而部分T1也需要Redo
(c) 日志中T0已經提交了,必須要對T0進行Redo,而T1雖然abort也需要Redo
可能有人有疑惑,commit的事務確實要Redo,但進行到一半未提交的事務及后來abort的事務可以不必進行Redo。確實,在日志中的每一個“閉合”的事務最終應該或者有一條commit記錄,或者有一條abort記錄,其他就是“未閉合”的事務片段,完全能篩選出目標事務再Redo,但這樣增加了Redo階段的復雜性,所以是根據日志統一Redo,之后的撤銷工作交給Undo來進行。這也是Redo具有事務無關性的一個體現。
1.2 Checkpoint
檢查點的引入有好幾個方面的原因。
原則上,系統恢復時可以通過檢查整個日志來完成,但無論Redo還是Undo,當日志很長時:
1.搜索過程太耗時
除了上面這點,針對Redo而言還有:
2.盡管Redo是冪等的,大多數需要重做的事務已經把更新寫入,對其重做不會有不良后果,但這會使恢復過程變得很長。
針對Undo日志:
3.一旦事務commit日志記錄寫入磁盤,邏輯上而言本事務的Undo記錄在恢復時已經不需要,在commit時可以刪除之前的Undo記錄。但由於多事務同時執行的原因,有時候不能這樣做,盡管本事務已經commit,但其他事務可能在使用Undo中的舊值。為此需要checkpoint來處理這些當前活躍的事務。
檢查點技術可分為簡單檢查點與更優化的非靜止檢查點。在一個簡單檢查點中有如下過程:
(1)停止接受新的事務
(2)等待當前所有活躍事務完成或中止,並在日志中寫入commit或abort記錄。
(3)將當前位於內存的日志,將緩沖塊刷新到磁盤
(4)寫入日志記錄<CKPT>,並再次刷新到磁盤
(5)重新開始接受事務
系統恢復時,可以從日志尾端反向搜索,直到找到第一個<CKPT>標志,而沒有必要處理<CKPT>之前的記錄。
非靜止檢查點
簡單檢查點期間需要停止響應,如果當前活躍事務需要很長時間來處理,那系統看起來似乎卡住了。非靜止檢查點允許進行檢查點時接受新事務進入,步驟如下:
(1)寫入日志記錄<START CKPT(t1,…tn)>,其中t1,…tn是當前活躍的事務
(2)等待t1,…tn所有事務提交或中止,但仍接受新事務的進入
(3)當t1,…tn所有事務都已完成,寫入日志記錄<END CKPT>
當使用非靜止檢查點技術,恢復時的也是從日志尾向前掃描,可能先遇到<START CKPT>標志,也可能先遇到<END CKPT>標志:
1.先遇到<START CKPT(t1,…tn)>時,說明系統在檢查點過程中崩潰,未完成事務包括2部分:(t1,…tn)記錄的部分及<START>標志后新進入部分。這部分事務中最早那個事務的開始點就是掃描的截止點,再往前可以不必掃描。
2.先遇到<END CKPT>,說明系統完成了上一個周期的檢查點,新的檢查點還沒開始。需要處理2部分事務:<END CKPT>標志之后到系統崩潰時這段時間內的事務及上一個<START>,<END>區間內新接受的事務。為此掃描到上一個檢查點<START CKPT()>就可以截止。
多說一句,很容易發現,非靜止檢查點是將一個點擴展為一個處理區間了,類似的設計其他技術也有,如JVM的GC處理,從stop the world到安全區的處理[1]。
1.3 Undo
Undo是邏輯日志,並不冪等,在撤銷時,根據undo記錄進行補償操作。Undo本身也產生redo記錄。通過Undo日志數據庫可以實現MVCC。
Undo保證了事務失敗或主動abort時的機能,除此之外,系統崩潰恢復時,也確保數據庫狀態能恢復到一致。
系統恢復時,Undo需要Redo的配合來實現,或者說二者是一套機制的兩個方面。因為在Redo日志有commit或abort記錄的事務是無需undo的。
假設以靜止的檢查點為日志類型,以<CKPT (t0,…,tn)>做檢查點,期間不接受新事務進入,整個Undo過程可以描述如下:
1.以進行檢查點時記錄的活躍事務(t0,…,tn)為undo-list
2.在Redo階段,發現一條<T,START>記錄,就將T加入到undo-list
3.在redo階段,發現一條<T,END>或<T,ABORT>記錄,就將T從undo-list刪除
4.此時undo-list中的事務都是些未提交也沒回滾的事務,系統如同普通的事務回滾樣進行具體的undo操作
5.當undo-list中發現<T,START>時,說明完成了具體的回滾操作,系統寫入一個<T,ABORT>記錄,並從undo-list中刪除T。
6.直到undo-list為空,撤銷階段完成
Undo的原語表示可以如下:
1.4 寫日志
寫日志有2種處理:一是等待一次IO,確實得寫入到存儲介質。二是先寫入到緩沖,在之后的某一時間點統一寫入磁盤。
以fsync函數與sync為例:
fsync函數等待磁盤操作結束,然后返回,它能確保數據持久化到存儲介質,而不是停留在OS或存儲的寫緩沖中;
sync則把修改過的塊緩沖區排入OS的寫隊列后就返回。fsync能確保數據寫入,同時,這也意味着一次IO及性能消耗。
不同的數據庫部件有各自的設計目的,負責不同的命令,Read和Write由事務發起,Input和Output由緩沖區管理器發出。也就是說,日志記錄響應的是寫入內存的write命令,而不是寫入磁盤的output命令,除非顯示的控制。
具體的實現上會有很多策略,但應保證一些原則:
針對Undo
1.如果事務T改變了數據庫元素X,那么必須保證對應的一條Undo記錄在X的新值寫入磁盤之前落盤。
2.如果發生commit,那么該條commit記錄寫入磁盤前,所有之前的修改能確保先行落盤。
針對Redo,有一條先寫日志規則(Write-Ahead Logging,WAL):
1.對數據庫元素X的修改被寫入磁盤前,一條對應的Redo日志保證先行落盤。
2.提交時,修改的數據庫元素在寫入磁盤前,一條commit記錄保證落盤。
注意這里說的數據庫元素X,不是事務層面的更新記錄集,通常假定是一個最小的原子處理單位,一個磁盤塊。當某塊在output時,不能有對該塊的write。為此在某塊輸出時可以在塊上設置排他鎖,這種短期持有的閂鎖(latch)與事務並發控制的鎖無關,按照非兩階段的方式釋放這樣的鎖對於事務可串行性沒有影響。如果數據庫元素小於單個塊,一個糟糕的情景是不同事務的2個數據元素位於同一塊,這時候一個事務對塊的寫磁盤動作可能導致另一個事務違反寫入規則,一個建議是以塊作為數據庫元素。
在InnoDB的實現中,並不嚴格按照WAL規則,而是通過一種事務的序列編號LSN保證邏輯上的WAL。下面對InnoDB的一些實現細節嘗試分析下。
2.MySQL InnoDB中的實現
2.1 redo log
每個Innodb存儲引擎至少有一個重做日志文件組(group),每個文件組下至少有2個重做日志文件,如默認的ib_logfile0和ib_logfile1,其默認路徑位於引擎的數據目錄。
設置多個日志文件時,其名字以ib_logfile[num]形式命名。多個日志文件循環利用,第一個文件寫滿時,換到第二個日志文件,最后一個文件寫滿時,回到第一個文件,組成邏輯上無限大的空間。在Innodb1.2.x前,重做日志文件的總大小不能大於等於4GB,1.2.x版本該限制以擴大到512GB.
重做日志文件設置的越大,越可以減少checkpoint刷新臟頁的頻率,這有時候對提升MySQL的性能非常重要,但缺點是增加了恢復時的耗時;如果設置的過小,則可能需要頻繁地切換文件,甚至一個事務的日志要多次切換文件,導致性能的抖動。
Innodb中各種不同的操作有着不同類型的重做日志,類型數量有幾十種,但記錄條目的基本格式可以如下表示:
圖2.1
在存儲結構上,redo log文件以block塊來組織,每個block大小為512字節。每個文件的開頭有一個2k大小的File Header區域用來保存一些控制信息,File Header之后就是連續的block。雖然每個redo log文件在頭部划出了File Header區域,但實際存儲信息的只有group中第一個redo log文件。
圖2.2
當redo log實際由mtr(Mini transaction)產生時,首先位於mtr的cache,之后輸出到redo log 緩沖區,再從緩沖區寫入到磁盤。Log buffer與文件中的block大小對應,以512字節為單位對齊,一個mtr日志可能不足一個block,也可能跨block。
File Header
File Header位於每個redo log文件的開始,大小為2k,格式如下:
圖2.3
log group中的第一個文件實際存儲這些信息,其他文件僅保留了空間。在寫入日志時,除了完成block部分,還要更新File Header里的信息,這些信息對Innodb引擎的恢復操作非常關鍵。
Block
一個block塊有512字節大小,每塊中還有塊頭和塊尾,中間是日志本身。其中塊頭Block Header占有12字節大小,塊尾Block Trailer占有4字節大小,中間實際的日志存儲容量為496字節(512-12-4):
圖2.4
LOG_BLOCK_HDR_NO
在log buffer內部,可以看成是單位大小是512字節的log block組成的數組,LOG_BLOCK_HDR_NO就用來標記數組中的位置。其根據該塊的LSN計算轉換而來,遞增且循環使用,占有4個字節,第一位用來判斷是否flush bit,所以總容量是2G。(LSN在之后一段說明)
LOG_BLOCK_HDR_DATA_LEN
標識寫入本block的日志長度,占有2個字節,當寫滿時用0X200表示,即有512字節。
LOG_BLOCK_FIRST_REC_GROUP
占有2個字節,記錄本block中第一個記錄的偏移量。如果該值與LOG_BLOCK_HDR_DATA_LEN
相同,說明此block被單一記錄占有,不包含新的日志。如果有新日志寫入,LOG_BLOCK_FIRST_REC_GROUP就是新日志的位置。
圖2.5
LOG_BLOCK_CHECKPOINT_NO
占有4字節,記錄該block最后被寫入時檢查點第4字節值。
LOG_BLOCK_TRL_NO
Block trailer中只由這1個部分組成。記錄本block中的checksum值,與LOG_BLOCK_HDR_NO值相同。
LSN
LSN是Log Sequence Number的縮寫,占有8字節,單調遞增,記錄重做日志寫入的字節總量,也表示日志序列號。
LSN除了記錄在redo日志中,還存於每個頁中。頁的頭部有一個FIL_PAGE_LSN用於記錄該頁的LSN,反應的是頁的當前版本。
LSN同樣也用於記錄checkpoint的位置。使用SHOW ENGINE INNODB STATUS命令查看LSN情況時,Log sequence number是當前LSN,Log flushed up to 是刷新到重做日志文件的LSN,Last checkpoint at 是刷新到磁盤的LSN。
由於LSN具有單調增長性,如果重做日志中的LSN大於當前頁中LSN,說明頁是滯后的,如果日志記錄的LSN對應的事務已經提交,那么當前頁需要重做恢復。
如果頁被新事務修改了,頁中LSN記錄的是新寫入的結束點的LSN,大於重做日志中的LSN,那么當前頁是新數據,是臟頁。
臟頁根據提交情況可能需要加入flush list中,此時flush list上的所以臟頁也是以LSN排序。
寫redo log時是追加寫,需要保證寫入順序,或者說應保證LSN的有序。當並發寫時可以通過加鎖來控制順序但效率低下,8.0中使用了無鎖的方式完成並發寫,mtr寫時已經提前知道自己在log buffer上的區間位置,不必等待直接寫入log buffer就可。這樣大的LSN值可能先寫到log buffer上,而小的LSN還沒寫入,即log buffer上有空洞。所以有一個單獨的線程log_write,負責不斷的掃描log buffer,檢測新的連續內容並進行刷新,是真正的寫線程。
2.2 Undo
undo是邏輯日志,在事務回滾時對數據庫進行一些補償性的修改,以使數據在邏輯上回到修改前的樣子,它並不冪等。
在Innodb中使用表空間,回滾段,頁等多級概念結構實現undo功能,並隨版本多次改進,為方便討論,下面放一張5.7版本的大致結構圖,在此基礎上進行描述:
圖2.6
1. 在undo這部分,MySQL 5.7版本在5.6(InnoDB 1.2)的基礎上主要增加有innodb_undo_log_truncate 收縮等功能,但在大致結構方面5.6可以參考上面5.7的圖。
2. 在5.5(Innodb1.1)版本之前,只有一個undo回滾段(rollback segment),支持1024個事務同時在線。
3.在5.5版中,支持最大128個回滾段,理論上支持128*1024個事務同時在線。
4.在之前的版本中,回滾段都存儲於共享表空間中,一個常見的問題是ibdata膨脹。在5.6版本(Innodb1.2)時,可以對回滾段做更多的設置:
innodb_undo_directory
innodb_undo_logs
innodb_undo_tablespaces
這3個參數分別用來設置
(1)回滾段文件所在位置,這意味着回滾段可以存儲到共享表空降值外,能使用獨立的表空間。
(2)回滾段的數量,默認是128個。
(3)回滾段文件的數量。如設置為3個,則在上面指定的directory文件生成3個undo為前綴的文件:undo001,undo002,undo003,默認的128個回滾段將被依次平均分配到這3個文件中。具體分配時,總是從第一個space開始輪詢,所以如果將回滾段的數量依次遞增到128,那所有的段都將落入undo001中。
5. 如上圖,共享表空間偏移量為5的頁記錄有所有回滾段的指向信息,這頁的類型為FIL_PAGE_TYPE_SYS(trx_sys)。 0號回滾段被預留在ibdata中,1~32號的32個回滾段是臨時表的回滾段,存儲於ibtmpl文件,其余從33號開始的回滾段才是可配置的,因此InnoDB實際支出96*1024個普通事務同時在線。
6.每個回滾段的頭部維護着一個段頭頁,該頁中划分了1024個槽位slot(TRX_RSEG_N_SLOTS),每個slot可以對應一個undo log對象,這也是為什么說一個回滾段支持1024個事務。
7.MySQL8.0中,每個Undo tablespace都可以創建128個回滾段,所以總共可以有總共有innodb_rollback_segments * innodb_undo_tablespaces個回滾段。
結構體
回滾段的信息以數組的形式存放,數組大小為128,數組位於trx_sys->rseg_array
rseg_array數組中的元素類型是trx_rseg_t,表示一個回滾段。
每個trx_rseg_t中管理着許多trx_undo_t,這些trx_undo_t同時也屬於多個鏈表,不同的鏈表有着不同的功能,如insert_undo_list或update_undo_list等。
圖2.7
undo log格式
Innodb中undo log可以分為兩種:
inser undo log
update undo log
insert undo log是insert操作中產生的undo log,因為只對本事務可見,該類undo log在事務提交后就可以刪除,不需要進行purge操作。格式如下:
圖2.8
update undo log是delete和update操作產生的undo log。此類undo log是MVCC的基礎,在本事務提交后不能簡單的刪除,需要放入purge隊列purge_sys->purge_queue
等待purge線程進行最后的刪除。格式如下:
圖2.9
圖上可見update undo log的格式比insert undo log復雜,同名的部分功能類似,其中的type_cmpl部分,由於update undo log本身還有分類,所以值可能有:
TRX_UNDO_DEL_MARK_REC,將記錄標記為delete
TRX_UNDO_UPD_DEL_REC,將delete的記錄標記為not delete
TRX_UNDO_UPD_EXIST_REC,更新未被標記delete的記錄
--完--