1. 程式人生 > >Redo與Undo的理解

Redo與Undo的理解

本文概要

本文分兩部分,
第一部分概念介紹,重在理解。
第二部分通過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) 在日誌中只有部分記錄,可能事務在執行時系統發生了崩潰,這時需要根據日誌重做(以下統一使用術語redo)。
(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的記錄

 

--