在前面幾期關于 innodb redo 和 undo 實作的鋪墊後,本節我們從上層的角度來闡述 innodb 的事務子系統是如何實作的,涉及的内容包括:innodb的事務相關子產品、如何實作mvcc及acid、如何進行事務的并發控制、事務系統如何進行管理等相關知識。本文的目的是讓讀者對事務系統有一個較全面的了解。
由于不同版本對事務系統都有改變,本文的所有分析基于目前ga的最新版本mysql5.7.9,但也會在闡述的過程中,順帶描述之前版本的一些内容。本文也會介紹5.7版本對事務系統的一些優化點。
另外盡管 innodb 鎖系統和事務有着非常密切的聯系,但鑒于本文主要介紹事務子產品,并且計劃中的篇幅已經足夠長。而鎖系統又是一個非常複雜的子產品,将在後面的月報中單獨開一篇文章來講述。
在閱讀本文之前,強烈建議先閱讀下之前兩節的内容,因為事務系統和這些子產品有着非常緊密的聯系:
<a href="http://mysql.taobao.org/monthly/2015/04/01/" target="_blank">mysql · 引擎特性 · innodb undo log 漫遊</a>
<a href="http://mysql.taobao.org/monthly/2015/05/01/" target="_blank">mysql · 引擎特性 · innodb redo log漫遊</a>
<a href="http://mysql.taobao.org/monthly/2015/06/01/" target="_blank">mysql · 引擎特性 · innodb 崩潰恢複過程</a>
innodb 提供了多種方式來開啟一個事務,最簡單的就是以一條 begin 語句開始,也可以以 start transaction 開啟事務,你還可以選擇開啟一個隻讀事務還是讀寫事務。所有顯式開啟事務的行為都會隐式的将上一條事務送出掉。
所有顯示開啟事務的入口函數均為<code>trans_begin</code>,如下列出了幾種常用的事務開啟方式。
當以begin開啟一個事務時,首先會去檢查是否有活躍的事務還未送出,如果沒有送出,則調用<code>ha_commit_trans</code>送出之前的事務,并釋放之前事務持有的mdl鎖。
執行begin指令并不會真的去引擎層開啟一個事務,僅僅是為目前線程設定标記,表示為顯式開啟的事務。
和begin等效的指令還有“begin work”及“start transaction”。
使用該選項開啟一個隻讀事務,當以這種形式開啟事務時,會為目前線程的<code>thd->tx_read_only</code>設定為true。當server層接受到任何資料更改的sql時,都會直接拒絕請求,傳回錯誤碼<code>er_cant_execute_in_read_only_transaction</code>,不會進入引擎層。
這個選項可以強限制一個事務為隻讀的,而隻讀事務在引擎層可以走優化過的邏輯,相比讀寫事務的開銷更小,例如不用配置設定事務id、不用配置設定復原段、不用維護到全局事務連結清單中。
該事務開啟的方式從5.6版本開始引入。我們知道,在mysql5.6版本中引入的一個對事務子產品的重要優化:将全局事務連結清單拆成了兩個連結清單:一個用于維護隻讀事務,一個用于維護讀寫事務。這樣我們在建構一個一緻性視圖時,隻需要周遊讀寫事務連結清單即可。但是在5.6版本中,innodb并不具備事務從隻讀模式自動轉換成讀寫事務的能力,是以需要使用者顯式的使用以下兩種方式來開啟隻讀事務:
執行start transaction read only
或者将變量<code>tx_read_only</code>設定為true
5.7版本引入了模式自動轉換的功能,但該文法依然保留了。
另外一個有趣的點是,在5.7版本中,你可以通過設定<code>session_track_transaction_info</code>變量來跟蹤事務的狀态,這貨主要用于官方的分布式套件(例如fabric),例如在一個負載均衡系統中,你需要知道哪些 statement 開啟或處于一個事務中,哪些 statement 允許連接配接配置設定器排程到另外一個 connection。隻讀事務是一種特殊的事務狀态,是以也需要記錄到線程的<code>transaction_state_tracker</code>中。
和上述相反,該sql用于開啟讀寫事務,這也是預設的事務模式。但有一點不同的是,如果目前執行個體的 read_only 打開了且目前連接配接不是超級賬戶,則顯示開啟讀寫事務會報錯。
同樣的事務狀态<code>tx_read_write</code>也要加入到session tracker中。另外包括上述幾種顯式開啟的事務,其标記<code>tx_explicit</code>也加入到session tracker中。
讀寫事務并不意味着一定在引擎層就被認定為讀寫事務了,5.7版本innodb裡總是預設一個事務開啟時的狀态為隻讀的。舉個簡單的例子,如果你事務的第一條sql是隻讀查詢,那麼在innodb層,它的事務狀态就是隻讀的,如果第二條sql是更新操作,就将事務轉換成讀寫模式。
和上面幾種方式不同的是,在開啟事務時還會順便建立一個視圖(read view),在innodb中,視圖用于描述一個事務的可見性範圍,也是多版本特性的重要組成部分。
這裡會進入innodb層,調用函數<code>innobase_start_trx_and_assign_read_view</code>,注意隻有你的隔離級别設定成repeatable read(可重複讀)時,才會顯式開啟一個read view,否則會抛出一個warning。
使用這種方式開啟事務時,事務狀态已經被設定成active的。
狀态變量<code>tx_with_snapshot</code>會加入到session tracker中。
當autocommit設定成0時,就無需顯式開啟事務,如果你執行多條sql但不顯式的調用commit(或者執行會引起隐式送出的sql)進行送出,事務将一直存在。通常我們不建議将該變量設定成0,因為很容易由于程式邏輯或使用習慣造成事務長時間不送出。而事務長時間不送出,在mysql裡簡直就是噩夢,各種詭異的問題都會紛紛出現。一種典型的場景就是,你開啟了一條查詢,但由于未送出,導緻後續對該表的ddl堵塞住,進而導緻随後的所有sql全部堵塞,簡直就是災難性的後果。
另外一種情況是,如果你長時間不送出一個已經建構read view的事務,purge線程就無法清理一些已經送出的事務鎖産生的undo日志,進而導緻undo空間膨脹,具體的表現為ibdata檔案瘋狂膨脹。我們曾線上上觀察到好幾百g的ibdata檔案。
tips:所幸的是從5.7版本開始提供了可以線上truncate undo log的功能,前提是開啟了獨立的undo表空間,并保留了足夠的 undo 復原段配置(預設128個),至少需要35個復原段。其truncate 原理也比較簡單:當purge線程發現一個undo檔案超過某個定義的閥值時,如果沒有活躍事務引用這個undo檔案,就将其設定成不可配置設定,并直接實體truncate檔案。
事務的送出分為兩種方式,一種是隐式送出,一種是顯式送出。
當你顯式開啟一個新的事務,或者執行一條非臨時表的ddl語句時,就會隐式的将上一個事務送出掉。另外一種就是顯式的執行“commit” 語句來送出事務。
然而,在不同的場景下,mysql在送出時進行的動作并不相同,這主要是因為 mysql 是一種伺服器層-引擎層的架構,并存在兩套日志系統:binary log及引擎事務日志。mysql支援兩種xa事務方式:隐式xa和顯式xa;當然如果關閉binlog,并且僅使用一種事務引擎,就沒有xa可言了。
關于隐式xa的控制對象,在執行個體啟動時決定使用何種xa模式,如下代碼段:
若打開binlog,且使用了事務引擎,則xa控制對象為<code>mysql_bin_log</code>;
若關閉了binlog,且存在不止一種事務引擎時,則xa控制對象為<code>tc_log_mmap</code>;
其他情況,使用<code>tc_log_dummy</code>,這種場景下就沒有什麼xa可言了,無需任何協調者來進行xa。
這三者是<code>tc_log</code>的子類,關系如下圖所示:

tc log
具體的,包含以下幾種類型的xa(不對資料産生變更的隻讀事務無需走xa)
當開啟binlog時, mysql預設使用該隐式xa模式。 在5.7版本中,事務的送出流程包括:
binlog prepare
設定<code>thd->durability_property= ha_ignore_durability</code>, 表示在innodb prepare時,不刷redo log。
innodb prepare (入口函數<code>innobase_xa_prepare --> trx_prepare</code>):
更新innodb的undo復原段,将其設定為prepare狀态(<code>trx_undo_prepared</code>)。
進入組送出 (<code>ordered_commit</code>)
flush stage:此時形成一組隊列,由leader依次為别的線程寫binlog檔案
sync stage:如果<code>sync_binlog</code>計數超過配置值,則進行一次檔案fsync,注意,參數<code>sync_binlog</code>的含義不是指的這麼多個事務之後做一次fsync,而是這麼多組事務隊列後做一次fsync。
semisync stage (rds mysql only):如果我們在事務commit之前等待備庫ack(設定成after_sync模式),使用者線程會釋放上一個stage的鎖,并等待ack。這意味着在等待ack的過程中,我們并不堵塞上一個stage的binlog寫入,可以增加一定的吞吐量。
tips:如果你關閉了<code>binlog_order_commits</code>選項,那麼事務就各自進行送出,這種情況下不能保證innodb commit順序和binlog寫入順序一緻,這不會影響到資料一緻性,在高并發場景下還能提升一定的吞吐量。但可能影響到實體備份的資料一緻性,例如使用 xtrabackup(而不是基于其上的innobackup腳本)依賴于事務頁上記錄的binlog位點,如果位點發生亂序,就會導緻備份的資料不一緻。
當binlog關閉時,如果事務跨引擎了,就可以在事務引擎間進行xa了,典型的例如innodb和tokudb(在rds mysql裡已同時支援這兩種事務引擎)。當支援超過1種事務引擎時,并且binlog關閉了,就走tc log mmap邏輯。對應的xa控制對象為<code>tc_log_mmap</code>。
由于需要持久化事務資訊以用于重新開機恢複,是以在該場景下,<code>tc_log_mmap</code>子產品會建立一個檔案,名為tc.log,檔案初始化大小為24kb,使用mmap的方式映射到記憶體中。
tc.log 以page來進行劃分,每個page大小為8k,至少需要3個page,初始化的檔案大小也為3個page(<code>tc_log_min_size</code>),每個page對應的結構體對象為st_page,是以需要根據page數,完成檔案對應的記憶體控制對象的初始化。初始化第一個page的header,寫入magic number以及目前的2pc引擎數(也就是<code>total_ha_2pc</code>)
下圖描述了tc.log的檔案結構:
tc.log 檔案結構
在事務執行的過程中,例如遇到第一條資料變更sql時,會注冊一個唯一辨別的xid(實際上通過目前查詢的query_id來唯一辨別),之後直到事務送出,這個xid都不會改變。事務引擎本身在使用undo時,必須加上這個xid辨別。
在進行事務prepare階段,若事務涉及到多個引擎,先在各自引擎裡做事務prepare。
然後進入commit階段,這時候會将xid記錄到tc.log中(如上圖所示),這類涉及到相對複雜的page選擇流程,這裡不展開描述,具體的參閱函數<code>tc_log_mmap::commit</code>
在完成記錄到tc.log後,就到引擎層各自送出事務。這樣即使在引擎送出時失敗,我們也可以在crash recovery時,通過讀取tc.log記錄的xid,指導引擎層将符合xid的事務進行送出。
當關閉binlog時,且事務隻使用了一個事務引擎時,就無需進行xa了,相應的事務commit的流程也有所不同。
首先事務無需進入prepare狀态,因為對單引擎事務做xa沒有任何意義。
其次,因為沒有prepare狀态的保護,事務在commit時需要對事務日志進行持久化。這樣才能保證所有成功傳回的事務變更, 能夠在崩潰恢複時全部完成。
mysql支援顯式的開啟一個帶命名的xa事務,例如:
一個有趣的問題是,在5.7之前的版本中,如果執行xa的過程中,在完成xa prepare後,如果kill掉session,事務就丢失了,而不是像崩潰恢複那樣,可以直接恢複出來。這主要是因為mysql對kill session的行為處理是直接復原事務。
為了解決這個問題,mysql5.7版本做了不小的改動,将xa的兩階段都記錄到了binlog中。這樣狀态是持久化了的,一次幹淨的shutdown後,可以通過掃描binlog恢複出xa事務的狀态,對于kill session導緻的xa事務丢失,邏輯則比較簡單:記憶體中使用一個transaction_cache維護了所有的xa事務,在斷開連接配接調用thd::cleanup時不做復原,僅設定事務标記即可。
當由于各種原因(例如死鎖,或者顯式rollback)需要将事務復原時,會調用handler接口<code>ha_rollback_low</code>,進而調用innodb函數<code>trx_rollback_for_mysql</code>來復原事務。復原的方式是提取undo日志,做逆向操作。
由于innodb的undo是單獨寫在表空間中的,本質上和普通的資料頁是一樣的。如果在事務復原時,undo頁已經被從記憶體淘汰,復原操作(特别是大事務變更復原)就可能伴随大量的磁盤io。是以innodb的復原效率非常低。有的資料庫管理系統,例如postgresql,通過在資料頁上備援資料産生版本鍊的方式來實作多版本,是以復原起來非常友善,隻需要設定标記即可,但額外帶來的問題就是無效資料清理開銷。
在事務執行的過程中,你可以通過設定savepoint的方式來管理事務的執行過程。
在介紹savepoint之前,需要先介紹下<code>trx_t::undo_no</code>。在事務每次成功寫入一次undo後,這個計數都會遞增一次(參閱函數<code>trx_undo_report_row_operation</code>)。事務的<code>undo_no</code>也會記錄到undo page中進行持久化,是以在undo連結清單上的<code>undo_no</code>總是有序遞增的。
總的來說,主要有以下幾種操作類型。
設定savepoint
文法:savepoint sp_name
入口函數:<code>trans_savepoint</code>
在事務中設定一個savepoint,你可以随意命名一個名字,在事務中設定的所有 savepoint 實際上維護了兩份連結清單,一份挂在thd變量上(<code>thd->get_transaction()->m_savepoints</code>),包含了基本的savepoint資訊及到引擎層的映射,另一份在引擎層的事務對象上(維持在連結清單<code>trx_t::trx_savepoints</code>中)。
如下圖所示:
savepoint 連結清單
總共分為以下幾步:
在增加新的savepoint時,總是先判斷下是否同名的savepoint已經存在,如果存在,就用後者替換前者;
server層維護的savepoint資訊記錄了命名資訊及mdl鎖的savepoint點。其中mdl鎖的savepoint,可以實作復原操作時釋放該savepoint之後再獲得的mdl鎖;
在目前線程的binlog cache中寫入設定savepoint的sql, 并儲存binlog cache中的位點 (<code>binlog_savepoint_set</code>);
引擎層的savepoint中記錄了最近一次的<code>trx_t::undo_no</code>及savepoint名字。通過這些資訊可以準确的定位在設定savepoint點時undo位點。(參閱引擎層入口函數:<code>trx_savepoint_for_mysql</code>)。
復原savepoint
文法:rollback to [ savepoint ] sp_name
入口函數:<code>trans_rollback_to_savepoint</code>
檢查點的復原主要包括:
如果事務是一個xa事務,且已經處于xa prepare狀态時是不允許復原到某個savepoint的;
如果涉及非事務引擎,在binlog中寫入復原sql,否則直接将binlog cache truncate到之前設定sp時儲存的位點。(<code>binlog_savepoint_rollback</code>)
在引擎層進行復原(<code>trx_rollback_to_savepoint_for_mysql</code>)
根據之前記錄的undo_no,可以逆向操作目前事務占用的undo slot上的undo記錄來進行復原。
判斷是否允許復原mdl鎖:
binlog關閉的情況下,總是允許復原mdl鎖
或者由引擎來确認(<code>ha_rollback_to_savepoint_can_release_mdl</code>),同時滿足:
innodb:如果目前事務不持有任何事務鎖(表級或者行級),則認為可以復原mdl鎖
binlog:如果沒有更改非事務引擎,則可以釋放mdl鎖
如果允許復原mdl,則通過之前記錄的<code>st_savepoint::mdl_savepoint</code>進行復原
釋放savepoint
文法為:release savepoint sp_name
顧名思義,就是删除一個savepoint,操作也很簡單,直接根據命名從server層和innodb層的清理掉,并釋放對應的記憶體。
隐式savepoint
在innodb中,還有一種隐式的savepoint,通過變量<code>trx_t::last_sql_stat_start</code>來維護。
初始狀态下<code>trx_t::last_sql_stat_start</code>的值為0,當執行完一條sql時,會調用函數<code>trx_mark_sql_stat_end</code>将目前的<code>trx_t::undo_no</code>儲存到<code>trx_t::last_sql_stat_start</code>中。
如果sql執行失敗,就可以據此進行statement級别的復原操作(<code>trx_rollback_last_sql_stat_for_mysql</code>)。
無論是顯式savepoint還是隐式savepoint,都是通過undo_no來訓示復原到哪個事務狀态。
兩個有趣的bug
<a href="http://bugs.mysql.com/bug.php?id=79493" target="_blank">bug#79493</a>
在一個隻讀事務中,如果設定了savepoint,任意執行一次<code>rollback to savepoint</code>都會将事務從隻讀模式改變成讀寫模式。這主要是因為在活躍事務中執行rollback 操作會強制轉換read-write模式。實際上這是沒必要的,因為并沒有造成任何的資料變更。
<a href="http://bugs.mysql.com/bug.php?id=79596" target="_blank">bug#79596</a>
這個bug可以認為是一個相當嚴重的bug:在一個活躍的做過資料變更操作的事務中,任意執行一次rollback to savepoint(即使savepoint不存在),然後kill掉用戶端,會發現事務卻送出了,并且沒有寫到binlog中。這會導緻主備的資料不一緻。
重制步驟如下:
最後一步直接對session的程序kill -9時會導緻事務commit。這主要是因為如果直接kill用戶端,伺服器端在清理線程資源,進行事務復原時,相關的變量并沒有被重設,thd的command類型還是<code>sqlcom_rollback_to_savepoint</code>,在函數<code>mysql_bin_log::rollback</code>函數中将不會調用<code>ha_rollback_low</code>的引擎層復原邏輯。原因是復原到某個savepoint有特殊的處理流程,如果是通過ctrl+c的方式關閉client端,實際上會發送一個類型為<code>com_quit</code>的command,它會将<code>thd->lex->sql_command</code>設定為<code>sqlcom_end</code>,這時候會走正常的復原邏輯。
在事務執行的過程中,需要多個子產品來輔助事務的正常執行:
server層的mdl鎖子產品,維持了一個事務過程中所有涉及到的表級鎖對象。通過mdl鎖,可以堵塞ddl,避免ddl和dml記錄binlog亂序;
innodb的trx_sys子系統,維持了所有的事務狀态,包括活躍事務、非活躍事務對象、讀寫事務連結清單、負責配置設定事務id、復原段、readview等資訊,是事務系統的總控子產品;
innodb的lock_sys子系統,維護事務鎖資訊,用于對修改資料操作做并發控制,保證了在一個事務中被修改的記錄,不可以被另外一個事務修改;
innodb的log_sys子系統,負責事務redo日志管理子產品;
innodb的purge_sys子系統,則主要用于在事務送出後,進行垃圾回收,以及資料頁的無效資料清理。
總的來說,事務管理子產品的架構圖,如下圖所示:
innodb 事務管理
下面就幾個事務子產品的關鍵點展開描述。
在innodb中一直維持了一個不斷遞增的整數,存儲在<code>trx_sys->max_trx_id</code>中;每次開啟一個新的讀寫事務時,都将該id配置設定給事務,同時遞增全局計數。事務id可以看做一個事務的唯一辨別。
在mysql5.6及之前的版本中,總是為事務配置設定id。但實際上這是沒有必要的,畢竟隻有做過資料更改的讀寫事務,我們才需要去根據事務id判斷可見性。是以在mysql5.7版本中,隻有讀寫事務才會配置設定事務id,隻讀事務的id預設為0。
那麼問題來了,怎麼去區分不同的隻讀事務呢?這裡在需要輸出事務id時(例如執行<code>show engine innodb status</code> 或者查詢information_schema.innodb_trx表),使用隻讀事務對象的指針或上一個常量來辨別其唯一性,具體的計算方式見函數<code>trx_get_id_for_print</code>。是以如果你show出來的事務id看起來數字特别龐大,千萬不要驚訝。
對于全局最大事務id,每做256次指派(<code>trx_sys_trx_id_write_margin</code>)就持久化一次到ibdata的事務頁(<code>trx_sys_page_no</code>)中。
已配置設定的事務id會加入到全局讀寫事務id集合中(<code>trx_sys->rw_trx_ids</code>),事務id和事務對象的map加入到<code>trx_sys->rw_trx_set</code>中,這是個有序的集合(<code>std::set</code>),可以用于通過trx id快速定位到對應的事務對象。
事務配置設定得到的id并不是立刻就被使用了,而是在做了資料修改時,需要建立或重用一個undo slot時,會将目前事務的id寫入到undo page頭,狀态為<code>trx_undo_active</code>。這也是崩潰恢複時,innodb判斷是否有未完成事務的重要依據。
在執行資料更改的過程中,如果我們更新的是聚集索引記錄,事務id + 復原段指針會被寫到聚集索引記錄中,其他會話可以據此來判斷可見性以及是否要回溯undo鍊。
對于普通的二級索引頁更新,則采用回溯聚集索引頁的方式來判斷可見性(如果需要的話)。關于mvcc,後文會有單獨描述。
事務子系統維護了三個不同的連結清單,用來管理事務對象。
trx_sys->mysql_trx_list
包含了所有使用者線程的事務對象,即使是未開啟的事務對象,隻要還沒被回收到trx_pool中,都被放在該連結清單上。當session斷開時,事務對象從連結清單上摘取,并被回收到trx_pool中,以待重用。
trx_sys->rw_trx_list
讀寫事務連結清單,當開啟一個讀寫事務,或者事務模式轉換成讀寫模式時,會将目前事務加入到讀寫事務連結清單中,連結清單上的事務是按照<code>trx_t::id</code>有序的;在事務送出階段将其從讀寫事務連結清單上移除。
trx_sys->serialisation_list
序列化事務連結清單,在事務送出階段,需要先将事務的undo狀态設定為完成,在這之前,獲得一個全局序列号<code>trx->no</code>,從<code>trx_sys->max_trx_id</code>中配置設定,并将目前事務加入到該連結清單中。随後更新undo等一系列操作後,是以進入送出階段的事務并不是trx->id有序的,而是根據trx->no排序。當完成undo更新等操作後,再将事務對象同時從<code>serialisation_list</code>和<code>rw_trx_list</code>上移除。
這裡需要說明下<code>trx_t::no</code>,這是個不太好理清的概念,從代碼邏輯來看,在建立readview時,會用到序列化連結清單,連結清單的第一個元素具有最小的<code>trx_t::no</code>,會指派給<code>readview::m_low_limit_no</code>。purge線程據此建立的readview,隻有小于該值的undo,才可以被purge掉。
總的來說,<code>mysql_trx_list</code>包含了<code>rw_trx_list</code>上的事務對象,<code>rw_trx_list</code>包含了<code>serialisation_list</code>上的事務對象。
事務id集合有兩個:
trx_sys->rw_trx_ids
記錄了目前活躍的讀寫事務id集合,主要用于建構readview時快速拷貝一個快照
trx_sys->rw_trx_set
這是<trx_id, trx_t>的映射集合,根據trx_id排序,用于通過trx_id快速獲得對應的事務對象。一個主要的用途就是用于隐式鎖轉換,需要為記錄中的事務id所對應的事務對象建立記錄鎖,通過該集合可以快速獲得事務對象
對于普通的讀寫事務,總是為其指定一個復原段(預設128個復原段)。而對于隻讀事務,如果使用到了innodb臨時表,則為其配置設定(1~32)号復原段。(復原段指定參閱函數<code>trx_assign_rseg_low</code>)
當為事務指定了復原段後,後續在事務需要寫undo頁時,就從該復原段上分别配置設定兩個slot,一個用于<code>update_undo</code>,一個用于<code>insert_undo</code>。分别處理的原因是事務送出後,update_undo需要purge線程來進行回收,而insert_undo則可以直接被重利用。
在介紹事務引用計數之前,我們首先要了解下什麼是隐式鎖。所謂隐式鎖,其實并不是一個真正的事務鎖對象,可以了解為一個标記:對于聚集索引頁的更新,記錄本身天然帶事務id,對于二級索引頁,則在page上記錄最近一次更新的最大事務id,通過回表的方式判斷可見性。
由于事務鎖涉及到全局資源,建立鎖的開銷高昂,innodb對于新插入的記錄,在沒有沖突的情況下是不建立記錄鎖的。舉個例子,session 1插入一條記錄,并保持未送出狀态。另外一個session想更新這條記錄,從資料頁上讀取到這條記錄後,發現對應的事務id還處于活躍狀态,根據目前的并發規則,這個更新需要被阻塞住。是以第二個session需要為session 1建立一條記錄鎖,然後将自己放入等待隊列中。
在mysql5.7版本之前,隐式鎖轉換的邏輯為(函數<code>lock_rec_convert_impl_to_expl</code>)
首先判斷記錄對應的事務id是否還處于活躍狀态
聚集索引: <code>lock_clust_rec_some_has_impl</code>
二級索引: <code>lock_sec_rec_some_has_impl</code>
如果不活躍,說明事務已送出,我們可以對這條記錄做任何更改操作,直接傳回;否則傳回擷取的trx_id
持有lock_sys->mutex;
持有trx_sys->mutex ,并擷取目前記錄中的事務id對應的記憶體事務對象trx_t;
為該事務建立一個鎖對象,并加入到鎖隊列中;
釋放lock_sys->mutex。
上述流程中長時間持有<code>lock_sys->mutex</code>,目的是防止在為其轉換隐式鎖為顯式鎖時事務被送出掉。尤其是在第三步,同時持有兩把大鎖去查找事務對象。在5.6官方版本中,這種查找操作還需要周遊連結清單,開銷巨大,推高了臨界資源的競争。
是以在5.7中引入事務計數<code>trx_t::n_ref</code>來輔助判斷,在隐式鎖轉換時,通過讀寫事務集合(<code>rw_trx_set</code>)快速獲得事務對象,同時對<code>trx_t::n_def</code>遞增。這個過程無需加<code>lock_sys->mutex</code>鎖。随後再持有lock_sys->mutex去建立顯式鎖。在完成建立後,遞減<code>trx_t::n_ref</code>。
為了防止為一個已送出的事務建立顯式鎖;在事務送出階段也做了處理:在事務釋放事務鎖之前,如果引用計數非0,則表示有人正在做隐式鎖轉換,這裡需要等待其完成。(參考函數<code>lock_trx_release_locks</code>)。
實際上上述修改是在官方優化讀寫事務連結清單之前完成的。由于在5.7裡已經使用一個有序的集合儲存了<code>trx_id</code>到<code>trx_t</code>的關聯,可以非常快速的定位到事務對象,這個優化帶來的性能提升已經沒那麼明顯了。
關于隐式鎖更詳細的資訊,我們将在之後專門講述“事務鎖”的月報中再單獨描述。
在mysql5.7中,由于消除了大量臨界資源的競争,innodb隻讀查詢的性能非常優化,幾乎可以随着cpu線性擴充。但如果進入到讀寫混合的場景,就不可避免的使用到一些臨界資源,例如事務、鎖、日志等子系統。當競争越激烈,就可能導緻性能的下降。通常系統會有個吞吐量和響應時間最優的性能拐點。
innodb本身提供了并發控制機制,一種是語句級别的并發控制,另外一種是事務送出階段的并發控制。
語句級别的并發通過參數<code>innodb_thread_concurrency</code>來控制,表示允許同時在innodb層活躍的并發sql數。
每條sql在進入innodb層進行操作之前都需要先遞增全局計數,并為目前sql配置設定<code>innodb_concurrency_tickets</code>個ticket。也就是說,如果目前sql需要進出innodb層很多次(例如一個大查詢需要掃描很多行資料時),<code>innodb_concurrency_tickets</code>次都可以自由進入innodb,無需判斷<code>innodb_thread_concurrency</code>。當ticket用完時,就需要重新進入,當sql執行完成後,會将ticket重置為0。
如果目前innodb層的并發度已滿,使用者線程就需要等待,目前的實作使用sleep一段時間的方式,sleep的時間是自适應的,但你可以通過參數<code>innodb_adaptive_max_sleep_delay</code>來設定一個最大sleep事件,具體的算法參閱函數<code>srv_conc_enter_innodb_with_atomics</code>。
提到并發控制,另外一個不得不提的問題就是熱點更新問題。事務在進入innodb層,準備更新一條資料,但發現行記錄被其他線程鎖住,這時候該線程會強制退出innodb并發控制,同時将自己suspend住,進入睡眠等待。如果有大量并發的更新同一條記錄,就意味着大量線程進入innodb層,通路熱點競争資源鎖系統,然後再退出。最終會呈現出大量線程在innodb中suspend住,相當于并發控制并沒有達到降低臨界資源争用的效果。早期我們對該問題的優化就是将線程從堵在innodb層,轉移到堵在進入innodb層時的外部排隊中,這樣就不涉及到innodb的資源争用了。具體的實作是将statement級别的并發控制提升為事務級别的并發控制,是以這個方案的缺陷是對長事務不友好。
另外還有一些并發控制方案,例如線程池、水位限流、按pk排隊等政策,我們的rds mysql也很早就支援了。如果你存在熱點争用(例如秒殺場景),并且正在使用rds mysql,你可以去咨詢售後如何使用這些特性。
除了語句級别的并發外,innodb也提供了送出階段的并發控制,主要通過參數<code>innodb_commit_concurrency</code>來控制。該參數的預設值為0,表示不控制commit階段的并發。在進入函數<code>innobase_commit</code>時,如果該參數被設定,且目前并發度超過,就需要等待。然而由于目前在預設配置下所有事務都走組送出(<code>ordered_commit</code>),innodb層的送出大多數情況下隻會有一個活躍線程。你隻有關閉binlog或者關閉參數<code>binlog_order_commits</code>,這個參數設定才有意義。
mysql5.7 實作了一種高優先級的事務排程方式。當事務處于高優先級模式時,它将永遠不會被選作deadlock場景的犧牲者,擁有獲得鎖的最高優先級,并能kill掉阻塞它的的低優先級事務。這個特性主要是為了支援官方開發的group replication plugin套件,以保證事務總是能在所有的節點上送出。
如何使用
目前ga版本還沒有提供公共接口來使用該功能,但代碼實作都是完備的,如果想使用該功能,直接寫一個設定變量的接口即可,非常簡單。在server層,每個thd上新增了兩個變量來辨別事務的優先級:
<code>thd::tx_priority</code> 事務級别有效,當兩個事務在innodb層沖突時,擁有更高值的事務将赢得鎖;
<code>thd::thd_tx_priority</code> 線程級别有效,當該變量被設定時,選擇該值作為事務優先級,否則選擇tx_priority。
死鎖檢測
在進行死鎖檢測時,需要對死鎖的兩個事務的優先級進行比較,低優先級的總是會被優先復原掉,以保證高優先級的事務正常執行(<code>deadlockchecker::check_and_resolve</code>)。
處理鎖等待
在對記錄嘗試加鎖時,如果發現有别的事務和目前事務沖突(<code>lock_re_other_has_conflicting</code>),需要判斷是否要加入到等待隊列中(<code>reclock::add_to_wait</code>):
如果兩個事務都設定了高優先級、但目前事務優先級較低,或者沖突的事務是一個背景程序開啟的事務(例如dict_stat線程進行統計資訊更新),則立刻失敗該事務,并傳回db_deadlock錯誤碼;
嘗試将目前鎖對象加入到等待隊列中(<code>reclock::enqueue_priority</code>),高優先級的事務可以跳過鎖等待隊列(<code>reclock::jump_queue</code>),被跳過的事務需要被标記為異步復原狀态(<code>reclock::mark_trx_for_rollback</code>),搜集到目前事務的<code>trx_t::hit_list</code>連結清單中。當阻塞目前事務的另外一個事務也處于等待狀态、但等待另外一個不同的記錄鎖時,調用<code>rollback_blocking_trx</code>直接復原掉,否則在進入鎖等待之前再調用<code>trx_kill_blocking</code>依次復原。
閱讀代碼時發現這個在5.7版本新加的變量,從它的命名可以看出,其應該和髒頁flush相關。<code>flush_observer</code>可以認為是一個标記,當某種操作完成時,對于帶這種标記的page(<code>buf_page_t::flush_observer</code>),需要保證完全刷到磁盤上。
為了解決這一問題,引入了<code>flush_observer</code>,在建索引之前建立一個<code>flushobserver</code>并配置設定給事務對象(<code>trx_set_flush_observer</code>),同時傳遞給<code>btrbulk::m_flush_observer</code>。
在建構索引的過程中産生的髒頁,通過<code>mtr_commit</code>将髒頁轉移到flush_list上時,順便标記上flush_observer(<code>add_dirty_page_to_flush_list —> buf_flush_note_modification</code>)。
當做完索引建構操作後,由于bulk load操作不記redo,需要保證ddl産生的所有髒頁都寫到磁盤,是以調用<code>flushobserver::flush</code>,将髒頁寫盤(<code>buf_lru_flush_or_remove_pages</code>)。在做完這一步後,才開始apply online ddl過程中産生的row log(<code>row_log_apply</code>)。
如果ddl被中斷(例如session被kill),也需要調用<code>flushobserver::flush</code>,将這些産生的髒頁從記憶體移除掉,無需寫盤。
為了減少建構事務對象時的記憶體操作開銷,尤其是短連接配接場景下的性能,innodb引入了一個池結構,可以很友善的配置設定和釋放事務對象。實際上事務的事務鎖對象也引用了池結構。
事務池對應的全局變量為<code>trx_pools</code>,初始化為:
<code>trx_pools</code>是操作trx pool的接口,類型為<code>trx_pools_t</code>,其定義如下:
其中,<code>trx_t</code>表示事務對象類型,trxfactory封裝了事務的初始化,trxpoollock封裝了pool鎖的建立、銷毀、加鎖、解鎖,poolmanager封裝了池的管理方法。
這裡涉及到多個類:
pool 及 poolmanager 是公共用的類;
trxfactory 和 trxpoollock, trxpoolmanagerlock是trx pool私有的類;
trxfactory用于定義池中事務對象的初始化和銷毀動作;
trxpoollock用于定義每個池中對象的互斥鎖操作;
由于pool的管理結構支援多個pool對象,trxpoolmanagerlock用于互斥操作增加pool對象。支援多個pool對象的目的是分拆單個pool對象的鎖開銷,避免引入熱點。因為從pool中擷取和返還對象,都是需要排他鎖的。
相關類的關系如下圖所示:
事務池相關類
innodb有兩個非常重要的子產品來實作mvcc,一個是undo日志,用于記錄資料的變化軌迹,另外一個是readview,用于判斷該session對哪些資料可見,哪些不可見。實際上我們已經在之前的月報中介紹過這部分内容,這裡再簡要介紹下。
前面已經多次提到過readview,也就是事務視圖,它用于控制資料的可見性。在innodb中,隻有查詢才需要通過readview來控制可見性,對于dml等資料變更操作,如果操作了不可見的資料,則直接進入鎖等待。
readview包含幾個重要的變量:
<code>readview::id</code> 建立該視圖的事務id;
<code>readview::m_ids</code> 建立readview時,活躍的讀寫事務id數組,有序存儲;
<code>readview::m_low_limit_id</code> 設定為目前最大事務id;
<code>readview::m_up_limit_id</code> m_ids集合中的最小值,如果m_ids集合為空,表示目前沒有活躍讀寫事務,則設定為目前最大事務id。
很顯然readview的建立需要在<code>trx_sys->mutex</code>的保護下進行,相當于拿到了當時的一個全局事務快照。基于上述變量,我們就可以判斷資料頁上的記錄是否對目前事務可見了。
為了管理readview,mvcc子系統使用多個連結清單進行配置設定、維護、回收readview:
<code>mvcc::m_free</code> 用于維護空閑的readview對象,初始化時建立1024個readview對象(<code>trx_sys_create</code>),當釋放一個活躍的視圖時,會将其加到該連結清單上,以便下次重用;
<code>mvcc::m_views</code> 這裡存儲了兩類視圖,一類是目前活躍的視圖,另一類是上次被關閉的隻讀事務視圖。後者主要是為了減少視圖配置設定開銷。因為當系統的讀占大多數時,如果在兩次查詢中間沒有進行過任何讀寫操作,那我們就可以重用這個readview,而無需去持有<code>trx_sys->mutex</code>鎖重新配置設定;
另外purge系統在開始purge任務時,需去克隆<code>mvcc::m_views</code>連結清單上未被close的最老視圖,并在本地視圖中将該最老事務的事務id也加入到不可見的事務di集合<code>readview::m_ids</code>中(<code>mvcc::clone_oldest_view</code>)。
復原段undo是實作innodb mvcc的根基。每次修改聚集索引頁上的記錄時,變更之前的記錄都會寫到undo日志中。復原段指針包括undo log所在的復原段id、日志所在的page no、以及page内的偏移量,可以據此找到最近一次修改之前的undo記錄,而每條undo記錄又能再次找到之前的變更。
當有可能undo被通路到時,purge_sys将不會去清理undo log,如上所述,purge_sys隻會去清理最老readview不會看到的事務。這意味着,如果你運作了一個長時間的查詢sql,或者以大于rc的隔離級别開啟了一個事務視圖但沒有送出事務,purge系統将一直無法前行,即使你的會話并不活躍。這時候undo日志将無法被及時回收,最直覺的後果就是undo空間急劇膨脹。
如上所述,聚集索引的可見性判斷和二級索引的可見性判斷略有不同。因為二級索引記錄并沒有存儲事務id資訊,相應的,隻是在資料頁頭存儲了最近更新該page的trx_id。
對于聚集索引記錄,當我們從btree獲得一條記錄後,先判斷(<code>lock_clust_rec_cons_read_sees</code>)目前的readview是否滿足該記錄的可見性:
如果記錄的<code>trx_id</code>小于<code>readview::m_up_limit_id</code>,則說明該事務在建立readview時已經送出了,肯定可見;
如果記錄的<code>trx_id</code>大于等于<code>readview::m_low_limit_id</code>,則說明該事務是建立readview之後開啟的,肯定不可見;
當<code>trx_id</code>在<code>m_up_limit_id</code>和<code>m_low_limit_id</code>之間時,如果在<code>readview::m_ids</code>數組中,說明建立readview時該事務是活躍的,其做的變更對目前視圖不可見,否則對該<code>trx_id</code>的變更可見。
如果基于上述判斷,該資料變更不可見時,就嘗試通過undo去建構老版本記錄(<code>row_sel_build_prev_vers_for_mysql -->row_vers_build_for_consistent_read</code>),直到找到可見的記錄,或者到達undo連結清單頭都未找到。
注意當隔離級别設定為read uncommitted時,不會去建構老版本。
如果我們查詢得到的是一條二級索引記錄:
首先将page頭的<code>trx_id</code>和目前視圖相比較:如果小于<code>readview::m_up_limit_id</code>,目前事務肯定可見;否則就需要去找到對應的聚集索引記錄(<code>lock_sec_rec_cons_read_sees</code>);
如果需要進一步判斷,先根據icp條件,檢查是否該記錄滿足push down的條件,以減少回聚集索引的次數;
滿足icp條件,則需要查詢聚集索引記錄(<code>row_sel_get_clust_rec_for_mysql</code>),之後的判斷就和上述聚集索引記錄的判斷一緻了。
在innodb中,隻有讀查詢才會去建構readview視圖,對于類似dml這樣的資料更改,無需判斷可見性,而是單純的發現事務鎖沖突,直接堵塞操作。
然而在不同的隔離級别下,可見性的判斷有很大的不同。
read-uncommitted
在該隔離級别下會讀到未送出事務所産生的資料更改,這意味着可以讀到髒資料,實際上你可以從函數<code>row_search_mvcc中</code>發現,當從btree讀到一條記錄後,如果隔離級别設定成read-uncommitted,根本不會去檢查可見性或是檢視老版本。這意味着,即使在同一條sql中,也可能讀到不一緻的資料。
read-committed
在該隔離級别下,可以在sql級别做到一緻性讀,當事務中的sql執行完成時,readview被立刻釋放了,在執行下一條sql時再重建readview。這意味着如果兩次查詢之間有别的事務送出了,是可以讀到不一緻的資料的。
repeatable-read
可重複讀和read-committed的不同之處在于,當第一次建立readview後(例如事務内執行的第一條seelct語句),這個視圖就會一直維持到事務結束。也就是說,在事務執行期間的可見性判斷不會發生變化,進而實作了事務内的可重複讀。
serializable
序列化的隔離是最高等級的隔離級别,當一個事務在對某個表做記錄變更操作時,另外一個查詢操作就會被該操作堵塞住。同樣的,如果某個隻讀事務開啟并查詢了某些記錄,那麼另外一個session對這些記錄的更改操作是被堵塞的。内部的實作其實很簡單:
對innodb表級别加<code>lock_is</code>鎖,防止表結構變更操作
對查詢得到的記錄加<code>lock_s</code>共享鎖,這意味着在該隔離級别下,讀操作不會互相阻塞。而資料變更操作通常會對記錄加<code>lock_x</code>鎖,和<code>lock_s</code>鎖相沖突,innodb通過給查詢加記錄鎖的方式來保證了序列化的隔離級别。
注意不同的隔離級别下,資料具有不同的隔離性,甚至事務鎖的加鎖政策也不盡相同,你需要根據自己實際的業務情況來進行選擇。
在read-committed隔離級别下,我們考慮如下執行序列:
查詢條件不同,但指向的确是同一條已插入未送出的記錄,為什麼會有兩種不同的表現呢? 這主要是不同索引在資料檢索時的政策不同造成的。
實際上session2的第一條update也為session1做了隐式鎖轉換,但是在傳回到<code>row_search_mvcc</code>時,會走到如下判斷:
對于第一條和第二條update,<code>prebuilt->row_read_type</code>值均為<code>row_read_try_semi_consistent</code>,不滿足第一個條件;
均不滿足<code>unique_search</code>(通過pk,或uk作為where條件進行查詢);
第一個使用的聚集索引,三個條件都不滿足;而第二個update使用的二級索引,是以走<code>lock_wait_or_error</code>的邏輯,進入鎖等待。
第一條update繼續往下走,根據undo去建構老版本記錄(<code>row_sel_build_committed_vers_for_mysql</code>),一條新插入的記錄老版本就是空了,是以認為這條更新沒有查詢到目标記錄,進而忽略了鎖阻塞的邏輯。
如果使用pk或者二級索引作為where條件查詢的話,都會走到鎖等待條件。
推而廣之,如果表上沒有索引的話,那麼對于任意插入的記錄,更新操作都見不到插入的記錄(但是會為插入操作建立記錄鎖)。
本小節針對acid這四種資料庫特性分别進行簡單描述。
所謂原子性,就是一個事務要麼全部完成變更,要麼全部失敗。如果在執行過程中失敗,復原操作需要保證“好像”資料庫從沒執行過這個事務一樣。
從使用者的角度來看,使用者發起一個commit語句,要保證事務肯定成功完成了;若發起rollback語句,則幹淨的復原掉事務所有的變更。
從内部實作的角度看,innodb對事務過程中的資料變更總是維持了undo log,若使用者想要復原事務,能夠通過undo追溯最老版本的方式,将資料全部復原回來。若使用者需要送出事務,則将送出日志刷到磁盤。
一緻性指的是資料庫需要總是保持一緻的狀态,即使執行個體崩潰了,也要能保證資料的一緻性,包括内部資料存儲的準确性,資料結構(例如btree)不被破壞。innodb通過doublewrite buffer 和crash recovery實作了這一點:前者保證資料頁的準确性,後者保證恢複時能夠将所有的變更apply到資料頁上。如果崩潰恢複時存在還未送出的事務,那麼根據xa規則送出或者復原事務。最終執行個體總能處于一緻的狀态。
另外一種一緻性指的是資料之間的限制不應該被事務所改變,例如外鍵限制。mysql支援自動檢查外鍵限制,或是做級聯操作來保證資料完整性,但另外也提供了選項<code>foreign_key_checks</code>,如果您關閉了這個選項,資料間的限制和一緻性就會失效。有些情況下,資料的一緻性還需要使用者的業務邏輯來保證。
隔離性是指多個事務不可以對相同資料同時做修改,事務檢視的資料要麼就是修改之前的資料,要麼就是修改之後的資料。innodb支援四種隔離級别,如上文所述,這裡不再重複。
當一個事務完成了,它所做的變更應該持久化到磁盤上,永不丢失。這個特性除了和資料庫系統相關外,還和你的硬體條件相關。innodb給出了許多選項,你可以為了追求性能而弱化持久性,也可以為了完全的持久性而弱化性能。
和大多數dbms一樣,innodb 也遵循wal(write-ahead logging)的原則,在寫資料檔案前,總是保證日志已經寫到了磁盤上。通過redo日志可以恢複出所有的資料頁變更。
為了保證資料的正确性,redo log和資料頁都做了checksum校驗,防止使用損壞的資料。目前5.7版本預設支援使用crc32的資料校驗算法。
為了解決半寫的問題,即寫一半資料頁時執行個體crash,這時候資料頁是損壞的。innodb使用double write buffer來解決這個問題,在寫資料頁到使用者表空間之前,總是先持久化到double write buffer,這樣即使沒有完整寫頁,我們也可以從double write buffer中将其恢複出來。你可以通過innodb_doublewrite選項來開啟或者關閉該特性。
innodb通過這種機制保證了資料和日志的準确性的。你可以将執行個體配置成事務送出時将redo日志fsync到磁盤(<code>innodb_flush_log_at_trx_commit = 1</code>),資料檔案的flush政策(<code>innodb_flush_method</code>)修改為0_direct,以此來保證強持久化。你也可以選擇更弱化的配置來保證執行個體的性能。