天天看點

「資料庫」庖丁解InnoDB之REDO LOG

作者:架構思考
資料庫故障恢複機制的前世今生一文中提到,今生磁盤資料庫為了在保證資料庫的原子性(A, Atomic) 和持久性(D, Durability)的同時,還能以靈活的刷盤政策來充分利用磁盤順序寫的性能,會記錄REDO和UNDO日志,即ARIES方法。本文将重點介紹REDO LOG的作用,記錄的内容,組織結構,寫入方式等内容,希望讀者能夠更全面準确的了解REDO LOG在InnoDB中的位置。本文基于MySQL 8.0代碼。

一 為什麼需要記錄REDO

為了取得更好的讀寫性能,InnoDB會将資料緩存在記憶體中(InnoDB Buffer Pool),對磁盤資料的修改也會落後于記憶體,這時如果程序或機器崩潰,會導緻記憶體資料丢失,為了保證資料庫本身的一緻性和持久性,InnoDB維護了REDO LOG。修改Page之前需要先将修改的内容記錄到REDO中,并保證REDO LOG早于對應的Page落盤,也就是常說的WAL,Write Ahead Log。當故障發生導緻記憶體資料丢失後,InnoDB會在重新開機時,通過重放REDO,将Page恢複到崩潰前的狀态。

二 需要什麼樣的REDO

那麼我們需要什麼樣的REDO呢?首先,REDO的維護增加了一份寫盤資料,同時為了保證資料正确,事務隻有在他的REDO全部落盤才能傳回使用者成功,REDO的寫盤時間會直接影響系統吞吐,顯而易見,REDO的資料量要盡量少。其次,系統崩潰總是發生在始料未及的時候,當重新開機重放REDO時,系統并不知道哪些REDO對應的Page已經落盤,是以REDO的重放必須可重入,即REDO操作要保證幂等。最後,為了便于通過并發重放的方式加快重新開機恢複速度,REDO應該是基于Page的,即一個REDO隻涉及一個Page的修改。

熟悉的讀者會發現,資料量小是Logical Logging的優點,而幂等以及基于Page正是Physical Logging的優點,是以InnoDB采取了一種稱為Physiological Logging的方式,來兼得二者的優勢。所謂Physiological Logging,就是以Page為機關,但在Page内以邏輯的方式記錄。舉個例子,MLOG_REC_UPDATE_IN_PLACE類型的REDO中記錄了對Page中一個Record的修改,方法如下:

(Page ID,Record Offset,(Filed 1, Value 1) ... (Filed i, Value i) ... )

其中,PageID指定要操作的Page頁,Record Offset記錄了Record在Page内的偏移位置,後面的Field數組,記錄了需要修改的Field以及修改後的Value。

由于Physiological Logging的方式采用了實體Page中的邏輯記法,導緻兩個問題:

1、需要基于正确的Page狀态上重放REDO

由于在一個Page内,REDO是以邏輯的方式記錄了前後兩次的修改,是以重放REDO必須基于正确的Page狀态。然而InnoDB預設的Page大小是16KB,是大于檔案系統能保證原子的4KB大小的,是以可能出現Page内容成功一半的情況。InnoDB中采用了Double Write Buffer的方式來通過寫兩次的方式保證恢複的時候找到一個正确的Page狀态。這部分會在之後介紹Buffer Pool的時候詳細介紹。

2、需要保證REDO重放的幂等

Double Write Buffer能夠保證找到一個正确的Page狀态,我們還需要知道這個狀态對應REDO上的哪個記錄,來避免對Page的重複修改。為此,InnoDB給每個REDO記錄一個全局唯一遞增的标号LSN(Log Sequence Number)。Page在修改時,會将對應的REDO記錄的LSN記錄在Page上(FIL_PAGE_LSN字段),這樣恢複重放REDO時,就可以來判斷跳過已經應用的REDO,進而實作重放的幂等。

三 REDO中記錄了什麼内容

知道了InnoDB中記錄REDO的方式,那麼REDO裡具體會記錄哪些内容呢?為了應對InnoDB各種各樣不同的需求,到MySQL 8.0為止,已經有多達65種的REDO記錄。用來記錄這不同的資訊,恢複時需要判斷不同的REDO類型,來做對應的解析。根據REDO記錄不同的作用對象,可以将這65中REDO劃分為三個大類:作用于Page,作用于Space以及提供額外資訊的Logic類型。

1、作用于Page的REDO

這類REDO占所有REDO類型的絕大多數,根據作用的Page的不同類型又可以細分為,Index Page REDO,Undo Page REDO,Rtree PageREDO等。比如MLOG_REC_INSERT,MLOG_REC_UPDATE_IN_PLACE,MLOG_REC_DELETE三種類型分别對應于Page中記錄的插入,修改以及删除。這裡還是以MLOG_REC_UPDATE_IN_PLACE為例來看看其中具體的内容:

「資料庫」庖丁解InnoDB之REDO LOG

其中,Type就是MLOG_REC_UPDATE_IN_PLACE類型,Space ID和Page Number唯一辨別一個Page頁,這三項是所有REDO記錄都需要有的頭資訊,後面的是MLOG_REC_UPDATE_IN_PLACE類型獨有的,其中Record Offset用給出要修改的記錄在Page中的位置偏移,Update Field Count說明記錄裡有幾個Field要修改,緊接着對每個Field給出了Field編号(Field Number),資料長度(Field Data Length)以及資料(Filed Data)。

2、作用于Space的REDO

這類REDO針對一個Space檔案的修改,如MLOG_FILE_CREATE,MLOG_FILE_DELETE,MLOG_FILE_RENAME分别對應對一個Space的建立,删除以及重命名。由于檔案操作的REDO是在檔案操作結束後才記錄的,是以在恢複的過程中看到這類日志時,說明檔案操作已經成功,是以在恢複過程中大多隻是做對檔案狀态的檢查,以MLOG_FILE_CREATE來看看其中記錄的内容:

「資料庫」庖丁解InnoDB之REDO LOG

同樣的前三個字段還是Type,Space ID和Page Number,由于是針對Page的操作,這裡的Page Number永遠是0。在此之後記錄了建立的檔案flag以及檔案名,用作重新開機恢複時的檢查。

3、提供額外資訊的Logic REDO

除了上述類型外,還有少數的幾個REDO類型不涉及具體的資料修改,隻是為了記錄一些需要的資訊,比如最常見的MLOG_MULTI_REC_END就是為了辨別一個REDO組,也就是一個完整的原子操作的結束。

4、REDO是如何組織的

所謂REDO的組織方式,就是如何把需要的REDO内容記錄到磁盤檔案中,以友善高效的REDO寫入,讀取,恢複以及清理。我們這裡把REDO從上到下分為三層:邏輯REDO層、實體REDO層和檔案層。

4.1 邏輯REDO層

這一層是真正的REDO内容,REDO由多個不同Type的多個REDO記錄收尾相連組成,有全局唯一的遞增的偏移sn,InnoDB會在全局log_sys中維護目前sn的最大值,并在每次寫入資料時将sn增加REDO内容長度。如下圖所示:

「資料庫」庖丁解InnoDB之REDO LOG

4.2 實體REDO層

磁盤是塊裝置,InnoDB中也用Block的概念來讀寫資料,一個Block的長度OS_FILE_LOG_BLOCK_SIZE等于磁盤扇區的大小512B,每次IO讀寫的最小機關都是一個Block。

除了REDO資料以外,Block中還需要一些額外的資訊,下圖所示一個Log Block的的組成,包括12位元組的Block Header:

  1. 前4位元組中Flush Flag占用最高位bit,辨別一次IO的第一個Block,剩下的31個個bit是Block編号。
  2. 之後是2位元組的資料長度,取值在[12,508];緊接着2位元組的First Record Offset用來指向Block中第一個REDO組的開始,這個值的存在使得我們對任何一個Block都可以找到一個合法的的REDO開始位置。
  3. 最後的4位元組Checkpoint Number記錄寫Block時的next_checkpoint_number,用來發現檔案的循環使用,這個會在檔案層詳細講解。

Block末尾是4位元組的Block Tailer,記錄目前Block的Checksum,通過這個值,讀取Log時可以明确Block資料有沒有被完整寫完。

「資料庫」庖丁解InnoDB之REDO LOG

Block中剩餘的中間498個位元組就是REDO真正内容的存放位置,也就是我們上面說的邏輯REDO。我們現在将邏輯REDO放到實體REDO空間中,由于Block内的空間固定,而REDO長度不定,是以可能一個Block中有多個REDO,也可能一個REDO被拆分到多個Block中,如下圖所示,棕色和紅色分别代表Block Header和Tailer,中間的REDO記錄由于前一個Block剩餘空間不足,而被拆分在連續的兩個Block中。

「資料庫」庖丁解InnoDB之REDO LOG

由于增加了Block Header和Tailer的位元組開銷,在實體REDO空間中用LSN來辨別偏移,可以看出LSN和SN之間有簡單的換算關系:

constexpr inline lsn_t log_translate_sn_to_lsn(lsn_t sn) {

  return (sn / LOG_BLOCK_DATA_SIZE * OS_FILE_LOG_BLOCK_SIZE +

          sn % LOG_BLOCK_DATA_SIZE + LOG_BLOCK_HDR_SIZE);

}           

SN加上之前所有的Block的Header以及Tailer的長度就可以換算到對應的LSN,反之亦然。

4.3 檔案層

最終REDO會被寫入到REDO日志檔案中,以ib_logfile0、ib_logfile1...命名,為了避免建立檔案及初始化空間帶來的開銷,InooDB的REDO檔案會循環使用,通過參數innodb_log_files_in_group可以指定REDO檔案的個數。多個檔案收尾相連順序寫入REDO内容。每個檔案以Block為機關劃分,每個檔案的開頭固定預留4個Block來記錄一些額外的資訊,其中第一個Block稱為Header Block,之後的3個Block在0号檔案上用來存儲Checkpoint資訊,而在其他檔案上留白:

「資料庫」庖丁解InnoDB之REDO LOG

其中第一個Header Block的資料區域記錄了一些檔案資訊,如下圖所示,4位元組的Formate字段記錄Log的版本,不同版本的LOG,會有REDO類型的增減,這個資訊是8.0開始才加入的;8位元組的Start LSN辨別目前檔案開始LSN,通過這個資訊可以将檔案的offset與對應的lsn對應起來;最後是最長32位的Creator資訊,正常情況下會記錄MySQL的版本。

「資料庫」庖丁解InnoDB之REDO LOG

現在我們将REDO放到檔案空間中,如下圖所示,邏輯REDO是真正需要的資料,用sn索引,邏輯REDO按固定大小的Block組織,并添加Block的頭尾資訊形成實體REDO,以lsn索引,這些Block又會放到循環使用的檔案空間中的某一位置,檔案中用offset索引:

「資料庫」庖丁解InnoDB之REDO LOG

雖然通過LSN可以唯一辨別一個REDO位置,但最終對REDO的讀寫還需要轉換到對檔案的讀寫IO,這個時候就需要表示檔案空間的offset,他們之間的換算方式如下:

const auto real_offset =  log.current_file_real_offset + (lsn - log.current_file_lsn);           

切換檔案時會在記憶體中更新目前檔案開頭的檔案offset,current_file_real_offset,以及對應的LSN,current_file_lsn,通過這兩個值可以友善地用上面的方式将LSN轉化為檔案offset。注意這裡的offset是相當于整個REDO檔案空間而言的,由于InnoDB中讀寫檔案的space層實作支援多個檔案,是以,可以将首位相連的多個REDO檔案看成一個大檔案,那麼這裡的offset就是這個大檔案中的偏移。

五 如何高效地寫REDO

作為維護資料庫正确性的重要資訊,REDO日志必須在事務送出前保證落盤,否則一旦斷電将會有資料丢失的可能,是以從REDO生成到最終落盤的完整過程成為資料庫寫入的關鍵路徑,其效率也直接決定了資料庫的寫入性能。這個過程包括REDO内容的産生,REDO寫入InnoDB Log Buffer,從InnoDB Log Buffer寫入作業系統Page Cache,以及REDO刷盤,之後還需要喚醒等待的使用者線程完成Commit。下面就通過這幾個階段來看看InnoDB如何在高并發的情況下還能高效地完成寫REDO。

5.1 REDO産生

我們知道事務在寫入資料的時候會産生REDO,一次原子的操作可能會包含多條REDO記錄,這些REDO可能是通路同一Page的不同位置,也可能是通路不同的Page(如Btree節點分裂)。InnoDB有一套完整的機制來保證涉及一次原子操作的多條REDO記錄原子,即恢複的時候要麼全部重放,要不全部不重放,這部分将在之後介紹恢複邏輯的時候詳細介紹,本文隻涉及其中最基本的要求,就是這些REDO必須連續。InnoDB中通過min-transaction實作,簡稱mtr,需要原子操作時,調用mtr_start生成一個mtr,mtr中會維護一個動态增長的m_log,這是一個動态配置設定的記憶體空間,将這個原子操作需要寫的所有REDO先寫到這個m_log中,當原子操作結束後,調用mtr_commit将m_log中的資料拷貝到InnoDB的Log Buffer。

5.2 寫入InnoDB Log Buffer

高并發的環境中,會同時有非常多的min-transaction(mtr)需要拷貝資料到Log Buffer,如果通過鎖互斥,那麼毫無疑問這裡将成為明顯的性能瓶頸。為此,從MySQL 8.0開始,設計了一套無鎖的寫log機制,其核心思路是允許不同的mtr,同時并發地寫Log Buffer的不同位置。不同的mtr會首先調用log_buffer_reserve函數,這個函數裡會用自己的REDO長度,原子地對全局偏移log.sn做fetch_add,得到自己在Log Buffer中獨享的空間。之後不同mtr并行的将自己的m_log中的資料拷貝到各自獨享的空間内。

/* Reserve space in sequence of data bytes: */

const sn_t start_sn = log.sn.fetch_add(len);           

5.3 寫入Page Cache

寫入到Log Buffer中的REDO資料需要進一步寫入作業系統的Page Cache,InnoDB中有單獨的log_writer來做這件事情。這裡有個問題,由于Log Buffer中的資料是不同mtr并發寫入的,這個過程中Log Buffer中是有空洞的,是以log_writer需要感覺目前Log Buffer中連續日志的末尾,将連續日志通過pwrite系統調用寫入作業系統Page Cache。整個過程中應盡可能不影響後續mtr進行資料拷貝,InnoDB在這裡引入一個叫做link_buf的資料結構,如下圖所示:

「資料庫」庖丁解InnoDB之REDO LOG

link_buf是一個循環使用的數組,對每個lsn取模可以得到其在link_buf上的一個槽位,在這個槽位中記錄REDO長度。另外一個線程從開始周遊這個link_buf,通過槽位中的長度可以找到這條REDO的結尾位置,一直周遊到下一位置為0的位置,可以認為之後的REDO有空洞,而之前已經連續,這個位置叫做link_buf的tail。下面看看log_writer和衆多mtr是如何利用這個link_buf資料結構的。這裡的這個link_buf為log.recent_written,如下圖所示:

「資料庫」庖丁解InnoDB之REDO LOG

圖中上半部分是REDO日志示意圖,write_lsn是目前log_writer已經寫入到Page Cache中日志末尾,current_lsn是目前已經配置設定給mtr的的最大lsn位置,而buf_ready_for_write_lsn是目前log_writer找到的Log Buffer中已經連續的日志結尾,從write_lsn到buf_ready_for_write_lsn是下一次log_writer可以連續調用pwrite寫入Page Cache的範圍,而從buf_ready_for_write_lsn到current_lsn是目前mtr正在并發寫Log Buffer的範圍。下面的連續方格便是log.recent_written的資料結構,可以看出由于中間的兩個全零的空洞導緻buf_ready_for_write_lsn無法繼續推進,接下來,假如reserve到中間第一個空洞的mtr也完成了寫Log Buffer,并更新了log.recent_written*,如下圖:

「資料庫」庖丁解InnoDB之REDO LOG

這時,log_writer從目前的buf_ready_for_write_lsn向後周遊log.recent_written,發現這段已經連續:

「資料庫」庖丁解InnoDB之REDO LOG

是以提升目前的buf_ready_for_write_lsn,并将log.recent_written的tail位置向前滑動,之後的位置清零,供之後循環複用:

「資料庫」庖丁解InnoDB之REDO LOG

緊接log_writer将連續的内容刷盤并提升write_lsn。

5.4 刷盤

log_writer提升write_lsn之後會通知log_flusher線程,log_flusher線程會調用fsync将REDO刷盤,至此完成了REDO完整的寫入過程。

5.5 喚醒使用者線程

為了保證資料正确,隻有REDO寫完後事務才可以commit,是以在REDO寫入的過程中,大量的使用者線程會block等待,直到自己的最後一條日志結束寫入。預設情況下innodb_flush_log_at_trx_commit = 1,需要等REDO完成刷盤,這也是最安全的方式。當然,也可以通過設定innodb_flush_log_at_trx_commit = 2,這樣,隻要REDO寫入Page Cache就認為完成了寫入,極端情況下,掉電可能導緻資料丢失。

大量的使用者線程調用log_write_up_to等待在自己的lsn位置,為了避免大量無效的喚醒,InnoDB将阻塞的條件變量拆分為多個,log_write_up_to根據自己需要等待的lsn所在的block取模對應到不同的條件變量上去。同時,為了避免大量的喚醒工作影響log_writer或log_flusher線程,InnoDB中引入了兩個專門負責喚醒使用者的線程:log_wirte_notifier和log_flush_notifier,當超過一個條件變量需要被喚醒時,log_writer和log_flusher會通知這兩個線程完成喚醒工作。下圖是整個過程的示意圖:

「資料庫」庖丁解InnoDB之REDO LOG

多個線程通過一些内部資料結構的輔助,完成了高效的從REDO産生,到REDO寫盤,再到喚醒使用者線程的流程,下面是整個這個過程的時序圖:

「資料庫」庖丁解InnoDB之REDO LOG

六 如何安全地清除REDO

由于REDO檔案空間有限,同時為了盡量減少恢複時需要重放的REDO,InnoDB引入log_checkpointer線程周期性的打Checkpoint。重新開機恢複的時候,隻需要從最新的Checkpoint開始回放後邊的REDO,是以Checkpoint之前的REDO就可以删除或被複用。

我們知道REDO的作用是避免隻寫了記憶體的資料由于故障丢失,那麼打Checkpiont的位置就必須保證之前所有REDO所産生的記憶體髒頁都已經刷盤。最直接的,可以從Buffer Pool中獲得目前所有髒頁對應的最小REDO LSN:lwm_lsn。但光有這個還不夠,因為有一部分min-transaction的REDO對應的Page還沒有來的及加入到Buffer Pool的髒頁中去,如果checkpoint打到這些REDO的後邊,一旦這時發生故障恢複,這部分資料将丢失,是以還需要知道目前已經加入到Buffer Pool的REDO lsn位置:dpa_lsn。取二者的較小值作為最終checkpoint的位置,其核心邏輯如下:

/* LWM lsn for unflushed dirty pages in Buffer Pool */

lsn_t lwm_lsn = buf_pool_get_oldest_modification_lwm();

/* Note lsn up to which all dirty pages have already been added into Buffer Pool */

const lsn_t dpa_lsn = log_buffer_dirty_pages_added_up_to_lsn(log);

lsn_t checkpoint_lsn = std::min(lwm_lsn, dpa_lsn);
MySQL 8.0中為了能夠讓mtr之間更大程度的并發,允許并發地給Buffer Pool注冊髒頁。類似與log.recent_written和log_writer,這裡引入一個叫做recent_closed的link_buf來處理并發帶來的空洞,由單獨的線程log_closer來提升recent_closed的tail,也就是目前連續加入Buffer Pool髒頁的最大LSN,這個值也就是上面提到的dpa_lsn。需要注意的是,由于這種亂序的存在,lwm_lsn的值并不能簡單的擷取目前Buffer Pool中的最老的髒頁的LSN,保守起見,還需要減掉一個recent_closed的容量大小,也就是最大的亂序範圍,簡化後的代碼如下:           
/* LWM lsn for unflushed dirty pages in Buffer Pool */

const lsn_t lsn = buf_pool_get_oldest_modification_approx();

const lsn_t lag = log.recent_closed.capacity();

lsn_t lwm_lsn = lsn - lag;

/* Note lsn up to which all dirty pages have already been added into Buffer Pool */

const lsn_t dpa_lsn = log_buffer_dirty_pages_added_up_to_lsn(log);

lsn_t checkpoint_lsn = std::min(lwm_lsn, dpa_lsn);           

這裡有一個問題,由于lwm_lsn已經減去了recent_closed的capacity,是以理論上這個值一定是小于dpa_lsn的。那麼再去比較lwm_lsn和dpa_lsn來擷取Checkpoint位置或許是沒有意義的。

上面已經提到,ib_logfile0檔案的前三個Block有兩個被預留作為Checkpoint Block,這兩個Block會在打Checkpiont的時候交替使用,這樣來避免寫Checkpoint過程中的崩潰導緻沒有可用的Checkpoint。Checkpoint Block中的内容如下:

「資料庫」庖丁解InnoDB之REDO LOG

首先8個位元組的Checkpoint Number,通過比較這個值可以判斷哪個是最新的Checkpiont記錄,之後8位元組的Checkpoint LSN為打Checkpoint的REDO位置,恢複時會從這個位置開始重放後邊的REDO。之後8個位元組的Checkpoint Offset,将Checkpoint LSN與檔案空間的偏移對應起來。最後8位元組是前面提到的Log Buffer的長度,這個值目前在恢複過程并沒有使用。

七 總結

本文系統的介紹了InnoDB中REDO的作用、特性、組織結構、寫入方式已經清理時機,基本覆寫了REDO的大多數内容。關于重新開機恢複時如何使用REDO将資料庫恢複到正确的狀态,将在之後介紹InnoDB故障恢複機制的時候詳細介紹。

文章來源:https://mp.weixin.qq.com/s?__biz=MzIzOTU0NTQ0MA==&mid=2247506098&idx=1&sn=fb95c7ca9c82ae785fccf0a265cd2ff9&chksm=e92ae5bdde5d6cabf6200a515f67085265d60fc363ef2744c0781a62c342ae8712fefb86ba5e&scene=178&cur_album_id=1530994292440301570#rd