MySQL 中的事務理解

ZhanLi發表於2023-02-13

MySQL 中的事務

前言

MySQL 中的事務操作,要麼修改都成功,要麼就什麼也不做,這就是事務的目的。事務有四大特性 ACID,原子性,一致性,隔離性,永續性。

A(Atomic),原子性:指的是整個資料庫事務操作是不可分割的工作單元,要麼全部執行,要麼都不執行;

C(Consistent),一致性:指的是事務將資料庫從一種狀態轉換成下一種一致性狀態,在事務開始之前和事務結束之後,資料庫的完整性約束沒有被破壞;

  • 資料的完整性: 實體完整性、列完整性(如欄位的型別、大小、長度要符合要求)、外來鍵約束等;

  • 業務的一致性:例如在銀行轉賬時,不管事務成功還是失敗,雙方錢的總額不變。

  • 如果事務執行過程中,每個操作失敗了,系統可以撤銷事務,系統可以撤銷事務,返回系統初始化的狀態。

I(isolation): 隔離性還有其它稱呼,如併發控制,可序列化,鎖等,事務的隔離性要求每個讀寫事務物件對其他事務操作物件能夠相互隔離,即事務提交之前對其它事務都不可見;

D(durability), 永續性: 指的是一旦資料提交,對資料庫中資料的改變就是永久的。即使發生當機,資料庫也能恢復。

原子性

原子性:指的是整個資料庫事務操作是不可分割的工作單元,要麼全部執行,要麼都不執行。

在對資料庫進行修改的時候,就會記錄 undo log ,這樣當事務執行失敗的時候,就能使用這些 undo log 恢復到修改之前的樣子。

InnoDB 透過 undo log 進行事務的回滾,實際上做的是和之前相反的工作,對於每個 INSERT ,InnoDB 會生成一個 DELETE ;對於 DELETE 操作,InnoDB 會生成一個 INSERT。。。透過反向的操作來實現事務資料的回滾操作。實現事務的原子性。

一致性

一致性:指的是事務將資料庫從一種狀態轉換成下一種一致性狀態,在事務開始之前和事務結束之後,資料庫的完整性約束沒有被破壞。

一致性是事務追求的最終目標:前面提到的原子性、永續性和隔離性,都是為了保證資料庫狀態的一致性。此外,除了資料庫層面的保障,一致性的實現也需要應用層面進行保障。

永續性

永續性: 指的是一旦資料提交,對資料庫中資料的改變就是永久的。即使發生當機,資料庫也能恢復。

redo log 用來從保證事務的永續性。

redo log 簡單點講就是 MySQL 異常當機後,將沒來得及提交的事物資料重做出來。

redo log 包括兩部分:一個是記憶體中的日誌緩衝( redo log buffer ),另一個是磁碟上的日誌檔案( redo log file )。

MySQL 每執行一條 DML 語句,先將記錄寫入 redo log buffer,後續某個時間點再一次性將多個操作記錄寫到 redo log file 。這種 先寫日誌,再寫磁碟 的技術就是 MySQL 裡經常說到的 WAL(Write-Ahead Logging) 技術。

有了 redo log,InnoDB 就可以保證即使資料庫發生異常重啟,之前提交的記錄都不會丟失,這個能力稱為 crash-safe

併發事務存在的問題

事務併發可能會出現下面幾種問題,【髒讀,不可重複讀,幻讀】 等情況。

  • 髒讀:讀到其他事務未提交的資料;

  • 不可重複讀:前後讀取的資料不一致;

  • 幻讀:前後讀取的記錄數量不一致。

髒讀

髒讀又稱無效資料的讀出,是指在資料庫訪問中,事務T1將某一值修改,然後事務T2讀取該值,此後T1因為某種原因撤銷對該值的修改,這就導致了T2所讀取到的資料是無效的,值得注意的是,髒讀一般是針對於 update 操作的。

幻讀

The so-called phantom problem occurs within a transaction when the same query produces different sets of rows at different times. For example, if a SELECT is executed twice, but returns a row the second time that was not returned the first time, the row is a “phantom” row.

幻讀是指當事務不是獨立執行時發生的一種現象,例如第一個事務對一個表中的資料進行了修改,比如這種修改涉及到表中的“全部資料行”。同時,第二個事務也修改這個表中的資料,這種修改是向表中插入“一行新資料”。那麼,以後就會發生操作第一個事務的使用者發現表中還存在沒有修改的資料行,就好象發生了幻覺一樣。

簡單的講就是,幻讀指的是一個事務在前後兩次查詢同一個範圍的時候,後一次查詢看到了前一次查詢沒有看到的行。

不可重複讀

不可重複讀,是指在資料庫訪問中,一個事務範圍內兩個相同的查詢卻返回了不同資料。

在一個事務內,多次讀同一個資料。在這個事務還沒有結束時,另一個事務也訪問該同一資料並修改資料。那麼,在第一個事務的兩次讀資料之間。由於另一個事務的修改,那麼第一個事務兩次讀到的資料可能不一樣,這樣就發生了在一個事務內兩次讀到的資料是不一樣的,因此稱為不可重複讀,即原始讀取不可重複。

幻讀和不可重複讀的的區別

不可重複讀和幻讀都是讀的過程中資料前後不一致,只是前者側重於修改,後者側重於增刪。

隔離性

事務的隔離級別

MySQL 中標準的事務隔離級別包括:讀未提交(read uncommitted)、讀提交(read committed)、可重複讀(repeatable read)和序列化(serializable )。

  • 讀未提交:一個事務還沒提交時,它的變更就能被別的事務看到,讀取未提交的資料也叫做髒讀;

  • 讀提交:一個事務提交之後,它的變更才能被其他的事務看到;

  • 可重複讀:MySQL 中預設的事務隔離級別,一個事務執行的過程中看到的資料,總是跟這個事務在啟動時看到的資料是一致的,在此隔離級別下,未提交的變更對其它事務也是不可見的,此隔離級別基本上避免了幻讀;

  • 序列化:這是事務的最高階別,顧名思義就是對於同一行記錄,“寫”會加“寫鎖”,“讀”會加“讀鎖”。當出現讀寫鎖衝突的時候,後訪問的事務必須等前一個事務執行完成,才能繼續執行。

序列化,不是所有的事務都序列執行,沒衝突的事務是可以併發執行的。

隔離級別 髒讀 不可重複讀 幻讀
讀未提交 可能 可能 可能
讀提交 不可能 可能 可能
可重複讀 不可能 不可能 可能
序列化 不可能 不可能 不可能

可以看到,只有序列化的隔離級別解決了【髒讀,不可重複讀,幻讀】這 3 個問題。

下面來詳細的介紹下 讀提交可重複讀

準備下資料表

create table user
(
id int auto_increment primary key,
username varchar(64) not null,
age int not null
);
insert into user values(2, "小張", 1);

來分析下下面的栗子

mysql

讀未提交

V1、V2,V3 的值都是2,雖然事務1的修改還沒有提交,但是讀未提交的隔離能夠看到事務未提交的資料,所以 V1 看到的資料就是 2 了。

讀提交

V1 的值是1,V2 是2,V3 是2。因為事務1提交了,讀提交可以看到提交的資料,所以 V2 的值就是2,V3 查詢的結果肯定也是2了。

可重複讀

V1、V2 的值是1,V3 的值是 2。

雖然事務1提交了,但是 V2 還是在事務2 中沒有提交,根據可重複讀的要求,一個事務執行的過程中看到的資料,總是跟這個事務在啟動時看到的資料是一致的,所以 V2 也是 1。

序列化

V1、V2 的值是1,V3 的值是 2。因為事務2,先啟動查詢,所以事務1必須等到事務2提交之後才能提交事務的修改,所以 V1、V2 的值是1,因為 V3 的查詢時在事務1提交之後,所以 V3 查詢的值就是2。

事務隔離是如何實現

在瞭解了四種隔離級別,下面來聊聊這幾種隔離級別是如何實現的。

首先來介紹一個非常重要的概念 Read View

Read View 是一個資料庫的內部快照,用於 InnoDB 中 MVCC 機制。

可重複讀 和 讀提交

可重複讀 和 讀提交 主要是透過 MVCC 來實現,MVCC 的實現主要用到了 undo log 日誌版本鏈和 Read View

undo log 日誌版本鏈

undo log 是一種邏輯日誌,當一個事務對記錄做了變更操作就會產生 undo log,裡面記錄的是資料的邏輯變更。

對於使用 InnoDB 儲存引擎的表來說,它的聚簇索引記錄中都包含兩個必要的隱藏列。

  • trx_id:每次對某條聚簇索引記錄進行改動時,都會把對應的事務 id 賦值給 trx_id 隱藏列;

  • roll_pointer:每次對某條聚簇索引記錄進行改動時,都會把舊的版本寫入到 undo 日誌中,然後這個隱藏列就相當於一個指標,可以透過它來找到該記錄修改前的資訊。

mysql

每次事務更新的時候,undo log 就會用 trx_id 記錄下當前事務的事務 ID,同時記錄下當前更新的資料,透過 roll_pointer 指向上個更新的舊版本資料,這樣就形成了一個歷史的版本鏈。

Read View

undo log 版本鏈會將歷史事務進行快照儲存,並且根據事務的版本大小,透過指標串聯起來,對於 可重複讀 和 讀提交 這兩種事務隔離級別,只需要在 undo log 中選擇合適的事務版本進行資料讀取,就能實現對應的讀取隔離效果。

判斷 undo log 版本鏈中,那個事務版本對當前事務可見,InnoDB 中透過 Read View來解決,作用是事務執行期間用來定義“我能看到什麼資料”。

Read View 中有四個重要的欄位

  • m_ids :指的是在建立 Read View 時,當前資料庫中「活躍事務」的事務 id 列表,注意是一個列表,“活躍事務”指的就是,啟動了但還沒提交的事務。

  • min_trx_id :指的是在建立 Read View 時,當前資料庫中「活躍事務」中事務 id 最小的事務,也就是 m_ids 的最小值。

  • max_trx_id :這個並不是 m_ids 的最大值,而是建立 Read View 時當前資料庫中應該給下一個事務的 id 值,也就是全域性事務中最大的事務 id 值 + 1;

  • creator_trx_id :指的是建立該 Read View 的事務的事務 ID。

mysql

Read View 可以在理解為一個資料的快照,可重複讀隔離級別會在每次啟動的事務的時候生成一個 Read View 記錄下當前事務啟動瞬間,當前所有活躍的事務 ID。

建立該 Read View 的事務的事務 ID,會在 Read View 中根據事務 ID 大小,判斷當前事務落在了那個區域,然後判斷當前事務 ID 對應的資料快照是否可讀。

  • 已提交的事務:對於當前事務來講,因為都是已經提交或者是當前事務生成的,這部分資料都是可見的;

  • 未開始事務:Read View 中應該給下一個事務的 ID,這部分的資料是不可見;

  • 未提交事務集合:這種有下面兩種情況;

1、如果當前事務 ID 在未提交事務集合中,表示這個版本是由還沒提交的事務生成的,不可見;

2、如果當前事務 ID 不在未提交事務集合中,表示這個版本是已經提交了的事務生成的,可見。

總結下來就是

1、首先建立當前 Read View 時的事務 ID 會判斷當前事務落在了 Read View 中那個區域中;

2、然後判斷當前事務 ID 對應的資料是否可讀;

3、如果可讀透過 undo log 版本鏈找到對應事務的快照資料,這就是目前該事物能夠讀到的資料;

4、如果不可讀,在順著 undo log 版本鏈找到上個事務的版本,持續重複 1~3 的步驟,直到找到版本鏈中最後一個資料,如果最後一個版本的資料也是不可見,那就表示當前查詢找不到記錄。

可重複讀讀提交 事務隔離級別的區別就在於建立 Read View 的時機不同,可重複讀事務隔離級別會在每次啟動事務的時候建立 Read View讀提交 會在每次查詢的時候建立 Read View

因為可重複讀事務隔離級別在事務開始建立了 Read View,就能保證事務中的看到的資料一致了,而讀提交事務隔離級別在每次查詢的時候,建立 Read View,就能在每次查詢的時候讀到已經提交的事務資料。

下面來個栗子具體分析下可重複讀隔離級別的讀取過程

mysql

慄如,上面的三個事務,在可重複讀隔離級別中,事務的查詢結果

其中 V1 的查詢結果是 3,V2 的查詢結果是 1。

這裡具體的分析下,假定現在有下面幾種場景

1、事務 1 開始前,系統裡面只有一個活躍事務 ID 是 99;

2、事務1,2,3的版本號分別是 100、101、102,且當前系統裡只有這四個事務;

3、三個事務開始前,id = 2 的 age 為 1 這一行資料的 trx_id 是 90。

這樣三個事務的 Read View 分別是

mysql

可以看到事務3 提交修改,102 的 trx_id 就是最新版本,此時的資料 id = 2 的 age 為 2。

然後事務 2 提交修改 trx_id 的最新版本就變成了 101,此時的資料 id = 2 的 age 為 3。

這時候事務1 的查詢,事務1 中的事務版本號是100。在事務1 Read View 中的未提交事務集合中,所以資料不可見,需要根據版本鏈尋找上一個版本。

1、找到(1,3)的時候,判斷出 trx_id=101,處於當前 Read View 的未開始事務中,所以資料不可見;

2、接著,找到上一個歷史版本,trx_id=102,同樣處於當前 Read View 的未開始事務中,所以資料不可見;

3、再往前找,找到了(1,1),它的 trx_id=90,處於當前 Read View 的已提交事務中,所以資料可見;

總結下就是,一個資料版本,對於一個事務檢視來說,除了自己的更新總是可見以外,有三種情況:

1、版本未提交,不可見;

2、版本已提交,但是是在檢視建立後提交的,不可見;

3、版本已提交,而且是在檢視建立前提交的,可見。

資料更新

資料更新需要注意,事務2 時候先於事務3 生成,按道理應該事務2 的修改當時看到的資料應該是1,而不是2,那麼資料庫是如何處理的呢?

如果事務2在資料更新之前先去查詢一次,那麼看到的資料就是id = 2 的 age 為 1,但是更新資料,就不能在老的資料中更新了,否則事務3的更新資料就會丟失了。

更新資料都是先讀後寫的,讀使用的是當前讀,每次讀取的內容都是最新的。

這樣更新的時候使用當前讀,就保證了事務2拿到的最新的資料,所以更新完成之後的查詢 id = 2 的 age 就為 3 了。

除了update語句外,select語句如果加鎖,也是當前讀。例如,加上 讀鎖(S鎖,共享鎖) lock in share mode 或 寫鎖(X鎖,排他鎖)for update

select age from user where id=2 lock in share mode;

select age from user where id=2 for update;

如果當前事務3 的事務還沒提交,這時候,事務2就開始了寫入

mysql

這時候雖然事務3還沒有提交,但是資料已經生成了,這時候事務2去讀取,是不能讀取的,這裡會用到兩階段鎖協議,首先事務3會給資料加寫鎖,事務2讀取的時候會加讀鎖,這樣資料會被鎖住,直到事務3釋放鎖。

什麼是兩階段鎖協議:在InnoDB事務中,行鎖是在需要的時候才加上的,但並不是不需要了就立刻釋放,而是要等到事務結束時才釋放。這個就是兩階段鎖協議。

事務更新資料的時候,只能用當前讀。如果當前的記錄的行鎖被其他事務佔用的話,就需要進入鎖等待。

而讀提交的邏輯和可重複讀的邏輯類似,它們最主要的區別是:

在可重複讀隔離級別下,只需要在事務開始的時候建立一致性檢視,之後事務裡的其他查詢都共用這個一致性檢視;

在讀提交隔離級別下,每一個語句執行前都會重新算出一個新的檢視。

讀提交隔離級別,這裡就不展開分析了。

序列化

讀寫都需要加鎖,讀的時候加讀鎖,寫的時候加寫鎖。

讀未提交

讀取最新的資料,讀不用加鎖,不用遍歷版本鏈,直接讀取最新的資料,不管這條記錄是不是已提交。不過這種會導致髒讀。

對寫仍需要鎖定,策略和讀已提交類似,避免髒寫。

可重複讀解決了幻讀嗎

MySQL InnoDB 引擎的預設隔離級別雖然是「可重複讀」,但是它很大程度上避免幻讀現象(並不是完全解決了),解決的方案有兩種:

針對快照讀(普通 select 語句),是透過 MVCC 方式解決了幻讀,因為可重複讀隔離級別下,事務執行過程中看到的資料,一直跟這個事務啟動時看到的資料是一致的,即使中途有其他事務插入了一條資料,是查詢不出來這條資料的,所以就很好了避免幻讀問題。

針對當前讀(select ... for update 等語句),是透過 next-key lock(記錄鎖+間隙鎖)方式解決了幻讀,因為當執行 select ... for update 語句的時候,會加上 next-key lock,如果有其他事務在 next-key lock 鎖範圍內插入了一條記錄,那麼這個插入語句就會被阻塞,無法成功插入,所以就很好了避免幻讀問題。

可重複讀是如何解決幻讀的呢?

讀操作,可重複讀隔離級別會使用 MVCC 針對當前事務生成事務快照,透過對比事務版本,就能保證事務中的快照讀。

讀寫操作,這種就需要藉助於 Next-Key,MySQL 把行鎖和間隙鎖合併在一起,解決了併發寫和幻讀的問題,這個鎖叫做 Next-Key 鎖。

Next-Key 演算法中,對於索引的掃描,不僅僅是鎖住掃描到的索引,而且還鎖著這些索引覆蓋的範圍。因此對於範圍內的插入都是不允許的,這樣就能避免幻讀的發生。

create table user
(
id int auto_increment primary key,
username varchar(64) not null,
age int not null
);
insert into user values(2, "小張", 1);
insert into user values(4, "小明", 1);
insert into user values(6, "小紅", 1);
insert into user values(8, "小白", 1);
mysql

上面的這兩個事務,事務1 中的 select * from where age>4 for updated 會加上一個間隙鎖 (4,6] 這樣事務 2 中的插入就需要等待事務1中鎖釋放才能提交修改。

事務 1 中的前後兩次查詢,就不會出現幻讀的情況了。

mysql

不過可重讀真的就完全避免了幻讀嗎?下面來看兩種異常的情況。

場景1

mysql

因為 事務1 在 事務2 提交插入操作之後,使用了 for updated 當前讀,這就會出現讀取的結果和第一次讀取結果的不一致。

場景2

mysql

類似於場景1,因為事務1,在事務2進行插入資料之後,執行了資料更新的操作,資料更新的操作會先查後修改,這個查詢就是當前讀,所以就能找到剛插入的資料並且修改,這時候當前事務中的 trx_id 就是最新的了,後面的查詢就能讀取到剛剛更新的資料。

上面兩種場景的最終原因,就是後面的查詢間接或直接的使用了當前讀,造成了資料的不一致,所以只需要在最開始的查詢加上 for updated,就能避免幻讀的出現了。

總結

1、MySQL 中的事務隔離級別分別是

  • 讀未提交:一個事務還沒提交時,它的變更就能被別的事務看到,讀取未提交的資料也叫做髒讀;

  • 讀提交:一個事務提交之後,它的變更才能被其他的事務看到;

  • 可重複讀:MySQL 中預設的事務隔離級別,一個事務執行的過程中看到的資料,總是跟這個事務在啟動時看到的資料是一致的,在此隔離級別下,未提交的變更對其它事務也是不可見的,此隔離級別基本上避免了幻讀;

  • 序列化:這是事務的最高階別,顧名思義就是對於同一行記錄,“寫”會加“寫鎖”,“讀”會加“讀鎖”。當出現讀寫鎖衝突的時候,後訪問的事務必須等前一個事務執行完成,才能繼續執行。

2、可重複讀 是 MySQL 中預設的事務隔離級別;

3、可重複讀 和 讀提交 主要是透過 MVCC 來實現,MVCC 的實現主要用到了 undo log 日誌版本鏈和 Read View

4、序列化,讀寫都需要加鎖,讀的時候加讀鎖,寫的時候加寫鎖;

5、讀未提交,讀取最新的資料,讀不用加鎖,不用遍歷版本鏈,直接讀取最新的資料,不管這條記錄是不是已提交。不過這種會導致髒讀。對寫仍需要鎖定,策略和讀已提交類似,避免髒寫;

6、MySQL InnoDB 引擎的預設隔離級別雖然是「可重複讀」,但是它很大程度上避免幻讀現象(並不是完全解決了),解決的方案有兩種:

針對快照讀(普通 select 語句),是透過 MVCC 方式解決了幻讀,因為可重複讀隔離級別下,事務執行過程中看到的資料,一直跟這個事務啟動時看到的資料是一致的,即使中途有其他事務插入了一條資料,是查詢不出來這條資料的,所以就很好了避免幻讀問題。

針對當前讀(select ... for update 等語句),是透過 next-key lock(記錄鎖+間隙鎖)方式解決了幻讀,因為當執行 select ... for update 語句的時候,會加上 next-key lock,如果有其他事務在 next-key lock 鎖範圍內插入了一條記錄,那麼這個插入語句就會被阻塞,無法成功插入,所以就很好了避免幻讀問題。

7、可重複讀雖然最大程度的避免了幻讀,但是還是還有幻讀的場景出現。

參考

【高效能MySQL(第3版)】https://book.douban.com/subject/23008813/
【MySQL 實戰 45 講】https://time.geekbang.org/column/100020801
【MySQL技術內幕】https://book.douban.com/subject/24708143/
【MySQL學習筆記】https://github.com/boilingfrog/Go-POINT/tree/master/mysql
【MySQL總結--MVCC(read view和undo log)】https://blog.csdn.net/huangzhilin2015/article/details/115195777
【深入理解 MySQL 事務:隔離級別、ACID 特性及其實現原理】https://blog.csdn.net/qq_35246620/article/details/61200815
【分散式事務】https://mp.weixin.qq.com/s/MbPRpBudXtdfl8o4hlqNlQ

相關文章