多個(gè)事務(wù)的并發(fā)會(huì)出現(xiàn)以下幾個(gè)問題:
1)臟讀:事務(wù)A讀取了一條記錄的值,然后基于這個(gè)值做業(yè)務(wù)邏輯,在事務(wù)A提交之前,事務(wù)B回滾了該記錄,導(dǎo)致事務(wù)A讀到的這條記錄是一個(gè)臟數(shù)據(jù);
2)不可重復(fù)讀:在同一個(gè)事務(wù)里面,兩次讀到同一行記錄,但結(jié)果不一樣,因?yàn)榱硪粋€(gè)事務(wù)在此期間對(duì)該條記錄進(jìn)行update操作;
3)幻讀:在同一個(gè)事務(wù)里面,同樣的select查詢語句執(zhí)行兩次,得到的記錄條數(shù)不一樣,因?yàn)榱硪粋€(gè)事務(wù)在此期間對(duì)表記錄進(jìn)行insert/delete操作;
4)丟失更新:兩個(gè)事務(wù)同時(shí)修改同一條記錄,事務(wù)A的修改被事務(wù)B覆蓋了。
為了解決這些問題,數(shù)據(jù)庫設(shè)置了不同的事務(wù)隔離級(jí)別。包括:
1)讀未提交:
2)讀已提交:只解決了臟讀問題
3)可重復(fù)讀:解決了臟讀、不可重復(fù)讀、幻讀
4)串行化:解決了臟讀、不可重復(fù)讀、幻讀、丟失更新
以上四種隔離級(jí)別一級(jí)比一級(jí)嚴(yán)格,通常讀未提交和串行化不會(huì)被數(shù)據(jù)庫查詢引擎采用,因?yàn)榍罢呤裁锤綦x措施都沒有做,后者將事務(wù)完全按照先后順序依次執(zhí)行,導(dǎo)致數(shù)據(jù)庫的事務(wù)吞吐量太小。
MySQL的InnoDB引擎采用的是可重復(fù)讀隔離級(jí)別。這就導(dǎo)致使用者要自己處理丟失更新問題,例如在下表(tbl)中
| user_id | balance |
|---|---|
| 1 | 30 |
| 2 | 80 |
兩個(gè)事務(wù)操縱同一條記錄,一個(gè)充錢一個(gè)扣錢,偽代碼如下:
# 事務(wù)A
start transaction
int b = select balance from tbl where user_id = 1
b = b + 50
update tbl set balance = b where user_id = 1
commit
# 事務(wù)B
start transaction
int b = select balance from tbl where user_id = 1
b = b - 50
update tbl set balance = b where user_id = 1
commit
在事務(wù)A與B并發(fā)的情況下,由于事務(wù)A與B的多條語句之間可能被交叉執(zhí)行,因此最后balance的值可能是80、-20、30。解決這個(gè)問題有如下的幾種方法:
1)利用單條SQL語句的原子性改寫事務(wù)代碼,例如:
# 事務(wù)A
start transaction
update tbl set balance = balance + 50 where user_id = 1
commit
# 事務(wù)B
start transaction
update tbl set balance = balance - 50 where user_id = 1
commit
2)使用悲觀鎖(也就是select xxx for update語句),悲觀鎖在讀記錄之前就上鎖,偽代碼如下:
# 事務(wù)A
start transaction
int b = select balance from tbl where user_id = 1 for update
b = b + 50
update tbl set balance = b where user_id = 1
commit
# 事務(wù)B
start transaction
int b = select balance from tbl where user_id = 1 for update
b = b - 50
update tbl set balance = b where user_id = 1
commit
# 之所以說悲觀鎖降低了事務(wù)的并發(fā)度,是因?yàn)閱蝹€(gè)記錄被鎖住之后,其他訪問該記錄的事務(wù)都會(huì)被阻塞
# 另外,使用悲觀鎖,會(huì)導(dǎo)致死鎖問題(例如事務(wù)A鎖住user_id = 1的記錄后,在Commit之前發(fā)生故障
# 會(huì)導(dǎo)致鎖無法被釋放)
3)使用樂觀鎖,即利用版本號(hào) + CAS操作來控制,這需要對(duì)表加一個(gè)版本號(hào)字段(可以使用version、flag、tag等來命名)
| user_id | balance | version |
|---|---|---|
| 1 | 30 | |
| 2 | 80 |
有了版本號(hào)字段,就可以使用下面的偽代碼來規(guī)避丟失更新問題:
# 事務(wù)A
while(!success) {
start transaction
int b, v1 = select balance, version from tbl where user_id = 1
b = b + 50
int v2 = v1 + 1
success = update tbl set balance = b, version = v2 where user_id = 1 and version = v1
commit
}
# 事務(wù)B
while(!success) {
start transaction
int b, v1 = select balance, version from tbl where user_id = 1
b = b - 50
int v2 = v1 + 1
success = update tbl set balance = b, version = v2 where user_id = 1 and version = v1
commit
}
# CAS的核心思想是:記錄讀出來的時(shí)候帶一個(gè)版本號(hào)v1,然后在內(nèi)存里面修改(包括修改業(yè)務(wù)字段和更
# 新版本號(hào),比如更新成v2),當(dāng)再寫回去的時(shí)候,如果發(fā)現(xiàn)版本號(hào)不是v1,說明自己在修改期間有別的
# 事務(wù)修改了該記錄,則放棄當(dāng)前修改。重新讀取記錄,重新修改記錄以及更新版本號(hào),重新對(duì)比版本號(hào),
# 如此不斷重試
# 使用樂觀鎖,即利用版本號(hào) + CAS操作的時(shí)候,有一個(gè)要點(diǎn):version的比較、balance的更新、version
# 的更新要寫在同一個(gè)SQL語句中,使用單條SQL的原子性保證整個(gè)過程的原子性
4)使用分布式鎖,以上方法3)只能解決同一張表的事務(wù)操作場(chǎng)景中的丟失更新問題,但是一個(gè)事務(wù)可能跨表進(jìn)行操作,例如:
start transaction
select xxx from tbl1
select yyy from tbl2
根據(jù)tbl1和tbl2的查詢結(jié)果進(jìn)行邏輯計(jì)算,然后用計(jì)算結(jié)果更新tbl3
update tbl3
commit
要實(shí)現(xiàn)update tbl3的同時(shí),tbl1和tbl2是鎖住的狀態(tài),不能讓其他事務(wù)修改,這種情況可以考慮使用分布式鎖。
實(shí)現(xiàn)讀寫并發(fā)往往有三種策略:互斥鎖、讀寫鎖、COW
1)互斥鎖:一個(gè)數(shù)據(jù)對(duì)象上面只有一把鎖,任何時(shí)候只能由一個(gè)線程持有鎖,其他線程被阻塞。這種策略能夠?qū)崿F(xiàn)寫寫互斥、讀寫互斥、讀讀互斥;
2)讀寫鎖:一個(gè)數(shù)據(jù)對(duì)象上面只有一把鎖,但是有兩個(gè)視圖${TO-COMPREHENSION}。這種策略能夠?qū)崿F(xiàn)寫寫互斥、讀寫互斥、讀讀并發(fā);
3)COW:CopyOnWrite,寫的時(shí)候,把該數(shù)據(jù)對(duì)象拷貝一份,在拷貝對(duì)象上進(jìn)行寫操作,等寫完之后,再用拷貝對(duì)象代替原始對(duì)象(將引用從原始對(duì)象指向拷貝對(duì)象),讀的時(shí)候讀取原始對(duì)象,這種策略能夠?qū)崿F(xiàn):讀讀并發(fā)、讀寫并發(fā)、寫寫并發(fā)。
以上三種策略,并發(fā)度越來越高。MySQL中的InnoDB引擎就利用了COW策略實(shí)現(xiàn)了MVCC,以提高數(shù)據(jù)庫的查詢并發(fā)度。
每個(gè)事務(wù)修改某個(gè)記錄之前,都會(huì)先把這個(gè)記錄拷貝一份出來,每一次修改,就是一個(gè)版本,因此InnoDB維護(hù)了數(shù)據(jù)從舊到新的每一個(gè)版本,各個(gè)版本之間的記錄通過鏈表串聯(lián)。也正是因?yàn)槊織l記錄都有多個(gè)版本,每個(gè)版本都和一個(gè)事務(wù)相關(guān)聯(lián),因此才容易實(shí)現(xiàn)事務(wù)的隔離性。
要實(shí)現(xiàn)多個(gè)事務(wù)同時(shí)操縱同一條記錄的并發(fā)特性,要實(shí)現(xiàn)并發(fā)過程中的“讀未提交”、“可重復(fù)讀”的事務(wù)隔離級(jí)別,就不能讓事務(wù)讀取到正在被修改的數(shù)據(jù),只能讀取與該事務(wù)關(guān)聯(lián)的歷史版本,這體現(xiàn)了COW思想。
InnoDB的這個(gè)特性叫做多版本并發(fā)控制,即MVCC。InnoDB正是有了MVCC的特性,通常的select xxx from語句都是不加鎖的,讀取的全部是數(shù)據(jù)的歷史版本,從而支持高并發(fā)的查詢,這種讀,叫做“快照讀”;與之相對(duì)的是“當(dāng)前讀”,使用當(dāng)前讀的語句包括:select xxx from yyy for update語句、select xxx from yyy lock in share mode語句、insert / update / delete語句。
MVCC解決了快照讀和寫之間的并發(fā),但是對(duì)于寫與寫之間、當(dāng)前讀與寫之間的并發(fā),MVCC就無能為力了。這時(shí)候就需要用到鎖。MySQL的官方文檔列出了InnoDB的七種鎖:共享鎖 / 排它鎖、意向鎖、記錄鎖、間隙鎖、臨鍵鎖、插入意向鎖、自增鎖。(MySQL的官方文檔從不同的維度劃分,得到了這七種鎖。樂觀鎖與悲觀鎖是對(duì)行鎖的另一種維度的劃分。)
1)共享鎖 / 排它鎖:共享鎖(S)與排它鎖(X)是讀寫鎖的另外一種叫法,共享鎖即讀鎖,支持讀讀之間并發(fā);排它鎖就是寫鎖,保證讀寫之間不能并發(fā)、寫寫之間也不能并發(fā)。一個(gè)事務(wù)讀記錄的時(shí)候,對(duì)該記錄添加共享鎖,其他事務(wù)也可以繼續(xù)添加共享鎖,但不能添加排它鎖,從而使得共享鎖支持讀讀并發(fā);一個(gè)事務(wù)寫記錄的時(shí)候,對(duì)該記錄添加排他鎖,其他事務(wù)既不能再對(duì)該記錄添加共享鎖,也不能添加排它鎖,只能阻塞,從而保證讀寫之間不能并發(fā)、寫寫之間也不能并發(fā)。InnoDB通常對(duì)行添加共享鎖 / 排它鎖,但有些場(chǎng)景會(huì)對(duì)整個(gè)表加共享鎖 / 排它鎖,如DDL語句。
2)意向鎖:意向鎖是一種表鎖,具體分為意向排它鎖(IX)和意向共享鎖(IS)。當(dāng)一個(gè)事務(wù)要給某表的某記錄添加共享鎖的時(shí)候,必先給這個(gè)表添加IS(IS可重入,有多少個(gè)S,IS就重入多少次);當(dāng)一個(gè)事務(wù)要給某表的某記錄添加排他鎖的時(shí)候,必先給這個(gè)表添加IX(IX可重入,有多少個(gè)X,IX就重入多少次)。反之,行共享鎖 / 排它鎖撤銷的時(shí)候,意向鎖的重入次數(shù)也會(huì)相應(yīng)減掉,直至為0,意向鎖撤銷。有了意向鎖,事務(wù)在給整個(gè)表加共享鎖 / 排它鎖的時(shí)候,就不用遍歷所有記錄以確定表中是否存在行的共享鎖 / 排它鎖,只需看表上是否存在意向鎖即可,這樣就很容易避免表鎖與行鎖的沖突。
3)記錄鎖:即行鎖,相對(duì)來講還有表鎖,間隙鎖(Gap Lock,鎖住一個(gè)范圍內(nèi)的記錄,也可以叫做范圍鎖),這些鎖的叫法反映了鎖的粒度。
4)間隙鎖:按照鎖的粒度劃分,除了有行鎖、表鎖之外,還有一種用于鎖住范圍的間隙鎖(Gap Lock)。鎖Gap和鎖行密切相關(guān),Gap Lock肯定建立在某一行的基準(zhǔn)之上,Gap Lock鎖住一個(gè)范圍時(shí)不包含記錄本身,一個(gè)事務(wù)用Gap Lock鎖住一個(gè)表區(qū)間時(shí),可以避免另一個(gè)事務(wù)在這個(gè)區(qū)間上插入新紀(jì)錄。一個(gè)事務(wù)操作表記錄時(shí)是否需要加間隙鎖,這和事務(wù)的隔離級(jí)別密切相關(guān)。之所以要鎖Gap,一個(gè)主要目的是避免幻讀,如果事務(wù)的隔離級(jí)別允許幻讀,則不需要使用間隙鎖。另外,間隙鎖往往針對(duì)非唯一索引,因?yàn)閷?duì)唯一索引(例如主鍵索引、唯一字段上的輔助索引)的每次修改都可以具體定位到哪條或哪幾條記錄,不需要鎖Gap。
5)臨鍵鎖(Next-Key Lock):臨鍵鎖可以看成是行鎖和間隙鎖的綜合,不僅鎖住某個(gè)記錄,也會(huì)鎖住該記錄之前的范圍。
6)插入意向鎖:插入意向鎖也算作一種間隙鎖,專門針對(duì)Insert操作。多個(gè)事務(wù)在同一索引、同一個(gè)范圍內(nèi)的記錄可以并發(fā)插入,即插入意向鎖之間并不互相阻礙。
7)自增鎖:例如有兩個(gè)事務(wù):
事務(wù)A 事務(wù)B
insert into tbl1 values xxx
insert into tbl1 values xxx
insert into t1 values xxx
select xxx from tbl1
insert into tbl1 values xxx
在事務(wù)A中,連續(xù)進(jìn)行了兩次記錄插入,并且表tbl1使用了自增主鍵。因此事務(wù)A中最后一條語句查詢出來的結(jié)果里,我們期望看到插入的兩條記錄的id遞增且連續(xù)。但是由于事務(wù)B和事務(wù)A的語句可能會(huì)交叉執(zhí)行,且事務(wù)B中也在插入記錄,因此最后事務(wù)A中看到的兩條插入的記錄的id是不連續(xù)的,這不符合主鍵自增的要求。因此,需要一種自增鎖來保證同一個(gè)事務(wù)中插入的遞增列的值是連續(xù)的。
InnoDB設(shè)計(jì)了如此多類型的鎖,是因?yàn)椴煌腟QL語句、不同的事務(wù)并發(fā)場(chǎng)景、不同的事務(wù)隔離級(jí)別、不同的索引類型,所需求的鎖的特性可能都不一樣,針對(duì)具體場(chǎng)景使用合適的鎖,才能更好保證事務(wù)的隔離、查詢的并發(fā)、查詢的速度等等。
InnoDB通過MVCC + 鎖實(shí)現(xiàn)了事務(wù)的隔離性和并發(fā)性。
利用數(shù)據(jù)庫鎖進(jìn)行數(shù)據(jù)操作時(shí),無論是顯式的鎖還是隱式的鎖,若使用不當(dāng)會(huì)導(dǎo)致死鎖問題。利用有向圖對(duì)事務(wù)對(duì)鎖的持有進(jìn)行建模,利用有向圖的環(huán)檢測(cè)算法可以檢測(cè)死鎖。
下面給出一個(gè)數(shù)據(jù)庫發(fā)生死鎖的實(shí)例:
事務(wù)A 事務(wù)B
delete from tbl1 where id = 1
update tbl2 set xxx where id = 2
update tbl2 set xxx where id = 2
delete from tbl1 where id = 1
再給出一個(gè)較為隱晦的死鎖實(shí)例:
事務(wù)A 事務(wù)B
delete from tbl1 where id = 1
update tbl1 set xxx where id = 5
insert into tbl1 values xxx
insert into tbl1 values xxx
# 在InnoDB使用可重復(fù)讀的事務(wù)隔離級(jí)別時(shí),以上兩個(gè)事務(wù)的執(zhí)行可能會(huì)產(chǎn)生死鎖。原因是insert操作
# 會(huì)對(duì)tbl1加間隙鎖,導(dǎo)致兩個(gè)事務(wù)死鎖。${TO-COMPREHENSION}