數據庫鎖知識(INNODB)
庫鎖
庫鎖主要分為兩類:
- FTWRL(Flush tables with read lock),將數據庫設置為只讀狀態,當客戶端異常斷開后,該鎖自動釋放,官方推薦使用的庫鎖。
- 設置global全局變量,即
set global readonly=true
,同樣是將數據庫設置為只讀狀態,但無論何時,數據庫絕不主動釋放鎖,即時客戶端異常斷開連接。
Flush tables with read lock 具備更安全的異常處理機制,因此建議使用Flush tables with read lock而不是修改全局變量。
表鎖
約定:為了方便,文章將SELECT語句歸於MDL語句。
MDL鎖
MDL鎖,是一種表鎖,也稱元數據鎖(metadata lock),元數據鎖是server層的鎖,MYSQL下的所有引擎幾乎都提供表級鎖定,表級鎖定分為表共享讀鎖(S鎖)與表獨占寫鎖(X鎖)。
在我們執行DDL語句時,諸如創建表、修改表數據等語句,我們都需要申請表鎖以防止DDL語句之間出現的並發問題,只有S鎖與S鎖是共享的,其余鎖都是互斥的,這種鎖不需要我們顯示的申請,當我們執行DDL語句時會自動申請鎖。
當然我們也可以顯示的申請表鎖:
LOCK TABLE table_name READ;
使用讀鎖鎖表,會阻塞其他事務修改表數據,而對讀表共享。LOCK TABLE table_name WRITE;
使用寫鎖鎖表,會阻塞其他事務讀和寫。
MDL鎖主要用於解決多條DDL語句之間的並發安全問題,但除了DDL與DDL之間的問題,DDL與DML語句之間也會出現並發問題,因此INNODB下,還會存在一種隱式的上鎖方式。
意向鎖
事實上,為解決DDL和DML之間的沖突問題,在INNODB下,數據庫還會為每一條DML語句隱式地加上表級鎖,這並不需要我們顯示的指定。
來看看數據庫為什么這么做,我們假設事務A執行一條DDL語句ALTER TABLE test DROP COLUMN id;
,而事務B正在執行兩條DML語句SELECT * FROM
,如果對數據庫比較了解,你應該很快的就會發現其中存在一些並發安全問題:
事務A | 事務B |
---|---|
BEGIN |
BEGIN |
SELECT * FROM test; |
|
... | ALTER TABLE test DROP COLUMN id; |
SELECT * FROM test; |
|
COMMIT |
COMMIT |
這就產生了沖突現象,事務A執行的兩條查詢語句不一致,違背了事務的一致性,而為了解決這個問題,MYSQL引入了意向鎖,官方將這種鎖分為意向共享鎖(IS鎖)和意向排他鎖(IX鎖),意向鎖是由DML操作產生的,請注意區分與上文所說的S鎖與X鎖,意向共享鎖表明當前表上存在某行記錄持有行共享鎖(區別表S鎖),意向排他鎖表明當前表上存在某行記錄持有行排他鎖(區別表X鎖)。
每執行一條DML語句都要申請意向鎖,意向鎖的類型是由行鎖的類型決定的,例如SELECT語句會申請行共享鎖,同時也會申請意向共享鎖,UPDATE會申請意向排他鎖,同一個表內的DML語句不會沖突,這意味着意向鎖都是兼容的,要注意意向鎖是由DML語句產生的,而DDL語句不會申請意向鎖。
如上文說所,DDL語句申請的只是普通的X鎖或S鎖,但它必須根據規則要等待IX鎖或IS鎖釋放。
表鎖兼容性規則如下圖所示:
現在可以正常執行了,事務B必須要等到事務A提交后才可執行:
事務A | 事務B |
---|---|
BEGIN |
BEGIN |
SELECT * FROM test; (申請IX鎖) |
|
... | ALTER TABLE test DROP COLUMN id; (申請X鎖,需要等待IX釋放) |
SELECT * FROM test;(重入) |
|
COMMIT (釋放IX鎖) |
COMMIT (釋放X鎖) |
這種隱式的上鎖是在MYSQL5.5之后才引入的,在之前的版本中,執行DML操作並不會對表上鎖,因此執行DDL操作不僅需要申請X鎖,還需要遍歷表中的每一行記錄,判定是否存在行鎖,如果的確存在,則放棄操作。因此在以前的版本中,是不支持在線DDL的,要想執行DDL操作就必須停止關於該表的一切活動,除此之外,執行DDL操作需要遍歷所有行,這也是非常低效的。
有了這種隱式MDL鎖之后,解決了DML與DDL操作之間的沖突,在線DDL變得可能,同時無須遍歷所有行,只需要申請表鎖即可。
所以說,這種隱式的表鎖,解決了DML與DDL操作之間的沖突,使得數據庫可以支持在線DDL,同時增加了執行DDL的效率。
注意一個包含關系,IX、IS、X、S鎖均屬於MDL鎖。
在線DDL的效率問題
盡管在MYSQL5.5后提出隱式MDL鎖后,在線DDL操作變得可能,但我們不得不來思考它的效率問題,考慮下圖:

有三個事務,第一個事務執行DQL語句同時申請IS鎖;第二個事務執行DDL語句同時申請X鎖,X鎖是排他的,因此必須等待事務一IS鎖的釋放,事務二被堵塞;事務三同樣執行DQL語句,但由於寫鎖的優先級高於讀鎖,事務三不得不排在事務二的后面,事務三被堵塞(不只是數據庫中,絕大多數場景下都是寫鎖優先);如果后面有N個DQL語句,那么這N個語句都會被堵塞,而如果沒有事務二,由於讀是共享的,所有事務都不會堵塞,在線DDL使得整體效率變得異常低下。
這種現象產生的原因主要是使用了隱式MDL鎖和寫鎖優先原則,因此我們很難根治這種現象,只能去緩解,MYSQL5.6版本后提出鎖升降級機制。
鎖升降級機制
在上述示例中,事務二以后的事務都必須等待事務二執行完畢,而事務二是一種DDL操作,DDL操作涉及到文件讀寫,會寫REDO LOG並發起磁盤IO,這是非常緩慢的,既然無法改變寫者優先的原則,MYSQL試圖加快DDL操作的執行以減少后續事務的等待,但DDL操作本身已經很難再做改變了,MYSQL想到了一種曲線救國的方式——讓它暫時放棄對寫鎖持有!
具體的流程為:
- 事務開始,申請表級寫鎖;
- 降級為表級讀鎖,使得后續DQL操作不被堵塞(DML仍被堵塞);
- 執行具體更改。
- 升級為寫鎖;
- 事務提交,釋放鎖;
當DDL事務由寫鎖降級時,后續的DQL操作得以運行,提高了效率。
要理解這一升一降,當事務開始時,DDL操作由寫鎖降級為讀鎖時,由於讀鎖與寫鎖排斥,可以保證DDL更改表數據時不會有任何其他寫表操作,避免了並發問題;當事務提交時,讀鎖升級為寫鎖,又可以保證同一時刻沒有其他讀表操作,即避免了讀寫不一致問題。
但假如有過多的讀者,使得該鎖無法從讀鎖升級為寫鎖,就可能存在餓死該DDL操作的問題,這是為了提高性能而帶來的弊端。
行鎖
行鎖是對針對某一行記錄上鎖,是更細粒度的一種鎖,在MYSQL中,只有INNODB執行行鎖,而其他的引擎不支持行鎖。
INNODB下實現了兩種標准的行級鎖(區別表鎖中的S鎖與X鎖,這里的鎖是行鎖!):
- 共享鎖(S鎖),允許事務讀一行數據。
- 排他鎖(X鎖),允許事務修改或刪除一行數據。
行鎖在INNODB下是基於索引實現的,當索引未命中時,任何操作都將全表匹配查詢,行鎖會退化為表鎖,數據庫會先鎖表,再執行全表檢索。
因此要注意所有的行鎖都是在索引上的。
四種隔離級別
- 讀未提交(Read uncommitted)。即事務可以讀取其他事務還未提交的數據,事務完全是透明的,這種級別下連臟讀都無法避免。
- 讀已提交(Read committed)。這種隔離級別下,事務可以通過MVVC讀取數據而無須等待X鎖的釋放,但事務總是讀取最新版本的記錄,例如事務A正在修改某行數據,事務B讀取兩次,第一次讀取發現A正在修改,數據被上鎖,因此讀取上一個版本的數據,此時A事務修改完畢並提交,事務B開始第二次讀取,它總是會嘗試讀取最新版本的數據,於是事務B第二次讀取了事務A修改后的數據,事務B兩次讀取不一致,發生了不可重復讀問題。
- 可重復讀(Repeatable read)。INNDOB下默認的級別,與讀已提交類似,唯一的區別是這種隔離級別下,在一個事務內,總是會讀取一致的記錄版本,一開始讀什么,后面就讀什么,不會發生不可重復讀問題。起初存在幻讀問題,后來引入Next Key Lock解決了這個問題。
- 可串行化(Serializable)。禁止MVVC功能,不會出現問題,但效率低。
隔離級別 | 臟讀 | 不可重復讀 | 幻讀(Phantom Read) |
---|---|---|---|
讀未提交(Read uncommitted) | 可能 | 可能 | 可能 |
讀已提交(Read committed) | 不可能 | 可能 | 可能 |
可重復讀(Repeatable read) | 不可能 | 不可能 | 不可能 |
可串行化(Serializable) | 不可能 | 不可能 | 不可能 |
行鎖的分類
INNODB下有三種行鎖,根據不同的條件使用不同的鎖。
為了統一,我們執行如下SQL語句,建立test表:
drop table if exists test;
create table test(
a int,
b int,
primary key(a),
key(b)
)engine=InnoDB charset=utf8;
insert into test select 1, 1;
insert into test select 3, 1;
insert into test select 5, 3;
insert into test select 7, 6;
insert into test select 10, 8;
a(主索引) | b(普通索引) |
---|---|
1 | 1 |
3 | 1 |
5 | 3 |
7 | 6 |
10 | 8 |
行記錄鎖(Record Locks)
記錄鎖鎖住唯一索引上的行記錄,請注意這里是鎖住記錄而不是鎖住索引,這意味着你無法繞開索引去訪問記錄。
行記錄鎖何時生效?僅當查詢列是唯一索引等值查詢時(等值查詢就是where xxx = xxx),next key lock會降級為行記錄鎖。
為什么查詢列是唯一索引等值查詢時,可以使用行記錄鎖呢?其實很簡單,由於唯一索引列僅對應唯一的行記錄,當我們執行等值查詢時,已經確保了我們只會訪問這一條行記錄,因此對該記錄上鎖,使得其他操作無法無法影響該記錄,並且插入新記錄的操作會由於主鍵沖突而被拒絕,幻讀問題也不會產生,並發安全得以保證。
要注意MYSQL默認條件下是使用next key lock的,而僅僅在條件滿足時降級為行記錄鎖。而使用Record Lock的沖要條件是查詢的記錄是唯一的。
其他條件下難道不可以使用行記錄鎖嗎?答案是不可以!其他任意條件,我們都無法滿足行記錄鎖的重要條件。
如果不是等值查詢,那么必然會出現多個結果,在RR級別下,我們來看一個范圍查詢,考慮事務A與事務B:
事務A | 事務B |
---|---|
BEGIN; | BEGIN; |
SELECT * FROM test WHERE id >= 1 FOR UPDATE;(上X鎖,禁止讀取快照) | ... |
... | INSERT INTO test SELECT 101, 5; |
SELECT * FROM test WHERE id >= 1 FOR UPDATE; | ... |
COMMIT; | COMMIT; |
試想,如果僅鎖住一條記錄,事務A前后兩次將讀出不同的結果,第二次讀取將多一條記錄,即幻讀現象!因此,我們必須要鎖住一個范圍。
再考慮非唯一索引下的等值查詢,想想為什么這種情況下不能加行記錄鎖。
其實也很簡單,回到我們之前說的,行記錄鎖鎖的是一條記錄而不是索引值,例如語句SELECT * FROM test WHERE b = 1 FOR UPDATE;
,該語句對應的是兩條記錄,行記錄鎖只能鎖住一條記錄,而另一條記錄卻可以隨意修改,並且可以增加其他記錄,產生幻讀!這是沖突的,因此無法使用行記錄鎖,請再次理解鎖記錄數據而非鎖條件值這句話。
間隙鎖(Gap Lock)
間隙鎖鎖住的是一個范圍,但不會鎖住記錄本身,即鎖條件值而非鎖記錄數據,這是與行記錄鎖相反的,我們等到研究臨鍵鎖時再所說何時使用間隙鎖,因為間隙鎖是在條件符合時由臨鍵鎖退化而成的。
臨鍵鎖(Next Key Lock)
臨鍵鎖與間隙鎖僅在RR(可重復讀)隔離級別下生效,目的是為了解決幻讀問題,而RC級別下連不可重復讀都無法解決,更別說幻讀問題了。
臨鍵鎖也是一個范圍鎖,但與間隙鎖不同,臨建鎖不僅會鎖住一個范圍,還會鎖住記錄本身,鎖住記錄本身采用的是行記錄鎖,是基於唯一索引的鎖,可將臨鍵鎖看作是間隙鎖和行記錄鎖的結合。
臨鍵鎖采用臨鍵鎖算法(Next Key Locking),對一個范圍執行左開右閉的封鎖,閉區間意味着該記錄也被鎖住。
臨鍵鎖規則
-
對非唯一索引查詢的上一區間與下一區間上臨鍵鎖,對區間右側的值采用行記錄鎖,封鎖非唯一索引對應的唯一索引的行記錄,此時區間符合左開右閉原則。
-
對於區間右側的值,如果不在查詢范圍內,則將該區間降級為間隙鎖,不再封鎖行記錄。
-
如果是唯一索引下的等值查詢,則降級為行記錄鎖。
來看幾個示例以理解臨鍵鎖,以及為什么能避免幻讀。
我們首先來看看普通索引下的等值搜索,我們通過開啟多個終端模擬並發。
假設事務A執行下列語句:
BEGIN;
SELECT * FROM test WHERE b = 1 FOR UPDATE;
//不提交,模擬並發。
同時開啟另一個終端,事務B執行下列語句:
BEGIN;
SELECT * FROM test WHERE a = 3 FOR UPDATE;
SELECT * FROM test WHERE a = 5 FOR UPDATE;
INSERT INTO test SELECT -1, -1;
結果是什么呢?除了SELECT * FROM test WHERE a = 7 FOR UPDATE;
正常執行外,其余兩句都被堵塞。

讓我們分析一下這個結果,事務A執行SELECT * FROM test WHERE b = 1 FOR UPDATE;
,根據規則1,我們對普通索引b的左右區間上臨鍵鎖,此時b在 (負無窮,1] 和 (1,3] 內上鎖;根據規則2,由於b=3與我們的查詢b=1不符,因此右區間退化,此時鎖范圍為 (負無窮,1] 和 (1,3) ;
SELECT * FROM test WHERE a = 3 FOR UPDATE;
語句查詢 a = 3的記錄,但根據臨建鎖規則,對 b = 1對應的唯一索引上行記錄鎖,因此 a = 1 & a= 3兩條記錄都被上鎖,該語句堵塞。
SELECT * FROM test WHERE a = 5 FOR UPDATE;
語句查詢 a = 5的記錄,由於右區間降級為間隙鎖,b = 3 不在鎖記錄,即該記錄為被上鎖,查詢成功。
INSERT INTO test SELECT -1, -1;
語句插入一條記錄,但由於 b 在 (負無窮,1)處存在間隙鎖(等於1處是行記錄鎖),插入記錄b=-1在范圍內,被鎖住,堵塞。
你可能會覺得如果插入一條 b = 1的語句呢?例如 INSERT INTO test SELECT -1, 1;
,由於間隙鎖鎖住的是(負無窮,1),而b等於1的所有記錄是被行記錄鎖住,這看上去插入一條 b = 1的語句沒有任何問題,產生幻讀。
但實際上,這仍然是被間隙鎖鎖住的,要理解這一點,必須從B+樹層面去解釋,由於b=1兩邊范圍均被鎖住,而插入算法中定位葉子節點是一定需要用到左右區間的,因此插入被堵塞,從而避免了幻讀。
為何等值查詢語句也需要范圍鎖?前面已經解釋過了,非唯一索引等值查詢會查詢多條語句,行記錄鎖只能鎖一條,如果每條都上行記錄鎖,效率太低了,因此需要一個范圍鎖。
類似地,范圍語句也會遵守臨鍵鎖規則,例如語句select * from test where b >= 1;
,最終鎖的范圍為(負無窮, 1] & (1, 正無窮);
事務A | 事務B |
---|---|
BEGIN; | BEGIN; |
SELECT * FROM test WHERE id >= 1 FOR UPDATE;(上范圍鎖) | ... |
... | INSERT INTO test SELECT 101, 5;(b = 5落入范圍內,堵塞等待) |
SELECT * FROM test WHERE id >= 1 FOR UPDATE; | ... |
COMMIT; | COMMIT; |
此時幻讀現象便不再發生。
AUTO-INC Locking
自增長鎖,在InnoDB引擎中,每個表都會維護一個表級別的自增長計數器,當對表進行插入的時候,會通過以下的命令來獲取當前的自增長的值。
SELECT MAX(auto_inc_col) FROM user FOR UPDATE;
插入操作會在這個基礎上加1得到即將要插入的自增長id,然后在一個事務內設置id。
為了提高插入性能,自增長的鎖不會等到事務提交之后才釋放,而是在相關插入sql語句完成后立刻就釋放,這也導致了一些事務回滾之后,id不連續。
由於自增長會申請寫鎖,盡管不用等到事務結束,但仍然降低了數據庫的性能,5.1.2版本后InnoDB支持互斥量的方式來實現自增長,通過互斥量可以對內存中的計數器進行累加操作,比AUTO-INC Locking要快些。