前言
用過MySQL的同學都知道,它的InnoDB存儲引擎,是通過事務來保證數據的一致性的。
數據庫事務通常包含了一個序列的對數據庫的讀/寫操作。包含有以下兩個目的:
爲數據庫操作序列提供了一個從失敗中恢復到正常狀態的方法,同時提供了數據庫即使在異常狀態下仍能保持一致性的方法。
當多個應用程序在併發訪問數據庫時,可以在這些應用程序之間提供一個隔離方法,以防止彼此的操作互相干擾。
特性
說到事務就不得不說它的四個特性:ACID。分別是原子性(Atomicity)、一致性(Consistency)、隔離性(Isolation)、持久性(Durability)
- 原子性(Atomicity):事務作爲一個整體被執行,包含在其中的對數據庫的操作要麼全部被執行,要麼都不執行。
- 一致性(Consistency):事務應確保數據庫的狀態從一個一致狀態轉變爲另一個一致狀態。一致狀態的含義是數據庫中的數據應滿足完整性約束。
- 隔離性(Isolation):多個事務併發執行時,一個事務的執行不應影響其他事務的執行。
- 持久性(Durability):已被提交的事務對數據庫的修改應該永久保存在數據庫中。
其中一致性不太好理解,一致性是說無論事務提交還是回滾,不會破壞數據的完整性。比如A給B轉100元,如果成功了,A的賬戶必定會扣100元,而B的賬戶必定會增加100元;如果失敗了,A和B的賬戶餘額不會改變。不會存在其他的情況,這樣就是符合一致性的。
事實上,原子性、持久性和隔離性都是爲了保證一致性。
原子性
事務的原子性是指:一個事務中的多個操作都是不可分割的,只能是全部執行成功、或者全部執行失敗。
MySQL事務的原子性是通過undo log
來實現的。undo log
是InnoDB存儲引擎特有的。具體的實現方式是:將所有對數據的修改(增、刪、改)都寫入日誌(undo log)。如果一個事務中的一部分操作已經成功,但另一部分操作,由於斷電/系統崩潰/其它的軟硬件錯誤而無法成功執行,則通過回溯日誌,將已經執行成功的操作撤銷,從而達到全部操作失敗的目的。
undo log
是邏輯日誌,可以理解爲:記錄和事務操作相反的SQL語句,事務執行insert
語句,undo log
就記錄delete
語句。它以追加寫的方式記錄日誌,不會覆蓋之前的日誌。除此之外undo log
還用來實現數據庫多版本併發控制(Multiversion Concurrency Control,簡稱MVCC)
持久性
事務的持久性是指:一個事務對數據的所有修改,都會永久的保存在數據庫中。
MySQL事務的持久性是通過redo log
來實現的。redo log
也是InnoDB存儲引擎特有的。具體實現方式是:當發生數據修改(增、刪、改)的時候,InnoDB引擎會先將記錄寫到redo log
中,並更新內存,此時更新就算完成了。同時InnoDB引擎會在合適的時機將記錄刷到磁盤中。
redo log
是物理日誌,記錄的是在某個數據頁做了什麼修改,而不是SQL語句的形式。它有固定大小,是循環寫的方式記錄日誌,空間用完後會覆蓋之前的日誌。
undo log
和redo log
並不是直接寫到磁盤上的,而是先寫入log buffer
。再等待合適的時機同步到OS buffer
,再由操作系統決定何時刷到磁盤,具體過程如下:
既然undo log
和redo log
都是從log buffer
到 OS buffer
,再到磁盤。所以中途還是有可能因爲斷電/硬件故障等原因導致日誌丟失。爲此MySQL提供了三種持久化方式,之前的博客:MySQL優化:參數 中提到過一個參數innodb_flush_log_at_trx_commit
,這個參數主要控制InnoDB將log buffer
中的數據寫入OS buffer
,並刷到磁盤的時間點,取值分別爲0,1,2,默認是1。這三個值的意思分別如下
首先查看MySQL默認設置的方式1,也就是每次提交後直接寫入OS buffer
,並且調用系統函數fsync()
把日誌寫到磁盤上。就保證數據一致性的角度來說,這種方式無疑是最安全的。但是我們都知道,安全大多數時候意味着效率偏低。每次提交都直接寫入OS buffer
並且寫到磁盤,無疑會導致單位時間內IO的次數過多而效率低下。除此之外,還有方式0和方式2。基本上都是每秒寫入磁盤一次,所以效率都比方式1更高。但是方式0是把數據先寫入log buffer
再寫入OS buffer
再寫入磁盤,而方式2是直接寫入OS buffer
,再寫入磁盤,少了一次數據拷貝的過程(從log buffer
到OS buffer
),所以方式2比方式0更加高效。
瞭解了undo log
和redo log
的作用和實現過程之後,再來看一下這兩個日誌具體是怎麼讓數據庫從異常的狀態恢復到正常狀態的。
數據庫系統崩潰後重啓,此時數據庫處於不一致的狀態,必須先執行一個crash recovery的過程:首先讀取redo log
,把成功提交但是還沒來得及寫入磁盤的數據重新寫入磁盤,保證了持久性。再讀取undo log
將還沒有成功提交的事務進行回滾,保證了原子性。crash recovery結束後,數據庫恢復到一致性狀態,可以繼續被使用。
隔離性
數據庫事務的隔離性是指:多個事務併發執行時,一個事務的執行不應影響其他事務的執行。正常情況下,肯定是多個事務同時操作同一個數據庫,所以事務之間的隔離就顯得必不可少。先看一下,如果沒有隔離性,會發生哪些問題。
第一類丟失更新
第一類丟失更新是指:一個事務在撤銷的時候,覆蓋了另一個事務已提交的更新數據
假設事務A和事務B操作同一個賬戶的金:
時間 | 事務A | 事務B |
---|---|---|
T1 | 開啓事務 | 開啓事務 |
T2 | 查詢賬戶餘額:500元 | 查詢賬戶餘額:500元 |
T3 | 取走100元,剩餘400元 | 取走100元,剩餘400元 |
T4 | 提交事務,賬戶餘額:400元 | - |
T5 | - | 撤銷事務,賬戶餘額:500元 |
事務B在撤銷事務的時候,覆蓋了事務A在T4的時候已經提交的更新數據。A在T3的時候已經取走了100元,此時的餘額應該是400元,但是由於事務B開始的時候,餘額是500元,所以回滾後,餘額也會變成500元。
髒讀
髒讀:一個事務讀到了另一個事務未提交的更新數據
時間 | 事務A | 事務B |
---|---|---|
T1 | 開啓事務 | 開啓事務 |
T2 | 查詢賬戶餘額:500元 | - |
T3 | 取走100元,剩餘400元 | - |
T4 | - | 查詢餘額:400元 |
T5 | 撤銷事務,賬戶餘額:500元 | - |
事務A在T3的時候取走了400元,但是未提交。事務B在T4時查詢餘額就能看到事務A未提交的更新。
幻讀
幻讀(虛讀)是指:一個事務讀到了另一個事務已提交的新增數據
時間 | 事務A | 事務B |
---|---|---|
T1 | 開啓事務 | 開啓事務 |
T2 | - | 執行select count 統計 |
T3 | 新增一條數據 | - |
T4 | 提交事務 | - |
T5 | - | 執行select count 統計 |
事務B在同一個事務中執行兩次統計操作,得到的結果不一樣
不可重複讀
不可重複讀:一個事務讀到了另一個事務已提交的更新數據
時間 | 事務A | 事務B |
---|---|---|
T1 | 開啓事務 | 開啓事務 |
T2 | 查詢餘額:500元 | 查詢餘額:500元 |
T3 | 取走100元,剩餘:400元 | - |
T4 | 提交事務 | - |
T5 | - | 查詢餘額:400元 |
事務B在同一個事務中,兩次讀取餘額,得到的結果卻不一樣。
第二類丟失更新
第二類丟失更新是指:一個事務在提交的時候,覆蓋了另一個事務已提交的更新數據
時間 | 事務A | 事務B |
---|---|---|
T1 | 開啓事務 | 開啓事務 |
T2 | 查詢賬戶餘額:500元 | 查詢賬戶餘額:500元 |
T3 | 取走100元,剩餘400元 | 取走100元,剩餘400元 |
T4 | 提交事務,賬戶餘額:400元 | - |
T5 | - | 提交事務,賬戶餘額:400元 |
事務A和事務B分別取了100元,所以餘額應該爲300元。但是事務B在提交的時候,覆蓋了事務A已提交的更新數據,所以導致結果出錯。
爲了解決這五類問題,MySQL提供了四種隔離級別:
- Serializable(串行化):事務之間以一種串行的方式執行,安全性非常高,效率低
- Repeatable Read(可重複讀):是MySQL默認的隔離級別,同一個事務中相同的查詢會看到同樣的數據行,安全性較高,效率較好
- Read Commited(讀已提交):一個事務可以讀到另一個事務已經提交的數據,安全性較低,效率較高
- Read Uncommited(讀未提交):一個事務可以讀到另一個事務未提交的數據,安全性低,效率高
隔離級別與併發性能的關係圖如下
需要注意的是,除了Serializable能完完全全的解決這五類問題之外,其餘的三種隔離級別都不能完全解決這五類問題。各種隔離級別能解決的問題對應如下
隔離級別 | 是否出現第一類丟失更新 | 是否出現髒讀 | 是否出現虛讀 | 是否出現不可重複讀 | 是否出現第二類丟失更新 |
---|---|---|---|---|---|
Serializable | 否 | 否 | 否 | 否 | 否 |
Repeatable Read | 否 | 否 | 是 | 否 | 否 |
Read Commited | 否 | 否 | 是 | 是 | 是 |
Read Uncommited | 否 | 是 | 是 | 是 | 是 |
Repeatable Read
Repeatable Read(可重複讀)是MySQL默認的隔離級別,也是使用最多的隔離級別,所以單獨拿出來深入理解很有必要。Repeatable Read無法解決虛讀(幻讀)問題。下面來看一個實例
SQL腳本
# 建表
CREATE TABLE student (
id int(4) primary key auto_increment,
name varchar(10)
) ENGINE = InnoDB;
# 插入一條數據
insert into student(name) values('zhangsan');
開啓兩個操作窗口,均關閉自動提交set autocommit = 0
,其餘的操作的時間線如下:
時間 | 事務A | 事務B |
---|---|---|
T1 | select * from student |
- |
T2 | - | insert into student(name) vaues ('lisi') |
T3 | - | commit |
T4 | select * from student |
- |
按照上述理論,會出現幻讀現象。也就是事務A的第二次select
會看到事務B提交的新增數據。
執行結果如下
mysql> select * from student;
+----+----------+
| id | name |
+----+----------+
| 1 | zhangsan |
+----+----------+
1 row in set (0.00 sec)
和預期的結果並不一致,沒有出現幻讀現象。
實際上MySQL在Repeatable Read
隔離級別下,用MVCC(Multiversion Concurrency Control,多版本併發控制)解決了select
普通查詢的幻讀現象。
具體的實現方式就是事務開始時,第一條select
語句查詢結果集會生成一個快照(snapshot),並且這個事務結束前,同樣的select
語句返回的都是這個快照的結果,而不是最新的查詢結果,這就是MySQL在Repeatable Read
隔離級別對普通select
語句使用的快照讀(snapshot read)。
快照讀和MVCC是什麼關係?
MVCC是多版本併發控制,快照就是其中的一個版本。所以可以說MVCC實現了快照讀,具體的實現方式涉及到MySQL的隱藏列。MySQL會給每個表自動創建三個隱藏列
DB_TRX_ID
:事務ID,記錄操作(增、刪、改)該數據事務的事務IDDB_ROLL_PTR
:回滾指針,記錄上一個版本的數據在undo log
中的位置DB_ROW_ID
:隱藏ID ,創建表沒有合適的索引作爲聚簇索引時,會用該隱藏ID創建聚簇索引
由於undo log
中記錄了各個版本的數據,並且通過DB_ROLL_PTR
可以找到各個歷史版本,並且由DB_TRX_ID
決定使用哪個版本(快照)。所以相當於undo log
實現了MVCC,MVCC實現了快照讀。
如此看來,MySQL的Repeatable Read
隔離級別利用快照讀,已經解決了幻讀的問題。但是事實並非如此,接下來再看一個例子
繼續上面的操作(注意:開始前,先提交上一次操作的事務)
時間 | 事務A | 事務B |
---|---|---|
T1 | select * from student |
- |
T2 | - | insert into student(name) values('wangwu') |
T3 | - | commit |
T4 | update student set name = 'zhaoliu' where name = 'wangwu' |
- |
T5 | select * from student |
- |
事務A在T1的時候生成快照,事務B在T2的時候插入一條數據wangwu
,然後提交。在T4的時候把wangwu
更新成zhaoliu
,根據上一個例子的經驗,此時事務A是看不到wangwu
這條數據的,所以更新也不會成功,並且在T5的時候查詢,和T1時候一樣,只有zhangsan
和lisi
兩條數據。
執行結果如下
mysql> select * from student;
+----+----------+
| id | name |
+----+----------+
| 1 | zhangsan |
| 2 | lisi |
| 3 | zhaoliu |
+----+----------+
3 rows in set (0.00 sec)
但是執行結果卻不是預期的那樣,事務A不僅看到了wangwu
,還把它成功的改成了zhaoliu
。即使事務A成功commit
之後,再次查詢還是這樣。
這其實是MySQL對insert
、update
和delete
語句所使用的當前讀(current read)。因爲涉及到數據的修改,所以MySQL必須拿到最新的數據才能修改,所以涉及到數據的修改肯定不能使用快照讀(snapshot read)。由於事務A讀到了事務B已提交的新增數據,所以就產生了前文所說的幻讀。
那麼在Repeatable Read
隔離級別是怎麼解決幻讀的呢?
是通過間隙鎖(Gap Lock)來解決的。我們都知道InnoDB支持行鎖,並且行鎖是鎖住索引。而間隙鎖用來鎖定索引記錄間隙,確保索引記錄的間隙不變。間隙鎖是針對事務隔離級別爲Repeatable Read
或以上級別而已的,間隙鎖和行鎖一起組成了Next-Key Lock
。當InnoDB掃描索引記錄的時候,會首先對索引記錄加上行鎖,再對索引記錄兩邊的間隙加上間隙鎖(Gap Lock)。加上間隙鎖之後,其他事務就不能在這個間隙插入記錄。這樣就有效的防止了幻讀的發生。
默認情況下,InnoDB工作在Repeatable Read
的隔離級別下,並且以Next-Key Lock
的方式對索引行進行加鎖。當查詢的索引具有唯一性(主鍵、唯一索引)時,Innodb存儲引擎會對Next-Key Lock
進行優化,將其降爲行鎖,僅僅鎖住索引本身,而不是範圍(除非鎖定不存在的值)。若是普通索引,則會使用Next-Key Lock
將記錄和間隙一起鎖定。
- 使用快照讀的語句
select * from ...
- 使用當前讀的語句
select * from ... lock in share mode select * from ... for update insert into table... update table set ... delete table where ...
總結
本文主要講解了MySQL事務的ACID四大特性、undo log
和redo log
分別實現了原子性和持久性、log
持久化的三種方式、數據庫併發下的五類問題、四種隔離級別、RR隔離級別下select
幻讀通過MVCC機制解決、select ... lock in share mode/select ... for update/insert/update/delete
的幻讀通過間隙鎖來解決。
參考
- https://dev.mysql.com/doc/refman/5.6/en/innodb-transaction-isolation-levels.html
- https://www.cnblogs.com/zhoujinyi/p/3435982.html