作者介紹
張永翔,現(xiàn)任網(wǎng)易云RDS開(kāi)發(fā),持續(xù)關(guān)注MySQL及數(shù)據(jù)庫(kù)運(yùn)維領(lǐng)域,擅長(zhǎng)MySQL運(yùn)維,知乎ID:雁南歸。
MySQL 8.0中一個(gè)重要的新特性是對(duì)Redo Log子系統(tǒng)的重構(gòu),通過(guò)引入兩個(gè)新的數(shù)據(jù)結(jié)構(gòu)recent_written和recent_closed,移除了之前的兩個(gè)熱點(diǎn)鎖:log_sys_t::mutex和log_sys_t::flush_order_mutex。
這種無(wú)鎖化的重構(gòu)使得不同的線程在寫(xiě)入redo_log_buffer時(shí)得以并行寫(xiě)入,但因此帶來(lái)了log_buffer不再按LSN增長(zhǎng)的順序?qū)懭氲膯?wèn)題,以及flush_list中的臟頁(yè)不再嚴(yán)格保證LSN的遞增順序問(wèn)題。
本文將介紹MySQL 8.0中對(duì)log_buffer相關(guān)代碼的重構(gòu),并介紹并發(fā)寫(xiě)log_buffer引入問(wèn)題的解決辦法。
一、MySQL Redo Log系統(tǒng)概述
Redo Log又被稱(chēng)為WAL ( Write Ahead Log),是InnoDB存儲(chǔ)引擎實(shí)現(xiàn)事務(wù)持久性的關(guān)鍵。
在InnoDB存儲(chǔ)引擎中,事務(wù)執(zhí)行過(guò)程被分割成一個(gè)個(gè)MTR (Mini TRansaction),每個(gè)MTR在執(zhí)行過(guò)程中對(duì)數(shù)據(jù)頁(yè)的更改會(huì)產(chǎn)生對(duì)應(yīng)的日志,這個(gè)日志就是Redo Log。事務(wù)在提交時(shí),只要保證Redo Log被持久化,就可以保證事務(wù)的持久化。
由于Redo Log在持久化過(guò)程中順序?qū)懳募奶匦裕沟贸志没疪edo Log的代價(jià)要遠(yuǎn)遠(yuǎn)小于持久化數(shù)據(jù)頁(yè),因此通常情況下,數(shù)據(jù)頁(yè)的持久化要遠(yuǎn)落后于Redo Log。
每個(gè)Redo Log都有一個(gè)對(duì)應(yīng)的序號(hào)LSN (Log Sequence Number),同時(shí)數(shù)據(jù)頁(yè)上也會(huì)記錄修改了該數(shù)據(jù)頁(yè)的Redo Log的LSN,當(dāng)數(shù)據(jù)頁(yè)持久化到磁盤(pán)上時(shí),就不再需要這個(gè)數(shù)據(jù)頁(yè)記錄的LSN之前的Redo日志,這個(gè)LSN被稱(chēng)作Checkpoint。
當(dāng)做故障恢復(fù)的時(shí)候,只需要將Checkpoint之后的Redo Log重新應(yīng)用一遍,便可得到實(shí)例Crash之前未持久化的全部數(shù)據(jù)頁(yè)。
InnoDB存儲(chǔ)引擎在內(nèi)存中維護(hù)了一個(gè)全局的Redo Log Buffer用以緩存對(duì)Redo Log的修改,mtr在提交的時(shí)候,會(huì)將mtr執(zhí)行過(guò)程中產(chǎn)生的本地日志copy到全局Redo Log Buffer中,并將mtr執(zhí)行過(guò)程中修改的數(shù)據(jù)頁(yè)(被稱(chēng)做臟頁(yè)dirty page)加入到一個(gè)全局的隊(duì)列中flush list。
InnoDB存儲(chǔ)引擎會(huì)根據(jù)不同的策略將Redo Log Buffer中的日志落盤(pán),或?qū)lush list中的臟頁(yè)刷盤(pán)并推進(jìn)Checkpoint。
在臟頁(yè)落盤(pán)以及Checkpoint推進(jìn)的過(guò)程中,需要嚴(yán)格保證Redo日志先落盤(pán)再刷臟頁(yè)的順序,在MySQL 8之前,InnoDB存儲(chǔ)引擎嚴(yán)格的保證MTR寫(xiě)入Redo Log Buffer的順序是按照LSN遞增的順序,以及flush list中的臟頁(yè)按LSN遞增順序排序。
在多線程并發(fā)寫(xiě)入Redo Log Buffer及flush list時(shí),這一約束是通過(guò)兩個(gè)全局鎖log_sys_t::mutex和log_sys_t::flush_order_mutex實(shí)現(xiàn)的。
二、MySQL 5.7中MTR的提交過(guò)程
在MySQL 5.7中,Redo Log寫(xiě)入全局的Redo Log Buffer以及將臟頁(yè)添加到flush list的操作均在mtr的提交階段中完成,簡(jiǎn)化后的代碼為:

MySQL官方博客中有一張圖可以很好的展示了這個(gè)過(guò)程:

三、MySQL 8中的無(wú)鎖化設(shè)計(jì)
從上面的代碼中可以看到,在有多個(gè)MTR并發(fā)提交的時(shí)候,實(shí)際在這些MTR是串行的完成從本地日志Copy redo到全局Redo Log Buffer以及添加Dirty Page到Flush list的。這里的串行操作就是整個(gè)MTR 提交過(guò)程的瓶頸,如果這里可以改成并行,想必可以提高M(jìn)TR的提交效率。
但是串行化的提交可以嚴(yán)格保證Redo Log的連續(xù)性以及flush list中Page修改LSN的遞增,這兩個(gè)約束使得將Redo Log和臟頁(yè)刷入磁盤(pán)的行為很簡(jiǎn)單。只要按順序?qū)edo Log Buffer中的內(nèi)容寫(xiě)入文件,以及按flush list的順序?qū)⑴K頁(yè)刷入表空間,并推進(jìn)Checkpoint即可。
當(dāng)MTR不再以串行的方式提交的時(shí)候,會(huì)導(dǎo)致以下問(wèn)題需要解決:
-
MTR串行的copy本地日志到全局Redo Log Buffer可以保證每個(gè)MTR的日志在Redo Log Buffer中都是連續(xù)的不會(huì)分割。當(dāng)并行copy日志的時(shí)候,需要有額外的手段保證mtr的日志copy到Redo Log Buffer后仍然連續(xù)。MySQL 8.0中使用一個(gè)全局的原子變量log_t::sn在copy數(shù)據(jù)前為MTR在Redo Log Buffer中預(yù)留好需要的位置,這樣并行copy數(shù)據(jù)到Redo Log Buffer時(shí)就不會(huì)相互干擾。
-
由于多個(gè)MTR并行copy數(shù)據(jù)到Redo Log Buffer,那必然會(huì)有一些MTR copy的快一些,有些MTR copy的比較慢,這時(shí)候Redo Log Buffer中可能會(huì)有空洞,那么就需要一種方法來(lái)確定Redo Log Buffer中的哪些內(nèi)容可以寫(xiě)入文件。MySQL 8.0中引入了新的數(shù)據(jù)結(jié)構(gòu)Link_buf解決了這個(gè)問(wèn)題。

-
并行的添加臟頁(yè)到flush list會(huì)打破flush list中每個(gè)數(shù)據(jù)頁(yè)對(duì)應(yīng)LSN的單調(diào)性約束,如果仍然按flush list中的順序?qū)⑴K頁(yè)落盤(pán),那如何確定Checkpoint的位置?
下面本文將分別討論以上三個(gè)問(wèn)題:
1、MTR復(fù)制日志到Redo Log Buffer的無(wú)鎖化
在MySQL 8.0中, MTR的提交部分可以用如下偽代碼表示:

同5.7的代碼相比,最明顯的區(qū)別就是移除了log_sys->mutex鎖和log_sys->flush_order_mutex鎖,而實(shí)現(xiàn)Redo Log無(wú)鎖化的關(guān)鍵在于 log_buffer_reserve(*log_sys, len) 這個(gè)函數(shù), 其中關(guān)鍵的代碼只有兩句:

可以看到,這里是通過(guò)一個(gè)原子操作std::atomic<uint64>.fetch_add(log_len)實(shí)現(xiàn)在Copy Redo之前在全局Redo Log Buffer中預(yù)分配空間,實(shí)現(xiàn)并行寫(xiě)入而不沖突。
2、Log Buffer空洞問(wèn)題
預(yù)分配的方式可以使多個(gè)MTR不沖突的copy數(shù)據(jù)到Redo Log Buffer,但由于有些線程快一些,有些線程慢一些,必然會(huì)造成Redo Log Buffer的空洞問(wèn)題,這個(gè)使得Redo Log Buffer刷入到磁盤(pán)的行為變得復(fù)雜。

如上圖所示,Redo Log Buffer中第一個(gè)和第三個(gè)線程已經(jīng)完成了Redo Log的寫(xiě)入,第二個(gè)線程正在寫(xiě)入到Redo Log Buffer中,這個(gè)時(shí)候是不能將三個(gè)線程的Redo都落盤(pán)的。MySQL 8.0中引入了一個(gè)數(shù)據(jù)結(jié)構(gòu)Link_buf解決這個(gè)問(wèn)題。
Link_buf實(shí)際上是一個(gè)定長(zhǎng)數(shù)組,并保證數(shù)組的每個(gè)元素的更新是原子性的,并以環(huán)形的方式復(fù)用已經(jīng)釋放的空間。
Link_buf用于輔助表示其他數(shù)據(jù)結(jié)構(gòu)的使用情況,在Link_buf中,如果一個(gè)索引位置i對(duì)應(yīng)的值為非0值n,則表示Link_buf輔助標(biāo)記的那個(gè)數(shù)據(jù)結(jié)構(gòu),從i開(kāi)始后面n個(gè)元素已被占用。同時(shí)Link_buf內(nèi)部維護(hù)了一個(gè)變量M表示當(dāng)前最大可達(dá)的LSN,Link_buf的結(jié)構(gòu)示意圖如下所示:

在接口層面,Link_buf實(shí)際上定義了3個(gè)有效的行為:

Redo Log Buffer內(nèi)部維護(hù)了兩個(gè)Link_buf類(lèi)型的變量recent_written和recent_closed來(lái)維護(hù)Redo Log Buffer和flush list的修改信息。
對(duì)于redo log buffer,buffer的使用情況和recent_written的對(duì)應(yīng)關(guān)系如下圖所示:

buf_ready_for_write_lsn這個(gè)變量維護(hù)的是可以保證無(wú)空洞的最大LSN值,也就是recent_written->tail的結(jié)果,在這之前的Redo Log都是可以安全的持久化到磁盤(pán)上的。
當(dāng)?shù)谝粋€(gè)空洞位置的數(shù)據(jù)被寫(xiě)入成功后,寫(xiě)入數(shù)據(jù)的mtr通過(guò)調(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后,都會(huì)觸發(fā)一個(gè)獨(dú)立的線程log_writer向后掃描recent_written并更新buf_ready_for_write_lsn 值(調(diào)用recent_written->advance_tail()方法)。log_writer線程實(shí)際上就是執(zhí)行日志寫(xiě)入到文件的線程。由log_writer線程掃描后的recent_written變量?jī)?nèi)部如下圖所示:

這樣就很好的解決了MTR并發(fā)寫(xiě)入log_buffer造成的空洞問(wèn)題。通過(guò)新引入的Link_buf類(lèi)型的數(shù)據(jù)結(jié)構(gòu),可用很方便的知道哪一部分的Redo Log可以執(zhí)行寫(xiě)入磁盤(pán)的操作。
關(guān)于更多落盤(pán)的細(xì)節(jié)
在MySQL 8中,Redo log的落盤(pán)過(guò)程交由兩個(gè)獨(dú)立的線程完成,分別 log_writer和log_flusher,前者負(fù)責(zé)將Redo Log Buffer中的數(shù)據(jù)寫(xiě)入到OS Cache中, 后者負(fù)責(zé)不停的執(zhí)行fsync操作將OS Cache中的數(shù)據(jù)真正的寫(xiě)入到磁盤(pán)里。
兩個(gè)線程通過(guò)一個(gè)全局的原子變量log_t::write_lsn同步,write_lsn表示當(dāng)前已經(jīng)寫(xiě)入到OS Cache的Redo log最大的LSN。

log buffer中的redo log的落盤(pán)不需要由用戶線程關(guān)心,用戶線程只需要在事務(wù)提交的時(shí)候,根據(jù)innodb_flush_log_at_trx_commit定義的不同行為,等待log_writer或log_flusher的通知即可。
log_writer線程會(huì)在監(jiān)聽(tīng)到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)聽(tīng)到write_lsn更新后調(diào)用一次fsync并更新flushed_to_disk_lsn,該變量保存的是最新fsync到文件的值。

在這種設(shè)計(jì)模式下,用戶線程只負(fù)責(zé)寫(xiě)日志到log_buffer中,日志的刷新和落盤(pán)是完全異步的,根據(jù)innodb_flush_log_at_trx_commit定義的不同行為,用戶線程在事務(wù)提交時(shí)需要等待日志寫(xiě)入操作系統(tǒng)緩存或磁盤(pán)。
在8.0之前,是由用戶線程觸發(fā)fsync或者等先提交的線程執(zhí)行fsync( Group Commit行為), 而在MySQL 8.0中,用戶線程只需要等待flushed_to_disk_lsn足夠大即可。

8.0中采用了一個(gè)分片的消息隊(duì)列來(lái)通知用戶線程,比如用戶線程需要等待flushed_to_disk_lsn >= X那么就會(huì)加入到X所屬的消息隊(duì)列。分片可以有效降低消息同步損耗及一次需要通知的線程數(shù)。

在8.0中,由后臺(tái)線程log_flush_notifier通知等待的用戶線程,用戶線程、log_writer、log_flusher、log_flush_notifier四個(gè)線程之間的同步關(guān)系為。

8.0中為了避免用戶線程在陷入等待狀態(tài)后立即被喚醒,用戶線程會(huì)在等待前做自旋以檢查等待條件。8.0中新增加了兩個(gè)Dynamic Variable: innodb_log_spin_cpu_abs_lwm 和innodb_log_spin_cpu_pct_hwm控制執(zhí)行自旋操作時(shí)CPU的水位,以免自旋操作占用了太多的CPU。
3、flush list 并發(fā)控制以及check point 推進(jìn)
回到上面的MTR提交的代碼,可以看到在將Redo Log寫(xiě)入全局的log buffer中以后,mtr立即開(kāi)始了將臟頁(yè)加入到flush list的步驟,其過(guò)程分為三個(gè)函數(shù)調(diào)用。

這里同樣是通過(guò)一個(gè)Link_Buf類(lèi)型的無(wú)鎖結(jié)構(gòu)recent_closed來(lái)跟蹤處理flush list并發(fā)寫(xiě)入狀態(tài)。
假設(shè)MTR在提交時(shí)產(chǎn)生的redo log的范圍是[start_lsn, end_lsn],MTR在將這些redo對(duì)應(yīng)的臟頁(yè)加入到某個(gè)flush list后,立即將start_lsn到end_lsn這段標(biāo)記在recent_closed結(jié)構(gòu)中。recent_closed同樣在內(nèi)部維護(hù)了變量M,M對(duì)應(yīng)著一個(gè)LSN,表示所有小于該LSN的臟頁(yè)都加入到了flush list中。
而與redo log寫(xiě)入不同的是,MTR在寫(xiě)入flush list之前,需要等待M值與start_lsn相差不是太多才可以寫(xiě)入。這是為了將flush list上的空洞控制在一個(gè)范圍之內(nèi),這個(gè)過(guò)程的示意圖如下:

MTR在寫(xiě)入到flush list之前,需要等待M值與start_lsn的相差范圍是一個(gè)常數(shù)L,這個(gè)常數(shù)度量了flush list中的無(wú)序度,它使得checkpoint的確定變得簡(jiǎn)單(實(shí)際代碼中,L值就是recent_closed內(nèi)部容量大小)。
從上面的代碼可以看到,在8.0中實(shí)際上加入到flush list的行為并不是完全并發(fā)的,但也不是5.7中完全串行的,而是被控制到一個(gè)范圍L之內(nèi)的并行寫(xiě)入。
由于MTR需要等待條件start_lsn - M < L成立才能加入到flush list , 反過(guò)來(lái)說(shuō),對(duì)于flush list中的每個(gè)Page ,如果其對(duì)應(yīng)的修改的LSN為L(zhǎng)n,那么可以斷定Ln - L對(duì)應(yīng)的Page一定已經(jīng)加入到了flush list中,而且一定在當(dāng)前Page之前(因?yàn)镻age添加時(shí)的檢查條件Ln-L < M,M之前是無(wú)空洞連續(xù)的LSN)。
也就是說(shuō),在延續(xù)原有的按flush list的順序刷新臟頁(yè)到磁盤(pán)的策略不變的情況下,只需要將Checkpoint的推進(jìn)由原來(lái)的Page對(duì)應(yīng)的LSN改成LSN-L即可。
MySQL 8.0中實(shí)際實(shí)現(xiàn)的時(shí)候,Checkpoint推進(jìn)仍然是按照Page對(duì)應(yīng)的LSN寫(xiě)入的,只不過(guò)Recover的時(shí)候從Checkpoint - L開(kāi)始執(zhí)行,這兩張方式實(shí)際上是等效的。
不過(guò)在MySQL 8.0中,Recover階段從Checkpoint - L的地方開(kāi)始,可能會(huì)遇到Checkpoint -L是某個(gè)Redo的中間位置而不是開(kāi)始位置的情況,所以要對(duì)一些邊界情況做一些額外的工作才行。
四、總結(jié)
對(duì)于InnoDB存儲(chǔ)引擎,Redo Log的處理是實(shí)現(xiàn)事務(wù)持久性的關(guān)鍵,在MySQL 5.7及以前,通過(guò)兩個(gè)全局鎖,實(shí)際上使MTR的提交過(guò)程串行化保證了RedoLog以及臟頁(yè)處理的正確性,這使得MTR的提交過(guò)程因?yàn)殒i競(jìng)爭(zhēng)的緣故無(wú)法充分的發(fā)揮多核的優(yōu)勢(shì)。
8.0中通過(guò)引入的Link_buf 數(shù)據(jù)結(jié)構(gòu)將整個(gè)模塊變成了Lock_free的模式,必然會(huì)帶來(lái)性能上的提升。
參考
-
MySQL8.0: 重新設(shè)計(jì)的日志子系統(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/