前言
數(shù)據(jù)庫(kù)系統(tǒng)與文件系統(tǒng)最大的區(qū)別在于數(shù)據(jù)庫(kù)能保證操作的原子性,一個(gè)操作要么不做要么都做,即使在數(shù)據(jù)庫(kù)宕機(jī)的情況下,也不會(huì)出現(xiàn)操作一半的情況,這個(gè)就需要數(shù)據(jù)庫(kù)的日志和一套完善的崩潰恢復(fù)機(jī)制來(lái)保證。本文仔細(xì)剖析了innodb的崩潰恢復(fù)流程,代碼基于5.6分支。
基礎(chǔ)知識(shí)
lsn: 可以理解為數(shù)據(jù)庫(kù)從創(chuàng)建以來(lái)產(chǎn)生的redo日志量,這個(gè)值越大,說(shuō)明數(shù)據(jù)庫(kù)的更新越多,也可以理解為更新的時(shí)刻。此外,每個(gè)數(shù)據(jù)頁(yè)上也有一個(gè)lsn,表示最后被修改時(shí)的lsn,值越大表示越晚被修改。比如,數(shù)據(jù)頁(yè)A的lsn為100,數(shù)據(jù)頁(yè)B的lsn為200,checkpoint lsn為150,系統(tǒng)lsn為300,表示當(dāng)前系統(tǒng)已經(jīng)更新到300,小于150的數(shù)據(jù)頁(yè)已經(jīng)被刷到磁盤(pán)上,因此數(shù)據(jù)頁(yè)A的最新數(shù)據(jù)一定在磁盤(pán)上,而數(shù)據(jù)頁(yè)B則不一定,有可能還在內(nèi)存中。
redo日志: 現(xiàn)代數(shù)據(jù)庫(kù)都需要寫(xiě)redo日志,例如修改一條數(shù)據(jù),首先寫(xiě)redo日志,然后再寫(xiě)數(shù)據(jù)。在寫(xiě)完redo日志后,就直接給客戶端返回成功。這樣雖然看過(guò)去多寫(xiě)了一次盤(pán),但是由于把對(duì)磁盤(pán)的隨機(jī)寫(xiě)入(寫(xiě)數(shù)據(jù))轉(zhuǎn)換成了順序的寫(xiě)入(寫(xiě)redo日志),性能有很大幅度的提高。當(dāng)數(shù)據(jù)庫(kù)掛了之后,通過(guò)掃描redo日志,就能找出那些沒(méi)有刷盤(pán)的數(shù)據(jù)頁(yè)(在崩潰之前可能數(shù)據(jù)頁(yè)僅僅在內(nèi)存中修改了,但是還沒(méi)來(lái)得及寫(xiě)盤(pán)),保證數(shù)據(jù)不丟。
undo日志: 數(shù)據(jù)庫(kù)還提供類似撤銷(xiāo)的功能,當(dāng)你發(fā)現(xiàn)修改錯(cuò)一些數(shù)據(jù)時(shí),可以使用rollback指令回滾之前的操作。這個(gè)功能需要undo日志來(lái)支持。此外,現(xiàn)代的關(guān)系型數(shù)據(jù)庫(kù)為了提高并發(fā)(同一條記錄,不同線程的讀取不沖突,讀寫(xiě)和寫(xiě)讀不沖突,只有同時(shí)寫(xiě)才沖突),都實(shí)現(xiàn)了類似MVCC的機(jī)制,在InnoDB中,這個(gè)也依賴undo日志。為了實(shí)現(xiàn)統(tǒng)一的管理,與redo日志不同,undo日志在Buffer Pool中有對(duì)應(yīng)的數(shù)據(jù)頁(yè),與普通的數(shù)據(jù)頁(yè)一起管理,依據(jù)LRU規(guī)則也會(huì)被淘汰出內(nèi)存,后續(xù)再?gòu)拇疟P(pán)讀取。與普通的數(shù)據(jù)頁(yè)一樣,對(duì)undo頁(yè)的修改,也需要先寫(xiě)redo日志。
檢查點(diǎn): 英文名為checkpoint。數(shù)據(jù)庫(kù)為了提高性能,數(shù)據(jù)頁(yè)在內(nèi)存修改后并不是每次都會(huì)刷到磁盤(pán)上。checkpoint之前的數(shù)據(jù)頁(yè)保證一定落盤(pán)了,這樣之前的日志就沒(méi)有用了(由于InnoDB redolog日志循環(huán)使用,這時(shí)這部分日志就可以被覆蓋),checkpoint之后的數(shù)據(jù)頁(yè)有可能落盤(pán),也有可能沒(méi)有落盤(pán),所以checkpoint之后的日志在崩潰恢復(fù)的時(shí)候還是需要被使用的。InnoDB會(huì)依據(jù)臟頁(yè)的刷新情況,定期推進(jìn)checkpoint,從而減少數(shù)據(jù)庫(kù)崩潰恢復(fù)的時(shí)間。檢查點(diǎn)的信息在第一個(gè)日志文件的頭部。
崩潰恢復(fù): 用戶修改了數(shù)據(jù),并且收到了成功的消息,然而對(duì)數(shù)據(jù)庫(kù)來(lái)說(shuō),可能這個(gè)時(shí)候修改后的數(shù)據(jù)還沒(méi)有落盤(pán),如果這時(shí)候數(shù)據(jù)庫(kù)掛了,重啟后,數(shù)據(jù)庫(kù)需要從日志中把這些修改后的數(shù)據(jù)給撈出來(lái),重新寫(xiě)入磁盤(pán),保證用戶的數(shù)據(jù)不丟。這個(gè)從日志中撈數(shù)據(jù)的過(guò)程就是崩潰恢復(fù)的主要任務(wù),也可以成為數(shù)據(jù)庫(kù)前滾。當(dāng)然,在崩潰恢復(fù)中還需要回滾沒(méi)有提交的事務(wù),提交沒(méi)有提交成功的事務(wù)。由于回滾操作需要undo日志的支持,undo日志的完整性和可靠性需要redo日志來(lái)保證,所以崩潰恢復(fù)先做redo前滾,然后做undo回滾。
我們從源碼角度仔細(xì)剖析一下數(shù)據(jù)庫(kù)崩潰恢復(fù)過(guò)程。整個(gè)過(guò)程都在引擎初始化階段完成(innobase_init),其中最主要的函數(shù)是innobase_start_or_create_for_mysql,innodb通過(guò)這個(gè)函數(shù)完成創(chuàng)建和初始化,包括崩潰恢復(fù)。首先來(lái)介紹一下數(shù)據(jù)庫(kù)的前滾。
redo日志前滾數(shù)據(jù)庫(kù)
前滾數(shù)據(jù)庫(kù),主要分為兩階段,首先是日志掃描階段,掃描階段按照數(shù)據(jù)頁(yè)的space_id和page_no分發(fā)redo日志到hash_table中,保證同一個(gè)數(shù)據(jù)頁(yè)的日志被分發(fā)到同一個(gè)哈希桶中,且按照l(shuí)sn大小從小到大排序。掃描完后,再遍歷整個(gè)哈希表,依次應(yīng)用每個(gè)數(shù)據(jù)頁(yè)的日志,應(yīng)用完后,在數(shù)據(jù)頁(yè)的狀態(tài)上至少恢復(fù)到了崩潰之前的狀態(tài)。我們來(lái)詳細(xì)分析一下代碼。
首先,打開(kāi)所有的ibdata文件(open_or_create_data_files)(ibdata可以有多個(gè)),每個(gè)ibdata文件有個(gè)flush_lsn在頭部,計(jì)算出這些文件中的max_flush_lsn和min_flush_lsn,因?yàn)閕bdata也有可能有數(shù)據(jù)沒(méi)寫(xiě)完整,需要恢復(fù),后續(xù)(recv_recovery_from_checkpoint_start_func)通過(guò)比較checkpont_lsn和這兩個(gè)值來(lái)確定是否需要對(duì)ibdata前滾。
接著,打開(kāi)系統(tǒng)表空間和日志表空間的所有文件(fil_open_log_and_system_tablespace_files),防止出現(xiàn)文件句柄不足,清空buffer pool(buf_pool_invalidate)。接下來(lái)就進(jìn)入最最核心的函數(shù):recv_recovery_from_checkpoint_start_func,注意,即使數(shù)據(jù)庫(kù)是正常關(guān)閉的,也會(huì)進(jìn)入。
雖然recv_recovery_from_checkpoint_start_func看過(guò)去很冗長(zhǎng),但是很多代碼都是為了LOG_ARCHIVE特性而編寫(xiě)的,真正數(shù)據(jù)崩潰恢復(fù)的代碼其實(shí)不多。
首先,初始化一些變量,查看srv_force_recovery這個(gè)變量,如果用戶設(shè)置跳過(guò)前滾階段,函數(shù)直接返回。
接著,初始化recv_sys結(jié)構(gòu),分配hash_table的大小,同時(shí)初始化flush list rbtree。recv_sys結(jié)構(gòu)主要在崩潰恢復(fù)前滾階段使用。hash_table就是之前說(shuō)的用來(lái)存不同數(shù)據(jù)頁(yè)日志的哈希表,哈希表的大小被初始化為buffer_size_in_bytes/512, 這個(gè)是哈希表最大的長(zhǎng)度,超過(guò)就存不下了,幸運(yùn)的是,需要恢復(fù)的數(shù)據(jù)頁(yè)的個(gè)數(shù)不會(huì)超過(guò)這個(gè)值,因?yàn)閎uffer poll最多(數(shù)據(jù)庫(kù)崩潰之前臟頁(yè)的上線)只能存放buffer_size_in_bytes/16KB個(gè)數(shù)據(jù)頁(yè),即使考慮壓縮頁(yè),最多也只有buffer_size_in_bytes/1KB個(gè),此外關(guān)于這個(gè)哈希表內(nèi)存分配的大小,可以參考bug#53122。flush list rbtree這個(gè)主要是為了加入插入臟頁(yè)列表,InnoDB的flush list必須按照數(shù)據(jù)頁(yè)的最老修改lsn(oldest_modifcation)從小到大排序,在數(shù)據(jù)庫(kù)正常運(yùn)行時(shí),可以通過(guò)log_sys->mutex和log_sys->log_flush_order_mutex保證順序,在崩潰恢復(fù)則沒(méi)有這種保證,應(yīng)用數(shù)據(jù)的時(shí)候,是從第一個(gè)元素開(kāi)始遍歷哈希表,不能保證數(shù)據(jù)頁(yè)按照最老修改lsn(oldest_modifcation)從小到大排序,這樣就需要線性遍歷flush_list來(lái)尋找插入位置,效率太低,因此引入紅黑樹(shù),加快查找插入的位置。
接著,從ib_logfile0的頭中讀取checkpoint信息,主要包括checkpoint_lsn和checkpoint_no。由于InnoDB日志是循環(huán)使用的,且最少要有2個(gè),所以ib_logfile0一定存在,把checkpoint信息存在里面很安全,不用擔(dān)心被刪除。checkpoint信息其實(shí)會(huì)寫(xiě)在文件頭的兩個(gè)地方,兩個(gè)checkpoint域輪流寫(xiě)。為什么要兩個(gè)地方輪流寫(xiě)呢?假設(shè)只有一個(gè)checkpoint域,一直更新這個(gè)域,而checkpoint域有512字節(jié)(OS_FILE_LOG_BLOCK_SIZE),如果剛好在寫(xiě)這個(gè)512字節(jié)的時(shí)候,數(shù)據(jù)庫(kù)掛了,服務(wù)器也掛了(先不考慮硬件的原子寫(xiě)特性,早期的硬件沒(méi)有這個(gè)特性),這個(gè)512字節(jié)可能只寫(xiě)了一半,導(dǎo)致整個(gè)checkpoint域不可用。這樣數(shù)據(jù)庫(kù)將無(wú)法做崩潰恢復(fù),從而無(wú)法啟動(dòng)。如果有兩個(gè)checkpoint域,那么即使一個(gè)寫(xiě)壞了,還可以用另外一個(gè)嘗試恢復(fù),雖然有可能這個(gè)時(shí)候日志已經(jīng)被覆蓋,但是至少提高了恢復(fù)成功的概率。兩個(gè)checkpoint域輪流寫(xiě),也能減少磁盤(pán)扇區(qū)故障帶來(lái)的影響。checkpoint_lsn之前的數(shù)據(jù)頁(yè)都已經(jīng)落盤(pán),不需要前滾,之后的數(shù)據(jù)頁(yè)可能還沒(méi)落盤(pán),需要重新恢復(fù)出來(lái),即使已經(jīng)落盤(pán)也沒(méi)關(guān)系,因?yàn)閞edo日志時(shí)冪等的,應(yīng)用一次和應(yīng)用兩次都一樣(底層實(shí)現(xiàn): 如果數(shù)據(jù)頁(yè)上的lsn大于等于當(dāng)前redo日志的lsn,就不應(yīng)用,否則應(yīng)用。checkpoint_no可以理解為checkpoint域?qū)懕P(pán)的次數(shù),每次刷盤(pán)遞增1,同時(shí)這個(gè)值取模2可以用來(lái)實(shí)現(xiàn)checkpoint_no域的輪流寫(xiě)。正常邏輯下,選取checkpoint_no值大的作為最終的checkpoint信息,用來(lái)做后續(xù)崩潰恢復(fù)掃描的起始點(diǎn)。
接著,使用checkpoint域的信息初始化recv_sys結(jié)構(gòu)體的一些信息后,就進(jìn)入日志解析的核心函數(shù)recv_group_scan_log_recs,這個(gè)函數(shù)后續(xù)我們?cè)俜治觯饕饔镁褪墙馕鰎edo日志,如果內(nèi)存不夠了,就直接調(diào)用應(yīng)用(recv_apply_hashed_log_recs)日志,然后再接著解析。如果需要應(yīng)用的日志很少,就僅僅解析分發(fā)日志,到recv_recovery_from_checkpoint_finish函數(shù)中在應(yīng)用日志。
接著,依據(jù)當(dāng)前刷盤(pán)的數(shù)據(jù)頁(yè)狀態(tài)做一次checkpoint,因?yàn)樵趓ecv_group_scan_log_recs里可能已經(jīng)應(yīng)用部分日志了。至此recv_recovery_from_checkpoint_start_func函數(shù)結(jié)束。
在recv_recovery_from_checkpoint_finish函數(shù)中,如果srv_force_recovery設(shè)置正確,就開(kāi)始調(diào)用函數(shù)recv_apply_hashed_log_recs應(yīng)用日志,然后等待刷臟的線程退出(線程是崩潰恢復(fù)時(shí)臨時(shí)啟動(dòng)的),最后釋放recv_sys的相關(guān)資源以及hash_table占用的內(nèi)存。
至此,數(shù)據(jù)庫(kù)前滾結(jié)束。接下來(lái),我們?cè)敿?xì)分析一下redo日志解析函數(shù)以及redo日志應(yīng)用函數(shù)的實(shí)現(xiàn)細(xì)節(jié)。
redo日志解析函數(shù)
解析函數(shù)的最上層是recv_group_scan_log_recs,這個(gè)函數(shù)調(diào)用底層函數(shù)(log_group_read_log_seg),按照RECV_SCAN_SIZE(64KB)大小分批讀取。讀取出來(lái)后,首先通過(guò)block_no和lsn之間的關(guān)系以及日志checksum判斷是否讀到了日志最后(所以可以看出,并沒(méi)一個(gè)標(biāo)記在日志頭標(biāo)記日志的有效位置,完全是按照上述兩個(gè)條件判斷是否到達(dá)了日志尾部),如果讀到最后則返回(之前說(shuō)了,即使數(shù)據(jù)庫(kù)是正常關(guān)閉的,也要走崩潰恢復(fù)邏輯,那么在這里就返回了,因?yàn)檎jP(guān)閉的checkpoint值一定是指向日志最后),否則則把日志去頭掐尾放到一個(gè)recv_sys->buf中,日志頭里面存了一些控制信息和checksum值,只是用來(lái)校驗(yàn)和定位,在真正的應(yīng)用中沒(méi)有用。在放到recv_sys->buf之前,需要檢驗(yàn)一下recv_sys->buf有沒(méi)有滿(RECV_PARSING_BUF_SIZE,2M),滿了就報(bào)錯(cuò)(如果上一批解析有不完整的日志,日志解析函數(shù)不會(huì)分發(fā),而是把這些不完整的日志留在recv_sys->buf中,直到解析到完整的日志)。接下的事情就是從recv_sys->buf中解析日志(recv_parse_log_recs)。日志分兩種:single_rec和multi_rec,前者表示只對(duì)一個(gè)數(shù)據(jù)頁(yè)進(jìn)行一種操作,后者表示對(duì)一個(gè)或者多個(gè)數(shù)據(jù)頁(yè)進(jìn)行多種操作。日志中還包括對(duì)應(yīng)數(shù)據(jù)頁(yè)的space_id,page_no,操作的type以及操作的內(nèi)容(recv_parse_log_rec)。解析出相應(yīng)的日志后,按照space_id和page_no進(jìn)行哈希(如果對(duì)應(yīng)的表空間在內(nèi)存中不存在,則表示表已經(jīng)被刪除了),放到hash_table里面(日志真正存放的位置依然在buffer pool)即可,等待后續(xù)應(yīng)用。這里有幾個(gè)點(diǎn)值得注意:
-
如果是multi_rec類型,則只有遇到MLOG_MULTI_REC_END這個(gè)標(biāo)記,日志才算完整,才會(huì)被分發(fā)到hash_table中。查看代碼,我們可以發(fā)現(xiàn)multi_rec類型的日志被解析了兩次,一次用來(lái)校驗(yàn)完整性(尋找MLOG_MULTI_REC_END),第二次才用來(lái)分發(fā)日志,感覺(jué)這是一個(gè)可以優(yōu)化的點(diǎn)。
-
目前日志的操作type有50多種,每種操作后面的內(nèi)容都不一樣,所以長(zhǎng)度也不一樣,目前日志的解析邏輯,需要依次解析出所有的內(nèi)容,然后確定長(zhǎng)度,從而定位下一條日志的開(kāi)始位置。這種方法效率略低,其實(shí)可以在每種操作的頭上加上一個(gè)字段,存儲(chǔ)后面內(nèi)容的長(zhǎng)度,這樣就不需要解析太多的內(nèi)容,從而提高解析速度,進(jìn)一步提高崩潰恢復(fù)速度,從結(jié)果看,可以提高一倍的速度(從38秒到14秒,詳情可以參見(jiàn)bug#82937)。
-
如果發(fā)現(xiàn)checkpoint之后還有日志,說(shuō)明數(shù)據(jù)庫(kù)之前沒(méi)有正常關(guān)閉,需要做崩潰恢復(fù),因此需要做一些額外的操作(recv_init_crash_recovery),比如在錯(cuò)誤日志中打印我們常見(jiàn)的“Database was not shutdown normally!”和“Starting crash recovery.”,還要從double write buffer中檢查是否發(fā)生了數(shù)據(jù)頁(yè)半寫(xiě),如果有需要恢復(fù)(buf_dblwr_process),還需要啟動(dòng)一個(gè)線程用來(lái)刷新應(yīng)用日志產(chǎn)生的臟頁(yè)(因?yàn)檫@個(gè)時(shí)候buf_flush_page_cleaner_thread還沒(méi)有啟動(dòng))。最后還需要打開(kāi)所有的表空間。。注意是所有的表。。。我們?cè)诎⒗镌芌DS MySQL的運(yùn)維中,常常發(fā)現(xiàn)數(shù)據(jù)庫(kù)hang在了崩潰恢復(fù)階段,在錯(cuò)誤日志中有類似“Reading tablespace information from the .ibd files…”字樣,這就表示數(shù)據(jù)庫(kù)正在打開(kāi)所有的表,然后一看表的數(shù)量,發(fā)現(xiàn)有幾十甚至上百萬(wàn)張表。。。數(shù)據(jù)庫(kù)之所以要打開(kāi)所有的表,是因?yàn)樵诜职l(fā)日志的時(shí)候,需要確定space_id對(duì)應(yīng)哪個(gè)ibd文件,通過(guò)打開(kāi)所有的表,讀取space_id信息來(lái)確定,另外一個(gè)原因是方便double write buffer檢查半寫(xiě)數(shù)據(jù)頁(yè)。針對(duì)這個(gè)表數(shù)量過(guò)多導(dǎo)致恢復(fù)過(guò)慢的問(wèn)題,MySQL 5.7做了優(yōu)化,WL#7142, 主要思想就是在每次checkpoint后,在第一次修改某個(gè)表時(shí),先寫(xiě)一個(gè)新日志mlog_file_name(包括space_id和filename的映射),來(lái)表示對(duì)這個(gè)表進(jìn)行了操作,后續(xù)對(duì)這個(gè)表的操作就不用寫(xiě)這個(gè)新日志了,當(dāng)需要崩潰恢復(fù)時(shí)候,多一次掃描,通過(guò)搜集mlog_file_name來(lái)確定哪些表被修改過(guò),這樣就不需要打開(kāi)所有的表來(lái)確定space_id了。
-
最后一個(gè)值得注意的地方是內(nèi)存。之前說(shuō)過(guò),如果有太多的日志已經(jīng)被分發(fā),占用了太多的內(nèi)存,日志解析函數(shù)會(huì)在適當(dāng)?shù)臅r(shí)候應(yīng)用日志,而不是等到最后才一起應(yīng)用。那么問(wèn)題來(lái)了,使用了多大的內(nèi)存就會(huì)出發(fā)應(yīng)用日志邏輯。答案是:buffer_pool_size_in_bytes – 512 * buffer_pool_instance_num * 16KB。由于buffer_pool_instance_num一般不會(huì)太大,所以可以任務(wù),buffer pool的大部分內(nèi)存都被用來(lái)存放日志。剩下的那些主要留給應(yīng)用日志時(shí)讀取的數(shù)據(jù)頁(yè),因?yàn)槟壳皝?lái)說(shuō)日志應(yīng)用是單線程的,讀取一個(gè)日志,把所有日志應(yīng)用完,然后就可以刷回磁盤(pán)了,不需要太多的內(nèi)存。
redo日志應(yīng)用函數(shù)
應(yīng)用日志的上層函數(shù)為recv_apply_hashed_log_recs(應(yīng)用日志也可能在io_helper函數(shù)中進(jìn)行),主要作用就是遍歷hash_table,從磁盤(pán)讀取對(duì)每個(gè)數(shù)據(jù)頁(yè),依次應(yīng)用哈希桶中的日志。應(yīng)用完所有的日志后,如果需要?jiǎng)t把buffer_pool的頁(yè)面都刷盤(pán),畢竟空間有限。有以下幾點(diǎn)值得注意:
-
同一個(gè)數(shù)據(jù)頁(yè)的日志必須按照l(shuí)sn從小到大應(yīng)用,否則數(shù)據(jù)會(huì)被覆蓋。只應(yīng)用redo日志lsn大于page_lsn的日志,只有這些日志需要重做,其余的忽略。應(yīng)用完日志后,把臟頁(yè)加入臟頁(yè)列表,由于臟頁(yè)列表是按照最老修改lsn(oldest_modification)來(lái)排序的,這里通過(guò)引入一顆紅黑樹(shù)來(lái)加速查找插入的位置,時(shí)間復(fù)雜度從之前的線性查找降為對(duì)數(shù)級(jí)別。
-
當(dāng)需要某個(gè)數(shù)據(jù)頁(yè)的時(shí)候,如果發(fā)現(xiàn)其沒(méi)有在Buffer Pool中,則會(huì)查看這個(gè)數(shù)據(jù)頁(yè)周?chē)?2個(gè)數(shù)據(jù)頁(yè),是否也需要做恢復(fù),如果需要?jiǎng)t可以一起讀取出來(lái),相當(dāng)于做了一次io合并,減少io操作(recv_read_in_area)。由于這個(gè)是異步讀取,所以最終應(yīng)用日志的活兒是由io_helper線程來(lái)做的(buf_page_io_complete),此外,為了防止短時(shí)間發(fā)起太多的io,在代碼中加了流量控制的邏輯(buf_read_recv_pages)。如果發(fā)現(xiàn)某個(gè)數(shù)據(jù)頁(yè)在內(nèi)存中,則直接調(diào)用recv_recover_page應(yīng)用日志。由此我們可以看出,InnoDB應(yīng)用日志其實(shí)并不是單線程的來(lái)應(yīng)用日志的,除了崩潰恢復(fù)的主線程外,io_helper線程也會(huì)參與恢復(fù)。并發(fā)線程數(shù)取決于io_helper中讀取線程的個(gè)數(shù)。
執(zhí)行完了redo前滾數(shù)據(jù)庫(kù),數(shù)據(jù)庫(kù)的所有數(shù)據(jù)頁(yè)已經(jīng)處于一致的狀態(tài),undo回滾數(shù)據(jù)庫(kù)就可以安全的執(zhí)行了。數(shù)據(jù)庫(kù)崩潰的時(shí)候可能有一些沒(méi)有提交的事務(wù)或者已經(jīng)提交的事務(wù),這個(gè)時(shí)候就需要決定是否提交。主要分為三步,首先是掃描undo日志,重新建立起undo日志鏈表,接著是,依據(jù)上一步建立起的鏈表,重建崩潰前的事務(wù),即恢復(fù)當(dāng)時(shí)事務(wù)的狀態(tài)。最后,就是依據(jù)事務(wù)的不同狀態(tài),進(jìn)行回滾或者提交。
undo日志回滾數(shù)據(jù)庫(kù)
在recv_recovery_from_checkpoint_start_func之后,recv_recovery_from_checkpoint_finish之前,調(diào)用了trx_sys_init_at_db_start,這個(gè)函數(shù)做了上述三步中的前兩步。
第一步在函數(shù)trx_rseg_array_init中處理,遍歷整個(gè)undo日志空間(最多TRX_SYS_N_RSEGS(128)個(gè)segment),如果發(fā)現(xiàn)某個(gè)undo segment非空,就進(jìn)行初始化(trx_rseg_create_instance)。整個(gè)每個(gè)undo segment,如果發(fā)現(xiàn)undo slot非空(最多TRX_RSEG_N_SLOTS(1024)個(gè)slot),也就行初始化(trx_undo_lists_init)。在初始化undo slot后,就把不同類型的undo日志放到不同鏈表中(trx_undo_mem_create_at_db_start)。undo日志主要分為兩種:TRX_UNDO_INSERT和TRX_UNDO_UPDATE。前者主要是提供給insert操作用的,后者是給update和delete操作使用。之前說(shuō)過(guò),undo日志有兩種作用,事務(wù)回滾時(shí)候用和MVCC快照讀取時(shí)候用。由于insert的數(shù)據(jù)不需要提供給其他線程用,所以只要事務(wù)提交,就可以刪除TRX_UNDO_INSERT類型的undo日志。TRX_UNDO_UPDATE在事務(wù)提交后還不能刪除,需要保證沒(méi)有快照使用它的時(shí)候,才能通過(guò)后臺(tái)的purge線程清理。
第二步在函數(shù)trx_lists_init_at_db_start中進(jìn)行,由于第一步中,已經(jīng)在內(nèi)存中建立起了undo_insert_list和undo_update_list(鏈表每個(gè)undo segment獨(dú)立),所以這一步只需要遍歷所有鏈表,重建起事務(wù)的狀態(tài)(trx_resurrect_insert和trx_resurrect_update)。簡(jiǎn)單的說(shuō),如果undo日志的狀態(tài)是TRX_UNDO_ACTIVE,則事務(wù)的狀態(tài)為T(mén)RX_ACTIVE,如果undo日志的狀態(tài)是TRX_UNDO_PREPARED,則事務(wù)的狀態(tài)為T(mén)RX_PREPARED。這里還要考慮變量srv_force_recovery的設(shè)置,如果這個(gè)變量值為非0,所有的事務(wù)都會(huì)回滾(即事務(wù)被設(shè)置為T(mén)RX_ACTIVE),即使事務(wù)的狀態(tài)應(yīng)該為T(mén)RX_STATE_PREPARED。重建起事務(wù)后,按照事務(wù)id加入到trx_sys->trx_list鏈表中。最后,在函數(shù)trx_sys_init_at_db_start中,會(huì)統(tǒng)計(jì)所有需要回滾的事務(wù)(事務(wù)狀態(tài)為T(mén)RX_ACTIVE)一共需要回滾多少行數(shù)據(jù),輸出到錯(cuò)誤日志中,類似:5 transaction(s) which must be rolled back or cleaned up。InnoDB: in total 342232 row operations to undo的字樣。
第三步的操作在兩個(gè)地方被調(diào)用。一個(gè)是在recv_recovery_from_checkpoint_finish的最后,另外一個(gè)是在recv_recovery_rollback_active中。前者主要是回滾對(duì)數(shù)據(jù)字典的操作,也就是回滾DDL語(yǔ)句的操作,后者是回滾DML語(yǔ)句。前者是在數(shù)據(jù)庫(kù)可提供服務(wù)之前必須完成,后者則可以在數(shù)據(jù)庫(kù)提供服務(wù)(也即是崩潰恢復(fù)結(jié)束)之后繼續(xù)進(jìn)行(通過(guò)新開(kāi)一個(gè)后臺(tái)線程trx_rollback_or_clean_all_recovered來(lái)處理)。因?yàn)镮nnoDB認(rèn)為數(shù)據(jù)字典是最重要的,必須要回滾到一致的狀態(tài)才行,而用戶表的數(shù)據(jù)可以稍微慢一點(diǎn),對(duì)外提供服務(wù)后,慢慢恢復(fù)即可。因此我們常常在會(huì)發(fā)現(xiàn)數(shù)據(jù)庫(kù)已經(jīng)啟動(dòng)起來(lái)了,然后錯(cuò)誤日志中還在不斷的打印回滾事務(wù)的信息。事務(wù)回滾的核心函數(shù)是trx_rollback_or_clean_recovered,邏輯很簡(jiǎn)單,只需要遍歷trx_sys->trx_list,按照事務(wù)不同的狀態(tài)回滾或者提交即可(trx_rollback_resurrected)。這里要注意的是,如果事務(wù)是TRX_STATE_PREPARED狀態(tài),那么在InnoDB層,不做處理,需要在Server層依據(jù)binlog的情況來(lái)決定是否回滾事務(wù),如果binlog已經(jīng)寫(xiě)了,事務(wù)就提交,因?yàn)閎inlog寫(xiě)了就可能被傳到備庫(kù),如果主庫(kù)回滾會(huì)導(dǎo)致主備數(shù)據(jù)不一致,如果binlog沒(méi)有寫(xiě),就回滾事務(wù)。
崩潰恢復(fù)相關(guān)參數(shù)解析
innodb_fast_shutdown:
innodb_fast_shutdown = 0。這個(gè)表示在MySQL關(guān)閉的時(shí)候,執(zhí)行slow shutdown,不但包括日志的刷盤(pán),數(shù)據(jù)頁(yè)的刷盤(pán),還包括數(shù)據(jù)的清理(purge),ibuf的合并,buffer pool dump以及l(fā)azy table drop操作(如果表上有未完成的操作,即使執(zhí)行了drop table且返回成功了,表也不一定立刻被刪除)。
innodb_fast_shutdown = 1。這個(gè)是默認(rèn)值,表示在MySQL關(guān)閉的時(shí)候,僅僅把日志和數(shù)據(jù)刷盤(pán)。
innodb_fast_shutdown = 2。這個(gè)表示關(guān)閉的時(shí)候,僅僅日志刷盤(pán),其他什么都不做,就好像MySQL crash了一樣。
這個(gè)參數(shù)值越大,MySQL關(guān)閉的速度越快,但是啟動(dòng)速度越慢,相當(dāng)于把關(guān)閉時(shí)候需要做的工作挪到了崩潰恢復(fù)上。另外,如果MySQL要升級(jí),建議使用第一種方式進(jìn)行一次干凈的shutdown。
innodb_force_recovery:
這個(gè)參數(shù)主要用來(lái)控制InnoDB啟動(dòng)時(shí)候做哪些工作,數(shù)值越大,做的工作越少,啟動(dòng)也更加容易,但是數(shù)據(jù)不一致的風(fēng)險(xiǎn)也越大。當(dāng)MySQL因?yàn)槟承┎豢煽氐脑虿荒軉?dòng)時(shí),可以設(shè)置這個(gè)參數(shù),從1開(kāi)始逐步遞增,知道MySQL啟動(dòng),然后使用SELECT INTO OUTFILE把數(shù)據(jù)導(dǎo)出,盡最大的努力減少數(shù)據(jù)丟失。
innodb_force_recovery = 0。這個(gè)是默認(rèn)的參數(shù),啟動(dòng)的時(shí)候會(huì)做所有的事情,包括redo日志應(yīng)用,undo日志回滾,啟動(dòng)后臺(tái)master和purge線程,ibuf合并。檢測(cè)到了數(shù)據(jù)頁(yè)損壞了,如果是系統(tǒng)表空間的,則會(huì)crash,用戶表空間的,則打錯(cuò)誤日志。
innodb_force_recovery = 1。如果檢測(cè)到數(shù)據(jù)頁(yè)損壞了,不會(huì)crash也不會(huì)報(bào)錯(cuò)(buf_page_io_complete),啟動(dòng)的時(shí)候也不會(huì)校驗(yàn)表空間第一個(gè)數(shù)據(jù)頁(yè)的正確性(fil_check_first_page),表空間無(wú)法訪問(wèn)也繼續(xù)做崩潰恢復(fù)(fil_open_single_table_tablespace、fil_load_single_table_tablespace),ddl操作不能進(jìn)行(check_if_supported_inplace_alter),同時(shí)數(shù)據(jù)庫(kù)也被不能進(jìn)行寫(xiě)入操作(row_insert_for_mysql、row_update_for_mysql等),所有的prepare事務(wù)也會(huì)被回滾(trx_resurrect_insert、trx_resurrect_update_in_prepared_state)。這個(gè)選項(xiàng)還是很常用的,數(shù)據(jù)頁(yè)可能是因?yàn)榇疟P(pán)壞了而損壞了,設(shè)置為1,能保證數(shù)據(jù)庫(kù)正常啟動(dòng)。
innodb_force_recovery = 2。除了設(shè)置1之后的操作不會(huì)運(yùn)行,后臺(tái)的master和purge線程就不會(huì)啟動(dòng)了(srv_master_thread、srv_purge_coordinator_thread等),當(dāng)你發(fā)現(xiàn)數(shù)據(jù)庫(kù)因?yàn)檫@兩個(gè)線程的原因而無(wú)法啟動(dòng)時(shí),可以設(shè)置。
innodb_force_recovery = 3。除了設(shè)置2之后的操作不會(huì)運(yùn)行,undo回滾數(shù)據(jù)庫(kù)也不會(huì)進(jìn)行,但是回滾段依然會(huì)被掃描,undo鏈表也依然會(huì)被創(chuàng)建(trx_sys_init_at_db_start)。srv_read_only_mode會(huì)被打開(kāi)。
innodb_force_recovery = 4。除了設(shè)置3之后的操作不會(huì)運(yùn)行,ibuf的操作也不會(huì)運(yùn)行(ibuf_merge_or_delete_for_page),表信息統(tǒng)計(jì)的線程也不會(huì)運(yùn)行(因?yàn)橐粋€(gè)壞的索引頁(yè)會(huì)導(dǎo)致數(shù)據(jù)庫(kù)崩潰)(info_low、dict_stats_update等)。從這個(gè)選項(xiàng)開(kāi)始,之后的所有選項(xiàng),都會(huì)損壞數(shù)據(jù),慎重使用。
innodb_force_recovery = 5。除了設(shè)置4之后的操作不會(huì)運(yùn)行,回滾段也不會(huì)被掃描(recv_recovery_rollback_active),undo鏈表也不會(huì)被創(chuàng)建,這個(gè)主要用在undo日志被寫(xiě)壞的情況下。
innodb_force_recovery = 6。除了設(shè)置5之后的操作不會(huì)運(yùn)行,數(shù)據(jù)庫(kù)前滾操作也不會(huì)進(jìn)行,包括解析和應(yīng)用(recv_recovery_from_checkpoint_start_func)。
總結(jié)
InnoDB實(shí)現(xiàn)了一套完善的崩潰恢復(fù)機(jī)制,保證在任何狀態(tài)下(包括在崩潰恢復(fù)狀態(tài)下)數(shù)據(jù)庫(kù)掛了,都能正常恢復(fù),這個(gè)是與文件系統(tǒng)最大的差別。此外,崩潰恢復(fù)通過(guò)redo日志這種物理日志來(lái)應(yīng)用數(shù)據(jù)頁(yè)的方法,給MySQL Replication帶來(lái)了新的思路,備庫(kù)是否可以通過(guò)類似應(yīng)用redo日志的方式來(lái)同步數(shù)據(jù)呢?阿里云RDS MySQL團(tuán)隊(duì)在后續(xù)的產(chǎn)品中,給大家?guī)?lái)了類似的特性,敬請(qǐng)期待。