作者介紹
張永翔,現(xiàn)任網(wǎng)易云RDS開發(fā),持續(xù)關(guān)注MySQL及數(shù)據(jù)庫運維領(lǐng)域,擅長MySQL運維,知乎ID:雁南歸。
MySQL 8.0中一個重要的新特性是對Redo Log子系統(tǒng)的重構(gòu),通過引入兩個新的數(shù)據(jù)結(jié)構(gòu)recent_written和recent_closed,移除了之前的兩個熱點鎖:log_sys_t::mutex和log_sys_t::flush_order_mutex。
這種無鎖化的重構(gòu)使得不同的線程在寫入redo_log_buffer時得以并行寫入,但因此帶來了log_buffer不再按LSN增長的順序?qū)懭氲膯栴},以及flush_list中的臟頁不再嚴(yán)格保證LSN的遞增順序問題。
本文將介紹MySQL 8.0中對log_buffer相關(guān)代碼的重構(gòu),并介紹并發(fā)寫log_buffer引入問題的解決辦法。
一、MySQL Redo Log系統(tǒng)概述
Redo Log又被稱為WAL ( Write Ahead Log),是InnoDB存儲引擎實現(xiàn)事務(wù)持久性的關(guān)鍵。
在InnoDB存儲引擎中,事務(wù)執(zhí)行過程被分割成一個個MTR (Mini TRansaction),每個MTR在執(zhí)行過程中對數(shù)據(jù)頁的更改會產(chǎn)生對應(yīng)的日志,這個日志就是Redo Log。事務(wù)在提交時,只要保證Redo Log被持久化,就可以保證事務(wù)的持久化。
由于Redo Log在持久化過程中順序?qū)懳募奶匦裕沟贸志没疪edo Log的代價要遠(yuǎn)遠(yuǎn)小于持久化數(shù)據(jù)頁,因此通常情況下,數(shù)據(jù)頁的持久化要遠(yuǎn)落后于Redo Log。
每個Redo Log都有一個對應(yīng)的序號LSN (Log Sequence Number),同時數(shù)據(jù)頁上也會記錄修改了該數(shù)據(jù)頁的Redo Log的LSN,當(dāng)數(shù)據(jù)頁持久化到磁盤上時,就不再需要這個數(shù)據(jù)頁記錄的LSN之前的Redo日志,這個LSN被稱作Checkpoint。
當(dāng)做故障恢復(fù)的時候,只需要將Checkpoint之后的Redo Log重新應(yīng)用一遍,便可得到實例Crash之前未持久化的全部數(shù)據(jù)頁。
InnoDB存儲引擎在內(nèi)存中維護(hù)了一個全局的Redo Log Buffer用以緩存對Redo Log的修改,mtr在提交的時候,會將mtr執(zhí)行過程中產(chǎn)生的本地日志copy到全局Redo Log Buffer中,并將mtr執(zhí)行過程中修改的數(shù)據(jù)頁(被稱做臟頁dirty page)加入到一個全局的隊列中flush list。
InnoDB存儲引擎會根據(jù)不同的策略將Redo Log Buffer中的日志落盤,或?qū)lush list中的臟頁刷盤并推進(jìn)Checkpoint。
在臟頁落盤以及Checkpoint推進(jìn)的過程中,需要嚴(yán)格保證Redo日志先落盤再刷臟頁的順序,在MySQL 8之前,InnoDB存儲引擎嚴(yán)格的保證MTR寫入Redo Log Buffer的順序是按照LSN遞增的順序,以及flush list中的臟頁按LSN遞增順序排序。
在多線程并發(fā)寫入Redo Log Buffer及flush list時,這一約束是通過兩個全局鎖log_sys_t::mutex和log_sys_t::flush_order_mutex實現(xiàn)的。
二、MySQL 5.7中MTR的提交過程
在MySQL 5.7中,Redo Log寫入全局的Redo Log Buffer以及將臟頁添加到flush list的操作均在mtr的提交階段中完成,簡化后的代碼為:
MySQL官方博客中有一張圖可以很好的展示了這個過程:
三、MySQL 8中的無鎖化設(shè)計
從上面的代碼中可以看到,在有多個MTR并發(fā)提交的時候,實際在這些MTR是串行的完成從本地日志Copy redo到全局Redo Log Buffer以及添加Dirty Page到Flush list的。這里的串行操作就是整個MTR 提交過程的瓶頸,如果這里可以改成并行,想必可以提高M(jìn)TR的提交效率。
但是串行化的提交可以嚴(yán)格保證Redo Log的連續(xù)性以及flush list中Page修改LSN的遞增,這兩個約束使得將Redo Log和臟頁刷入磁盤的行為很簡單。只要按順序?qū)edo Log Buffer中的內(nèi)容寫入文件,以及按flush list的順序?qū)⑴K頁刷入表空間,并推進(jìn)Checkpoint即可。
當(dāng)MTR不再以串行的方式提交的時候,會導(dǎo)致以下問題需要解決:
-
MTR串行的copy本地日志到全局Redo Log Buffer可以保證每個MTR的日志在Redo Log Buffer中都是連續(xù)的不會分割。當(dāng)并行copy日志的時候,需要有額外的手段保證mtr的日志copy到Redo Log Buffer后仍然連續(xù)。MySQL 8.0中使用一個全局的原子變量log_t::sn在copy數(shù)據(jù)前為MTR在Redo Log Buffer中預(yù)留好需要的位置,這樣并行copy數(shù)據(jù)到Redo Log Buffer時就不會相互干擾。
-
由于多個MTR并行copy數(shù)據(jù)到Redo Log Buffer,那必然會有一些MTR copy的快一些,有些MTR copy的比較慢,這時候Redo Log Buffer中可能會有空洞,那么就需要一種方法來確定Redo Log Buffer中的哪些內(nèi)容可以寫入文件。MySQL 8.0中引入了新的數(shù)據(jù)結(jié)構(gòu)Link_buf解決了這個問題。
-
并行的添加臟頁到flush list會打破flush list中每個數(shù)據(jù)頁對應(yīng)LSN的單調(diào)性約束,如果仍然按flush list中的順序?qū)⑴K頁落盤,那如何確定Checkpoint的位置?
下面本文將分別討論以上三個問題:
1、MTR復(fù)制日志到Redo Log Buffer的無鎖化
在MySQL 8.0中, MTR的提交部分可以用如下偽代碼表示:
同5.7的代碼相比,最明顯的區(qū)別就是移除了log_sys->mutex鎖和log_sys->flush_order_mutex鎖,而實現(xiàn)Redo Log無鎖化的關(guān)鍵在于 log_buffer_reserve(*log_sys, len) 這個函數(shù), 其中關(guān)鍵的代碼只有兩句:
可以看到,這里是通過一個原子操作std::atomic<uint64>.fetch_add(log_len)實現(xiàn)在Copy Redo之前在全局Redo Log Buffer中預(yù)分配空間,實現(xiàn)并行寫入而不沖突。
2、Log Buffer空洞問題
預(yù)分配的方式可以使多個MTR不沖突的copy數(shù)據(jù)到Redo Log Buffer,但由于有些線程快一些,有些線程慢一些,必然會造成Redo Log Buffer的空洞問題,這個使得Redo Log Buffer刷入到磁盤的行為變得復(fù)雜。
如上圖所示,Redo Log Buffer中第一個和第三個線程已經(jīng)完成了Redo Log的寫入,第二個線程正在寫入到Redo Log Buffer中,這個時候是不能將三個線程的Redo都落盤的。MySQL 8.0中引入了一個數(shù)據(jù)結(jié)構(gòu)Link_buf解決這個問題。
Link_buf實際上是一個定長數(shù)組,并保證數(shù)組的每個元素的更新是原子性的,并以環(huán)形的方式復(fù)用已經(jīng)釋放的空間。
Link_buf用于輔助表示其他數(shù)據(jù)結(jié)構(gòu)的使用情況,在Link_buf中,如果一個索引位置i對應(yīng)的值為非0值n,則表示Link_buf輔助標(biāo)記的那個數(shù)據(jù)結(jié)構(gòu),從i開始后面n個元素已被占用。同時Link_buf內(nèi)部維護(hù)了一個變量M表示當(dāng)前最大可達(dá)的LSN,Link_buf的結(jié)構(gòu)示意圖如下所示:
在接口層面,Link_buf實際上定義了3個有效的行為:
Redo Log Buffer內(nèi)部維護(hù)了兩個Link_buf類型的變量recent_written和recent_closed來維護(hù)Redo Log Buffer和flush list的修改信息。
對于redo log buffer,buffer的使用情況和recent_written的對應(yīng)關(guān)系如下圖所示:
buf_ready_for_write_lsn這個變量維護(hù)的是可以保證無空洞的最大LSN值,也就是recent_written->tail的結(jié)果,在這之前的Redo Log都是可以安全的持久化到磁盤上的。
當(dāng)?shù)谝粋€空洞位置的數(shù)據(jù)被寫入成功后,寫入數(shù)據(jù)的mtr通過調(diào)用log.recent_written.add_link(start_lsn, end_lsn)將recent_written內(nèi)部狀態(tài)更新為如下圖所示的樣子:
這部分代碼在log0log.cc文件的log_buffer_write_completed方法中。
每次修改recent_written后,都會觸發(fā)一個獨立的線程log_writer向后掃描recent_written并更新buf_ready_for_write_lsn 值(調(diào)用recent_written->advance_tail()方法)。log_writer線程實際上就是執(zhí)行日志寫入到文件的線程。由log_writer線程掃描后的recent_written變量內(nèi)部如下圖所示:
這樣就很好的解決了MTR并發(fā)寫入log_buffer造成的空洞問題。通過新引入的Link_buf類型的數(shù)據(jù)結(jié)構(gòu),可用很方便的知道哪一部分的Redo Log可以執(zhí)行寫入磁盤的操作。
關(guān)于更多落盤的細(xì)節(jié)
在MySQL 8中,Redo log的落盤過程交由兩個獨立的線程完成,分別 log_writer和log_flusher,前者負(fù)責(zé)將Redo Log Buffer中的數(shù)據(jù)寫入到OS Cache中, 后者負(fù)責(zé)不停的執(zhí)行fsync操作將OS Cache中的數(shù)據(jù)真正的寫入到磁盤里。
兩個線程通過一個全局的原子變量log_t::write_lsn同步,write_lsn表示當(dāng)前已經(jīng)寫入到OS Cache的Redo log最大的LSN。
log buffer中的redo log的落盤不需要由用戶線程關(guān)心,用戶線程只需要在事務(wù)提交的時候,根據(jù)innodb_flush_log_at_trx_commit定義的不同行為,等待log_writer或log_flusher的通知即可。
log_writer線程會在監(jiān)聽到recent_written被修改后,log_buffer中大于log_t::write_lsn小于buf_ready_for_write_lsn的redo log刷入到 OS Cache 中,并更新log_t::write_lsn。
log_flusher線程則在監(jiān)聽到write_lsn更新后調(diào)用一次fsync并更新flushed_to_disk_lsn,該變量保存的是最新fsync到文件的值。
在這種設(shè)計模式下,用戶線程只負(fù)責(zé)寫日志到log_buffer中,日志的刷新和落盤是完全異步的,根據(jù)innodb_flush_log_at_trx_commit定義的不同行為,用戶線程在事務(wù)提交時需要等待日志寫入操作系統(tǒng)緩存或磁盤。
在8.0之前,是由用戶線程觸發(fā)fsync或者等先提交的線程執(zhí)行fsync( Group Commit行為), 而在MySQL 8.0中,用戶線程只需要等待flushed_to_disk_lsn足夠大即可。
8.0中采用了一個分片的消息隊列來通知用戶線程,比如用戶線程需要等待flushed_to_disk_lsn >= X那么就會加入到X所屬的消息隊列。分片可以有效降低消息同步損耗及一次需要通知的線程數(shù)。
在8.0中,由后臺線程log_flush_notifier通知等待的用戶線程,用戶線程、log_writer、log_flusher、log_flush_notifier四個線程之間的同步關(guān)系為。
8.0中為了避免用戶線程在陷入等待狀態(tài)后立即被喚醒,用戶線程會在等待前做自旋以檢查等待條件。8.0中新增加了兩個Dynamic Variable: innodb_log_spin_cpu_abs_lwm 和innodb_log_spin_cpu_pct_hwm控制執(zhí)行自旋操作時CPU的水位,以免自旋操作占用了太多的CPU。
3、flush list 并發(fā)控制以及check point 推進(jìn)
回到上面的MTR提交的代碼,可以看到在將Redo Log寫入全局的log buffer中以后,mtr立即開始了將臟頁加入到flush list的步驟,其過程分為三個函數(shù)調(diào)用。
這里同樣是通過一個Link_Buf類型的無鎖結(jié)構(gòu)recent_closed來跟蹤處理flush list并發(fā)寫入狀態(tài)。
假設(shè)MTR在提交時產(chǎn)生的redo log的范圍是[start_lsn, end_lsn],MTR在將這些redo對應(yīng)的臟頁加入到某個flush list后,立即將start_lsn到end_lsn這段標(biāo)記在recent_closed結(jié)構(gòu)中。recent_closed同樣在內(nèi)部維護(hù)了變量M,M對應(yīng)著一個LSN,表示所有小于該LSN的臟頁都加入到了flush list中。
而與redo log寫入不同的是,MTR在寫入flush list之前,需要等待M值與start_lsn相差不是太多才可以寫入。這是為了將flush list上的空洞控制在一個范圍之內(nèi),這個過程的示意圖如下:
MTR在寫入到flush list之前,需要等待M值與start_lsn的相差范圍是一個常數(shù)L,這個常數(shù)度量了flush list中的無序度,它使得checkpoint的確定變得簡單(實際代碼中,L值就是recent_closed內(nèi)部容量大小)。
從上面的代碼可以看到,在8.0中實際上加入到flush list的行為并不是完全并發(fā)的,但也不是5.7中完全串行的,而是被控制到一個范圍L之內(nèi)的并行寫入。
由于MTR需要等待條件start_lsn - M < L成立才能加入到flush list , 反過來說,對于flush list中的每個Page ,如果其對應(yīng)的修改的LSN為Ln,那么可以斷定Ln - L對應(yīng)的Page一定已經(jīng)加入到了flush list中,而且一定在當(dāng)前Page之前(因為Page添加時的檢查條件Ln-L < M,M之前是無空洞連續(xù)的LSN)。
也就是說,在延續(xù)原有的按flush list的順序刷新臟頁到磁盤的策略不變的情況下,只需要將Checkpoint的推進(jìn)由原來的Page對應(yīng)的LSN改成LSN-L即可。
MySQL 8.0中實際實現(xiàn)的時候,Checkpoint推進(jìn)仍然是按照Page對應(yīng)的LSN寫入的,只不過Recover的時候從Checkpoint - L開始執(zhí)行,這兩張方式實際上是等效的。
不過在MySQL 8.0中,Recover階段從Checkpoint - L的地方開始,可能會遇到Checkpoint -L是某個Redo的中間位置而不是開始位置的情況,所以要對一些邊界情況做一些額外的工作才行。
四、總結(jié)
對于InnoDB存儲引擎,Redo Log的處理是實現(xiàn)事務(wù)持久性的關(guān)鍵,在MySQL 5.7及以前,通過兩個全局鎖,實際上使MTR的提交過程串行化保證了RedoLog以及臟頁處理的正確性,這使得MTR的提交過程因為鎖競爭的緣故無法充分的發(fā)揮多核的優(yōu)勢。
8.0中通過引入的Link_buf 數(shù)據(jù)結(jié)構(gòu)將整個模塊變成了Lock_free的模式,必然會帶來性能上的提升。
參考
-
MySQL8.0: 重新設(shè)計的日志子系統(tǒng)
https://yq.aliyun.com/articles/592215?utm_content=m_49932
-
MySQL 8.0: New Lock free, scalable WAL design
https://mysqlserverteam.com/mysql-8-0-new-lock-free-scalable-wal-design/
-
MySQL Source Code Documentation/InnoDB Redo Log
https://dev.mysql.com/doc/dev/mysql-server/8.0.11/PAGE_INNODB_REDO_LOG.html
-
InnoDB的Redo Log分析
http://www.leviathan.vip/2018/12/15/InnoDB%E7%9A%84Redo-Log%E5%88%86%E6%9E%90/
-
MySQL · 引擎特性 · WAL那些事兒
http://mysql.taobao.org/monthly/2018/07/01/