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崩潰恢複開始邏輯
入口函數:
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的描述,包含幾點改進:
- 避免崩潰恢複時讀取每個ibd的第一個page來確認其space id;
- 無需檢查$datadir/*.isl,新的日誌類型記錄了文件全路徑,並消除了isl文件和實際ibd目錄的不一致可能帶來的問題;
- 自動忽略那些還沒有導入到InnoDB的ibd文件(例如在執行IMPORT TABLESPACE時crash);
- 引入了新的日誌類型MLOG_FILE_DELETE來跟蹤ibd文件的刪除操作。
這裏可能會產生的問題是,如果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
), 宕機可能導致兩種丟數據的場景:
- 引擎層提交了,但binlog沒寫入,備庫丟事務;
- 引擎層沒有prepare,但binlog寫入了,主庫丟事務。
即使我們將參數設置成innodb_flush_log_at_trx_commit =1
和 sync_binlog = 1
,也還會麵臨這樣一種情況:主庫crash時還有binlog沒傳遞到備庫,如果我們直接提升備庫為主庫,同樣會導致主備不一致,老主庫必須根據新主庫重做,才能恢複到一致的狀態。針對這種場景,我們可以通過開啟semisync的方式來解決,一種可行的方案描述如下:
- 設置雙1強持久化配置;
- 我們將semisync的超時時間設到極大值,同時使用semisync AFTER_SYNC模式,即用戶線程在寫入binlog後,引擎層提交前等待備庫ACK;
- 基於步驟1的配置,我們可以保證在主庫crash時,所有老主庫比備庫多出來的事務都處於prepare狀態;
- 備庫完全apply日誌後,記下其執行到的relay log對應的位點,然後將備庫提升為新主庫;
- 將老主庫的最後一個binlog進行截斷,截斷的位點即為步驟3記錄的位點;
- 啟動老主庫,那些已經傳遞到備庫的事務都會提交掉,未傳遞到備庫的binlog都會回滾掉。
最後更新:2017-04-01 13:44:33