這是一個建立於 的文章,其中的資訊可能已經有所發展或是發生改變。
前言:
我們都知道事務的幾種性質,資料庫為了維護這些性質,尤其是一致性和隔離性,一般使用加鎖這種方式。同時資料庫又是個高並發的應用,同一時間會有大量的並發訪問,如果加鎖過度,會極大的降低並發處理能力。所以對於加鎖的處理,可以說就是資料庫對於交易處理的精髓所在。這裡通過分析MySQL中InnoDB引擎的加鎖機制,來拋磚引玉,讓讀者更好的理解,在交易處理中資料庫到底做了什麼。
#一次封鎖or兩段鎖?
因為有大量的並發訪問,為了預防死結,一般應用中推薦使用一次封鎖法,就是在方法的開始階段,已經預Crowdsourced Security Testing道會用到哪些資料,然後全部鎖住,在方法運行之後,再全部解鎖。這種方式可以有效避免迴圈死結,但在資料庫中卻不適用,因為在事務開始階段,資料庫並不知道會用到哪些資料。
資料庫遵循的是兩段鎖協議,將事務分成兩個階段,加鎖階段和解鎖階段(所以叫兩段鎖)
- 加鎖階段:在該階段可以進行加鎖操作。在對任何資料進行讀操作之前要申請並獲得S鎖(共用鎖定,其它事務可以繼續加共用鎖定,但不能加排它鎖),在進行寫操作之前要申請並獲得X鎖(排它鎖,其它事務不能再獲得任何鎖)。加鎖不成功,則事務進入等待狀態,直到加鎖成功才繼續執行。
- 解鎖階段:當事務釋放了一個封鎖以後,事務進入解鎖階段,在該階段只能進行解鎖操作不能再進行加鎖操作。
事務 |
加鎖/解鎖處理 |
begin; |
|
insert into test ..... |
加insert對應的鎖 |
update test set... |
加update對應的鎖 |
delete from test .... |
加delete對應的鎖 |
commit; |
事務提交時,同時釋放insert、update、delete對應的鎖 |
這種方式雖然無法避免死結,但是兩段鎖協議可以保證事務的並發調度是序列化(序列化很重要,尤其是在資料恢複和備份的時候)的。
#事務中的加鎖方式
##事務的四種隔離等級
在資料庫操作中,為了有效保證並發讀取資料的正確性,提出的交易隔離等級。我們的資料庫鎖,也是為了構建這些隔離等級存在的。
隔離等級 |
髒讀(Dirty Read) |
不可重複讀取(NonRepeatable Read) |
幻讀(Phantom Read) |
未提交讀(Read uncommitted) |
可能 |
可能 |
可能 |
已提交讀(Read committed) |
不可能 |
可能 |
可能 |
可重複讀(Repeatable read) |
不可能 |
不可能 |
可能 |
可序列化(Serializable ) |
不可能 |
不可能 |
不可能 |
- 未提交讀(Read Uncommitted):允許髒讀,也就是可能讀取到其他會話中未提交事務修改的資料
- 提交讀(Read Committed):只能讀取到已經提交的資料。Oracle等多數資料庫預設都是該層級 (不重複讀)
- 可重複讀(Repeated Read):可重複讀。在同一個事務內的查詢都是事務開始時刻一致的,InnoDB預設層級。在SQL標準中,該隔離等級消除了不可重複讀取,但是還存在幻象讀
- 串列讀(Serializable):完全序列化的讀,每次讀都需要獲得表級共用鎖定,讀寫相互都會阻塞
Read Uncommitted這種層級,資料庫一般都不會用,而且任何操作都不會加鎖,這裡就不討論了。
##MySQL中鎖的種類
MySQL中鎖的種類很多,有常見的表鎖和行鎖,也有新加入的Metadata Lock等等,表鎖是對一整張表加鎖,雖然可分為讀鎖和寫鎖,但畢竟是鎖住整張表,會導致並發能力下降,一般是做ddl處理時使用。
行鎖則是鎖住資料行,這種加鎖方法比較複雜,但是由於只鎖住有限的資料,對於其它資料不加限制,所以並發能力強,MySQL一般都是用行鎖來處理並發事務。這裡主要討論的也就是行鎖。
###Read Committed(讀取提交內容)
在RC層級中,資料的讀取都是不加鎖的,但是資料的寫入、修改和刪除是需要加鎖的。效果如下
MySQL> show create table class_teacher \G\Table: class_teacherCreate Table: CREATE TABLE `class_teacher` ( `id` int(11) NOT NULL AUTO_INCREMENT, `class_name` varchar(100) COLLATE utf8mb4_unicode_ci NOT NULL, `teacher_id` int(11) NOT NULL, PRIMARY KEY (`id`), KEY `idx_teacher_id` (`teacher_id`)) ENGINE=InnoDB AUTO_INCREMENT=5 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci1 row in set (0.02 sec)MySQL> select * from class_teacher;+----+--------------+------------+| id | class_name | teacher_id |+----+--------------+------------+| 1 | 初三一班 | 1 || 3 | 初二一班 | 2 || 4 | 初二二班 | 2 |+----+--------------+------------+
由於MySQL的InnoDB預設是使用的RR層級,所以我們先要將該session開啟成RC層級,並且設定binlog的模式
SET session transaction isolation level read committed;SET SESSION binlog_format = 'ROW';(或者是MIXED)
事務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; |
|
為了防止並發過程中的修改衝突,事務A中MySQL給teacher_id=1的資料行加鎖,並一直不commit(釋放鎖),那麼事務B也就一直拿不到該行鎖,wait直到逾時。
這時我們要注意到,teacher_id是有索引的,如果是沒有索引的class_name呢?update class_teacher set teacher_id=3 where class_name = '初三一班';
那麼MySQL會給整張表的所有資料行的加行鎖。這裡聽起來有點不可思議,但是當sql啟動並執行過程中,MySQL並不知道哪些資料行是 class_name = '初三一班'的(沒有索引嘛),如果一個條件無法通過索引快速過濾,儲存引擎層面就會將所有記錄加鎖後返回,再由MySQL Server層進行過濾。
但在實際使用過程當中,MySQL做了一些改進,在MySQL Server過濾條件,發現不滿足後,會調用unlock_row方法,把不滿足條件的記錄釋放鎖 (違背了二段鎖協議的約束)。這樣做,保證了最後只會持有滿足條件記錄上的鎖,但是每條記錄的加鎖操作還是不能省略的。可見即使是MySQL,為了效率也是會違反規範的。(參見《高效能MySQL》中文第三版p181)
這種情況同樣適用於MySQL的預設隔離等級RR。所以對一個資料量很大的表做批量修改的時候,如果無法使用相應的索引,MySQL Server過濾資料的的時候特別慢,就會出現雖然沒有修改某些行的資料,但是它們還是被鎖住了的現象。
###Repeatable Read(可重讀)
這是MySQL中InnoDB預設的隔離等級。我們姑且分“讀”和“寫”兩個模組來講解。
####讀
讀就是可重讀,可重讀這個概念是一事務的多個執行個體在並發讀取資料時,會看到同樣的資料行,有點抽象,我們來看一下效果。
RC(不可重讀)模式下的展現
事務A |
事務B |
begin; |
begin; |
select id,class_name,teacher_id from class_teacher where teacher_id=1;
id |
class_name |
teacher_id |
1 |
初三二班 |
1 |
2 |
初三一班 |
1 |
|
|
|
update class_teacher set class_name='初三三班' where id=1; |
|
commit; |
select id,class_name,teacher_id from class_teacher where teacher_id=1;
id |
class_name |
teacher_id |
1 |
初三三班 |
1 |
2 |
初三一班 |
1 |
讀到了事務B修改的資料,和第一次查詢的結果不一樣,是不可重讀的。 |
|
commit; |
|
事務B修改id=1的資料提交之後,事務A同樣的查詢,後一次和前一次的結果不一樣,這就是不可重讀(重新讀取產生的結果不一樣)。這就很可能帶來一些問題,那麼我們來看看在RR層級中MySQL的表現:
事務A |
事務B |
事務C |
begin; |
begin; |
begin; |
select id,class_name,teacher_id from class_teacher where teacher_id=1;
id |
class_name |
teacher_id |
1 |
初三二班 |
1 |
2 |
初三一班 |
1 |
|
|
|
|
update class_teacher set class_name='初三三班' where id=1; commit; |
|
|
|
insert into class_teacher values (null,'初三三班',1);commit; |
select id,class_name,teacher_id from class_teacher where teacher_id=1;
id |
class_name |
teacher_id |
1 |
初三二班 |
1 |
2 |
初三一班 |
1 |
沒有讀到事務B修改的資料,和第一次sql讀取的一樣,是可重複讀的。 沒有讀到事務C新添加的資料。 |
|
|
commit; |
|
|
我們注意到,當teacher_id=1時,事務A先做了一次讀取,事務B中間修改了id=1的資料,並commit之後,事務A第二次讀到的資料和第一次完全相同。所以說它是可重讀的。那麼MySQL是怎麼做到的呢?這裡姑且賣個關子,我們往下看。
####不可重複讀取和幻讀的區別####
很多人容易搞混不可重複讀取和幻讀,確實這兩者有些相似。但不可重複讀取重點在於update和delete,而幻讀的重點在於insert。
如果使用鎖機制來實現這兩種隔離等級,在可重複讀中,該sql第一次讀取到資料後,就將這些資料加鎖,其它事務無法修改這些資料,就可以實現可重複讀了。但這種方法卻無法鎖住insert的資料,所以當事務A先前讀取了資料,或者修改了全部資料,事務B還是可以insert資料提交,這時事務A就會發現莫名其妙多了一條之前沒有的資料,這就是幻讀,不能通過行鎖來避免。需要Serializable隔離等級 ,讀用讀鎖,寫用寫鎖,讀鎖和寫鎖互斥,這麼做可以有效避免幻讀、不可重複讀取、髒讀等問題,但會極大的降低資料庫的並發能力。
所以說不可重複讀取和幻讀最大的區別,就在於如何通過鎖機制來解決他們產生的問題。
上文說的,是使用悲觀鎖機制來處理這兩種問題,但是MySQL、ORACLE、PostgreSQL等成熟的資料庫,出於效能考慮,都是使用了以樂觀鎖為理論基礎的MVCC(多版本並發控制)來避免這兩種問題。
####悲觀鎖和樂觀鎖####
正如其名,它指的是對資料被外界(包括本系統當前的其他事務,以及來自外部系統的交易處理)修改持保守態度,因此,在整個資料處理過程中,將資料處於鎖定狀態。悲觀鎖的實現,往往依靠資料庫提供的鎖機制(也只有資料庫層提供的鎖機制才能真正保證資料訪問的排他性,否則,即使在本系統中實現了加鎖機制,也無法保證外部系統不會修改資料)。
在悲觀鎖的情況下,為了保證事務的隔離性,就需要一致性鎖定讀。讀取資料時給加鎖,其它事務無法修改這些資料。修改刪除資料時也要加鎖,其它事務無法讀取這些資料。
相對悲觀鎖而言,樂觀鎖機制採取了更加寬鬆的加鎖機制。悲觀鎖大多數情況下依靠資料庫的鎖機制實現,以保證操作最大程度的獨佔性。但隨之而來的就是資料庫效能的大量開銷,特別是對長事務而言,這樣的開銷往往無法承受。
而樂觀鎖機制在一定程度上解決了這個問題。樂觀鎖,大多是基於資料版本( Version )記錄機制實現。何謂資料版本?即為資料增加一個版本標識,在基於資料庫表的版本解決方案中,一般是通過為資料庫表增加一個 “version” 欄位來實現。讀取出資料時,將此版本號碼一同讀出,之後更新時,對此版本號碼加一。此時,將提交資料的版本資料與資料庫表對應記錄的目前的版本資訊進行比對,如果提交的資料版本號碼大於資料庫表目前的版本號,則予以更新,否則認為是到期資料。
要說明的是,MVCC的實現沒有固定的規範,每個資料庫都會有不同的實現方式,這裡討論的是InnoDB的MVCC。
####MVCC在MySQL的InnoDB中的實現
在InnoDB中,會在每行資料後添加兩個額外的隱藏的值來實現MVCC,這兩個值一個記錄這行資料何時被建立,另外一個記錄這行資料何時到期(或者被刪除)。 在實際操作中,儲存的並不是時間,而是事務的版本號碼,每開啟一個新事務,事務的版本號碼就會遞增。 在可重讀Repeatable reads交易隔離等級下:
- SELECT時,讀取建立版本號碼<=當前事務版本號碼,刪除版本號碼為空白或>當前事務版本號碼。
- INSERT時,儲存當前事務版本號碼為行的建立版本號碼
- DELETE時,儲存當前事務版本號碼為行的刪除版本號碼
- UPDATE時,插入一條新紀錄,儲存當前事務版本號碼為行建立版本號碼,同時儲存當前事務版本號碼到原來刪除的行
通過MVCC,雖然每行記錄都需要額外的儲存空間,更多的行檢查工作以及一些額外的維護工作,但可以減少鎖的使用,大多數讀操作都不用加鎖,讀資料操作很簡單,效能很好,並且也能保證只會讀取到符合標準的行,也只鎖住必要行。
我們不管從資料庫方面的教課書中學到,還是從網路上看到,大都是上文中事務的四種隔離等級這一模組列出的意思,RR層級是可重複讀的,但無法解決幻讀,而只有在Serializable層級才能解決幻讀。於是我就加了一個事務C來展示效果。在事務C中添加了一條teacher_id=1的資料commit,RR層級中應該會有幻讀現象,事務A在查詢teacher_id=1的資料時會讀到事務C新加的資料。但是測試後發現,在MySQL中是不存在這種情況的,在事務C提交後,事務A還是不會讀到這條資料。可見在MySQL的RR層級中,是解決了幻讀的讀問題的。參見
讀問題解決了,根據MVCC的定義,並發提交資料時會出現衝突,那麼衝突時如何解決呢?我們再來看看InnoDB中RR層級對於寫資料的處理。
####“讀”與“讀”的區別
可能有讀者會疑惑,事務的隔離等級其實都是對於讀資料的定義,但到了這裡,就被拆成了讀和寫兩個模組來講解。這主要是因為MySQL中的讀,和交易隔離等級中的讀,是不一樣的。
我們且看,在RR層級中,通過MVCC機制,雖然讓資料變得可重複讀,但我們讀到的資料可能是曆史資料,是不及時的資料,不是資料庫當前的資料!這在一些對於資料的時效特別敏感的業務中,就很可能出問題。
對於這種讀取曆史資料的方式,我們叫它快照讀 (snapshot read),而讀取資料庫目前的版本資料的方式,叫當前讀 (current read)。很顯然,在MVCC中:
- 快照讀:就是select
- select * from table ....;
- 當前讀:特殊的讀操作,插入/更新/刪除操作,屬於當前讀,處理的都是當前的資料,需要加鎖。
- select * from table where ? lock in share mode;
- select * from table where ? for update;
- insert;
- update ;
- delete;
事務的隔離等級實際上都是定義了當前讀的層級,MySQL為了減少鎖處理(包括等待其它鎖)的時間,提升並發能力,引入了快照讀的概念,使得select不用加鎖。而update、insert這些“當前讀”,就需要另外的模組來解決了。
###寫("當前讀")
事務的隔離等級中雖然只定義了讀資料的要求,實際上這也可以說是寫資料的要求。上文的“讀”,實際是講的快照讀;而這裡說的“寫”就是當前讀了。
為瞭解決當前讀中的幻讀問題,MySQL事務使用了Next-Key鎖。
####Next-Key鎖
Next-Key鎖是行鎖和GAP(間隙鎖)的合并,行鎖上文已經介紹了,接下來說下GAP間隙鎖。
行鎖可以防止不同事務版本的資料修改提交時造成資料衝突的情況。但如何避免別的事務插入資料就成了問題。我們可以看看RR層級和RC層級的對比
RC層級:
事務A |
事務B |
begin; |
begin; |
select id,class_name,teacher_id from class_teacher where teacher_id=30;
id |
class_name |
teacher_id |
2 |
初三二班 |
30 |
|
|
update class_teacher set class_name='初三四班' where teacher_id=30; |
|
|
insert into class_teacher values (null,'初三二班',30); commit; |
select id,class_name,teacher_id from class_teacher where teacher_id=30;
id |
class_name |
teacher_id |
2 |
初三四班 |
30 |
10 |
初三二班 |
30 |
|
|
RR層級:
事務A |
事務B |
begin; |
begin; |
select id,class_name,teacher_id from class_teacher where teacher_id=30;
id |
class_name |
teacher_id |
2 |
初三二班 |
30 |
|
|
update class_teacher set class_name='初三四班' where teacher_id=30; |
|
|
insert into class_teacher values (null,'初三二班',30); waiting.... |
select id,class_name,teacher_id from class_teacher where teacher_id=30;
id |
class_name |
teacher_id |
2 |
初三四班 |
30 |
|
|
commit; |
事務Acommit後,事務B的insert執行。 |
通過對比我們可以發現,在RC層級中,事務A修改了所有teacher_id=30的資料,但是當事務Binsert進新資料後,事務A發現莫名其妙多了一行teacher_id=30的資料,而且沒有被之前的update語句所修改,這就是“當前讀”的幻讀。
RR層級中,事務A在update後加鎖,事務B無法插入新資料,這樣事務A在update前後讀的資料保持一致,避免了幻讀。這個鎖,就是Gap鎖。
MySQL是這麼實現的:
在class_teacher這張表中,teacher_id是個索引,那麼它就會維護一套B+樹的資料關係,為了簡化,我們用鏈表結構來表達(實際上是個樹形結構,但原理相同)
,InnoDB使用的是叢集索引,teacher_id身為二級索引,就要維護一個索引欄位和主鍵id的樹狀結構(這裡用鏈表形式表現),並保持順序排列。
Innodb將這段資料分成幾個個區間
- (negative infinity, 5],
- (5,30],
- (30,positive infinity);
update class_teacher set class_name='初三四班' where teacher_id=30;不僅用行鎖,鎖住了相應的資料行;同時也在兩邊的區間,(5,30]和(30,positive infinity),都加入了gap鎖。這樣事務B就無法在這個兩個區間insert進新資料。
受限於這種實現方式,Innodb很多時候會鎖住不需要鎖的區間。如下所示:
事務A |
事務B |
事務C |
begin; |
begin; |
begin; |
select id,class_name,teacher_id from class_teacher;
id |
class_name |
teacher_id |
1 |
初三一班 |
5 |
2 |
初三二班 |
30 |
|
|
|
update class_teacher set class_name='初一一班' where teacher_id=20; |
|
|
|
insert into class_teacher values (null,'初三五班',10); waiting ..... |
insert into class_teacher values (null,'初三五班',40); |
commit; |
事務A commit之後,這條語句才插入成功 |
commit; |
|
commit; |
|
update的teacher_id=20是在(5,30]區間,即使沒有修改任何資料,Innodb也會在這個區間加gap鎖,而其它區間不會影響,事務C正常插入。
如果使用的是沒有索引的欄位,比如update class_teacher set teacher_id=7 where class_name='初三八班(即使沒有匹配到任何資料)',那麼會給全表加入gap鎖。同時,它不能像上文中行鎖一樣經過MySQL Server過濾自動解除不滿足條件的鎖,因為沒有索引,則這些欄位也就沒有排序,也就沒有區間。除非該事務提交,否則其它事務無法插入任何資料。
行鎖防止別的事務修改或刪除,GAP鎖防止別的事務新增,行鎖和GAP鎖結合形成的的Next-Key鎖共同解決了RR層級在寫資料時的幻讀問題。
###Serializable
這個層級很簡單,讀加共用鎖定,寫加獨佔鎖定,讀寫互斥。使用的悲觀鎖的理論,實現簡單,資料更加安全,但是並發能力非常差。如果你的業務並發的特別少或者沒有並發,同時又要求資料及時可靠的話,可以使用這種模式。
這裡要吐槽一句,不要看到select就說不會加鎖了,在Serializable這個層級,還是會加鎖的!
參考資料
回答“思考題”、發現文章有錯誤、對內容有疑問,都可以來公眾號(美團點評技術團隊)後台給我們留言。我們每周會挑選出一位“優秀回答者”,贈送一份精美的小禮品。快來掃碼關注我們吧!