面試題:MySQL事務的ACID如何實現?

Ki1tFJ

大家好,我是【碼老思】,事務是一個數據庫繞不開的話題,今天和大家一起聊聊。

事務是什麼?

事務(Transaction)是併發控制的基本單位。所謂的事務呢,它是一個操作序列,這些操作要麼都執行,要麼都不執行,它是一個不可分割的工作單位。

在介紹事務的特性之前,我們先看下MySQL的邏輯架構,

WrntnJ

如上圖所示,MySQL服務器邏輯架構從上往下可以分爲三層:

  1. 第一層:處理客戶端連接、授權認證等。
  2. 第二層:服務器層,負責查詢語句的解析、優化、緩存以及內置函數的實現、存儲過程等。
  3. 第三層:存儲引擎,負責MySQL中數據的存儲和提取。MySQL 中服務器層不管理事務,事務是由存儲引擎實現的。**MySQL支持事務的存儲引擎有InnoDB、NDB Cluster等,其中InnoDB的使用最爲廣泛;其他存儲引擎不支持事務,如MyISAM、Memory等。

後續討論主要以InnoDB爲主。

事務有什麼特徵?

事務的特性,可以總結爲如下4個方面:

  • 原子性(Atomicity):原子性是指整個數據庫的事務是一個不可分割的工作單位,在每一個都應該是原子操作。當我們執行一個事務的時候,如果在一系列的操作中,有一個操作失敗了,那麼需要將這一個事務中的所有操作恢復到執行事務之前的狀態,這就是事務的原子性。

  • 一致性(Consistency): 一致性呢是指事務將數據庫從一種狀態轉變成爲下一種一致性的狀態,也就是說是在事務的執行前後,這兩種狀態應該是一樣的,也就是在數據庫的完整性約束不會被破壞。另外的話,還需要注意的是一致性不關注中間的過程是發生了什麼。

  • 隔離性(lsolation): Mysql數據庫可以同時的話啓動很多的事務,但是呢,事務跟事務之間他們是相互分離的,也就是互不影響的,這就是事務的隔離性。下面有介紹事務的四大隔離級別。

  • 持久性(Durability): 事務的持久性是指事務一旦提交,就是永久的了。說白了就是發生了問題,數據庫也是可以恢復的。因此持久性保證事務的高可靠性。

談到事務的四大特性,不得不說一下MySQL事務的隔離機制,在不同的數據庫連接中,一個連接的事務並不會影響其他連接,這是基於事務隔離機制實現的。在MySQL中,事務隔離機制分爲了四個級別:

  • Read uncommitted / RU:讀未提交,就是一個事務可以讀取另一個未提交事務的數據。毫無疑問,這樣會造成大量的髒讀,所以數據庫一般不會採用這種隔離級別。

  • Read committed / RC:讀已提交,就是一個事務讀到的數據必須是其他事務已經提交的數據,這樣就避免了髒讀的情況。但是如果有兩個並行的事務A和B,處理同一批的數據,如果事務A在這個過程中,修改了數據並提交;那麼在事務B中可能前後看到兩個不一樣的數據,這就造成不可重複讀的情況。

  • Repeatable read / RR:可重複讀,就是在開始讀取數據(事務開啓)時,不再允許修改操作。這樣就解決了不可重複讀的問題,但是需要注意的是,不可重複讀對應的是修改,即UPDATE操作。但是可能還會有幻讀問題。因爲幻讀問題對應的是插入INSERT操作,而不是UPDATE操作。

  • Serializable:序列化/串行化。它通過強制事務排序,使之不可能相互衝突,從而解決幻讀問題。簡言之,它是在每個讀的數據行上加上共享鎖。這種情況下所有事務串行執行,可以避免上面的出現的各種問題,但是在大併發場景下會導致大量的超時現象和鎖競爭,所以一般也很少採用。

上述四個級別,越靠後併發控制度越高,也就是在多線程併發操作的情況下,出現問題的機率越小,但對應的也性能越差,MySQL的事務隔離級別,默認爲第三級別:Repeatable read可重複讀。

按照嚴格的標準,只有同時滿足ACID特性纔是事務;但是目前各大數據庫廠商的實現中,真正滿足ACID的事務很少。例如MySQL的NDB Cluster事務不滿足持久性;Oracle默認的事務隔離級別爲READ COMMITTED,不滿足隔離性;InnoDB默認事務隔離級別是可重複讀,完全滿足ACID的特性。因此與其說ACID是事務必須滿足的條件,不如說它們是衡量事務的四個維度。

**MySQL InnoDB 引擎的默認隔離級別雖然是「可重複讀」,但是它很大程度上避免幻讀現象,解決的方案有兩種:

  • 針對快照讀(普通 select 語句),是通過 MVCC 方式解決了不可重複讀和幻讀,因爲可重複讀隔離級別下,事務執行過程中看到的數據,一直跟這個事務啓動時看到的數據是一致的,即使中途有其他事務插入了一條數據,是查詢不出來這條數據的。MVVC在下面會仔細介紹。

Read Committed隔離級別:每次select都生成一個快照讀。
Read Repeatable隔離級別:開啓事務後第一個select語句纔是快照讀的地方,而不是一開啓事務就快照讀。

  • 針對當前讀(select ... for update, delete, insert; select...lock in share mode (共享讀鎖) 等語句),是通過 next-key lock(行記錄鎖+間隙鎖)方式解決了幻讀,因爲當執行 select ... for update 語句的時候,會加上 next-key lock,如果有其他事務在 next-key lock 鎖範圍內插入了一條記錄,那麼這個插入語句就會被阻塞,無法成功插入,所以就很好了避免幻讀問題。對主鍵或唯一索引,如果select查詢時where條件全部精確命中(=或者in),這種場景本身就不會出現幻讀,所以只會加行記錄鎖。關於鎖這塊,後續有專門的章節進行介紹。

總結:事務的隔離性由MVCC和鎖來實現,而原子性、一致性、持久性通過數據庫的redo和undo日誌來完成。接下來會詳細介紹其實現原理。

MVVC如何實現事務的隔離?

MVCC,全稱Multi-Version Concurrency Control,即多版本併發控制。MVCC是一種併發控制的方法,一般在數據庫管理系統中,實現對數據庫的併發訪問。MVCC在MySQL InnoDB中的實現主要是爲了提高數據庫併發性能,用更好的方式去處理讀-寫衝突,做到即使有讀寫衝突時,也能做到不加鎖,非阻塞併發讀。

MVVC是一種用來解決讀-寫衝突的無鎖併發控制,簡單總結就是爲事務分配單向增長的時間戳,爲每個修改保存一個版本,版本與事務時間戳關聯,讀操作只讀該事務開始前的數據庫的快照。 所以MVCC可以爲數據庫解決以下問題:在併發讀寫數據庫時,可以做到在讀操作時不用阻塞寫操作,寫操作也不用阻塞讀操作,提高了數據庫併發讀寫的性能;同時還可以解決髒讀,幻讀,不可重複讀等事務隔離問題,但不能解決更新丟失問題。

MVVC的實現,依賴4個隱式字段undo日誌 ,Read View 來實現的。

隱式字段

每行記錄除了我們自定義的字段外,還有數據庫隱式定義的DB_TRX_ID,DB_ROLL_PTR,DB_ROW_ID等字段

  • DB_ROW_ID 6byte, 隱含的自增ID(隱藏主鍵),如果數據表沒有主鍵,InnoDB會自動以DB_ROW_ID產生一個聚簇索引
  • DB_TRX_ID 6byte, 最近修改(修改/插入)事務ID:記錄創建這條記錄/最後一次修改該記錄的事務ID
  • DB_ROLL_PTR 7byte, 回滾指針,指向這條記錄的上一個版本(存儲於rollback segment裏)
  • DELETED_BIT 1byte, 記錄被更新或刪除並不代表真的刪除,而是刪除flag變了。

jwlfZm
如上圖,DB_ROW_ID是數據庫默認爲該行記錄生成的唯一隱式主鍵;DB_TRX_ID是當前操作該記錄的事務ID; 而DB_ROLL_PTR是一個回滾指針,用於配合undo日誌,指向上一個舊版本;delete flag沒有展示出來。

undo log

InnoDB把這些爲了回滾而記錄的這些東西稱之爲undo log。這裏需要注意的一點是,由於查詢操作(SELECT)並不會修改任何用戶記錄,所以在查詢操作執行時,並不需要記錄相應的undo log。undo log主要分爲3種:

  • Insert undo log :插入一條記錄時,至少要把這條記錄的主鍵值記下來,之後回滾的時候只需要把這個主鍵值對應的記錄刪掉就好了。
  • Update undo log:修改一條記錄時,至少要把修改這條記錄前的舊值都記錄下來,這樣之後回滾時再把這條記錄更新爲舊值就好了。
  • Delete undo log:刪除一條記錄時,至少要把這條記錄中的內容都記下來,這樣之後回滾時再把由這些內容組成的記錄插入到表中就好了。刪除操作都只是設置一下老記錄的DELETED_BIT,並不真正將過時的記錄刪除。

這裏舉一個例子,比如我們想更新Person表中的數據,有兩個事務先後對同一行數據進行了修改,那麼undo log中,不會僅僅只保存最近修改的舊版本記錄,而是通過鏈表的方式將不同版本連接起來。在下面的例子中,

  1. Person表中有一行數據,name爲Jerry,age是24歲。
  2. 事務A將name修改爲Tom,數據修改完成之後,會把舊記錄拷貝到undo log中,並將隱藏字段的事務ID修改爲當前事務ID,這裏假設從1開始,回滾指針指向undo log的副本記錄,說明上一個版本就是它。
  3. 事務B將年齡修改爲30,相同的方式,A事務修改過後的記錄會被放到undo log,而事務B會把事務ID修改爲2,同時回滾指針指向undo log中A事務修改過後的數據。
  4. 最後的形成的回滾鏈路如下。
    TTwcrp

ReadView

在上面介紹undo log的時候可以看到,undo log中維護了每條數據的多個版本,如果新來的一個事務也訪問這同一條數據,如何判斷該讀取這條數據的哪個版本呢?此時就需要ReadView來做多版本的併發控制,根據查詢的時機來選擇一個當前事務可見的舊版本數據讀取。

當一個事務啓動後,首次執行select操作時,MVCC就會生成一個數據庫當前的ReadView,通常而言,一個事務與一個ReadView屬於一對一的關係(不同隔離級別下也會存在細微差異),ReadView一般包含四個核心內容:

  • creator_trx_id:代表創建當前這個ReadView的事務ID。
  • trx_ids:表示在生成當前ReadView時,系統內活躍的事務ID列表。
  • up_limit_id:活躍的事務列表中,最小的事務ID。
  • low_limit_id:表示在生成當前ReadView時,系統中要給下一個事務分配的ID值。

可以通過如下的示意圖進一步理解ReadView,

b6VgN5

假設目前數據庫中共有T1~T5這五個事務,T1、T2、T4還在執行,T3已經回滾,T5已經提交,此時當有一條查詢語句執行時,就會利用MVCC機制生成一個ReadView,由於前面講過,單純由一條select語句組成的事務並不會分配事務ID,因此默認爲0,所以目前這個快照的信息如下:

{ "creator_trx_id" : "0", "trx_ids" : "[1,2,4]", "up_limit_id" : "1", "low_limit_id" : "6" }

當我們拿到ReadView之後,如何判斷當前的事務能夠看到哪些版本的數據,這裏會遵循一個可見性算法,簡單來講就是將要被修改數據的最新記錄的DB_TRX_ID(即當前事務ID),與ReadView維護的其他事務ID進行比較,來確定當前事務能看到的最新老版本。

這裏結合MySQL的算法實現來看,下面是MySQL 8.1裏面關於這個可見性算法的實現。可以看到,整體流程如下:

  1. 首先判斷 DB_TRX_ID < up_limit_id,此時說明該事務已經結束,所以DB_TRX_ID對應的舊版本對ReadView可見。如果 DB_TRX_ID = creator_trx_id,說明ReadView是當前事務中生成的,當然可以看到自己的修改,所以也是可見的。
  2. 接着判斷 DB_TRX_ID >= low_limit_id,則代表DB_TRX_ID 所在的記錄在Read View生成後纔出現的,那對當前事務肯定不可見。但是如果DB_TRX_ID < low_limit_id,並且當前無活躍的事務id,說明所有事務已經提交了,因此該條記錄也是可見的。
  3. 判斷DB_TRX_ID 是否在活躍事務之中。如果在,則代表Read View生成時刻,這個事務還在活躍,還沒有Commit,因此這個事務修改的數據,我當前事務也是看不見的;如果不在,則說明,你這個事務在Read View生成之前就已經Commit了,你修改的結果,我當前事務是能看見的。
// https://dev.mysql.com/doc/dev/mysql-server/latest/read0types_8h_source.html

/** Check whether the changes by id are visible.
  @param[in]    id      transaction id to check against the view
  @param[in]    name    table name
  @return whether the view sees the modifications of id. */
  [[nodiscard]] bool changes_visible(trx_id_t id, const table_name_t &name) const {
    ut_ad(id > 0);
 
    if (id < m_up_limit_id || id == m_creator_trx_id) {
      return (true);
    }
 
    check_trx_id_sanity(id, name);
 
    if (id >= m_low_limit_id) {
      return (false);
    } else if (m_ids.empty()) {
      return (true);
    }
 
    const ids_t::value_type *p = m_ids.data();
 
    return (!std::binary_search(p, p + m_ids.size(), id));
  }

MVCC原理總結

MVCC主要由下面兩個核心功能組成,undo log實現數據的多版本,ReadView實現多版本的併發控制。

  1. 當一個事務嘗試改動某條數據時,會將原本表中的舊數據放入undo log中。
  2. 當一個事務嘗試查詢某條數據時,MVCC會生成一個ReadView快照。

這裏舉一個例子回顧下整個流程:

假設有A和B兩個併發事務,其中事務A在修改第一行的數據,而事務B準備讀取這條數據,那麼B在具體執行過程中,當出現SELECT語句時,會根據MySQL的當前情況生成一個ReadView。

  1. 判斷數據行中的隱藏列TRX_ID與ReadView中的creator_trx_id是否相同,如果相同表示是同一個事務,數據可見。
  2. 判斷TRX_ID是否小於up_limit_id,也就是最小活躍事務ID,如果小的話,說明改動這行數據的事務在ReadView生成之前就結束了,所以是可見的;如果大於的話,繼續往下走。
  3. 判斷TRX_ID是否小於low_limit_id,也就是當前ReadView生成時,下一個會分配的事務ID。如果大於或等於low_limit_id,說明修改該數據的事務是生成ReadView之後纔開啓的,當然是不可見的。如果小於low_limit_id,則進行下一步判斷。
  4. 如果TRX_ID在trx_ids中,說明該數據行對應的事務還在執行,因此對於當前事務而言,該數據不可見;如果TRX_ID不在trx_ids中,說明該事務在生成ReadView時已經結束,因此是可見的。

如果undo log中存在某行數據的多個版本,那麼在實際中會根據隱藏列roll_ptr依次遍歷整個鏈表,按照上面的流程,找到第一條滿足條件的數據並返回。

RC、RR不同級別下的MVVC機制

ReadView是一個事務中只生成一次,還是每次select時都會生成呢?這個問題和MySQL的事務隔離機制有關,RC和RR下的實現有些許不同。

  • RC(讀已提交):每個快照讀都會生成並獲取最新的Read View,保證已經提交事務的修改對當前事務可見。
  • RR(可重複讀):同一個事務中的第一個快照讀纔會創建Read View, 之後的快照讀獲取的都是使用同一個Read View;這樣整個事務期間讀到的記錄都是事務啓動前的記錄。

undo log和redo log在事務裏面有什麼用?

上面介紹了事務隔離性的實現原理,即通過多版本併發控制(MVCC,Multiversion Concurrency Control )解決不可重複讀問題,加上間隙鎖(也就是併發控制)解決幻讀問題。保證了較好的併發性能。

而事務的原子性、一致性和持久性則是通過事務日誌實現,主要就是redo log和undo log。瞭解完下面這些內容,那就明白了其中的原理和實現。

1. redo log

爲什麼需要redo log

在 MySQL 中,如果每一次的更新要寫進磁盤,這麼做會帶來嚴重的性能問題:

  • 因爲 Innodb 是以頁爲單位進行磁盤交互的,而一個事務很可能只修改一個數據頁裏面的幾個字節,這時將完整的數據頁刷到磁盤的話,太浪費資源了。
  • 一個事務可能涉及修改多個數據頁,並且這些數據頁在物理上並不連續,使用隨機 IO 寫入性能太差

因此每當有一條新的數據需要更新時,InnoDB 引擎就會先更新內存(同時標記爲髒頁),然後將本次對這個頁的修改以 redo log 的形式記錄下來,這個時候更新就算完成了。之後,InnoDB 引擎會在適當的時候,由後臺線程將緩存在 Buffer Pool 的髒頁刷新到磁盤裏,這就是 WAL (Write-Ahead Logging)技術

WAL 技術指的是, MySQL 的寫操作並不是立刻寫到磁盤上,而是先寫日誌,然後在合適的時間再寫到磁盤上。

整個過程如下:

sOkpW5

什麼是redo log

redo log 是物理日誌,記錄了某個數據頁做了什麼修改,比如對A表空間中的B數據頁C偏移量的地方做了D更新,每當執行一個事務就會產生這樣的一條或者多條物理日誌。

在事務提交時,只要先將 redo log 持久化到磁盤即可,可以不需要等到將緩存在 Buffer Pool 裏的髒頁數據持久化到磁盤。當系統崩潰時,雖然髒頁數據沒有持久化,但是 redo log 已經持久化,接着 MySQL 重啓後,可以根據 redo log 的內容,將所有數據恢復到最新的狀態。

redo log有什麼好處

總結來看,有一下兩點:

  • 將寫數據的操作,由隨機寫變成了順序寫。在寫入redo log時,使用的是追加操作,所以對應磁盤是順序寫。而直接寫數據,需要先找到數據的位置,然後才能寫磁盤,所以磁盤操作是隨機寫。因此直接寫入redo log比直接寫入磁盤效率高很多。
  • 實現事務的持久性。 使用redo log之後,雖然每次修改數據之後,數據處於緩衝中,如果MySQL重啓,緩衝中的數據會丟失,但是我們可以根據redo log的內容將數據恢復到最新的狀態;保證了事務修改的數據,不會丟失,也就是實現了持久性。

redo log如何寫入磁盤?

redo log並不是每次寫入都會刷新到數據頁,而是採取一定的策略週期性的刷寫到磁盤上。所以,它其實包括了兩部分,分別是內存中的日誌緩衝(redo log buffer)和磁盤上的日誌文件(redo log file)

由於MySQL處於用戶空間,而用戶空間下的緩衝區數據是無法直接寫入磁盤的,因爲中間必須經過操作系統的內核空間緩衝區(OS Buffer)。所以,redo log buffer 寫入 redo logfile 實際上是先寫入 OS Buffer,然後操作系統調用 fsync() 函數將日誌刷到磁盤。過程如下:

x6JECP

MySQL支持用戶自定義在commit時如何將log buffer中的日誌刷log file中。這種控制通過變量 innodb_flush_log_at_trx_commit 的值來決定。該變量有3種值:0、1、2,默認爲1。但注意,這個變量只是控制commit動作是否刷新log buffer到磁盤。

參數值 含義
0(延遲寫) 事務提交時不會將 redo log buffer 中日誌寫到 os buffer,而是每秒寫入os buffer 並調用 fsync() 寫入到 redo logfile 中。也就是說設置爲 0 時是(大約)每秒刷新寫入到磁盤中的,當系統崩潰,會丟失1秒鐘的數據。
1(實時寫、實時刷新) 事務每次提交都會將 redo log buffer 中的日誌寫入 os buffer 並調用 fsync() 刷到 redo logfile 中。這種方式即使系統崩潰也不會丟失任何數據,但是因爲每次提交都寫入磁盤,IO的性能差。
2(實時寫、延遲刷新) 每次提交都僅寫入到 os buffer,然後是每秒調用 fsync() 將 os buffer 中的日誌寫入到 redo log file。

三種方案總結如下:

nMdcL9

  • 針對參數 0 :會把緩存在 redo log buffer 中的 redo log ,通過調用 write() 寫到系統緩存,然後調用 fsync() 持久化到磁盤。所以參數爲 0 的策略,MySQL 進程的崩潰會導致上一秒鐘所有事務數據的丟失;
  • 針對參數 2 :調用 fsync,將緩存在系統緩存裏的 redo log 持久化到磁盤。所以參數爲 2 的策略,較取值爲 0 情況下更安全,因爲 MySQL 進程的崩潰並不會丟失數據,只有在操作系統崩潰或者系統斷電的情況下,上一秒鐘所有事務數據纔可能丟失

在主從複製結構中,要保證事務的持久性和一致性,需要對日誌相關變量設置爲如下:

  1. 如果啓用了二進制日誌,則設置sync_binlog=1,即每提交一次事務同步寫到磁盤中。
  2. 總是設置innodb_flush_log_at_trx_commit=1,即每提交一次事務都寫到磁盤中。
    上述兩項變量的設置保證了:每次提交事務都寫入二進制日誌和事務日誌,並在提交時將它們刷新到磁盤中。

redo log file結構是怎麼樣的?

InnoDB 的 redo log 是固定大小的。比如可以配置爲一組 4 個文件,每個文件的大小是 1GB,那麼 redo log file 可以記錄 4GB 的操作。從頭開始寫。寫到末尾又回到開頭循環寫。如下圖:

Uebok6

上圖中,write pos 表示 redo log 當前記錄的 LSN (邏輯序列號) 位置,一邊寫一遍後移,寫到第 3 號文件末尾後就回到 0 號文件開頭; check point 表示數據頁更改記錄刷盤後對應 redo log 所處的 LSN(邏輯序列號) 位置,也是往後推移並且循環的。

write pos 到 check point 之間的部分是 redo log 的未寫區域,可用於記錄新的記錄;check point 到 write pos 之間是 redo log 已寫區域,是待刷盤的數據頁更改記錄。

當 write pos 追上 check point 時,表示 redo log file 寫滿了,這時候有就不能執行新的更新。得停下來先擦除一些記錄(擦除前要先把記錄刷盤),再推動 check point 向前移動,騰出位置再記錄新的日誌。

2. undo log

undo log有兩個作用:提供回滾和多個行版本控制(MVCC)。

在數據修改的時候,不僅記錄了redo,還記錄了相對應的undo,如果因爲某些原因導致事務失敗或回滾了,可以藉助該undo進行回滾。

cEK3S1

undo log和redo log記錄物理日誌不一樣,它是邏輯日誌。可以認爲當delete一條記錄時,undo log中會記錄一條對應的insert記錄,反之亦然,當update一條記錄時,它記錄一條對應相反的update記錄。

當執行rollback時,就可以從undo log中的邏輯記錄讀取到相應的內容並進行回滾。有時候應用到行版本控制的時候,也是通過undo log來實現的:當讀取的某一行被其他事務鎖定時,它可以從undo log中分析出該行記錄以前的數據是什麼,從而提供該行版本信息,讓用戶實現非鎖定一致性讀取。

undo log 和數據頁的刷盤策略是一樣的,都需要通過 redo log 保證持久化。
buffer pool 中有 undo 頁,對 undo 頁的修改也都會記錄到 redo log。redo log 會每秒刷盤,提交事務時也會刷盤,數據頁和 undo 頁都是靠這個機制保證持久化的。

總結回顧

InnoDB通過MVVC、undo log和redo log實現了事務的ACID特性,

  • MVCC 是通過 ReadView + undo log 實現的。undo log 爲每條記錄保存多份歷史數據,MySQL 在執行快照讀(普通 select 語句)的時候,會根據事務的 Read View 裏的信息,順着 undo log 的版本鏈找到滿足其可見性的記錄。實現了事務的隔離性。
  • undo log記錄了每行數據的歷史版本,當現了錯誤或者用戶執 行了 ROLLBACK 語句,MySQL 可以利用 undo log 中的歷史數據將數據恢復到事務開始之前的狀態。保證了事務的一致性和原子性。
  • 使用redo log之後,雖然每次修改數據之後,數據處於緩衝中,如果MySQL重啓,緩衝中的數據會丟失,但是我們可以根據redo log的內容將數據恢復到最新的狀態;保證了事務修改的數據,不會丟失,也就是實現了事務的持久性。

參考:


歡迎關注公衆號【碼老思】,第一時間獲取最通俗易懂的原創技術乾貨。

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章