MySQL · 引擎特性 · InnoDB 崩溃恢复过程
在前面兩期月報中,我們詳細介紹了 InnoDB redo log 和 undo log 的相關知識,本文將介紹 InnoDB 在崩潰恢復時的主要流程。
本文代碼分析基于 MySQL 5.7.7-RC 版本,函數入口為?innobase_start_or_create_for_mysql,這是一個非常冗長的函數,本文只涉及和崩潰恢復相關的代碼。
在閱讀本文前,強烈建議翻閱我們之前的兩期月報:
1.?MySQL · 引擎特性 · InnoDB undo log 漫游
2.?MySQL · 引擎特性 · InnoDB redo log漫游
初始化崩潰恢復
首先初始化崩潰恢復所需要的內存對象:
recv_sys_create();recv_sys_init(buf_pool_get_curr_size());當InnoDB正常shutdown,在flush redo log 和臟頁后,會做一次完全同步的checkpoint,并將checkpoint的LSN寫到ibdata的第一個page中(fil_write_flushed_lsn)。
在重啟實例時,會打開系統表空間ibdata,并讀取存儲在其中的LSN:
err = srv_sys_space.open_or_create(false, &sum_of_new_sizes, &flushed_lsn);上述調用將從ibdata中讀取的LSN存儲到變量flushed_lsn中,表示上次shutdown時的checkpoint點,在后面做崩潰恢復時會用到。另外這里也會將double write buffer內存儲的page載入到內存中(buf_dblwr_init_or_load_pages),如果ibdata的第一個page損壞了,就從dblwr中恢復出來。
Tips:注意在MySQL 5.6.16之前的版本中,如果InnoDB的表空間第一個page損壞了,就認為無法確定這個表空間的space id,也就無法決定使用dblwr中的哪個page來進行恢復,InnoDB將崩潰恢復失敗(bug#70087),
由于每個數據頁上都存儲著表空間id,因此后面將這里的邏輯修改成往后多讀幾個page,并嘗試不同的page size,直到找到一個完好的數據頁, (參考函數Datafile::find_space_id())。因此為了能安全的使用double write buffer保護數據,建議使用5.6.16及之后的MySQL版本。
恢復truncate操作
為了保證對 undo log 獨立表空間和用戶獨立表空間進行 truncate 操作的原子性,InnoDB 采用文件日志的方式為每個 truncate 操作創建一個獨特的文件,如果在重啟時這個文件存在,說明上次 truncate 操作還沒完成實例就崩潰了,在重啟時,我們需要繼續完成truncate操作。
這一塊的崩潰恢復是獨立于redo log系統之外的。
對于 undo log 表空間恢復,在初始化 undo 子系統時完成:
err = srv_undo_tablespaces_init(create_new_db,srv_undo_tablespaces,&srv_undo_tablespaces_open);對于用戶表空間,掃描數據目錄,找到 truncate 日志文件:如果文件中沒有任何數據,表示truncate還沒開始;如果文件中已經寫了一個MAGIC NUM,表示truncate操作已經完成了;這兩種情況都不需要處理。
err = TruncateLogParser::scan_and_parse(srv_log_group_home_dir);但對用戶表空間truncate操作的恢復是redo log apply完成后才進行的,這主要是因為恢復truncate可能涉及到系統表的更新操作(例如重建索引),需要在redo apply完成后才能進行。
進入redo崩潰恢復開始邏輯
入口函數:
c err = recv_recovery_from_checkpoint_start(flushed_lsn);
傳遞的參數flushed_lsn即為從ibdata第一個page讀取的LSN,主要包含以下幾步:
Step 1: 為每個buffer pool instance創建一棵紅黑樹,指向buffer_pool_t::flush_rbt,主要用于加速插入flush list (buf_flush_init_flush_rbt);
Step 2: 讀取存儲在第一個redo log文件頭的CHECKPOINT LSN,并根據該LSN定位到redo日志文件中對應的位置,從該checkpoint點開始掃描。
在這里會調用三次recv_group_scan_log_recs掃描redo log文件:
1. 第一次的目的是找到MLOG_CHECKPOINT日志
MLOG_CHECKPOINT 日志中記錄了CHECKPOINT LSN,當該日志中記錄的LSN和日志頭中記錄的CHECKPOINT LSN相同時,表示找到了符合的MLOG_CHECKPOINT LSN,將掃描到的LSN號記錄到?recv_sys->mlog_checkpoint_lsn?中。(在5.6版本里沒有這一次掃描)
MLOG_CHECKPOINT在WL#7142中被引入,其目的是為了簡化 InnoDB 崩潰恢復的邏輯,根據WL#7142的描述,包含幾點改進:
這里可能會產生的問題是,如果MLOG_CHECKPOINT日志和文件頭記錄的CHECKPOINT LSN差距太遠的話,在第一次掃描時可能花費大量的時間做無謂的解析,感覺這里還有優化的空間。
在我的測試實例中,由于崩潰時施加的負載比較大,MLOG_CHECKPOINT和CHECKPOINT點的LSN相差約1G的redo log。
2. 第二次掃描,再次從checkpoint點開始重復掃描,存儲日志對象
日志解析后的對象類型為recv_t,包含日志類型、長度、數據、開始和結束LSN。日志對象的存儲使用hash結構,根據 space id 和 page no 計算hash值,相同頁上的變更作為鏈表節點鏈在一起,大概結構可以表示為:
掃描的過程中,會基于MLOG_FILE_NAME 和MLOG_FILE_DELETE 這樣的redo日志記錄來構建recv_spaces,存儲space id到文件信息的映射(fil_name_parse?–>?fil_name_process),這些文件可能需要進行崩潰恢復。(實際上第一次掃描時,也會向recv_spaces中插入數據,但只到MLOG_CHECKPOINT日志記錄為止)
Tips:在一次checkpoint后第一次修改某個表的數據時,總是先寫一條MLOG_FILE_NAME 日志記錄;通過該類型的日志可以跟蹤一次CHECKPOINT后修改過的表空間,避免打開全部表。
在第二次掃描時,總會判斷將要修改的表空間是否在recv_spaces中,如果不存在,則認為產生列嚴重的錯誤,拒絕啟動(recv_parse_or_apply_log_rec_body)
默認情況下,Redo log以一批64KB(RECV_SCAN_SIZE)為單位讀入到log_sys->buf中,然后調用函數recv_scan_log_recs處理日志塊。這里會判斷到日志塊的有效性:是否是完整寫入的、日志塊checksum是否正確, 另外也會根據一些標記位來做判斷:
- 在每次寫入redo log時,總會將寫入的起始block頭的flush bit設置為true,表示一次寫入的起始位置,因此在重啟掃描日志時,也會根據flush bit來推進掃描的LSN點;
- 每次寫redo時,還會在每個block上記錄下一個checkpoint no(每次做checkpoint都會遞增),由于日志文件是循環使用的,因此需要根據checkpoint no判斷是否讀到了老舊的redo日志。
對于合法的日志,會拷貝到緩沖區recv_sys->buf中,調用函數recv_parse_log_recs解析日志記錄。 這里會根據不同的日志類型分別進行處理,并嘗試進行apply,堆棧為:
recv_parse_log_recs--> recv_parse_log_rec--> recv_parse_or_apply_log_rec_body如果想理解InnoDB如何基于不同的日志類型進行崩潰恢復的,非常有必要細讀函數recv_parse_or_apply_log_rec_body,這里是redo日志apply的入口。
例如如果解析到的日志類型為MLOG_UNDO_HDR_CREATE,就會從日志中解析出事務ID,為其重建undo log頭(trx_undo_parse_page_header);如果是一條插入操作標識(MLOG_REC_INSERT 或者 MLOG_COMP_REC_INSERT),就需要從中解析出索引信息(mlog_parse_index)和記錄信息(page_cur_parse_insert_rec);或者解析一條IN-PLACE UPDATE (MLOG_REC_UPDATE_IN_PLACE)日志,則調用函數btr_cur_parse_update_in_place。
第二次掃描只會應用MLOG_FILE_*類型的日志,記錄到recv_spaces中,對于其他類型的日志在解析后存儲到哈希對象里。然后調用函數recv_init_crash_recovery_spaces對涉及的表空間進行初始化處理:
-
首先會打印兩條我們非常熟悉的日志信息:
[Note] InnoDB: Database was not shutdown normally![Note] InnoDB: Starting crash recovery. -
如果recv_spaces中的表空間未被刪除,且ibd文件存在時,則表明這是個普通的文件操作,將該table space加入到fil_system->named_spaces鏈表上(fil_names_dirty),后續可能會對這些表做redo apply操作;
-
對于已經被刪除的表空間,我們可以忽略日志apply,將對應表的space id在recv_sys->addr_hash上的記錄項設置為RECV_DISCARDED;
-
調用函數buf_dblwr_process(),該函數會檢查所有記錄在double write buffer中的page,其對應的數據文件頁是否完好,如果損壞了,則直接從dblwr中恢復;
-
最后創建一個臨時的后臺線程,線程函數為recv_writer_thread,這個線程和page cleaner線程配合使用,它會去通知page cleaner線程去flush崩潰恢復產生的臟頁,直到recv_sys中存儲的redo記錄都被應用完成并徹底釋放掉(recv_sys->heap == NULL)
3. 如果第二次掃描hash表空間不足,無法全部存儲到hash表中,則發起第三次掃描,清空hash,重新從checkpoint點開始掃描。
hash對象的空間最大一般為buffer pool size - 512個page大小。
第三次掃描不會嘗試一起全部存儲到hash里,而是一旦發現hash不夠了,就立刻apply redo日志。但是…如果總的日志需要存儲的hash空間略大于可用的最大空間,那么一次額外的掃描開銷還是非常明顯的。
簡而言之,第一次掃描找到正確的MLOG_CHECKPOINT位置;第二次掃描解析 redo 日志并存儲到hash中;如果hash空間不夠用,則再來一輪重新開始,解析一批,應用一批。
三次掃描后,hash中通常還有redo日志沒有被應用掉。這個留在后面來做,隨后將recv_sys->apply_log_recs?設置為true,并從函數recv_recovery_from_checkpoint_start返回。
對于正常shutdown的場景,一次checkpoint完成后是不記錄MLOG_CHECKPOINT日志的,如果掃描過程中沒有找到對應的日志,那就認為上次是正常shutdown的,不用考慮崩潰恢復了。
Tips:偶爾我們會看到日志中報類似這樣的信息:
“The log sequence number xxx in the system tablespace does not match the log sequence number xxxx in the ib_logfiles!”
從內部邏輯來看是因為ibdata中記錄的lsn和iblogfile中記錄的checkpoint lsn不一致,但系統又判定無需崩潰恢復時會報這樣的錯。單純從InnoDB實例來看是可能的,因為做checkpint 和更新ibdata不是原子的操作,這樣的日志信息一般我們也是可以忽略的。
初始化事務子系統(trx_sys_init_at_db_start)
這里會涉及到讀入undo相關的系統頁數據,在崩潰恢復狀態下,所有的page都要先進行日志apply后,才能被調用者使用,例如如下堆棧:
trx_sys_init_at_db_start--> trx_sysf_get -->....->buf_page_io_complete --> recv_recover_page因此在初始化回滾段的時候,我們通過讀入回滾段頁并進行redo log apply,就可以將回滾段信息恢復到一致的狀態,從而能夠 “復活”在系統崩潰時活躍的事務,維護到讀寫事務鏈表中。對于處于prepare狀態的事務,我們后續需要做額外處理。
關于事務如何從崩潰恢復中復活,參閱4月份的月報 “MySQL · 引擎特性 · InnoDB undo log 漫游“最后一節。
應用redo日志(recv_apply_hashed_log_recs)
根據之前搜集到recv_sys->addr_hash中的日志記錄,依次將page讀入內存,并對每個page進行崩潰恢復操作(recv_recover_page_func):
-
已經被刪除的表空間,直接跳過其對應的日志記錄;
-
在讀入需要恢復的文件頁時,會主動嘗試采用預讀的方式多讀點page (recv_read_in_area),搜集最多連續32個(RECV_READ_AHEAD_AREA)需要做恢復的page no,然后發送異步讀請求。 page 讀入buffer pool時,會主動做崩潰恢復邏輯;
-
只有LSN大于數據頁上LSN的日志才會被apply; 忽略被truncate的表的redo日志;
-
在恢復數據頁的過程中不產生新的redo 日志;
-
在完成修復page后,需要將臟頁加入到buffer pool的flush list上;由于innodb需要保證flush list的有序性,而崩潰恢復過程中修改page的LSN是基于redo 的LSN而不是全局的LSN,無法保證有序性;InnoDB另外維護了一顆紅黑樹來維持有序性,每次插入到flush list前,查找紅黑樹找到合適的插入位置,然后加入到flush list上。(buf_flush_recv_note_modification)
完成崩潰恢復(recv_recovery_from_checkpoint_finish)
在完成所有redo日志apply后,基本的崩潰恢復也完成了,此時可以釋放資源,等待recv writer線程退出 (崩潰恢復產生的臟頁已經被清理掉),釋放紅黑樹,回滾所有數據詞典操作產生的非prepare狀態的事務 (trx_rollback_or_clean_recovered)
無效數據清理及事務回滾:
調用函數recv_recovery_rollback_active完成下述工作:
- 刪除臨時創建的索引,例如在DDL創建索引時crash時的殘留臨時索引(row_merge_drop_temp_indexes());
- 清理InnoDB臨時表 (row_mysql_drop_temp_tables);
- 清理全文索引的無效的輔助表(fts_drop_orphaned_tables());
- 創建后臺線程,線程函數為trx_rollback_or_clean_all_recovered,和在recv_recovery_from_checkpoint_finish中的調用不同,該后臺線程會回滾所有不處于prepare狀態的事務。
至此InnoDB層的崩潰恢復算是告一段落,只剩下處于prepare狀態的事務還有待處理,而這一部分需要和Server層的binlog聯合來進行崩潰恢復。
Binlog/InnoDB XA Recover
回到Server層,在初始化完了各個存儲引擎后,如果binlog打開了,我們就可以通過binlog來進行XA恢復:
- 首先掃描最后一個binlog文件,找到其中所有的XID事件,并將其中的XID記錄到一個hash結構中(MYSQL_BIN_LOG::recover);
- 然后對每個引擎調用接口函數xarecover_handlerton, 拿到每個事務引擎中處于prepare狀態的事務xid,如果這個xid存在于binlog中,則提交;否則回滾事務。
很顯然,如果我們弱化配置的持久性(innodb_flush_log_at_trx_commit != 1?或者?sync_binlog != 1), 宕機可能導致兩種丟數據的場景:
即使我們將參數設置成innodb_flush_log_at_trx_commit =1?和?sync_binlog = 1,也還會面臨這樣一種情況:主庫crash時還有binlog沒傳遞到備庫,如果我們直接提升備庫為主庫,同樣會導致主備不一致,老主庫必須根據新主庫重做,才能恢復到一致的狀態。針對這種場景,我們可以通過開啟semisync的方式來解決,一種可行的方案描述如下:
總結
以上是生活随笔為你收集整理的MySQL · 引擎特性 · InnoDB 崩溃恢复过程的全部內容,希望文章能夠幫你解決所遇到的問題。
- 上一篇: MySQL内核月报 2014.11-My
- 下一篇: CSS 之 控制图片与文字对齐