1. 程式人生 > >MySQL · 引擎特性 · InnoDB 事務系統

MySQL · 引擎特性 · InnoDB 事務系統

事務回滾 實時 delay num 除了 lis logs proc phy

前言

關系型數據庫的事務機制因其有原子性,一致性等優秀特性深受開發者喜愛,類似的思想已經被應用到很多其他系統上,例如文件系統等。本文主要介紹InnoDB事務子系統,主要包括,事務的啟動,事務的提交,事務的回滾,多版本控制,垃圾清理,回滾段以及相應的參數和監控方法。代碼主要基於RDS 5.6,部分特性已經開源到AliSQL。事務系統是InnoDB最核心的中控系統,涉及的代碼比較多,主要集中在trx目錄,read目錄以及row目錄中的一部分,包括頭文件和IC文件,一共有兩萬兩千多行代碼。

基礎知識

事務ACID: 原子性,指的是整個事務要麽全部成功,要麽全部失敗,對InnoDB來說,只要client收到server發送過來的commit成功報文,那麽這個事務一定是成功的。如果收到的是rollback的成功報文,那麽整個事務的所有操作一定都要被回滾掉,就好像什麽都沒執行過一樣。另外,如果連接中途斷開或者server crash事務也要保證會滾掉。InnoDB通過undolog保證rollback的時候能找到之前的數據。一致性,指的是在任何時刻,包括數據庫正常提供服務的時候,數據庫從異常中恢復過來的時候,數據都是一致的,保證不會讀到中間狀態的數據。在InnoDB中,主要通過crash recovery和double write buffer的機制保證數據的一致性。隔離性,指的是多個事務可以同時對數據進行修改,但是相互不影響。InnoDB中,依據不同的業務場景,有四種隔離級別可以選擇。默認是RR隔離級別,因為相比於RC,InnoDB的RR性能更加好。持久性,值的是事務commit的數據在任何情況下都不能丟。在內部實現中,InnoDB通過redolog保證已經commit的數據一定不會丟失。

多版本控制: 指的是一種提高並發的技術。最早的數據庫系統,只有讀讀之間可以並發,讀寫,寫讀,寫寫都要阻塞。引入多版本之後,只有寫寫之間相互阻塞,其他三種操作都可以並行,這樣大幅度提高了InnoDB的並發度。在內部實現中,與Postgres在數據行上實現多版本不同,InnoDB是在undolog中實現的,通過undolog可以找回數據的歷史版本。找回的數據歷史版本可以提供給用戶讀(按照隔離級別的定義,有些讀請求只能看到比較老的數據版本),也可以在回滾的時候覆蓋數據頁上的數據。在InnoDB內部中,會記錄一個全局的活躍讀寫事務數組,其主要用來判斷事務的可見性。

垃圾清理: 對於用戶刪除的數據,InnoDB並不是立刻刪除,而是標記一下,後臺線程批量的真正刪除。類似的還有InnoDB的二級索引的更新操作,不是直接對索引進行更新,而是標記一下,然後產生一條新的。這個線程就是後臺的Purge線程。此外,過期的undolog也需要回收,這裏說的過期,指的是undo不需要被用來構建之前的版本,也不需要用來回滾事務。

回滾段: 可以理解為數據頁的修改鏈,鏈表最前面的是最老的一次修改,最後面的最新的一次修改,從鏈表尾部逆向操作可以恢復到數據最老的版本。在InnoDB中,與之相關的還有undo tablespace, undo segment, undo slot, undo log這幾個概念。undo log是最小的粒度,所在的數據頁稱為undo page,然後若幹個undo page構成一個undo slot。一個事務最多可以有兩個undo slot,一個是insert undo slot, 用來存儲這個事務的insert undo,裏面主要記錄了主鍵的信息,方便在回滾的時候快速找到這一行。另外一個是update undo slot,用來存儲這個事務delete/update產生的undo,裏面詳細記錄了被修改之前每一列的信息,便於在讀請求需要的時候構造。1024個undo slot構成了一個undo segment。然後若幹個undo segemnt構成了undo tablespace。

歷史鏈表: insert undo可以在事務提交/回滾後直接刪除,沒有事務會要求查詢新插入數據的歷史版本,但是update undo則不可以,因為其他讀請求可能需要使用update undo構建之前的歷史版本。因此,在事務提交的時候,會把update undo加入到一個全局鏈表(history list)中,鏈表按照事務提交的順序排序,保證最先提交的事務的update undo在前面,這樣Purge線程就可以從最老的事務開始做清理。這個鏈表如果太長說明有很多記錄沒被徹底刪除,也有很多undolog沒有被清理,這個時候就需要去看一下是否有個長事務沒提交導致Purge線程無法工作。在InnoDB具體實現上,history list其實只是undo segment維度的,全局的history list采用最小堆來實現,最小堆的元素是某個undo segment中最小事務no對應的undopage。當這個undolog被Purge清理後,通過history list找到次小的,然後替換掉最小堆元素中的值,來保證下次Purge的順序的正確性。

回滾點: 又稱為savepoint,事務回滾的時候可以指定回滾點,這樣可以保證回滾到指定的點,而不是回滾掉整個事務,對開發者來說,這是一個強大的功能。在InnoDB內部實現中,每打一個回滾點,其實就是保存一下當前的undo_no,回滾的時候直接回滾到這個undo_no點就可以了。

核心數據結構

在分析核心的代碼之前,先介紹一下幾個核心的數據結構。這些結構貫穿整個事務系統,理解他們對理解整個InnoDB的工作原理也頗有幫助。

trx_t: 整個結構體每個連接持有一個,也就是在創建連接後執行第一個事務開始,整個結構體就被初始化了,後續這個連接的所有事務一直復用裏面的數據結構,直到這個連接斷開。同時,事務啟動後,就會把這個結構體加入到全局事務鏈表中(trx_sys->mysql_trx_list),如果是讀寫事務,還會加入到全局讀寫事務鏈表中(trx_sys->rw_trx_list)。在事務提交的時候,還會加入到全局提交事務鏈表中(trx_sys->trx_serial_list)。state字段記錄了事務四種狀態:TRX_STATE_NOT_STARTED, TRX_STATE_ACTIVE, TRX_STATE_PREPARED, TRX_STATE_COMMITTED_IN_MEMORY
這裏有兩個字段值得區分一下,分別是id和no字段。id是在事務剛創建的時候分配的(只讀事務永遠為0,讀寫事務通過一個全局id產生器產生,非0),目的就是為了區分不同的事務(只讀事務通過指針地址來區分),而no字段是在事務提交前,通過同一個全局id生產器產生的,主要目的是為了確定事務提交的順序,保證加入到history list中的update undo有序,方便purge線程清理。
此外,trx_t結構體中還有自己的read_view用來表示當前事務的可見範圍。分配的insert undo slot和update undo slot。如果是只讀事務,read_only也會被標記為true。

trx_sys_t: 這個結構體用來維護系統的事務信息,全局只有一個,在數據庫啟動的時候初始化。比較重要的字段有:max_trx_id,這個字段表示系統當前還未分配的最小事務id,如果有一個新的事務,直接把這個值作為新事務的id,然後這個字段遞增即可。descriptors,這個是一個數組,裏面存放著當前所有活躍的讀寫事務id,當需要開啟一個readview的時候,就從這個字段裏面拷貝一份,用來判斷記錄的對事務的可見性。rw_trx_list,這個主要是用來存放當前系統的所有讀寫事務,包括活躍的和已經提交的事務。按照事務id排序,此外,奔潰恢復後產生的事務和系統的事務也放在上面。mysql_trx_list,這裏面存放所有用戶創建的事務,系統的事務和奔潰恢復後的事務不會在這個鏈表上,但是這個鏈表上可能會有還沒開始的用戶事務。trx_serial_list,按照事務no(trx_t->no)排序的已經提交的事務。rseg_array,這個指向系統所有可以用的回滾段(undo segments),當某個事務需要回滾段的時候,就從這裏分配。rseg_history_len, 所有提交事務的update undo的長度,也就是上文提到的歷史鏈表的長度,具體的update undo鏈表是存放在這個undo log中以文件指針的形式管理起來。view_list,這個是系統當前所有的readview, 所有開啟的readview的事務都會把自己的readview放在這個上面,按照事務no排序。

trx_purge_t: Purge線程使用的結構體,全局只有一個,在系統啟動的時候初始化。view,是一個readview,Purge線程不會嘗試刪除所有大於view->low_limit_no的undolog。limit,所有小於這個值的undolog都可以被truncate掉,因為標記的日誌已經被刪除且不需要用他們構建之前的歷史版本。此外,還有rseg,page_no, offset,hdr_page_no, hdr_offset這些字段,主要用來保存最後一個還未被purge的undolog。

read_view_t: InnDB為了判斷某條記錄是否對當前事務可見,需要對此記錄進行可見性判斷,這個結構體就是用來輔助判斷的。每個連接都的trx_t裏面都有一個readview,在事務需要一致性的讀時候(不同隔離級別不同),會被初始化,在讀結束的時候會釋放(緩存)。low_limit_no,這個主要是給purge線程用,readview創建的時候,會把當前最小的提交事務id賦值給low_limit_no,這樣Purge線程就可以把所有已經提交的事務的undo日誌給刪除。low_limit_id, 所有大於等於此值的記錄都不應該被此readview看到,可以理解為high water mark。up_limit_id, 所有小於此值的記錄都應該被此readview看到,可以理解為low water mark。descriptors, 這是一個數組,裏面存了readview創建時候所有全局讀寫事務的id,除了事務自己做的變更外,此readview應該看不到descriptors中事務所做的變更。view_list,每個readview都會被加入到trx_sys中的全局readview鏈表中。

trx_id_t: 每個讀寫事務都會通過全局id產生器產生一個id,只讀事務的事務id為0,只有當其切換為讀寫事務時候再分配事務id。為了保證在任何情況下(包括數據庫不斷異常恢復),事務id都不重復,InnoDB的全局id產生器每分配256(TRX_SYS_TRX_ID_WRITE_MARGIN)個事務id,就會把當前的max_trx_id持久化到ibdata的系統頁上面。此外,每次數據庫重啟,都從系統頁上讀取,然後加上256(TRX_SYS_TRX_ID_WRITE_MARGIN)。

trx_rseg_t: undo segment內存中的結構體。每個undo segment都對應一個。update_undo_list表示已經被分配出去的正在使用的update undo鏈表,insert_undo_list表示已經被分配出去的正在使用的insert undo鏈表。update_undo_cached和insert_undo_cached表示緩存起來的undo鏈表,主要為了快速使用。last_page_no, last_offset, last_trx_no, last_del_marks表示這個undo segment中最後沒有被Purge的undolog。

事務的啟動

在InnoDB裏面有兩種事務,一種是讀寫事務,就是會對數據進行修改的事務,另外一種是只讀事務,僅僅對數據進行讀取。讀寫事務需要比只讀事務多做以下幾點工作:首先,需要分配回滾段,因為會修改數據,就需要找地方把老版本的數據給記錄下來,其次,需要通過全局事務id產生器產生一個事務id,最後,把讀寫事務加入到全局讀寫事務鏈表(trx_sys->rw_trx_list),把事務id加入到活躍讀寫事務數組中(trx_sys->descriptors)。因此,可以看出,讀寫事務確實需要比只讀事務多做不少工作,在使用數據庫的時候盡可能把事務申明為只讀。

start transaction語句啟動事務。這種語句和begin work,begin等效。這些語句默認是以只讀事務的方式啟動。start transaction read only語句啟動事務。這種語句就把thd->tx_read_only置為true,後續如果做了DML/DDL等修改數據的語句,會返回錯誤ER_CANT_EXECUTE_IN_READ_ONLY_TRANSACTIONstart transaction read write語句啟動事務。這種語句會把thd->tx_read_only置為true,此外,允許super用戶在read_only參數為true的情況下啟動讀寫事務。start transaction with consistent snapshot語句啟動事務。這種啟動方式還會進入InnoDB層,並開啟一個readview。註意,只有在RR隔離級別下,這種操作才有效,否則會報錯。

上述的幾種啟動方式,都會先去檢查前一個事務是否已經提交,如果沒有則先提交,然後釋放MDL鎖。此外,除了with consistent snapshot的方式會進入InnoDB層,其他所有的方式都只是在Server層做個標記,沒有進入InnoDB做標記,在InnoDB看來所有的事務在啟動時候都是只讀狀態,只有接受到修改數據的SQL後(InnoDB接收到才行。因為在start transaction read only模式下,DML/DDL都被Serve層擋掉了)才調用trx_set_rw_mode函數把只讀事務提升為讀寫事務。

新建一個連接後,在開始第一個事務前,在InnoDB層會調用函數innobase_trx_allocate分配和初始化trx_t對象。默認的隔離級別為REPEATABLE_READ,並且加入到mysql_trx_list中。註意這一步僅僅是初始化trx_t對象,但是真正開始事務的是函數trx_start_low,在trx_start_low中,如果當前的語句只是一條只讀語句,則先以只讀事務的形式開啟事務,否則按照讀寫事務的形式,這就需要分配事務id,分配回滾段等。

事務的提交

相比於事務的啟動,事務的提交就復雜許多。這裏只介紹事務在InnoDB層的提交過程,Server層涉及到與Binlog的XA事務暫時不介紹。入口函數為innobase_commit

函數有一個參數commit_trx來控制是否真的提交,因為每條語句執行結束的時候都會調用這個函數,而不是每條語句執行結束的時候事務都提交。如果這個參數為true,或者配置了autocommit=1, 則進入提交的核心邏輯。否則釋放因為auto_inc而造成的表鎖,並且記錄undo_no(回滾單條語句的時候用到,相關參數innodb_rollback_on_timeout)。
提交的核心邏輯:

  1. 依據參數innobase_commit_concurrency來判斷是否有過多的線程同時提交,如果太多則等待。
  2. 設置事務狀態為committing,我們可以在show processlist看到(trx_commit_for_mysql)。
  3. 使用全局事務id產生器生成事務no,然後把事務trx_t加入到trx_serial_list。如果當前的undo segment沒有設置最後一個未Purge的undo,則用此事務no更新(trx_serialisation_number_get)。
  4. 標記undo,如果這個事務只使用了一個undopage且使用量小於四分之三個page,則把這個page標記為(TRX_UNDO_CACHED)。如果不滿足且是insert undo則標記為TRX_UNDO_TO_FREE,否則undo為update undo則標記為TRX_UNDO_TO_PURGE。標記為TRX_UNDO_CACHED的undo會被回收,方便下次重新利用(trx_undo_set_state_at_finish)。
  5. 把update undo放入所在undo segment的history list,並遞增trx_sys->rseg_history_len(這個值是全局的)。同時更新page上的TRX_UNDO_TRX_NO, 如果刪除了數據,則重置delete_mark(trx_purge_add_update_undo_to_history)。
  6. 把undate undo從update_undo_list中刪除,如果被標記為TRX_UNDO_CACHED,則加入到update_undo_cached隊列中(trx_undo_update_cleanup)。
  7. 在系統頁中更新binlog名字和偏移量(trx_write_serialisation_history)。
  8. mtr_commit,至此,在文件層次事務提交。這個時候即使crash,重啟後依然能保證事務是被提交的。接下來要做的是內存數據狀態的更新(trx_commit_in_memory)。
  9. 如果是只讀事務,則只需要把readview從全局readview鏈表中移除,然後重置trx_t結構體裏面的信息即可。如果是讀寫事務,情況則復雜點,首先需要是設置事務狀態為TRX_STATE_COMMITTED_IN_MEMORY,其次,釋放所有行鎖,接著,trx_t從rw_trx_list中移除,readview從全局readview鏈表中移除,另外如果有insert undo則在這裏移除(update undo在事務提交前就被移除,主要是為了保證添加到history list的順序),如果有update undo,則喚醒Purge線程進行垃圾清理,最後重置trx_t裏的信息,便於下一個事務使用。

事務的回滾

InnoDB的事務回滾是通過undolog來逆向操作來實現的,但是undolog是存在undopage中,undopage跟普通的數據頁一樣,遵循bufferpool的淘汰機制,如果一個事務中的很多undopage已經被淘汰出內存了,那麽在回滾的時候需要重新把這些undopage從磁盤中撈上來,這會造成大量io,需要註意。此外,由於引入了savepoint的概念,事務不僅可以全部回滾,也可以回滾到某個指定點。

回滾的上層函數是innobase_rollback_trx,主要流程如下:

  1. 如果是只讀事務,則直接返回。
  2. 判斷當前是回滾整個事務還是部分事務,如果是部分事務,則記錄下需要保留多少個undolog,多余的都回滾掉,如果是全部回滾,則記錄0(trx_rollback_step)。
  3. 從update undo和insert undo中找出最後一條undo,從這條undo開始回滾(trx_roll_pop_top_rec_of_trx)。
  4. 如果是update undo則調用row_undo_mod進行回滾,標記刪除的記錄清理標記,更新過的數據回滾到最老的版本。如果是insert undo則調用row_undo_ins進行回滾,插入操作,直接刪除聚集索引和二級索引。
  5. 如果是在奔潰恢復階段且需要回滾的undolog個數大於1000條,則輸出進度。
  6. 如果所有undo都已經被回滾或者回滾到了指定的undo,則停止,並且調用函數trx_roll_try_truncate把undolog刪除(由於不需要使用unod構建歷史版本,所以不需要留給Purge線程)。
    此外,需要註意的是,回滾的代碼由於是嵌入在query graphy的框架中,因此有些入口函數不太好找。例如,確定回滾範圍的是在函數trx_rollback_step中,真正回滾的操作是在函數row_undo_step中,兩者都是在函數que_thr_step被調用。

多版本控制MVCC

數據庫需要做好版本控制,防止不該被事務看到的數據(例如還沒提交的事務修改的數據)被看到。在InnoDB中,主要是通過使用readview的技術來實現判斷。查詢出來的每一行記錄,都會用readview來判斷一下當前這行是否可以被當前事務看到,如果可以,則輸出,否則就利用undolog來構建歷史版本,再進行判斷,知道記錄構建到最老的版本或者可見性條件滿足。

在trx_sys中,一直維護這一個全局的活躍的讀寫事務id(trx_sys->descriptors),id按照從小到大排序,表示在某個時間點,數據庫中所有的活躍(已經開始但還沒提交)的讀寫(必須是讀寫事務,只讀事務不包含在內)事務。當需要一個一致性讀的時候(即創建新的readview時),會把全局讀寫事務id拷貝一份到readview本地(read_view_t->descriptors),當做當前事務的快照。read_view_t->up_limit_id是read_view_t->descriptors這數組中最小的值,read_view_t->low_limit_id是創建readview時的max_trx_id,即一定大於read_view_t->descriptors中的最大值。當查詢出一條記錄後(記錄上有一個trx_id,表示這條記錄最後被修改時的事務id),可見性判斷的邏輯如下(lock_clust_rec_cons_read_sees):

如果記錄上的trx_id小於read_view_t->up_limit_id,則說明這條記錄的最後修改在readview創建之前,因此這條記錄可以被看見。

如果記錄上的trx_id大於等於read_view_t->low_limit_id,則說明這條記錄的最後修改在readview創建之後,因此這條記錄肯定不可以被看家。

如果記錄上的trx_id在up_limit_id和low_limit_id之間,且trx_id在read_view_t->descriptors之中,則表示這條記錄的最後修改是在readview創建之時,被另外一個活躍事務所修改,所以這條記錄也不可以被看見。如果trx_id不在read_view_t->descriptors之中,則表示這條記錄的最後修改在readview創建之前,所以可以看到。

基於上述判斷,如果記錄不可見,則嘗試使用undo去構建老的版本(row_vers_build_for_consistent_read),直到找到可以被看見的記錄或者解析完所有的undo。
針對RR隔離級別,在第一次創建readview後,這個readview就會一直持續到事務結束,也就是說在事務執行過程中,數據的可見性不會變,所以在事務內部不會出現不一致的情況。針對RC隔離級別,事務中的每個查詢語句都單獨構建一個readview,所以如果兩個查詢之間有事務提交了,兩個查詢讀出來的結果就不一樣。從這裏可以看出,在InnoDB中,RR隔離級別的效率是比RC隔離級別的高。此外,針對RU隔離級別,由於不會去檢查可見性,所以在一條SQL中也會讀到不一致的數據。針對串行化隔離級別,InnoDB是通過鎖機制來實現的,而不是通過多版本控制的機制,所以性能很差。

由於readview的創建涉及到拷貝全局活躍讀寫事務id,所以需要加上trx_sys->mutex這把大鎖,為了減少其對性能的影響,關於readview有很多優化。例如,如果前後兩個查詢之間,沒有產生新的讀寫事務,那麽前一個查詢創建的readview是可以被後一個查詢復用的。

垃圾回收Purge線程

Purge線程主要做兩件事,第一,數據頁內標記的刪除操作需要從物理上刪除,為了提高刪除效率和空間利用率,由後臺Purge線程解析undolog定期批量清理。第二,當數據頁上標記的刪除記錄已經被物理刪除,同時undo所對應的記錄已經能被所有事務看到,這個時候undo就沒有存在的必要了,因此Purge線程還會把這些undo給truncate掉,釋放更多的空間。

Purge線程有兩種,一種是Purge Worker(srv_worker_thread), 另外一種是Purge Coordinator(srv_purge_coordinator_thread),前者的主要工作就是從隊列中取出Purge任務,然後清理已經被標記的記錄。後者的工作除了清理刪除記錄外,還需要把Purge任務放入隊列,喚醒Purge Worker線程,此外,它還要truncate undolog。

我們先來分析一下Purge Coordinator的流程。啟動線程後,會進入一個大的循環,循環的終止條件是數據庫關閉。在循環內部,首先是自適應的sleep,然後才會進入核心Purge邏輯。sleep時間與全局歷史鏈表有關系,如果歷史鏈表沒有增長,且總數小於5000,則進入sleep,等待事務提交的時候被喚醒(srv_purge_coordinator_suspend)。退出循環後,也就是數據庫進入關閉的流程,這個時候就需要依據參數innodb_fast_shutdown來確定在關閉前是否需要把所有記錄給清除。接下來,介紹一下核心Purge邏輯。

  1. 首先依據當前的系統負載來確定需要使用的Purge線程數(srv_do_purge),即如果壓力小,只用一個Purge Cooridinator線程就可以了。如果壓力大,就多喚醒幾個線程一起做清理記錄的操作。如果全局歷史鏈表在增加,或者全局歷史鏈表已經超過innodb_max_purge_lag,則認為壓力大,需要增加處理的線程數。如果數據庫處於不活躍狀態(srv_check_activity),則減少處理的線程數。
  2. 如果歷史鏈表很長,超過innodb_max_purge_lag,則需要重新計算delay時間(不超過innodb_max_purge_lag_delay)。如果計算結果大於0,則在後續的DML中需要先sleep,保證不會太快產生undo(row_mysql_delay_if_needed)。
  3. 從全局視圖鏈表中,克隆最老的readview,所有在這個readview開啟之前提交的事務所產生的undo都被認為是可以清理的。克隆之後,還需要把最老視圖的創建者的id加入到view->descriptors中,因為這個事務修改產生的undo,暫時還不能刪除(read_view_purge_open)。
  4. 從undo segment的最小堆中,找出最早提交事務的undolog(trx_purge_get_rseg_with_min_trx_id),如果undolog標記過delete_mark(表示有記錄刪除操作),則把先關undopage信息暫存在purge_sys_t中(trx_purge_read_undo_rec)。
  5. 依據purge_sys_t中的信息,讀取出相應的undo,同時把相關信息加入到任務隊列中。同時更新掃描過的指針,方便後續truncate undolog。
  6. 循環第4步和第5步,直到全局歷史鏈表為空,或者接下到view->low_limit_no,即最老視圖創建時已經提交的事務,或者已經解析的page數量超過innodb_purge_batch_size
  7. 把所有的任務都放入隊列後,就可以通知所有Purge Worker線程(如果有的話)去執行記錄刪除操作了。刪除記錄的核心邏輯在函數row_purge_record_func中。有兩種情況,一種是數據記錄被刪除了,那麽需要刪除所有的聚集索引和二級索引(row_purge_del_mark),另外一種是二級索引被更新了(總是先刪除+插入新記錄),所以需要去執行清理操作。
  8. 在所有提交的任務都已經被執行完後,就可以調用函數trx_purge_truncate去刪除update undo(insert undo在事務提交後就被清理了)。每個undo segment分別清理,從自己的histrory list中取出最早的一個undo,進行truncate(trx_purge_truncate_rseg_history)。truncate中,最終會調用fseg_free_page來清理磁盤上的空間。

事務的復活

在奔潰恢復後,也就是所有的前滾redo都應用完後,數據庫需要做undo回滾,至於哪些事務需要提交,哪些事務需要回滾,這取決於undolog和binlog的狀態。啟動階段,事務相關的代碼邏輯主要在函數trx_sys_init_at_db_start中,簡單分析一下。

  1. 首先創建管理undo segment的最小堆,堆中的元素是每個undo segment提交最早的事務id和相應undo segment的指針,也就是說通過這個元素可以找到這個undo segment中最老的未被Purge的undo。通過這個最小堆,可以找到所有undo segment中最老未被Purge的undo,方便Purge線程操作。
  2. 創建全局的活躍讀寫事務id數組。主要是給readview使用。
  3. 初始化所有的undo segment。主要是從磁盤讀取undolog的內容,構建內存中的undo slot和undo segment,同時也構建每個undo segment中的history list,因為如果是fast shutdown,被標記為刪除的記錄可能還沒來得及被徹底清理。此外,也構建每個undo segment中的inset_undo_list和update_undo_list,理論上來說,如果數據庫關閉的時候所有事務都正常提交了,這兩個鏈表都為空,如果數據庫非正常關閉,則鏈表非空(trx_undo_mem_create_at_db_start, trx_rseg_mem_create)。
  4. 從系統頁裏面讀取max_trx_id,然後加上TRX_SYS_TRX_ID_WRITE_MARGIN來保證trx_id不會重復,即使在很極端的情況下。
  5. 遍歷所有的undo segment,針對每個undo segment,分別遍歷inset_undo_list和update_undo_list,依據undo的狀態來復活事務。
  6. insert/update undo的處理邏輯:如果undolog上的狀態是TRX_UNDO_ACTIVE,則事務也被設置為TRX_STATE_ACTIVE,如果undolog上的狀態是TRX_UNDO_PREPARED,則事務也被設置為TRX_UNDO_PREPARED(如果force_recovery不為0,則設置為TRX_STATE_ACTIVE)。如果undolog狀態是TRX_UNDO_CACHED,TRX_UNDO_TO_FREE,TRX_UNDO_TO_PURGE,那麽都任務事務已經提交了(trx_resurrect_inserttrx_resurrect_update)。
  7. 除了從undolog中復活出事務的狀態信息,還需要復活出當前的鎖信息(trx_resurrect_table_locks),此外還需要把事務trx_t加入到rw_trx_list中。
  8. 所有事務信息復活後,InnoDB會做個統計,告訴你有多少undo需要做,因此可以在錯誤日誌中看到類似的話: InnoDB: 120 transaction(s) which must be rolled back or cleaned up. InnoDB: in total 20M row operations to undo。
  9. 如果事務中操作了數據字典,比如創建刪除表和索引,則這個事務會在奔潰恢復結束後直接回滾,這個是個同步操作,會延長奔潰恢復的時間(recv_recovery_from_checkpoint_finish)。如果事務中沒有操作數據字典,則後臺會開啟一個線程,異步回滾事務,所以我們常常發現,在數據庫啟動後,錯誤日誌裏面依然會有很多事務正在回滾的信息。

事務運維相關命令和參數

  1. 首先介紹一下information_schema中的三張表: innodb_trx, innodb_locks和innodb_lock_waits。由於這些表幾乎需要查詢所有事務子系統的核心數據結構,為了減少查詢對系統性能的影響,InnoDB預留了一塊內存,內存裏面存了相關數據的副本,如果兩次查詢的時間小於0.1秒(CACHE_MIN_IDLE_TIME_US),則訪問的都是同一個副本。如果超過0.1秒,則這塊內存會做一次更新,每次更新會把三張表用到的所有數據統一更新一遍,因為這三張表經常需要做表連接操作,所以一起更新能保證數據的一致性。這裏簡單介紹一下innodb_trx表中的字段,另外兩張表涉及到事物鎖的相關信息,由於篇幅限制,後續有機會在介紹。
    trx_id: 就是trx_t中的事務id,如果是只讀事務,這個id跟trx_t的指針地址有關,所以可能是一個很大的數字(trx_get_id_for_print)。
    trx_weight: 這個是事務的權重,計算方法就是undolog數量加上事務已經加上鎖的數量。在事務回滾的時候,優先選擇回滾權重小的事務,有非事務引擎參與的事務被認為權重是最大的。
    trx_rows_modified:這個就是當前事務已經產生的undolog數量,每更新一條記錄一次,就會產生一條undo。
    trx_concurrency_tickets: 每次這個事務需要進入InnoDB層時,這個值都會減一,如果減到0,則事務需要等待(壓力大的情況下)。
    trx_is_read_only: 如果是以start transaction read only啟動事務的,那麽這個字段是1,否則為0。
    trx_autocommit_non_locking: 如果一個事務是一個普通的select語句(後面沒有跟for update, share lock等),且當時的autocommit為1,則這個字段為1,否則為0。
    trx_state: 表示事務當前的狀態,只能有RUNNING, LOCK WAIT, ROLLING BACK, COMMITTING這幾種狀態, 是比較粗粒度的狀態。
    trx_operation_state: 表示事務當前的詳細狀態,相比於trx_state更加詳細,例如有rollback to a savepoint, getting list of referencing foreign keys, rollback of internal trx on stats tables, dropping indexes等。

  2. 與事務相關的undo參數
    innodb_undo_directory: undo文件的目錄,建議放在獨立的一塊盤上,尤其在經常有大事務的情況下。
    innodb_undo_logs: 這個是定義了undo segment的個數。在給讀寫事務分配undo segment的時候,拿這個值去做輪訓分配。
    Innodb_available_undo_logs: 這個是一個status變量,在啟動的時候就確定了,表示的是系統上分配的undo segment。舉個例子說明其與innodb_undo_logs的關系:假設系統初始化的時候innodb_undo_logs為128,則在文件上一定有128個undo segment,Innodb_available_undo_logs也為128,但是啟動起來後,innodb_undo_logs動態被調整為100,則後續的讀寫事務只會使用到前100個回滾段,最後的20多個不會使用。
    innodb_undo_tablespaces: 存放undo segment的物理文件個數,文件名為undoN,undo segment會比較均勻的分布在undo tablespace中。

  3. 與Purge相關的參數
    innodb_purge_threads: Purge Worker和Purge Coordinator總共的個數。在實際的實現中,使用多少個線程去做Purge是InnoDB根據實時負載進行動態調節的。
    innodb_purge_batch_size: 一次性處理的undolog的數量,處理完這個數量後,Purge線程會計算是否需要sleep。
    innodb_max_purge_lag: 如果全局歷史鏈表超過這個值,就會增加Purge Worker線程的數量,也會使用sleep的方式delay用戶的DML。
    innodb_max_purge_lag_delay: 這個表示通過sleep方式delay用戶DML最大的時間。

  4. 與回滾相關的參數
    innodb_lock_wait_timeout: 等待行鎖的最大時間,如果超時,則會滾當前語句或者整個事務。發生回滾後返回類似錯誤:Lock wait timeout exceeded; try restarting transaction。
    innodb_rollback_on_timeout: 如果這個參數為true,則當發生因為等待行鎖而產生的超時時,回滾掉整個事務,否則只回滾當前的語句。這個就是隱式回滾機制。主要是為了兼容之前的版本。

總結

本文簡單介紹了InnoDB事務子系統的幾個核心模塊,在MySQL 5.7上,事務模塊還有很多特性,例如高優先級事務,事務對象池等。與事務相關的還有事務鎖系統,由於篇幅限制,本文不介紹,詳情可以參考本期月報的這篇。此外,在阿裏雲最新發布的POLARDB for MySQL的版本中,由於涉及到共享存儲架構,我們對事務子系統又進行了大量的改造,後續的月報會詳細介紹。

MySQL · 引擎特性 · InnoDB 事務系統