MVCC:
MySQL InnoDB存儲引擎,實現(xiàn)的是基于多版本的并發(fā)控制協(xié)議——MVCC (Multi-Version Concurrency Control) (注:與MVCC相對的,是基于鎖的并發(fā)控制,Lock-Based Concurrency Control)。MVCC最大的好處,相信也是耳熟能詳:讀不加鎖,讀寫不沖突。在讀多寫少的OLTP應用中,讀寫不沖突是非常重要的,極大的增加了系統(tǒng)的并發(fā)性能,這也是為什么現(xiàn)階段,幾乎所有的RDBMS,都支持了MVCC。
在MVCC并發(fā)控制中,讀操作可以分成兩類:快照讀 (snapshot read)與當前讀 (current read)??煺兆x,讀取的是記錄的可見版本 (有可能是歷史版本),不用加鎖。當前讀,讀取的是記錄的最新版本,并且,當前讀返回的記錄,都會加上鎖,保證其他事務不會再并發(fā)修改這條記錄。
在一個支持MVCC并發(fā)控制的系統(tǒng)中,哪些讀操作是快照讀?哪些操作又是當前讀呢?以MySQL InnoDB為例:
- 快照讀:簡單的select操作,屬于快照讀,不加鎖。(當然,也有例外,下面會分析)
select * from table where ?; - 當前讀:特殊的讀操作,插入/更新/刪除操作,屬于當前讀,需要加鎖。
select * from table where ? lock in share mode;
select * from table where ? for update;
insert into table values (…);
update table set ? where ?;
delete from table where ?;
所有以上的語句,都屬于當前讀,讀取記錄的最新版本。并且,讀取之后,還需要保證其他并發(fā)事務不能修改當前記錄,對讀取記錄加鎖。其中,除了第一條語句,對讀取記錄加S鎖 (共享鎖)外,其他的操作,都加的是X鎖 (排它鎖)。

從圖中,可以看到,一個Update操作的具體流程。當UpdateSQL被發(fā)給MySQL后,MySQLServer會根據where條件,讀取第一條滿足條件的記錄,然后InnoDB引擎會將第一條記錄返回,并加鎖 (current read)。待MySQL Server收到這條加鎖的記錄之后,會再發(fā)起一個Update請求,更新這條記錄。一條記錄操作完成,再讀取下一條記錄,直至沒有滿足條件的記錄為止。因此,Update操作內部,就包含了一個當前讀。同理,Delete操作也一樣。Insert操作會稍微有些不同,簡單來說,就是Insert操作可能會觸發(fā)Unique Key的沖突檢查,也會進行一個當前讀。
兩階段鎖(2PL Two-Phase Locking):
傳統(tǒng)RDBMS加鎖的一個原則,就是2PL (二階段鎖):Two-Phase Locking。相對而言,2PL比較容易理解,說的是鎖操作分為兩個階段:加鎖階段與解鎖階段,并且保證加鎖階段與解鎖階段不相交。下面,仍舊以MySQL為例,來簡單看看2PL在MySQL中的實現(xiàn)。

2PL就是將加鎖/解鎖分為兩個完全不相交的階段。加鎖階段:只加鎖,不放鎖。解鎖階段:只放鎖,不加鎖。
GAP鎖
GAP鎖就是RR隔離級別相對于RC隔離級別不會出現(xiàn)幻讀的關鍵。確實,GAP鎖鎖住的位置,也不是記錄本身,而是兩條記錄之間的GAP。所謂幻讀,就是同一個事務,連續(xù)做兩次當前讀 (例如:select * from t1 where id = 10 for update;),那么這兩次當前讀返回的是完全相同的記錄 (記錄數(shù)量一致,記錄本身也一致),第二次的當前讀,不會比第一次返回更多的記錄 (幻象)。
如何保證兩次當前讀返回一致的記錄,那就需要在第一次當前讀與第二次當前讀之間,其他的事務不會插入新的滿足條件的記錄并提交。為了實現(xiàn)這個功能,GAP鎖應運而生。

如圖中所示,有哪些位置可以插入新的滿足條件的項 (id = 10),考慮到B+樹索引的有序性,滿足條件的項一定是連續(xù)存放的。記錄[6,c]之前,不會插入id=10的記錄;[6,c]與[10,b]間可以插入[10, aa];[10,b]與[10,d]間,可以插入新的[10,bb],[10,c]等;[10,d]與[11,f]間可以插入滿足條件的[10,e],[10,z]等;而[11,f]之后也不會插入滿足條件的記錄。因此,為了保證[6,c]與[10,b]間,[10,b]與[10,d]間,[10,d]與[11,f]不會插入新的滿足條件的記錄,MySQL選擇了用GAP鎖,將這三個GAP給鎖起來。
Insert操作,如insert [10,aa],首先會定位到[6,c]與[10,b]間,然后在插入前,會檢查這個GAP是否已經被鎖上,如果被鎖上,則Insert不能插入記錄。因此,通過第一遍的當前讀,不僅將滿足條件的記錄鎖上 (X鎖),與組合三類似。同時還是增加3把GAP鎖,將可能插入滿足條件記錄的3個GAP給鎖上,保證后續(xù)的Insert不能插入新的id=10的記錄,也就杜絕了同一事務的第二次當前讀,出現(xiàn)幻象的情況。
隔離級別:
針對MySQL/InnoDB
當前讀:
| 隔離級別 | 臟讀(Dirty Read) | 不可重復讀(NonRepeatable Read) | 幻讀(Phantom Read) |
|---|---|---|---|
| 未提交讀(Read uncommitted) | 可能 | 可能 | 可能 |
| 已提交讀(Read committed) | 不可能 | 不可能 | 可能 |
| 可重復讀(Repeatable read) | 不可能 | 不可能 | 不可能 |
| 可序列化(Serializable ) | 不可能 | 不可能 | 不可能 |
快照讀:
| 隔離級別 | 臟讀(Dirty Read) | 不可重復讀(NonRepeatable Read) | 幻讀(Phantom Read) |
|---|---|---|---|
| 未提交讀(Read uncommitted) | 可能 | 可能 | 可能 |
| 已提交讀(Read committed) | 不可能 | 可能 | 可能 |
| 可重復讀(Repeatable read) | 不可能 | 不可能 | 可能 |
| 可序列化(Serializable ) | 不可能 | 不可能 | 不可能 |
Read Uncommitted:
這種級別,而且任何操作都不會加鎖,可以讀取未提交的記錄,一般都不會用。
Read Committed(讀取提交內容):
快照讀:數(shù)據的讀取都是不加鎖的(快照讀)。會產生不可重復讀和幻讀。
當前讀:數(shù)據的寫入、修改和刪除是需要加鎖的(當前讀,加記錄鎖)。這種隔離級別下,還是會存在幻讀現(xiàn)象,比如其他未被鎖的記錄被修改后符合了查詢條件條件。
| 事務A | 事務B | |
|---|---|---|
| begin; | begin; | |
| update class_teacher set class_name='初三二班' where teacher_id=1; | update class_teacher set class_name='初三三班' where teacher_id=1; | |
| ERROR 1205 (HY000): Lock wait timeout exceeded; try restarting transaction | ||
| commit; |
為了防止并發(fā)過程中的修改沖突,事務A中MySQL給teacher_id=1的數(shù)據行加鎖,并一直不commit(釋放鎖),那么事務B也就一直拿不到該行鎖,wait直到超時。
這時我們要注意到,teacher_id是有索引的,如果是沒有索引的class_name呢?update class_teacher set teacher_id=3 where class_name = '初三一班';
那么MySQL會給整張表的所有數(shù)據行的加行鎖。因為MySQL并不知道哪些數(shù)據行是 class_name = '初三一班'的(沒有索引),如果一個條件無法通過索引快速過濾,存儲引擎層面就會將所有記錄加鎖后返回,再由MySQL Server層進行過濾。
但在實際使用過程當中,MySQL做了一些改進,在MySQL Server過濾條件,發(fā)現(xiàn)不滿足后,會調用unlock_row方法,把不滿足條件的記錄釋放鎖 (違背了二段鎖協(xié)議的約束)。這樣做,保證了最后只會持有滿足條件記錄上的鎖,但是每條記錄的加鎖操作還是不能省略的。
這種情況同樣適用于MySQL的默認隔離級別RR。所以對一個數(shù)據量很大的表做批量修改的時候,如果無法使用相應的索引,MySQL Server過濾數(shù)據的的時候特別慢,就會出現(xiàn)雖然沒有修改某些行的數(shù)據,但是它們還是被鎖住了的現(xiàn)象(因為整張表被鎖了!)。
Repeatable Read(可重讀):
MySQL/InnoDB的默認隔離級別。不可重復讀與幻讀的區(qū)別是不可重復讀重點在修改,即讀取過的數(shù)據,兩次讀的值不一樣。而幻讀則側重于記錄數(shù)目變化插入和刪除。
快照讀:不加鎖。a:InnoDB 只查找版本早于當前事務版本的數(shù)據行(也即是行的版本號小于等于事務的系統(tǒng)版本號),這樣可以確保事務讀取的行,要么是在事務開始前已經存在的,要么是事務自身插入或者修改的。b:行的刪除版本要么未定義,要么大于當前事務版本號。這可以確保事務讀取到的行,在事務開始之前未被刪除。只有符合以上兩個條件的記錄,才能返回作為查詢結果。通過MVCC實現(xiàn)了可重讀。
當前讀:數(shù)據的寫入、修改和刪除是加鎖的(當前讀,加記錄鎖和gap鎖)。innodb對讀取的范圍加鎖,新的滿足查詢條件的記錄不能夠插入 (gap鎖/間隙鎖),所以不存在幻讀現(xiàn)象。對于當前讀,gap鎖是innodb中RC和RR最主要的區(qū)別。
Serializable:
從MVCC并發(fā)控制退化為基于鎖的并發(fā)控制。不區(qū)別快照讀與當前讀,所有的讀操作均為當前讀,讀加讀鎖 (S鎖),寫加寫鎖 (X鎖)。Serializable隔離級別下,讀寫沖突,因此并發(fā)度急劇下降,在MySQL/InnoDB下不建議使用。
死鎖:
死鎖1:

死鎖2:

上面的兩個死鎖用例。第一個非常好理解,也是最常見的死鎖,每個事務執(zhí)行兩條SQL,分別持有了一把鎖,然后加另一把鎖,產生死鎖。
第二個用例,雖然每個Session都只有一條語句,仍舊會產生死鎖。要分析這個死鎖,首先必須用到本文前面提到的MySQL加鎖的規(guī)則。針對Session 1,從name索引出發(fā),讀到的[hdc, 1],[hdc, 6]均滿足條件,不僅會加name索引上的記錄X鎖,而且會加聚簇索引上的記錄X鎖,加鎖順序為先[1,hdc,100],后[6,hdc,10]。而Session 2,從pubtime索引出發(fā),[10,6],[100,1]均滿足過濾條件,同樣也會加聚簇索引上的記錄X鎖,加鎖順序為[6,hdc,10],后[1,hdc,100]。發(fā)現(xiàn)沒有,跟Session 1的加鎖順序正好相反,如果兩個Session恰好都持有了第一把鎖,請求加第二把鎖,死鎖就發(fā)生了。
死鎖的發(fā)生與否,并不在于事務中有多少條SQL語句,死鎖的關鍵在于:各自占有對方的期望獲得的資源,形成的循環(huán)等待,彼此無法繼續(xù)執(zhí)行的一種狀態(tài)。
參考:
http://hedengcheng.com/?p=771
http://coderbee.net/index.php/db/20141020/1056
https://fdx321.github.io/2016/09/09/MySQL%E4%BA%8B%E5%8A%A1%E5%AD%A6%E4%B9%A0%E6%80%BB%E7%BB%93/#more
http://czj4451.iteye.com/blog/2037759