mysql事務原理及MVCC
事務是數據庫最為重要的機制之一,凡是使用過數據庫的人,都了解數據庫的事務機制,也對ACID四個
基本特性如數家珍。但是聊起事務或者ACID的底層實現原理,往往言之不詳,不明所以。在MySQL中
的事務是由存儲引擎實現的,而且支持事務的存儲引擎不多,我們主要講解InnoDB存儲引擎中的事
務。所以,今天我們就一起來分析和探討InnoDB的事務機制,希望能建立起對事務底層實現原理的具
體了解。
事務的特性
- 原子性:事務最小工作單元,事務開始要不全部成功,要不全部失敗.
- 一致性:事務的開始和結束后,數據庫的完整性不會被破壞
- 隔離性:不同事務之間互不影響,四種隔離級別為RU(讀未提交)、RC(讀已提
交)、RR(可重復讀)、SERIALIZABLE (串行化)。 - 持久性:事務提交后,對數據的修改是永久性的,即使系統故障也不會丟失 。
隔離級別
有一張表,結構如下:
-
未提交讀(RU)
- 一個事務讀取到另一個事務尚未提交的數據,稱之為臟讀
發生時間編號 session A session B 1 begin; 2 begin; 3 update t set c="關羽" where id = 1; 4 select * from t where id = 1;
時間編號為4時,AB兩個session均未提交事務,select語句讀取到的值為關羽,讀取到了B尚未提交的事務,此為臟讀,這種隔離級別是最不安全的一種.
-
已提交讀(RC)
- 一個事務讀取到另一個事務已提交的數據,導致對同一條記錄讀取兩次以上的結果不一致,稱之為不可重復讀
發生時間編號 session A session B 1 begin; 2 begin; 3 update t set c="關羽" where id = 1; 4 select * from t where id = 1; 5 commit; 6 select * from t where id = 1;
時間編號為4時,B尚未提交,此時讀取到的數據依然是劉備,時間編號為5,B事務提交,時間編號為6時再次讀取到的數據變成了關羽.這種情況是可以被理解的,因為B事務已經提交了.
-
可重復讀(RR)
- 一個事務讀取到另一個事務已經提交的delete或者insert數據,導致對同一張表讀取兩次以上結果不一致,稱之為幻讀
- 幻讀可以通過串行化或者間隙鎖來解決
發生時間編號 session A session B 1 begin; 2 begin; 3 update t set c="關羽" where id = 1; 4 select * from t where id = 1; 5 commit; 6 select * from t where id = 1; 時間編號為4時,B尚未提交,此時讀取到的數據依然是劉備,時間編號為5,B事務提交,時間編號為6時再次讀取到的數據依然是劉備.同一個事務中讀取到的數據永遠是一致的.
-
串行化
- 簡單來說就是加鎖,這種隔離級別是最安全的,可以解決其他隔離級別所產生的問題,但是效率較低.
發生時間編號 session A session B 1 begin; 2 begin; 3 update t set c="關羽" where id = 1; 4 select * from t where id = 1; 5 commit; 6 select * from t where id = 1; 時間編號為4時,B尚未提交,此時讀取時,將會被阻塞,處於等待中直到B事務提交釋放鎖,時間編號為5,B事務提交釋放鎖,時間編號為6時再次讀取到的數據是關羽.
-
丟失更新,兩個事務同時對一條數據進行修改時,會存在丟失更新問題.
時間 取款事務A 取款事務B 1 開始事務 2 開始事務 3 查詢余額為1000元 4 查詢余額為1000元 5 匯入100元,余額變為1100 6 提交事務 7 取出100元,余額變為900元 8 回滾事務 9 余額恢復為1000元,丟失更新
mysql的默認隔離級別為RR
數據庫的事務並發問題需要使用並發控制機制去解決,數據庫的並發控制機制有很多,最為常見
的就是鎖機制。鎖機制一般會給競爭資源加鎖,阻塞讀或者寫操作來解決事務之間的競爭條件,
最終保證事務的可串行化。而MVCC則引入了另外一種並發控制,它讓讀寫操作互不阻塞,每一個寫操作都會創建一個新版
本的數據,讀操作會從有限多個版本的數據中挑選一個最合適的結果直接返回,由此解決了事務
的競爭條件。
MVCC
mvcc也是多版本並發控制,mysql中引入了這種並發機制.我們接下來就聊聊mvcc
版本鏈
回滾段/undo log
-
insert undo log
-
是在 insert 操作中產生的 undo log。
-
因為 insert 操作的記錄只對事務本身可見,對於其它事務此記錄是不可見的,所以 insert undo
log 可以在事務提交后直接刪除而不需要進行 purge 操作。
-
-
update undo log
- 是 update 或 delete 操作中產生的 undo log
- 因為會對已經存在的記錄產生影響,為了提供 MVCC機制,因此 update undo log 不能在事務提交時就進行刪除,而是將事務提交時放到入 history list 上,等待 purge 線程進行最后的刪除操作
為了保證事務並發操作時,在寫各自的undo log時不產生沖突,InnoDB采用回滾段的方式來維護undo
log的並發寫入和持久化。回滾段實際上是一種 Undo 文件組織方式。
InnoDB行記錄有三個隱藏字段:分別對應該行的rowid、事務號db_trx_id和回滾指針db_roll_ptr,其
中db_trx_id表示最近修改的事務的id,db_roll_ptr指向回滾段中的undo log。
對於使用 InnoDB 存儲引擎的表來說,它的聚簇索引記錄中都包含兩個必要的隱藏列( row_id 並不是
必要的,我們創建的表中有主鍵或者非NULL唯一鍵時都不會包含 row_id 列):
- trx_id :每次對某條聚簇索引記錄進行改動時,都會把對應的事務id賦值給 trx_id 隱藏列。
- roll_pointer :每次對某條聚簇索引記錄進行改動時,都會把舊的版本寫入到 undo日志 中,然
后這個隱藏列就相當於一個指針,可以通過它來找到該記錄修改前的信息。
我們有一張表
create table user(
id int,
name varchar,
primary key (id)
)
insert into user values(1,'張三');
我們此時插入這條數據,假設事務id為80.
ps:咳咳~~理解意思就好,捂臉.jpg
每次對記錄進行改動,都會記錄一條 undo日志 ,每條 undo日志 也都有一個 roll_pointer 屬性
( INSERT 操作對應的 undo日志 沒有該屬性,因為該記錄並沒有更早的版本),可以將這些 undo日志
都連起來,串成一個鏈表,所以現在的情況就像下圖一樣:
對該記錄每次更新后,都會將舊值放到一條 undo日志 中,就算是該記錄的一個舊版本,隨着更新次數
的增多,所有的版本都會被 roll_pointer 屬性連接成一個鏈表,我們把這個鏈表稱之為 版本鏈 ,版本
鏈的頭節點就是當前記錄最新的值。另外,每個版本中還包含生成該版本時對應的事務id,這個信息很
重要,我們稍后就會用到。
如下圖所示(初始狀態):
當事務2使用UPDATE語句修改該行數據時,會首先使用排他鎖鎖定改行,將該行當前的值復制到undo
log中,然后再真正地修改當前行的值,最后填寫事務ID,使用回滾指針指向undo log中修改前的行。
如下圖所示(第一次修改):
當事務3進行修改與事務2的處理過程類似,如下圖所示(第二次修改):
REPEATABLE READ隔離級別下事務開始后使用MVCC機制進行讀取時,會將當時活動的事務id記錄下
來,記錄到Read View中。READ COMMITTED隔離級別下則是每次讀取時都創建一個新的Read View。
ReadView
對於使用 READ UNCOMMITTED 隔離級別的事務來說,直接讀取記錄的最新版本就好了,對於使用
SERIALIZABLE 隔離級別的事務來說,使用加鎖的方式來訪問記錄。對於使用 READ COMMITTED 和
REPEATABLE READ 隔離級別的事務來說,就需要用到我們上邊所說的 版本鏈 了,核心問題就是:需要
判斷一下版本鏈中的哪個版本是當前事務可見的。所以設計 InnoDB 的大叔提出了一個 ReadView 的概
念,這個 ReadView 中主要包含當前系統中還有哪些活躍的讀寫事務,把它們的事務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 的時機不同,我們來看一下。
RC隔離級別和RR隔離級別區別
-
每次讀取數據前都生成一個ReadView
比方說現在系統里有兩個 id 分別為 100 、 200 的事務在執行:
# Transaction 100 BEGIN; UPDATE user SET name = '張三' WHERE id = 1; UPDATE user SET name = '李四' WHERE id = 1; 復制代碼 # Transaction 200 BEGIN; # 更新了一些別的表的記錄 ...
假設現在有一個使用 READ COMMITTED 隔離級別的事務開始執行:
# 使用READ COMMITTED隔離級別的事務 BEGIN; # SELECT1:Transaction 100、200未提交 SELECT * FROM user WHERE id = 1; # 得到的列name的值為'王五'
這個 SELECT1 的執行過程如下:
- 在執行 SELECT 語句時會先生成一個 ReadView , ReadView 的 m_ids 列表的內容就是 [100,
200] 。 - 然后從版本鏈中挑選可見的記錄,最新版本的列name 的內容是 '張三' ,該版本的trx_id 值為 100 ,在 m_ids 列表內,所以不符合可見性要求,根據 roll_pointer 跳到下一個版本。
- 下一個版本的列 name 的內容是 '李四' ,該版本的 trx_id 值也為 100 ,也在 m_ids 列表內,所以也不符合要求,繼續跳到下一個版本。
- 下一個版本的列 name 的內容是 '王五' ,該版本的 trx_id 值為 80 ,小於 m_ids 列表中最小的事務id 100 ,所以這個版本是符合要求的,最后返回給用戶的版本就是這條列 name 為 '王五' 的記錄。
之后,我們把事務id為 100 的事務提交一下,就像這樣:
# Transaction 100 BEGIN; UPDATE user SET name = '關羽' WHERE id = 1; UPDATE user SET name = '張飛' WHERE id = 1; COMMIT;
然后再到事務id為 200 的事務中更新一下表 user 中 id 為1的記錄:
# Transaction 200 BEGIN; # 更新了一些別的表的記錄 ... UPDATE user SET name = '雲六' WHERE id = 1; UPDATE user SET name = '王麻子' WHERE id = 1;
然后再到剛才使用 READ COMMITTED 隔離級別的事務中繼續查找這個id為 1 的記錄,如下:
# 使用READ COMMITTED隔離級別的事務 BEGIN; # SELECT1:Transaction 100、200均未提交 SELECT * FROM user WHERE id = 1; # 得到的列name的值為'李四' # SELECT2:Transaction 100提交,Transaction 200未提交 SELECT * FROM user WHERE id = 1; # 得到的列name的值為'張三'
這個 SELECT2 的執行過程如下:
- 在執行 SELECT 語句時會先生成一個 ReadView , ReadView 的 m_ids 列表的內容就是 [200] (事務id為 100 的那個事務已經提交了,所以生成快照時就沒有它了)。
- 然后從版本鏈中挑選可見的記錄,最新版本的列 name 的內容是 '王麻子' ,該版本的 trx_id 值為 200 ,在 m_ids 列表內,所以不符合可見性要求,根據 roll_pointer 跳到下一個版本。
- 下一個版本的列 name 的內容是 '雲六' ,該版本的 trx_id 值為 200 ,也在 m_ids 列表內,所以也不符合要求,繼續跳到下一個版本。
- 下一個版本的列 name 的內容是 '張三' ,該版本的 trx_id 值為 100 ,比 m_ids 列表中最小的事務
id 200 還要小,所以這個版本是符合要求的,最后返回給用戶的版本就是這條列name 為 '張三' 的記錄。
以此類推,如果之后事務id為 200 的記錄也提交了,再此在使用 READ COMMITTED 隔離級別的事務中查詢表user 中 id 值為 1 的記錄時,得到的結果就是 '王麻子' 了,具體流程我們就不分析了。總結一下就
是:使用READ COMMITTED隔離級別的事務在每次查詢開始時都會生成一個獨立的ReadView。 - 在執行 SELECT 語句時會先生成一個 ReadView , ReadView 的 m_ids 列表的內容就是 [100,
-
只在第一次讀取數據生成一個ReadView
對於使用 REPEATABLE READ 隔離級別的事務來說,只會在第一次執行查詢語句時生成一個
ReadView ,之后的查詢就不會重復生成了。我們還是用例子看一下是什么效果。比方說現在系統里有兩個 id 分別為 100 、 200 的事務在執行:
# Transaction 100 BEGIN; UPDATE user SET name = '張三' WHERE id = 1; UPDATE user SET name = '李四' WHERE id = 1; 復制代碼 # Transaction 200 BEGIN; # 更新了一些別的表的記錄 ...
假設現在有一個使用 REPEATABLE READ 隔離級別的事務開始執行:
# 使用REPEATABLE READ隔離級別的事務 BEGIN; # SELECT1:Transaction 100、200未提交 SELECT * FROM user WHERE id = 1; # 得到的列name的值為'王五'
這個 SELECT1 的執行過程如下:
- 在執行 SELECT 語句時會先生成一個 ReadView , ReadView 的 m_ids 列表的內容就是 [100,
200] 。 - 然后從版本鏈中挑選可見的記錄,最新版本的列 name 的內容是 '張三' ,該版本的trx_id 值為 100 ,在 m_ids 列表內,所以不符合可見性要求,根據 roll_pointer 跳到下一個版
本。 - 下一個版本的列name 的內容是 '李四' ,該版本的 trx_id 值也為 100 ,也在 m_ids 列表內,所以也不符合要求,繼續跳到下一個版本。
- 下一個版本的列name 的內容是 '王五' ,該版本的 trx_id 值為 80 ,小於 m_ids 列表中最小的事務id 100 ,所以這個版本是符合要求的,最后返回給用戶的版本就是這條列 name 為 '王五' 的記錄。
之后,我們把事務id為 100 的事務提交一下,就像這樣:
# Transaction 100 BEGIN; UPDATE user SET name = '李四' WHERE id = 1; UPDATE user SET name = '張三' WHERE id = 1; COMMIT;
然后再到事務id為 200 的事務中更新一下表user 中 id 為1的記錄:
# Transaction 200 BEGIN; # 更新了一些別的表的記錄 ... UPDATE user SET name = '雲六' WHERE id = 1; UPDATE user SET name = '王麻子' WHERE id = 1;
然后再到剛才使用 REPEATABLE READ 隔離級別的事務中繼續查找這個id為 1 的記錄,如下:
# 使用REPEATABLE READ隔離級別的事務 BEGIN; # SELECT1:Transaction 100、200均未提交 SELECT * FROM user WHERE id = 1; # 得到的列name的值為'李四' # SELECT2:Transaction 100提交,Transaction 200未提交 SELECT * FROM user WHERE id = 1; # 得到的列name的值仍為'李四'
這個 SELECT2 的執行過程如下:
- 因為之前已經生成過 ReadView 了,所以此時直接復用之前的 ReadView ,之前的 ReadView 中的
m_ids 列表就是 [100, 200] 。 - 然后從版本鏈中挑選可見的記錄,最新版本的列 name 的內容是 '王麻子' ,該版本的 trx_id 值為 200 ,在 m_ids 列表內,所以不符合可見性要求,根據 roll_pointer 跳到下一個版本。
- 下一個版本的列 name的內容是 '雲六' ,該版本的 trx_id 值為 200 ,也在 m_ids 列表內,所以也不符合要求,繼續跳到下一個版本。
- 下一個版本的列 name 的內容是 '張三' ,該版本的 trx_id 值為 100 ,而 m_ids 列表中是包含值為
100 的事務id的,所以該版本也不符合要求,同理下一個列 name的內容是 '關羽' 的版本也不符合要求。繼續跳到下一個版本。 - 下一個版本的列 name 的內容是 '李四' ,該版本的 trx_id 值為 80 , 80 小於 m_ids 列表中最小的事務id 100 ,所以這個版本是符合要求的,最后返回給用戶的版本就是這條列 name 為 '李四' 的記錄。
也就是說兩次 SELECT 查詢得到的結果是重復的,記錄的列 name 值都是 '李四' ,這就是 可重復讀 的含義。如果我們之后再把事務id為 200 的記錄提交了,之后再到剛才使用 REPEATABLE READ 隔離級別的事務中繼續查找這個id為 1 的記錄,得到的結果還是 '李四' ,具體執行過程大家可以自己分析一下。
- 在執行 SELECT 語句時會先生成一個 ReadView , ReadView 的 m_ids 列表的內容就是 [100,
InnoDB的MVCC實現
我們首先來看一下wiki上對MVCC的定義:
Multiversion concurrency control (MCC or MVCC), is a concurrency control
method commonly used by database management systems to provide
concurrent access to the database and in programming languages to
implement transactional memory.
由定義可知,MVCC是用於數據庫提供並發訪問控制的並發控制技術。與MVCC相對的,是基於鎖的並
發控制, Lock-Based Concurrency Control 。MVCC最大的好處,相信也是耳熟能詳:讀不加鎖,讀
寫不沖突。在讀多寫少的OLTP應用中,讀寫不沖突是非常重要的,極大的增加了系統的並發性能,這
也是為什么現階段,幾乎所有的RDBMS,都支持了MVCC。
多版本並發控制僅僅是一種技術概念,並沒有統一的實現標准, 其核心理念就是數據快照,不同的事務
訪問不同版本的數據快照,從而實現不同的事務隔離級別。雖然字面上是說具有多個版本的數據快照,
但這並不意味着數據庫必須拷貝數據,保存多份數據文件,這樣會浪費大量的存儲空間。InnoDB通過
事務的undo日志巧妙地實現了多版本的數據快照。
數據庫的事務有時需要進行回滾操作,這時就需要對之前的操作進行undo。因此,在對數據進行修改
時,InnoDB會產生undo log。當事務需要進行回滾時,InnoDB可以利用這些undo log將數據回滾到修
改之前的樣子。
以上就是本篇博客分享的內容,歡迎提出問題,討論交流.
聯系方式:sx_wuyj@163.com