MySQL中的MVCC到底能不能解決幻讀

語言: CN / TW / HK
  • 在MySQL當中,只有使用了InnoDB儲存引擎的資料庫表才支援事務。
  • 有了事務就可以用來保證資料的完整以及一致性,保證成批的SQL語句要麼全部執行,要麼全部不執行。
  • 事務用來管理insert、update、delete語句。

1、四個特性(ACID):

  • 原子性:一個事務(transaction)中的所有操作,要麼全部完成,要麼全部不完成,不會結束在中間某個環節。事務在執行過程中發生錯誤,會被回滾(Rollback)到事務開始前的狀態,就像這個事務從來沒有執行過一樣。
  • 一致性:在事務開始之前和事務結束以後,資料庫的完整性沒有被破壞。這表示寫入的資料必須完全符合所有的預設規則,這包含資料的精確度、串聯性以及後續資料庫可以自發性地完成預定的工作。
  • 隔離性:資料庫允許多個併發事務同時對其資料進行讀寫和修改的能力,隔離性可以防止多個事務併發執行時由於交叉執行而導致資料的不一致。事務隔離分為不同級別,包括讀未提交(Read uncommitted)、讀提交(read committed)、可重複讀(repeatable read)和序列化(Serializable)。
  • 永續性:事務處理結束後,對資料的修改就是永久的,即便系統故障也不會丟失。

在 MySQL 命令列的預設設定下,事務都是自動提交的,即執行 SQL 語句後就會馬上執行 COMMIT 操作。因此要顯式地開啟一個事務務須使用命令 BEGIN 或 START TRANSACTION,或者執行命令 SET AUTOCOMMIT=0,用來禁止使用當前會話的自動提交。

2、隔離級別

**

  • 讀未提交(Read uncommitted),一個事務可以讀取到其他事務中做出操作且還未提交的資料。會出現髒讀,不可重複讀,幻讀現象。
  • 讀已提交(Read committed),一個事務只能讀取到其他事務中做出操作且已經做出提交的資料。會出現不可重複度,幻讀現象。
  • 可重複讀(Repeatable read),同一個事務內多次查詢的資料保持一致。會出現幻讀
  • 序列化(Serializable )是高的隔離級別,它求在選定物件上的讀鎖和寫鎖保持直到事務結束後才能釋放,所以能防住上訴所有問題,但因為是序列化的,所以效率較低.

3、幻讀、不可重複讀、髒讀

髒讀:當一個事務讀取到其他事務還未提交的資料,因為未提交的資料,不一定是最終有效的資料。所以我們稱為讀到髒資料了。也就是髒讀。 不可重複讀:一個事務A讀取資料之後,另外一個事務B將此資料修改,此時事務A再次查詢,發現數據不一樣了。這就是不可重複讀。也可以叫做幻讀。 幻讀:又叫"幻象讀",是''不可重複讀''的一種特殊場景:當事務1兩次執行''SELECT ... WHERE''檢索一定範圍內資料的操作中間,事務2在這個表中建立了(如[[INSERT]])了一行新資料,這條新資料正好滿足事務1的“WHERE”子句。 注:可能有點繞,一般情況下,“不可重複讀”和“幻讀”大致的意思相同。只不過不可重複度是在資料行上發生的,也就是發生了update操作,再去讀取這條資料,出現不可重複讀。而幻讀是在資料表上發生的,也就是發生了insert與delete操作。再去讀取這張表,出現數據條目或者行數(記錄數)不一樣。出現了幻覺一樣。 **

4、MVCC(Multiversion Concurrency Control)多版本併發控制

資料庫用於處理讀寫衝突的一種手段,目的在於提交資料庫高併發場景下的吞吐效能。 版本鏈: 對於使用InnoDB儲存引擎的表來說,它的聚簇索引記錄中都包含兩個必要的隱藏列(row_id並不是必要的,我們 建立的表中有主鍵或者非NULL唯一鍵時都不會包含row_id列): trx_id:每次對某條記錄進行改動時,都會把對應的事務id賦值給trx_id隱藏列。 roll_pointer:每次對某條記錄進行改動時,這個隱藏列會存一個指標,可以通過這個指標找到該記 錄修改前的資訊。 比如說現在有這樣一張表:t

| ID | Name | | --- | --- | | 1 | 小李 |

我們先假設新增這條記錄的事務ID為80,那麼此時此刻這條記錄的版本連結串列如下圖(因為是新增,所以這條版本鏈對應的roll_pointer是空): image.png 假如現在有兩個事務ID分別為100、200,對這條記錄進行update操作,具體走向流程如下: image.png 貼心小課堂: 兩個事務中不能交叉更新同一條記錄哦?第一個事務更新了某條記錄後,就會給這條記錄加鎖,另一個事務再次更新時就需要等待第一個事務提交了,把鎖釋放之後才可以繼續更新。 我們每一次對資料記錄的改動,MySQL都會記錄一條日誌,我們把它稱作undo日誌,每一條undo日誌對應著也都有一個roll_pointer屬性(insert操作對應的undo日誌沒有該屬性,因為該記錄並沒有更早的版本),可以將這些undo日誌都連起來,串成一個連結串列,所以現在的情況就像下圖一樣: image.png 對這條記錄每次更新後,都會將舊記錄放入到undo日誌中,就算是該記錄的一個歷史版本,隨著更新次數的一次次增加,所有的版本都會被roll_pointer屬性連線成一個連結串列,我們把這個連結串列稱之為【版本鏈】,版本鏈的頭節點就是當前記錄最新的值。另外,每個版本中還包含生成該版本時對應的事務id,這個ID(事務ID)非常重要,後續事務的隔離級別實現原理都是圍繞這個ID(事務ID)來的。

ReadView

** 對於使用【讀未提交READ_UNCOMMITTED】這種隔離級別的事務來說,直接讀取記錄的最新版本就好了,對於使用【序列化SERIALIZABLE】隔離級別的事務來說,使用加鎖的方式來訪問記錄。對於使用【讀已提交READ COMMITTED】和【可重複讀REPRATABLE_READ】隔離級別的事務來說,就需要用到我們上邊所說的【版本鏈】了,核心的問題就是:我們需要判斷版本鏈中的資料,哪個版本是當前事務可見的。所以設計MySQL官方提出了一個ReadView的概念,這個ReadView中主要包含當前MySQL中還有哪些活躍的讀寫事務,把它們的事務id放到一個列表中,我們把這個列表命名為為m_ids(一個數組)。這樣在我們訪問某一條記錄時,只需要按照下邊的步驟判斷記錄的某個版本是否可見(官方設計規則哦):

  • 如果被訪問版本的trx_id屬性值小於m_ids列表中最小的事務id,表明生成該版本的事務在生成ReadView前已經提交,所以該版本可以被當前事務訪問。

  • 如果被訪問版本的trx_id屬性值大於m_ids列表中最大的事務id,表明生成該版本的事務在生成ReadView後才生成,所以該版本不可以被當前事務訪問。

  • 如果被訪問版本的trx_id屬性值在m_ids列表中最大的事務id和最小事務id之間,那就需要判斷一下trx_id屬性值是不是在m_ids列表中,如果在,說明建立ReadView時生成該版本的事務還是活躍的,該版本不可以被訪問;如果不在,說明建立ReadView時生成該版本的事務已經被提交,該版本可以被訪問。

如果某個版本的資料對當前事務不可見的話,那就順著版本鏈繼續去找下一個版本的資料記錄,依然按照我們上邊所說的步驟判斷資料是否可見,依此類推,一直到版本鏈中的最後一個版本資料,如果最後一個版本的資料我也不可見的話,那麼也就意味著該條記錄對該事務不可見,查詢結果就不包含該記錄。 在MySQL當中,READ COMMITTED(讀已提交)和REPEATABLE READ(可重複讀)隔離級別的的一個非常大的區別就是它們生成ReadView的時機不同,我們來具體舉例看一下嘍。 按照上面我們畫的版本鏈,來具體分析一下,這個版本鏈是怎麼一步步生成的,以及我們查詢的時候,MySQL是怎麼來通過版本鏈決定資料我們是否可讀(可見)的。 --[1]--【READ COMMITTED --- 每次讀取資料前都生成一個ReadView】 假設說現在系統裡有一個id為100的事務在執行: ```sql

Transaction 100

BEGIN;

UPDATE t SET name = '小B' WHERE id = 1;

UPDATE t SET name = '小C' WHERE id = 1;

注意哦:我們這個事務,我並沒有提交。沒有commit指令哦

sql

Transaction 200

BEGIN;

更新了一些別的表的記錄

... 貼心小課堂:事務執行過程中,只有在第一次真正修改記錄時(比如使用INSERT、DELETE、UPDATE語句),才會被分配一個單獨的事務id,這個事務id是遞增的。 此刻,表t中id為1的記錄得到的版本連結串列如下所示: ![image.png](http://p3-juejin.byteimg.com/tos-cn-i-k3u1fbpfcp/f0aff056445d4ca79da402d1df77bdaf~tplv-k3u1fbpfcp-zoom-1.image) 千萬注意,我上面事務100,還沒提交哦,我可沒有執行commit指令。 假設現在有一個使用READ COMMITTED(讀已提交)隔離級別的事務開始執行:sql

使用READ COMMITTED隔離級別的事務(讀已提交)

BEGIN;

SELECT1:Transaction 100、200未提交

SELECT * FROM t WHERE id = 1; # 得到的列name的值為'小A' ``` 這個SELECT1的執行流程如下:

  • 在執行SELECT語句時會首先生成一個ReadView,ReadView的m_ids陣列列表的內容就是[100,200]。

  • 然後從版本鏈中挑選可見的記錄,從圖中可以看出,最新版本的列name的內容是'小C',該版本的trx_id值為100,在m_ids列表內,所以不符合我們的可見性要求,根據roll_pointer跳到下一個版本。

  • 下一個版本的列name的內容是'小B',該版本的trx_id值也為100,也在m_ids列表內,所以也不符合要求,繼續跳到下一個版本。

  • 下一個版本的列name的內容是'小A',該版本的trx_id值為80,小於m_ids列表中最小的事務id100,所以這個版本是符合要求的,最後返回給使用者的版本就是這條列name為'小A'的記錄。

之後,我們把事務id為100的這個事務提交一下,如下: ```sql

Transaction 100

BEGIN;

UPDATE t SET name = '小B' WHERE id = 1;

UPDATE t SET name = '小C' WHERE id = 1;

COMMIT; //提交了哦 然後再到事務id為200的事務中更新一下表t中id為1的記錄:sql

Transaction 200

BEGIN;

更新了一些別的表的記錄

...

UPDATE t SET name = '小D' WHERE id = 1;

UPDATE t SET name = '小F' WHERE id = 1; 此刻,表t中id為1的記錄的版本鏈就長這樣: ![image.png](http://p3-juejin.byteimg.com/tos-cn-i-k3u1fbpfcp/91e8260ae14e42f8b0d46e81151da8a8~tplv-k3u1fbpfcp-zoom-1.image) 然後再到剛才使用READ COMMITTED隔離級別的事務中繼續查詢這個id為1的記錄,如下:sql

使用READ COMMITTED隔離級別的事務

BEGIN;

SELECT1:Transaction 100、200均未提交的時候執行的查詢

SELECT * FROM t WHERE id = 1; # 得到的列name的值為'小A'

SELECT2:Transaction 100提交,Transaction 200未提交的時候執行的查詢

SELECT * FROM t WHERE id = 1; # 得到的列name的值為'小C' ``` 這個SELECT2的執行過程如下:

  • 在執行SELECT語句時會先生成一個ReadView,ReadView的m_ids列表的內容就是[200](事務id為100的那個事務已經提交了,所以生成快照時就沒有它了)。

  • 然後從版本鏈中挑選可見的記錄,從圖中可以看出,最新版本的列name的內容是'小F',該版本的trx_id值為200,在m_ids列表內,所以不符合可見性要求,根據roll_pointer跳到下一個版本。

  • 下一個版本的列name的內容是'小D',該版本的trx_id值為200,也在m_ids列表內,所以也不符合要求,繼續跳到下一個版本。

  • 下一個版本的列name的內容是'小C',該版本的trx_id值為100,比m_ids列表中最小的事務id200還要小,所以這個版本是符合要求的,最後返回給使用者的版本就是這條列name為'小C'的記錄。

以此類推,如果之後事務id為200的記錄也提交了,再此在使用READ COMMITTED隔離級別的事務中查詢表t中id值為1的記錄時,得到的結果就是'小F'了,具體流程我們就不分析了。總結一下就是:使用READ COMMITTED隔離級別的事務在每次查詢開始時都會生成一個獨立的ReadView。 說完了隔離級別為【讀已提交】不知道你理解了沒有?如果不理解,煩請聯絡我,我們一起進行探討。 接下來我們就來看一下當事務隔離級別為【可重複讀】的時候,MVCC是如何控制資料可見性的。 --[2]--【REPEATABLE READ ---在第一次讀取資料時生成一個ReadView】 對於使用REPEATABLE READ隔離級別的事務來說,只會在第一次執行查詢語句時生成一個ReadView,之後的查詢就不會重複生成了。我們還是用例子看一下是什麼效果。 比方說現在系統裡有兩個id分別為100、200的事務在執行: ```sql

Transaction 100

BEGIN;

UPDATE t SET name = '小B' WHERE id = 1;

UPDATE t SET name = '小C' WHERE id = 1; sql

Transaction 200

BEGIN;

更新了一些別的表的記錄

... 此刻,表t中id為1的記錄得到的版本連結串列如下所示: ![image.png](http://p3-juejin.byteimg.com/tos-cn-i-k3u1fbpfcp/306f72bf5129404b9207e02162b47c0a~tplv-k3u1fbpfcp-zoom-1.image) 假設現在有一個使用REPEATABLE READ隔離級別的事務開始執行:sql

使用REPEATABLE READ隔離級別的事務

BEGIN;

SELECT1:Transaction 100、200未提交

SELECT * FROM t WHERE id = 1; # 得到的列name的值為'小A' ``` 這個SELECT1的執行過程如下:

  • 在執行SELECT語句時會先生成一個ReadView,ReadView的m_ids列表的內容就是[100, 200]。

  • 然後從版本鏈中挑選可見的記錄,從圖中可以看出,最新版本的列name的內容是'小C',該版本的trx_id值為100,在m_ids列表內,所以不符合可見性要求,根據roll_pointer跳到下一個版本。

  • 下一個版本的列name的內容是'小B',該版本的trx_id值也為100,也在m_ids列表內,所以也不符合要求,繼續跳到下一個版本。

  • 下一個版本的列name的內容是'小A',該版本的trx_id值為80,小於m_ids列表中最小的事務id100,所以這個版本是符合要求的,最後返回給使用者的版本就是這條列name為'小A'的記錄。

之後,我們把事務id為100的事務提交一下,就像這樣: ```sql

Transaction 100

BEGIN;

UPDATE t SET name = '小B' WHERE id = 1;

UPDATE t SET name = '小C' WHERE id = 1;

COMMIT; 然後再到事務id為200的事務中更新一下表t中id為1的記錄:sql

Transaction 200

BEGIN;

更新了一些別的表的記錄

...

UPDATE t SET name = '小D' WHERE id = 1;

UPDATE t SET name = '小F' WHERE id = 1; 此刻,表t中id為1的記錄的版本鏈就長這樣: ![image.png](http://p3-juejin.byteimg.com/tos-cn-i-k3u1fbpfcp/507519d7c48944eb9e06a2c28deb624d~tplv-k3u1fbpfcp-zoom-1.image) 然後再到剛才使用REPEATABLE READ隔離級別的事務中繼續查詢這個id為1的記錄,如下:sql

使用REPEATABLE READ隔離級別的事務

BEGIN;

SELECT1:Transaction 100、200均未提交

SELECT * FROM t WHERE id = 1; # 得到的列name的值為'小A'

SELECT2:Transaction 100提交,Transaction 200未提交

SELECT * FROM t WHERE id = 1; # 得到的列name的值仍為'小A' ``` 這個SELECT2的執行過程如下:

  • 因為之前已經生成過ReadView了,所以此時直接複用之前的ReadView,之前的ReadView中的m_ids列表就是[100, 200]。

  • 然後從版本鏈中挑選可見的記錄,從圖中可以看出,最新版本的列name的內容是'小F',該版本的trx_id值為200,在m_ids列表內,所以不符合可見性要求,根據roll_pointer跳到下一個版本。

  • 下一個版本的列name的內容是'小D',該版本的trx_id值為200,也在m_ids列表內,所以也不符合要求,繼續跳到下一個版本。

  • 下一個版本的列name的內容是'小C',該版本的trx_id值為100,而m_ids列表中是包含值為100的事務id的,所以該版本也不符合要求,同理下一個列name的內容是'小B'的版本也不符合要求。繼續跳到下一個版本。

  • 下一個版本的列name的內容是'小A',該版本的trx_id值為80,80小於m_ids列表中最小的事務id100,所以這個版本是符合要求的,最後返回給使用者的版本就是這條列name為'小A'的記錄。

也就是說我們的兩次SELECT查詢得到的資料結果是一樣(重複)的,列name值都是'小A',這就是【可重複讀】的含義。如果我們之後再把事務id為200的記錄也提交了,之後再到剛才使用REPEATABLE READ隔離級別的事務中繼續查詢這個id為1的記錄,得到的結果還是'小A'。

MVCC總結

從上邊的描述中我們可以看出來,所謂的MVCC(Multi-Version Concurrency Control ,多版本併發控制)指的就是在使用READ COMMITTD、REPEATABLE READ這兩種隔離級別的事務在執行普通的SEELCT操作時訪問記錄的版本鏈的過程,這樣就可以使不同事務的讀-寫、寫-讀操作併發執行,從而提升系統性能。READ COMMITTD、REPEATABLE READ這兩個隔離級別的一個很大不同就是生成ReadView的時機不同,READ COMMITTD在每一次進行普通SELECT操作前都會生成一個ReadView,而REPEATABLE READ只在第一次進行普通SELECT操作前生成一個ReadView,之後的查詢操作都重複這個ReadView就好了。

回到我們的標題: MySQL到底能不能解決幻讀?或者說MySQL是如何解決幻讀的? 現在你明白了嗎? 歡迎一起討論

本文正在參加「技術專題19期 漫談資料庫技術」活動