一文搞懂數據庫鎖知識


數據庫鎖知識(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鎖釋放。

表鎖兼容性規則如下圖所示:

image-20211003214918504

現在可以正常執行了,事務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操作變得可能,但我們不得不來思考它的效率問題,考慮下圖:

image-20211003190017714

有三個事務,第一個事務執行DQL語句同時申請IS鎖;第二個事務執行DDL語句同時申請X鎖,X鎖是排他的,因此必須等待事務一IS鎖的釋放,事務二被堵塞;事務三同樣執行DQL語句,但由於寫鎖的優先級高於讀鎖,事務三不得不排在事務二的后面,事務三被堵塞(不只是數據庫中,絕大多數場景下都是寫鎖優先);如果后面有N個DQL語句,那么這N個語句都會被堵塞,而如果沒有事務二,由於讀是共享的,所有事務都不會堵塞,在線DDL使得整體效率變得異常低下。

這種現象產生的原因主要是使用了隱式MDL鎖和寫鎖優先原則,因此我們很難根治這種現象,只能去緩解,MYSQL5.6版本后提出鎖升降級機制。

鎖升降級機制

在上述示例中,事務二以后的事務都必須等待事務二執行完畢,而事務二是一種DDL操作,DDL操作涉及到文件讀寫,會寫REDO LOG並發起磁盤IO,這是非常緩慢的,既然無法改變寫者優先的原則,MYSQL試圖加快DDL操作的執行以減少后續事務的等待,但DDL操作本身已經很難再做改變了,MYSQL想到了一種曲線救國的方式——讓它暫時放棄對寫鎖持有!

具體的流程為:

  1. 事務開始,申請表級寫鎖;
  2. 降級為表級讀鎖,使得后續DQL操作不被堵塞(DML仍被堵塞);
  3. 執行具體更改。
  4. 升級為寫鎖;
  5. 事務提交,釋放鎖;

當DDL事務由寫鎖降級時,后續的DQL操作得以運行,提高了效率。

要理解這一升一降,當事務開始時,DDL操作由寫鎖降級為讀鎖時,由於讀鎖與寫鎖排斥,可以保證DDL更改表數據時不會有任何其他寫表操作,避免了並發問題;當事務提交時,讀鎖升級為寫鎖,又可以保證同一時刻沒有其他讀表操作,即避免了讀寫不一致問題。

假如有過多的讀者,使得該鎖無法從讀鎖升級為寫鎖,就可能存在餓死該DDL操作的問題,這是為了提高性能而帶來的弊端。

行鎖

行鎖是對針對某一行記錄上鎖,是更細粒度的一種鎖,在MYSQL中,只有INNODB執行行鎖,而其他的引擎不支持行鎖。

INNODB下實現了兩種標准的行級鎖(區別表鎖中的S鎖與X鎖,這里的鎖是行鎖!):

  • 共享鎖(S鎖),允許事務讀一行數據。
  • 排他鎖(X鎖),允許事務修改或刪除一行數據。

行鎖在INNODB下是基於索引實現的,當索引未命中時,任何操作都將全表匹配查詢,行鎖會退化為表鎖,數據庫會先鎖表,再執行全表檢索。

因此要注意所有的行鎖都是在索引上的。

四種隔離級別

  1. 讀未提交(Read uncommitted)。即事務可以讀取其他事務還未提交的數據,事務完全是透明的,這種級別下連臟讀都無法避免。
  2. 讀已提交(Read committed)。這種隔離級別下,事務可以通過MVVC讀取數據而無須等待X鎖的釋放,但事務總是讀取最新版本的記錄,例如事務A正在修改某行數據,事務B讀取兩次,第一次讀取發現A正在修改,數據被上鎖,因此讀取上一個版本的數據,此時A事務修改完畢並提交,事務B開始第二次讀取,它總是會嘗試讀取最新版本的數據,於是事務B第二次讀取了事務A修改后的數據,事務B兩次讀取不一致,發生了不可重復讀問題。
  3. 可重復讀(Repeatable read)。INNDOB下默認的級別,與讀已提交類似,唯一的區別是這種隔離級別下,在一個事務內,總是會讀取一致的記錄版本,一開始讀什么,后面就讀什么,不會發生不可重復讀問題。起初存在幻讀問題,后來引入Next Key Lock解決了這個問題。
  4. 可串行化(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),對一個范圍執行左開右閉的封鎖,閉區間意味着該記錄也被鎖住。

臨鍵鎖規則

  1. 對非唯一索引查詢的上一區間與下一區間上臨鍵鎖,對區間右側的值采用行記錄鎖,封鎖非唯一索引對應的唯一索引的行記錄,此時區間符合左開右閉原則。

  2. 對於區間右側的值,如果不在查詢范圍內,則將該區間降級為間隙鎖,不再封鎖行記錄。

  3. 如果是唯一索引下的等值查詢,則降級為行記錄鎖。

來看幾個示例以理解臨鍵鎖,以及為什么能避免幻讀。

我們首先來看看普通索引下的等值搜索,我們通過開啟多個終端模擬並發。

假設事務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;正常執行外,其余兩句都被堵塞。

image-20211004211050281

讓我們分析一下這個結果,事務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要快些。


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM