官方手冊:https://dev.mysql.com/doc/refman/5.7/en/innodb-locking-transaction-model.html
1.事務特性
事務具有ACID特性:原子性(A,atomicity)、一致性(C,consistency)、隔離性(I,isolation)、持久性(D,durabulity)。
- 原子性:事務內的所有操作要么都執行,要么都不執行。
- 一致性:事務開始和結束前后,數據都滿足數據一致性約束,而不是經過事務控制之后數據變得不滿足條件或業務規則。
- 隔離性:事務之間不能互影響,它們必須完全的各行其道,互不可見。
- 持久性:事務完成后,該事務內涉及的數據必須持久性的寫入磁盤保證其持久性。當然,這是從事務的角度來考慮的的持久性,從操作系統故障或硬件故障來說,這是不一定的。
2.事務分類
- 扁平事務
- 帶保存點的扁平事務
- 鏈事務
- 嵌套事務
- 分布式事務
2.1 扁平事務
即最常見的事務。由begin開始,commit或rollback結束,中間的所有操作要么都回滾要么都提交。扁平事務在生產環境中占絕大多數使用情況。因此每一種數據庫產品都支持扁平事務。
扁平事務的缺點在於無法回滾或提交一部分,只能全部回滾或全部提交,所以就有了"帶有保存點"的扁平事務。
2.2 帶有保存點的扁平事務
通過在事務內部的某個位置使用savepoint,將來可以在事務中回滾到此位置。
MariaDB/MySQL中設置保存點的命令為:
savepoint [savepoint_name]
回滾到指定保存點的命令為:
rollback to savepoint_name
刪除一個保存點的命令為:
release savepoint savepoint_name
實際上,扁平事務也是有保存點的,只不過它只有一個隱式的保存點,且自動建立在事務開始的位置,因此扁平事務只能回滾到事務開始處。
2.3 鏈式事務
鏈式事務是保存點扁平事務的變種。它在一個事務提交的時候自動隱式的將上下文傳給下一個事務,也就是說一個事務的提交和下一個事務的開始是原子性的,下一個事務可以看到上一個事務的處理結果。通俗地說,就是事務的提交和事務的開始是鏈接式下去的。
這樣的事務類型,在提交事務的時候,會釋放要提交事務內所有的鎖和要提交事務內所有的保存點。因此鏈式事務只能回滾到當前所在事務的保存點,而不能回滾到已提交的事務中的保存點。
2.4 嵌套事務
嵌套事務由一個頂層事務控制所有的子事務。子事務的提交完成后不會真的提交,而是等到頂層事務提交才真正的提交。
關於嵌套事務的機制,主要有以下3個結論:
- 回滾內部事務的同時會回滾到外部事務的起始點。
- 事務提交時從內向外依次提交。
- 回滾外部事務的同時會回滾所有事務,包括已提交的內部事務。因為只提交內部事務時沒有真的提交。
不管怎么樣,最好少用嵌套事務。且MariaDB/MySQL不原生態支持嵌套事務(SQL Server支持)。
2.5 分布式事務
將多個服務器上的事務(節點)組合形成一個遵循事務特性(acid)的分布式事務。
例如在工行atm機轉賬到建行用戶。工行atm機所在數據庫是一個事務節點A,建行數據庫是一個事務節點B,僅靠工行atm機是無法完成轉賬工作的,因為它控制不了建行的事務。所以它們組成一個分布式事務:
- 1.atm機發出轉賬口令。
- 2.atm機從工行用戶減少N元。
- 3.在建行用戶增加N元。
- 4.在atm機上返回轉賬成功或失敗。
上面涉及了兩個事務節點,這些事務節點之間的事務必須同時具有acid屬性,要么所有的事務都成功,要么所有的事務都失敗,不能只成功atm機的事務,而建行的事務失敗。
MariaDB/MySQL的分布式事務使用兩段式提交協議(2-phase commit,2PC)。最重要的是,MySQL 5.7.7之前,MySQL對分布式事務的支持一直都不完善(第一階段提交后不會寫binlog,導致宕機丟失日志),這個問題持續時間長達數十年,直到MySQL 5.7.7,才完美支持分布式事務。相關內容可參考網上一篇文章:https://www.linuxidc.com/Linux/2016-02/128053.htm。遺憾的是,MariaDB至今(MariaDB 10.3.6)都沒有解決這個問題。
3.事務控制語句
begin 和 start transaction
表示顯式開啟一個事務。它們之間並沒有什么區別,但是在存儲過程中,begin會被識別成begin...end的語句塊,所以存儲過程只能使用start transaction來顯式開啟一個事務。commit 和 commit work
用於提交一個事務。rollback 和 rollback work
用於回滾一個事務。savepoint identifier
表示在事務中創建一個保存點。一個事務中允許存在多個保存點。release savepoint identifier
表示刪除一個保存點。當要刪除的保存點不存在的時候會拋出異常。rollback to savepoint
表示回滾到指定的保存點,回滾到保存點后,該保存點之后的所有操縱都被回滾。注意,rollback to不會結束事務,只是回到某一個保存點的狀態。set transaction
用來設置事務的隔離級別。可設置的隔離級別有read uncommitted/read committed/repeatable read/serializable。
commit與commit work以及rollback與rollback work作用是一樣的。但是他們的作用卻和變量completion_type的值有關。
例如將completion_type設置為1,進行測試。
mysql> set completion_type=1;
mysql> begin;
mysql> insert into ttt values(1000);
mysql> commit work;
mysql> insert into ttt values(2000);
mysql> rollback;
mysql> select * from ttt where id>=1000;
+------+
| id |
+------+
| 1000 |
+------+
1 row in set (0.00 sec)
begin開始事務后,插入了值為1000的記錄,commit work了一次,然后再插入了值為2000的記錄后rollback,查詢結果結果中只顯示了1000,而沒有2000,因為commit work提交后自動又開啟了一個事務,使用rollback會回滾該事務。
將completion_type設置為2,進行測試。
mysql> set completion_type=2;
mysql> begin;
mysql> insert into ttt select 1000;
mysql> commit;
提交后,再查詢或者進行其他操作,結果提示已經和MariaDB/MySQL服務器斷開連接了。
mysql> select * from ttt;
ERROR 2006 (HY000): MySQL server has gone away
No connection. Trying to reconnect...
4.顯式事務的次數統計
通過全局狀態變量com_commit
和com_rollback
可以查看當前已經顯式提交和顯式回滾事務的次數。還可以看到回滾到保存點的次數。
mysql> show global status like "%com_commit%";
+---------------+-------+
| Variable_name | Value |
+---------------+-------+
| Com_commit | 14 |
+---------------+-------+
mysql> show global status like "%com_rollback%";
+---------------------------+-------+
| Variable_name | Value |
+---------------------------+-------+
| Com_rollback | 24 |
| Com_rollback_to_savepoint | 0 |
+---------------------------+-------+
5.一致性非鎖定讀(快照查詢)
在innodb存儲引擎中,存在一種數據查詢方式:快照查詢。因為查詢的是快照數據,所以查詢時不申請共享鎖。
當進行一致性非鎖定讀查詢的時候,查詢操作不會去等待記錄上的獨占鎖釋放,而是直接去讀取快照數據。快照數據是通過undo段來實現的,因此它基本不會產生開銷。顯然,通過這種方式,可以極大的提高讀並發性。
快照數據其實是行版本數據,一個行記錄可能會存在多個行版本,並發時這種讀取行版本的方式稱為多版本並發控制(MVCC)。在隔離級別為read committed和repeatable read時,采取的查詢方式就是一致性非鎖定讀方式。但是,不同的隔離級別下,讀取行版本的方式是不一樣的。在后面介紹對應的隔離級別時會作出說明。
下面是在innodb默認的隔離級別是repeatable read下的實驗,該隔離級別下,事務總是在開啟的時候獲取最新的行版本,並一直持有該版本直到事務結束。更多的"一致性非鎖定讀"見后文說明read committed和repeatable read部分。
當前示例表ttt的記錄如下:
mysql> select * from ttt;
+------+
| id |
+------+
| 1 |
| 2 |
+------+
在會話1執行:
mysql> begin;
mysql> update ttt set id=100 where id=1
在會話2中執行:
mysql> begin;
mysql> select * from ttt;
+------+
| id |
+------+
| 1 |
| 2 |
+------+
查詢的結果和預期的一樣,來自開啟事務前最新提交的行版本數據。
回到會話1提交事務:
mysql> commit;
再回到會話2中查詢:
mysql> select * from ttt;
+------+
| id |
+------+
| 1 |
| 2 |
+------+
再次去會話1更新該記錄:
mysql> begin;
mysql> update ttt set id=1000 where id=100;
mysql> commit;
再回到會話2執行查詢:
mysql> select * from ttt;
+------+
| id |
+------+
| 1 |
| 2 |
+------+
這就是repeatable read隔離級別下的一致性非鎖定讀的特性。
當然,MySQL也支持一致性鎖定讀的方式。
6.一致性鎖定讀
在隔離級別為read committed和repeatable read時,采取的查詢方式就是一致性非鎖定讀方式。但是在某些情況下,需要人為的對讀操作進行加鎖。MySQL中對這種方式的支持是通過在select語句后加上lock in share mode
或者for update
。
select ... from ... where ... lock in share mode;
select ...from ... where ... for update;
使用lock in share mode會對select語句要查詢的記錄加上一個共享鎖(S),使用for update語句會對select語句要查詢的記錄加上獨占鎖(X)。
另外,對於一致性非鎖定讀操作,即使要查詢的記錄已經被for update加上了獨占鎖,也一樣可以讀取,就和純粹的update加的鎖一樣,只不過此時讀取的是快照數據而已。
7.事務隔離級別
SQL標准定義了4中隔離級別:read uncommitted、read committed、repeatable read、serializable。
MariaDB/MySQL也支持這4種隔離級別。但是要注意的是,MySQL中實現的隔離級別和SQL Server實現的隔離級別在同級別上有些差別。在后面有必要說明地方會給出它們的差異之處。
MariaDB/MySQL中默認的隔離級別是repeatable read,SQL Server和oracle的默認隔離級別都是read committed。
事務特性(ACID)中的隔離性(I,isolation)就是隔離級別,它通過鎖來實現。也就是說,設置不同的隔離級別,其本質只是控制不同的鎖行為。例如操作是否申請鎖,什么時候申請鎖,申請的鎖是立刻釋放還是持久持有直到事務結束才釋放等。
7.1 設置和查看事務隔離級別
隔離級別是基於會話設置的,當然也可以基於全局進行設置,設置為全局時,不會影響當前會話的級別。設置的方法是:
set [global | session] transaction isolation level {type} type: read uncommitted | read committed | repeatable read | serializable
或者直接修改變量值也可以:
set @@global.tx_isolation = 'read-uncommitted' | 'read-committed' | 'repeatable-read' | 'serializable' set @@session.tx_isolation = 'read-uncommitted' | 'read-committed' | 'repeatable-read' | 'serializable'
查看當前會話的隔離級別方法如下:
mysql> select @@tx_isolation;
mysql> select @@global.tx_isolation;
mysql> select @@tx_isolation;select @@global.tx_isolation;
+-----------------+
| @@tx_isolation |
+-----------------+
| REPEATABLE-READ |
+-----------------+
+-----------------------+
| @@global.tx_isolation |
+-----------------------+
| REPEATABLE-READ |
+-----------------------+
注意,事務隔離級別的設置只需在需要的一端設置,不用在兩邊會話都設置。例如想要讓會話2的查詢加鎖,則只需在會話2上設置serializable,在會話1設置的serializable對會話2是沒有影響的,這和SQL Server中一樣。但是,MariaDB/MySQL除了serializable隔離級別,其他的隔離級別都默認會讀取舊的行版本,所以查詢永遠不會造成阻塞。而SQL Server中只有基於快照的兩種隔離級別才會讀取行版本,所以在4種標准的隔離級別下,如果查詢加的S鎖被阻塞,查詢會進入鎖等待。
在MariaDB/MySQL中不會出現更新丟失的問題,因為獨占鎖一直持有直到事務結束。當1個會話開啟事務A修改某記錄,另一個會話也開啟事務B修改該記錄,該修改被阻塞,當事務A提交后,事務B中的更新立刻執行成功,但是執行成功后查詢卻發現數據並沒有隨着事務B的想法而改變,因為這時候事務B更新的那條記錄已經不是原來的記錄了。但是事務A回滾的話,事務B是可以正常更新的,但這沒有丟失更新。
7.2 read uncommitted
該級別稱為未提交讀,即允許讀取未提交的數據。
在該隔離級別下,讀數據的時候不會申請讀鎖,所以也不會出現查詢被阻塞的情況。
在會話1執行:
create table ttt(id int);
insert into ttt select 1;
insert into ttt select 2;
begin;
update ttt set id=10 where id=1;
如果會話1的隔離級別不是默認的,那么在執行update的過程中,可能會遇到以下錯誤:
ERROR 1665 (HY000): Cannot execute statement: impossible to write to binary log since BINLOG_FORMAT = STATEMENT and at least one table uses a storage engine limited to row-based logging. InnoDB is limited to row-logging when transaction isolation level is READ COMMITTED or READ UNCOMMITTED.
這是read committed和read uncommitted兩個隔離級別只允許row格式的二進制日志記錄格式。而當前的二進制日志格式記錄方式為statement時就會報錯。要解決這個問題,只要將格式設置為row或者mixed即可。
set @@session.binlog_format=row;
在會話2執行:
set transaction isolation level read uncommitted;
select * from ttt;
+------+
| id |
+------+
| 10 |
| 2 |
+------+
發現查詢的結果是update后的數據,但是這個數據是會話1未提交的數據。這是臟讀的問題,即讀取了未提交的臟數據。
如果此時會話1進行了回滾操作,那么會話2上查詢的結果又變成了id=1。
在會話1上執行:
rollback;
在會話2上查詢:
mysql> select * from ttt;
+------+
| id |
+------+
| 1 |
| 2 |
+------+
這是讀不一致問題。即同一個會話中對同一條記錄的讀取結果不一致。
read uncommitted一般不會在生產環境中使用,因為問題太多,會導致臟讀、丟失的更新、幻影讀、讀不一致的問題。但由於不申請讀鎖,從理論上來說,它的並發性是最佳的。所以在某些特殊情況下還是會考慮使用該級別。
要解決臟讀、讀不一致問題,只需在查詢記錄的時候加上共享鎖即可。這樣在其他事務更新數據的時候就無法查詢到更新前的記錄。這就是read commmitted隔離級別。
7.3 read committed
對於熟悉SQL Server的人來說,在說明這個隔離級別之前,必須先給個提醒:MariaDB/MySQL中的提交讀和SQL Server中的提交讀完全不一樣,MariaDB/MySQL中該級別基本類似於SQL Server中基於快照的提交讀。
在SQL Server中,提交讀的查詢會申請共享鎖,並且在查詢結束的一刻立即釋放共享鎖,如果要查詢的記錄正好被獨占鎖鎖住,則會進入鎖等待,而沒有被獨占鎖鎖住的記錄則可以正常查詢。SQL Server中基於快照的提交讀實現的是語句級的事務一致性,每執行一次操作事務序列號加1,並且每次查詢的結果都是最新提交的行版本快照。
也就是說,MariaDB/MySQL中read committed級別總是會讀取最新提交的行版本。這在MySQL的innodb中算是一個術語:"一致性非鎖定讀",即只讀取快照數據,不加共享鎖。這在前文已經說明過。
MariaDB/MySQL中的read committed隔離級別下,除非是要檢查外鍵約束或者唯一性約束需要用到gap lock算法,其他時候都不會用到。也就是說在此隔離級別下,一般來說只會對行進行鎖定,不會鎖定范圍,所以會導致幻影讀問題。
這里要演示的就是在該級別下,會不斷的讀取最新提交的行版本數據。
當前示例表ttt的記錄如下:
mysql> select * from ttt;
+------+
| id |
+------+
| 1 |
| 2 |
+------+
在會話1中執行:
begin;update ttt set id=100 where id=1;
在會話2中執行:
set @@session.tx_isolation='read-committed';
begin;
select * from ttt;
會話2中查詢得到的結果為id=1,因為查詢的是最新提交的快照數據,而最新提交的快照數據就是id=1。
+------+
| id |
+------+
| 1 |
| 2 |
+------+
現在將會話1中的事務提交。
在會話1中執行:
commit;
在會話2中查詢記錄:
select * from ttt;
+------+
| id |
+------+
| 100 |
| 2 |
+------+
結果為id=100,因為這個值是最新提交的。
再次在會話1中修改該值並提交事務。
在會話1中執行:
begin;update ttt set id=1000 where id=100;commit;
在會話2中執行:
select * from ttt;
+------+
| id |
+------+
| 1000 |
| 2 |
+------+
發現結果變成了1000,因為1000是最新提交的數據。
read committed隔離級別的行版本讀取特性,在和repeatable read隔離級別比較后就很容易理解。
7.4 repeatable read
同樣是和上面一樣的廢話,對於熟悉SQL Server的人來說,在說明這個隔離級別之前,必須先給個提醒:MariaDB/MySQL中的重復讀和SQL Server中的重復讀完全不一樣,MariaDB/MySQL中該級別基本類似於SQL Server中快照隔離級別。
在SQL Server中,重復讀的查詢會申請共享鎖,並且在查詢結束的一刻不釋放共享鎖,而是持有到事務結束。所以會造成比較嚴重的讀寫並發問題。SQL Server中快照隔離級別實現的是事務級的事務一致性,每次事務開啟的時候獲取最新的已提交行版本,只要事務不結束,讀取的記錄將一直是該行版本中的數據,不管其他事務是否已經提交過對應的數據了。但是SQL Server中的快照隔離會有更新沖突:當檢測到兩邊都想要更新同一記錄時,會檢測出更新沖突,這樣會提前結束事務(進行的是回滾操作)而不用再顯式地commit或者rollback。
也就是說,MariaDB/MySQL中repeatable read級別總是會在事務開啟的時候讀取最新提交的行版本,並將該行版本一直持有到事務結束。但是MySQL中的repeatable read級別下不會像SQL Server一樣出現更新沖突的問題。
前文說過read committed隔離級別下,讀取數據時總是會去獲取最新已提交的行版本。這是這兩個隔離級別在"一致性非鎖定讀"上的區別。
另外,MariaDB/MySQL中的repeatable read的加鎖方式是next-key lock算法,它會進行范圍鎖定。這就避免了幻影讀的問題(官方手冊上說無法避免)。在標准SQL中定義的隔離級別中,需要達到serializable級別才能避免幻影讀問題,也就是說MariaDB/MySQL中的repeatable read隔離級別已經達到了其他數據庫產品(如SQL Server)的serializable級別,而且SQL Server中的serializable加范圍鎖時,在有索引的時候式鎖范圍比較不可控(你不知道范圍鎖鎖住哪些具體的范圍),而在MySQL中是可以判斷鎖定范圍的(見innodb鎖算法)。
這里要演示的就是在該級別下,讀取的行版本數據是不隨提交而改變的。
當前示例表ttt的記錄如下:
mysql> select * from ttt;
+------+
| id |
+------+
| 1 |
| 2 |
+------+
在會話1執行:
begin;update ttt set id=100 where id=1
在會話2中執行:
set @@session.tx_isolation='repeatable-read';
begin;select * from ttt;
+------+
| id |
+------+
| 1 |
| 2 |
+------+
查詢的結果和預期的一樣,來自開啟事務前最新提交的行版本數據。
回到會話1提交事務:
commit;
再回到會話2中查詢:
select * from ttt;
+------+
| id |
+------+
| 1 |
| 2 |
+------+
再次去會話1更新該記錄:
begin;update ttt set id=1000 where id=100;commit;
再回到會話2執行查詢:
select * from ttt;
+------+
| id |
+------+
| 1 |
| 2 |
+------+
發現結果根本就不會改變,因為會話2開啟事務時獲取的行版本的id=1,所以之后讀取的一直都是id=1所在的行版本。
7.5 serializable
在SQL Server中,serializable隔離級別會將查詢申請的共享鎖持有到事務結束,且申請的鎖是范圍鎖,范圍鎖的情況根據表有無索引而不同:無索引時鎖定整個表,有索引時鎖定某些范圍,至於鎖定哪些具體的范圍我發現是不可控的(至少我無法推測和計算)。這樣就避免了幻影讀的問題。
這種問題在MariaDB/MySQL中的repeatable read級別就已經實現了,MariaDB/MySQL中的next-key鎖算法在加范圍鎖時也分有無索引:無索引時加鎖整個表(實際上不是表而是無窮大區間的行記錄),有索引時加鎖部分可控的范圍。
MariaDB/MySQL中的serializable其實類似於repeatable read,只不過所有的select語句會自動在后面加上lock in share mode
。也就是說會對所有的讀進行加鎖,而不是讀取行版本的快照數據,也就不再支持"一致性非鎖定讀"。這樣就實現了串行化的事務隔離:每一個事務必須等待前一個事務(哪怕是只有查詢的事務)結束后才能進行哪怕只是查詢的操作。
這個隔離級別對並發性來說,顯然是有點太嚴格了。