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

碼老思發表於2023-10-28

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的內容將資料恢復到最新的狀態;保證了事務修改的資料,不會丟失,也就是實現了事務的永續性。

參考:


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

相關文章