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