InnoDB的鎖機制淺析 1. 前言 數據事務設計遵循ACID的原則。 MySQL資料庫提供了四種預設的隔離級別,讀未提交( read uncommitted )、讀已提交(或不可重覆讀)( read committed )、可重覆讀( repeatable read )、串列化( serializ ...
InnoDB的鎖機制淺析
本文是大而全版(五合一):InnoDB的鎖機制淺析(All in One)
如果想分開看可以點擊下麵鏈接:
文章總共分為五個部分:
- InnoDB的鎖機制淺析(一)—基本概念/相容矩陣
- InnoDB的鎖機制淺析(二)—探索InnoDB中的鎖(Record鎖/Gap鎖/Next-key鎖/插入意向鎖)
- InnoDB的鎖機制淺析(三)—幻讀
- InnoDB的鎖機制淺析(四)—不同SQL的加鎖狀況
- InnoDB的鎖機制淺析(五)—死鎖場景(Insert死鎖)
1. 前言
數據事務設計遵循ACID的原則。
MySQL資料庫提供了四種預設的隔離級別,讀未提交(read-uncommitted)、讀已提交(或不可重覆讀)(read-committed)、可重覆讀(repeatable-read)、串列化(serializable)。
MySQL的預設隔離級別是RR。
2. 鎖基本概念
2.1 共用鎖和排它鎖
InnoDB實現了兩種標準行級鎖,一種是共用鎖(shared locks,S鎖),另一種是獨占鎖,或者叫排它鎖(exclusive locks,X鎖)。
S鎖允許當前持有該鎖的事務讀取行。
X鎖允許當前持有該鎖的事務更新或刪除行。
S鎖
如果事務T1持有了行r上的S鎖
,則其他事務可以同時持有行r的S鎖
,但是不能對行r加X鎖
。
X鎖
如果事務T1持有了行r上的X鎖
,則其他任何事務不能持有行r的X鎖
,必須等待T1在行r上的X鎖
釋放。
如果事務T1在行r上保持S鎖
,則另一個事務T2對行r的鎖的請求按如下方式處理:
- T2可以同時持有S鎖
- T2如果想在行r上獲取
X鎖
,必須等待其他事務對該行添加的S鎖
或X鎖
的釋放。
2.2 意向鎖-Intention Locks
InnoDB支持多種粒度的鎖,允許行級鎖和表級鎖的共存。例如LOCK TABLES ... WRITE
等語句可以在指定的表上加上獨占鎖。
InnoBD使用意向鎖來實現多個粒度級別的鎖定。意向鎖是表級鎖,表示table中的row所需要的鎖(S鎖或X鎖)的類型。
意向鎖分為意向共用鎖(IS鎖)和意向排它鎖(IX鎖)。
IS鎖表示當前事務意圖在表中的行上設置共用鎖,下麵語句執行時會首先獲取IS鎖,因為這個操作在獲取S鎖:
SELECT ... LOCK IN SHARE MODE
IX鎖表示當前事務意圖在表中的行上設置排它鎖。下麵語句執行時會首先獲取IX鎖,因為這個操作在獲取X鎖:
SELECT ... FOR UPDATE
事務要獲取某個表上的S鎖和X鎖之前,必須先分別獲取對應的IS鎖和IX鎖。
2.3 鎖的相容性
鎖的相容矩陣如下:
--- | 排它鎖(X) | 意向排它鎖(IX) | 共用鎖(S) | 意向共用鎖(IS) |
---|---|---|---|---|
排它鎖(X) | N | N | N | N |
意向排它鎖(IX) | N | OK | N | OK |
共用鎖(S) | N | N | OK | OK |
意向共用鎖(IS) | N | OK | OK | OK |
按照上面的相容性,如果不同事務之間的鎖相容,則當前加鎖事務可以持有鎖,如果有衝突則會等待其他事務的鎖釋放。
如果一個事務請求鎖時,請求的鎖與已經持有的鎖衝突而無法獲取時,互相等待就可能會產生死鎖。
意向鎖不會阻止除了全表鎖定請求之外的任何鎖請求。
意向鎖的主要目的是顯示事務正在鎖定某行或者正意圖鎖定某行。
3. InnoDB中的鎖
常見的鎖有Record鎖、gap鎖、next-key鎖、插入意向鎖、自增鎖等。
下麵會對每一種鎖給出一個查看鎖的示例。
3.1 準備工作
3.1.1 測試用表結構
示例的基礎是一個只有兩列的資料庫表。
mysql> CREATE TABLE test (
id int(11) NOT NULL,
code int(11) NOT NULL,
PRIMARY KEY(id),
KEY (code)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
mysql> INSERT INTO test(id,code) values(1,1),(10,10);
數據表test
只有兩列,id
是主鍵索引,code
是普通的索引(註意,一定不要是唯一索引),並初始化了兩條記錄,分別是(1,1),(10,10)。
這樣,我們驗證唯一鍵索引就可以使用id列,驗證普通索引(非唯一鍵二級索引)時就使用code列。
3.1.2 查看鎖狀態的方式
要看到鎖的情況,必須手動開啟多個事務,其中一些鎖的狀態的查看則必須使鎖處於waiting
狀態,這樣才能在mysql的引擎狀態日誌中看到。
命令:
mysql> show engine innodb status;
這條命令能顯示最近幾個事務的狀態、查詢和寫入情況等信息。當出現死鎖時,命令能給出最近的死鎖明細。
3.2 記錄鎖 Record Locks
Record鎖
Record Lock
是對索引記錄的鎖定。記錄鎖有兩種模式,S模式和X模式。
例如SELECT id FROM test WHERE id = 10 FOR UPDATE;
表示防止任何其他事務插入、更新或者刪除id =10
的行。
記錄鎖始終只鎖定索引。即使表沒有建立索引,InnoDB也會創建一個隱藏的聚簇索引(隱藏的遞增主鍵索引),並使用此索引進行記錄鎖定。
查看記錄鎖
開啟第一個事務,不提交,測試完之後回滾。
mysql> start transaction;
Query OK, 0 rows affected (0.00 sec)
mysql> update test set id=2 where id=1;
Query OK, 1 row affected (0.00 sec)
Rows matched: 1 Changed: 1 Warnings: 0
事務加鎖情況
mysql> show engine innodb status\G;
...
------------
TRANSACTIONS
------------
---TRANSACTION 366811, ACTIVE 690 sec
2 lock struct(s), heap size 1136, 1 row lock(s), undo log entries 2
MySQL thread id 785, OS thread handle 123145432457216, query id 729076 localhost 127.0.0.1 root
...
可以看到有一行被加了鎖。由之前對鎖的描述可以推測出,update語句給id=1
這一行上加了一個X鎖
。
註意:X鎖廣義上是一種抽象意義的排它鎖,即鎖一般分為
X模式
和S模式
,狹義上指row或者index上的鎖,而Record鎖是索引上的鎖。
為了不修改數據,可以用select ... for update
語句,加鎖行為和update
、delete
是一樣的,insert
加鎖機制較為複雜,後面的章節會提到。
第一個事務保持原狀,不要提交或者回滾,現在開啟第二個事務。
mysql> start transaction;
Query OK, 0 rows affected (0.00 sec)
mysql> update test set id=3 where id=1;
執行update
時,sql語句的執行被阻塞了。查看下事務狀態:
mysql> show engine innodb status\G;
...
------- TRX HAS BEEN WAITING 4 SEC FOR THIS LOCK TO BE GRANTED:
RECORD LOCKS space id 62 page no 3 n bits 72 index PRIMARY of table `test`.`test` trx id 366820 lock_mode X locks rec but not gap waiting
Record lock, heap no 2 PHYSICAL RECORD: n_fields 3; compact format; info bits 32
0: len 8; hex 0000000000000001; asc ;;
1: len 6; hex 0000000598e3; asc ;;
2: len 7; hex 7e000001a80896; asc ~ ;;
------------------
...
喜聞樂見,我們看到了這個鎖的狀態。狀態標題是'事務正在等待獲取鎖',描述中的lock_mode X locks rec but not gap
就是本章節中的record記錄鎖,直譯一下'X鎖模式鎖住了記錄'。後面還有一句but not gap
意思是只對record本身加鎖,並不對間隙加鎖,間隙鎖的敘述見下一個章節。
3.3 間隙鎖 Gap Locks
間隙鎖
間隙鎖作用在索引記錄之間的間隔,又或者作用在第一個索引之前,最後一個索引之後的間隙。不包括索引本身。
例如,SELECT c1 FROM t WHERE c1 BETWEEN 10 and 20 FOR UPDATE;
這條語句阻止其他事務插入10和20之間的數字,無論這個數字是否存在。
間隙可以跨越0個,單個或多個索引值。
間隙鎖是性能和併發權衡的產物,只存在於部分事務隔離級別。
select * from table where id=1;
唯一索引可以鎖定一行,所以不需要間隙鎖鎖定。
如果列沒有索引或者具有非唯一索引,該語句會鎖定當前索引前的間隙。
在同一個間隙上,不同的事務可以持有上述相容/衝突表中衝突的兩個鎖。例如,事務T1現在持有一個間隙S鎖,T2可以同時在同一個間隙上持有間隙X鎖。
允許衝突的鎖在間隙上鎖定的原因是,如果從索引中清除一條記錄,則由不同事務在這條索引記錄上的加間隙鎖的動作必須被合併。
InnoDB中的間隙鎖的唯一目的是防止其他事務插入間隙。
間隙鎖是可以共存的,一個事務占用的間隙鎖不會阻止另一個事務獲取同一個間隙上的間隙鎖。
如果事務隔離級別改為RC,則間隙鎖會被禁用。
查看間隙鎖
按照官方文檔,where
子句查詢條件是唯一鍵且指定了值時,只有record鎖,沒有gap鎖。
如果where
語句指定了範圍,gap鎖是存在的。
這裡只測試驗證一下當指定非唯一鍵索引的時候,gap鎖的位置,按照文檔的說法,會鎖定當前索引及索引之前的間隙。(指定了非唯一鍵索引,例如code=10,間隙鎖仍然存在)
開啟第一個事務,鎖定一條非唯一的普通索引記錄
mysql> start transaction;
Query OK, 0 rows affected (0.00 sec)
mysql> select * from test where code = 10 for update;
+----+------+
| id | code |
+----+------+
| 10 | 10 |
+----+------+
1 row in set (0.00 sec)
由於預存了兩條數據,row(1,1)和row(10,10),此時這個間隙應該是1<gap<10
。我們先插入row(2,2)來驗證下gap鎖的存在,再插入row(0,0)來驗證gap的邊界。
按照間隙鎖的官方文檔定義,
select * from test where code = 10 for update;
會鎖定code=10
這個索引,並且會鎖定code<10
的間隙。
開啟第二個事務,在code=10
之前的間隙中插入一條數據,看下這條數據是否能夠插入。
mysql> start transaction;
Query OK, 0 rows affected (0.00 sec)
mysql> insert into test values(2,2);
插入的時候,執行被阻塞,查看引擎狀態:
mysql> show engine innodb status\G;
...
---TRANSACTION 366864, ACTIVE 5 sec inserting
mysql tables in use 1, locked 1
LOCK WAIT 2 lock struct(s), heap size 1136, 1 row lock(s), undo log entries 1
MySQL thread id 793, OS thread handle 123145434963968, query id 730065 localhost 127.0.0.1 root update
insert into test values(2,2)
------- TRX HAS BEEN WAITING 5 SEC FOR THIS LOCK TO BE GRANTED:
RECORD LOCKS space id 63 page no 4 n bits 72 index code of table `test`.`test` trx id 366864 lock_mode X locks gap before rec insert intention waiting
Record lock, heap no 3 PHYSICAL RECORD: n_fields 2; compact format; info bits 0
0: len 8; hex 800000000000000a; asc ;;
1: len 8; hex 000000000000000a; asc ;;
------------------
...
插入語句被阻塞了,lock_mode X locks gap before rec
,由於第一個事務鎖住了1到10之間的gap,需要等待獲取鎖之後才能插入。
如果再開啟一個事務,插入(0,0)
mysql> start transaction;
mysql> insert into test values(0,0);
Query OK, 1 row affected (0.00 sec)
可以看到:指定的非唯一建索引的gap鎖的邊界是當前索引到上一個索引之間的gap。
最後給出鎖定區間的示例,首先插入一條記錄(5,5)
mysql> insert into test values(5,5);
Query OK, 1 row affected (0.00 sec)
開啟第一個事務:
mysql> start transaction;
Query OK, 0 rows affected (0.00 sec)
mysql> select * from test where code between 1 and 10 for update;
+----+------+
| id | code |
+----+------+
| 1 | 1 |
| 5 | 5 |
| 10 | 10 |
+----+------+
3 rows in set (0.00 sec)
第二個事務,試圖去更新code=5的行:
mysql> begin;
Query OK, 0 rows affected (0.00 sec)
mysql> update test set code=4 where code=5;
執行到這裡,如果第一個事務不提交或者回滾的話,第二個事務一直等待直至mysql中設定的超時時間。
3.4 Next-key Locks
Next-key鎖
Next-key鎖實際上是Record鎖和gap鎖的組合。Next-key鎖是在下一個索引記錄本身和索引之前的gap加上S鎖或是X鎖(如果是讀就加上S鎖,如果是寫就加X鎖)。
預設情況下,InnoDB的事務隔離級別為RR,系統參數innodb_locks_unsafe_for_binlog
的值為false
。InnoDB使用next-key鎖對索引進行掃描和搜索,這樣就讀取不到幻象行,避免了幻讀
的發生。
幻讀是指在同一事務下,連續執行兩次同樣的SQL語句,第二次的SQL語句可能會返回之前不存在的行。
當查詢的索引是唯一索引時,Next-key lock會進行優化,降級為Record Lock,此時Next-key lock僅僅作用在索引本身,而不會作用於gap和下一個索引上。
查看Next-key鎖
Next-key鎖的作用範圍
如上述例子,數據表test
初始化了row(1,1),row(10,10),然後插入了row(5,5)。數據表如下:
mysql> select * from test;
+----+------+
| id | code |
+----+------+
| 1 | 1 |
| 5 | 5 |
| 10 | 10 |
+----+------+
3 rows in set (0.00 sec)
由於id
是主鍵、唯一索引,mysql會做優化,因此使用code
這個非唯一鍵的二級索引來舉例說明。
對於code
,可能的next-key鎖的範圍是:
(-∞,1]
(1,5]
(5,10]
(10,+∞)
開啟第一個事務,在code=5
的索引上請求更新:
mysql> start transaction;
Query OK, 0 rows affected (0.00 sec)
mysql> select * from test where code=5 for update;
+----+------+
| id | code |
+----+------+
| 5 | 5 |
+----+------+
1 row in set (8.81 sec)
之前在gap鎖的章節中介紹了,code=5 for update
會在code=5
的索引上加一個record鎖,還會在1<gap<5的間隙上加gap鎖。現在不再驗證,直接插入一條(8,8):
mysql> start transaction;
Query OK, 0 rows affected (0.00 sec)
mysql> insert into test values(8);
insert
處於等待執行的狀態,這就是next-key鎖
生效而導致的結果。第一個事務,鎖定了區間(1,5],由於RR的隔離級別下next-key鎖
處於開啟生效狀態,又鎖定了(5,10]區間。所以插入SQL語句的執行被阻塞。
解釋:在這種情況下,被鎖定的區域是
code=5
前一個索引到它的間隙,以及next-key的區域。code=5 for update
對索引的鎖定用區間表示,gap鎖鎖定了(1,5),record鎖鎖定了{5}索引記錄,next-key鎖鎖住了(5,10],也就是說整個(1,10]的區間被鎖定了。由於是for update
,所以這裡的鎖都是X鎖,因此阻止了其他事務中帶有衝突鎖定的操作執行。
如果我們在第一個事務中,執行了code>8 for update
,在掃描過程中,找到了code=10
,此時就會鎖住10之前的間隙(5到10之間的gap),10本身(record),和10之後的間隙(next-key)。此時另一個事務插入(6,6),(9,9)和(11,11)都是不被允許的,只有在前一個索引5及5之前的索引和間隙才能執行插入(更新和刪除也會被阻塞)。
3.5 插入意向鎖 Insert Intention Locks
插入意向鎖在行插入之前由INSERT設置一種間隙鎖,是意向排它鎖的一種。
在多事務同時寫入不同數據至同一索引間隙的時,不會發生鎖等待,事務之間互相不影響其他事務的完成,這和間隙鎖的定義是一致的。
假設一個記錄索引包含4和7,其他不同的事務分別插入5和6,此時只要行不衝突,插入意向鎖不會互相等待,可以直接獲取。參照鎖相容/衝突矩陣。
插入意向鎖的例子不再列舉,可以查看gap鎖的第一個例子。
3.6 自增鎖
自增鎖(AUTO-INC Locks)是事務插入時自增列上特殊的表級別的鎖。最簡單的一種情況:如果一個事務正在向表中插入值,則任何其他事務必須等待,以便第一個事務插入的行接收連續的主鍵值。
我們一般把主鍵設置為AUTO_INCREMENT
的列,預設情況下這個欄位的值為0,InnoDB會在AUTO_INCREMENT
修飾下的數據列所關聯的索引末尾設置獨占鎖。在訪問自增計數器時,InnoDB使用自增鎖,但是鎖定僅僅持續到當前SQL語句的末尾,而不是整個事務的結束,畢竟自增鎖是表級別的鎖,如果長期鎖定會大大降低資料庫的性能。由於是表鎖,在使用期間,其他會話無法插入表中。
4 幻讀
這一章節,我們通過幻讀,逐步展開對InnoDB鎖的探究。
4.1 幻讀概念
解釋了不同概念的鎖的作用域,我們來看一下幻讀到底是什麼。幻讀在RR條件下是不會出現的。因為RR是Repeatable Read,它是一種事務的隔離級別,直譯過來也就是“在同一個事務中,同樣的查詢語句的讀取是可重覆”,也就是說他不會讀到”幻影行”(其他事務已經提交的變更),它讀到的只能是重覆的(無論在第一次查詢之後其他事務做了什麼操作,第二次查詢結果與第一次相同)。
上面的例子都是使用for update
,這種讀取操作叫做當前讀,對於普通的select
語句均為快照讀。
當前讀,又叫加鎖讀,或者 阻塞讀。這種讀取操作不再是讀取快照,而是讀取最新版本並且加鎖。
快照讀不會添加任何鎖。
官方文檔對於幻讀的定義是這樣的:
原文:The so-called phantom problem occurs within a transaction when the same query produces different sets of rows at different times. For example, if a SELECT is executed twice, but returns a row the second time that was not returned the first time, the row is a “phantom” row.
手動無腦翻譯:所謂的幻影行問題是指,在同一個事務中,同樣的查詢語句執行多次,得到了不同的結果,這就是幻讀。例如,如果同一個SELECT
語句執行了兩次,第二次執行的時候比第一次執行時多出一行,則該行就是所謂的幻影行。
The so-called phantom problem occurs within a transaction when the same query produces different sets of rows at different times.
,這句話看起來應該是不可重覆讀的定義,同樣的查詢得到了不同的結果(兩次結果不是重覆的),但是後面的舉例給出了幻讀真正的定義,第二次比第一次多出了一行。也就是說,幻讀的出現有這樣一個前提,第二次查詢前其他事務提交了一個INSERT
插入語句。而不可重覆讀出現的前提是第二次查詢前其他事務提交了UPDATE
或者DELETE
操作。
mysql的快照讀,使得在RR的隔離級別上在next-Key的作用區間內,製造了一個快照副本,這個副本是隔離的,無論副本對應的區間里的數據被其他事務如何修改,在當前事務中,取到的數據永遠是副本中的數據。
RR級別下之所以可以讀到之前版本的數據,是由於資料庫的MVCC(Multi-Version Concurrency Control,多版本併發控制)。參見InnoDB Multi-Versioning
有些文章中提到“RR也不能完全避免幻讀”,實際上官方文檔實際要表達的意義是“在同一個事務內,多次連續查詢的結果是一樣的,不會因其他事務的修改而導致不同的查詢結果”,這裡先給出實驗結論:
1.當前事務如果未發生更新操作(增刪改),快照版本會保持不變,多次查詢讀取的副本是同一個。
2.當前事務如果發生更新(增刪改),再次查詢時,會刷新快照版本。
4.2 RC級別下的幻讀
RC情況下會出現幻讀。
首先設置隔離級別為RC,SET SESSION tx_isolation='READ-COMMITTED';
事務一 | 事務二 |
---|---|
mysql> SET SESSION tx_isolation='READ-COMMITTED'; mysql> start transaction; Query OK, 0 rows affected (0.00 sec) mysql> select * from test where code > 8; +----+------+ | id | code | +----+------+ | 10 | 10 | +----+------+ 1 row in set (0.01 sec) |
|
mysql> start transaction; Query OK, 0 rows affected (0.00 sec) mysql> insert into test values(9,9); Query OK, 1 row affected (0.00 sec) mysql> commit; Query OK, 0 rows affected (0.00 sec) |
|
mysql> start transaction; Query OK, 0 rows affected (0.00 sec) mysql> select * from test where code > 8; +----+------+ | id | code | +----+------+ | 9 | 9 | +----+------+ | 10 | 10 | +----+------+ 1 row in set (0.01 sec) |
RC(Read Commit)隔離級別可以避免臟讀,事務內無法獲取其他事務未提交的變更,但是由於能夠讀到已經提交的事務,因此會出現幻讀和不重覆讀。
也就是說,RC的快照讀是讀取最新版本數據,而RR的快照讀是讀取被next-key鎖作用區域的副本
4.3 RR級別下能否避免幻讀?
我們先來模擬一下RR隔離級別下沒有出現幻讀的情況:
開啟第一個事務並執行一次快照查詢。
事務一 | 事務二 |
---|---|
mysql> start transaction; Query OK, 0 rows affected (0.00 sec) mysql> select * from test where code > 8; +----+------+ | id | code | +----+------+ | 10 | 10 | +----+------+ 1 row in set (0.01 sec) |
|
mysql> start transaction; Query OK, 0 rows affected (0.00 sec) mysql> insert into test values(9,9); Query OK, 1 row affected (0.00 sec) mysql> commit; Query OK, 0 rows affected (0.00 sec) |
|
mysql> start transaction; Query OK, 0 rows affected (0.00 sec) mysql> select * from test where code > 8; +----+------+ | id | code | +----+------+ | 10 | 10 | +----+------+ 1 row in set (0.01 sec) |
這兩個事務的執行,有兩個問題:
1.為什麼之前的例子中,在第二個事務的INSERT
被阻塞了,而這次卻執行成功了。
這是因為原來的語句中帶有for update
,這種讀取是當前讀,會加鎖。而本次第一個事務中的SELECT
僅僅是快照讀,沒有加任何鎖。所以不會阻塞其他的插入。
2.資料庫中的數據已經改變,為什麼會讀不到?
這個就是之前提到的next-key lock鎖定的副本。RC及以下級別才會讀到已經提交的事務。更多的業務邏輯是希望在某段時間內或者某個特定的邏輯區間中,前後查詢到的數據是一致的,當前事務是和其他事務隔離的。這也是資料庫在設計實現時遵循的ACID原則。
再給出RR條件下出現幻讀的情形,這種情形不需要兩個事務,一個事務就已經可以說明,
mysql> start transaction;
Query OK, 0 rows affected (0.00 sec)
mysql> select * from test where id>8;
+----+------+
| id | code |
+----+------+
| 10 | 10 |
+----+------+
1 row in set (0.00 sec)
mysql> update test set code=9 where id=10;
Query OK, 1 row affected (0.00 sec)
Rows matched: 1 Changed: 1 Warnings: 0
mysql> select * from test where id>8;
+----+------+
| id | code |
+----+------+
| 10 | 9 |
+----+------+
1 row in set (0.00 sec)
至於RR隔離級別下到底會不會出現幻讀,就需要看幻讀的定義中的查詢到底是連續的查詢還是不連續的查詢。如果認為RR級別下可能會出現幻讀,那該級別下也會出現不重覆讀。
RR隔離級別下,雖然不會出現幻讀,但是會因此產生其他的問題。
前提:當前數據表中只存在(1,1),(5,5),(10,10)三組數據。
如果資料庫隔離級別不是預設,可以執行SET SESSION tx_isolation='REPEATABLE-READ';
(該語句不是全局設置)更新為RR。
然後執行下列操作:
事務一 | 事務二 | 備註 |
---|---|---|
mysql> start transaction; Query OK, 0 rows affected (0.00 sec) mysql> select * from test where code > 8; +----+------+ | id | code | +----+------+ | 10 | 10 | +----+------+ 1 row in set (0.01 sec) |
開啟事務一,並查詢code>8 的記錄,只有一條(10,10) |
|
mysql> start transaction; Query OK, 0 rows affected (0.00 sec) mysql> insert into test values(11,11); Query OK, 1 row affected (0.00 sec) mysql> commit; Query OK, 0 rows affected (0.00 sec) |
開啟第二個事務,插入(11,11)並提交 | |
mysql> select * from test where code > 8; +----+------+ | id | code | +----+------+ | 10 | 10 | +----+------+ 1 row in set (0.01 sec) |
事務一再查詢一次,由於RR級別並沒有讀到更新 | |
mysql> start transaction; Query OK, 0 rows affected (0.00 sec) mysql> insert into test values(11,11); ERROR 1062 (23000): Duplicate entry '11' for key 'PRIMARY' |
事務一明明沒有查到,卻插入不了 |
4.5 更新丟失(Lost Update)
4.5.1 更新丟失
除了上述這類問題外,RR還會有丟失更新的問題。
如下表給出的操作:
事務一 | 事務二 | 備註 |
---|---|---|
mysql> start transaction; Query OK, 0 rows affected (0.00 sec) mysql> select * from test where code > 8; +----+------+ | id | code | +----+------+ | 10 | 10 | +----+------+ 1 row in set (0.01 sec) |
開啟事務一,並查詢code>8 的記錄,只有一條(10,10) |
|
mysql> start transaction; Query OK, 0 rows affected (0.00 sec) mysql> update test set id=12,code=12 where id=10; Query OK, 1 row affected (0.00 sec) Rows matched: 1 Changed: 1 Warnings: 0 mysql> commit; Query OK, 0 rows affected (0.00 sec) |
開啟第二個事務,將(10,10)改為(12,12)並提交,註意這裡matched是1,changed也是1 | |
mysql> select * from test where code > 8; +----+------+ | id | code | +----+------+ | 10 | 10 | +----+------+ 1 row in set (0.01 sec) |
事務一再次查詢code>8 的記錄,仍然只有一條(10,10) |
|
mysql> update test set id=9,code=9 where id=10; Query OK, 0 row affected (0.00 sec) mysql> commit; Query OK, 0 rows affected (0.00 sec) Rows matched: 0 Changed: 0 Warnings: 0 |
這裡查詢到0條,更新了0條 |
這個例子里,事務一的更新是無效的,儘管在這個事務里程式認為還存在(10,10)記錄。
事務一中更新之前的SELECT
操作是快照讀,所以讀到了快照里的(10,10),而UPDATE
中的WHERE
子句是當前讀,取得是最新版本的數據,所以matched: 0 Changed: 0
。
如果上述例子中的操作是對同一條記錄做修改,就會引起更新丟失。例如,事務一和二同時開啟,事務一先執行update test set code=100 where id=10;
,事務二再執行update test set code=200 where id=10;
,事務一的更新就會被覆蓋。
這就是經典的丟失更新問題,英文叫
Lost Update
,又叫提交覆蓋,因為是最後執行更新的事務提交導致的覆蓋。還有一種更新丟失叫做回滾覆蓋,即一個事務的回滾把另一個事務提交的數據給回滾覆蓋了,但是目前市面上所有的資料庫都不支持這種stupid的操作,因此不再詳述。
4.5.2 樂觀鎖與悲觀鎖
這種情況下,引入我們常見的兩種方式來解決該問題
- 樂觀鎖:在
UPDATE
的WHERE
子句中加入版本號信息來確定修改是否生效 - 悲觀鎖:在
UPDATE
執行前,SELECT
後面加上FOR UPDATE
來給記錄加鎖,保證記錄在UPDATE
前不被修改。SELECT ... FOR UPDATE
是加上了X鎖,也可以通過SELECT ... LOCK IN SHARE MODE
加上S鎖,來防止其他事務對該行的修改。
無論是樂觀鎖還是悲觀鎖,使用的思想都是一致的,那就是當前讀。樂觀鎖利用當前讀
判斷是否是最新版本,悲觀鎖利用當前讀
鎖定行。
但是使用樂觀鎖時仍然需要非常謹慎,因為RR是可重覆讀的,一定不能在UPDATE之前先把版本號讀取出來。
5. InnoDB對不同語句執行時的加鎖狀況
如果一個SQL語句要對二級索引(非主鍵索引)設置X模式的Record鎖,InnoDB還會檢索出相應的聚簇索引(主鍵索引)並對它們設置鎖定。
5.1 SELECT ... FROM...不加鎖
SELECT ... FROM
是快照讀取,除了SERIALIZABLE
的事務隔離級別,該SQL語句執行時不會加任何鎖。
SERIALIZABLE
級別下,SELECT
語句的執行會在遇到的索引記錄上設置S模式的next-key鎖。但是對於唯一索引,只鎖定索引記錄,而不會鎖定gap。
5.2 UPDATE系列
S鎖讀取(SELECT ... LOCK IN SHARE MODE
),X鎖讀取(SELECT ... FOR UPDATE
)、更新UPDATE
和刪除DELETE
這四類語句,採用的鎖取決於搜索條件中使用的索引類型。
- 如果使用唯一索引,InnoDB僅鎖定索引記錄本身,不鎖定間隙。
- 如果使用非唯一索引,或者未命中索引,InnoDB使用間隙鎖或者next-key鎖來鎖定索引範圍,這樣就可以阻止其他事務插入鎖定範圍。
5.2.1 UPDATE語句
UPDATE ... WHERE ...
在搜索遇到的每條記錄上設置一個獨占的next-key鎖,如果是唯一索引只鎖定記錄。
當UPDATE
修改聚簇索引時,將對受影響的二級索引採用隱式鎖,隱式鎖是在索引中對二級索引的記錄邏輯加鎖,實際上不產生鎖對象,不占用記憶體空間。
例如
update test set code=100 where id=10;
執行的時候code=10
的索引(code是二級索引,見文中給出的建表語句)會被加隱式鎖,只有隱式鎖產生衝突時才會變成顯式鎖(如S鎖、X鎖)。即此時另一個事務也去更新id=10
這條記錄,隱式鎖就會升級為顯示鎖。
這樣做的好處是降低了鎖的開銷。
UPDATE
可能會導致新的普通索引的插入。當新的索引插入之前,會首先執行一次重覆索引檢查。在重覆檢查和插入時,更新操作會對受影響的二級索引記錄採用共用鎖定(S鎖)。
5.2.2 DELETE語句
DELETE FROM ... WHERE ...
在搜索遇到的每條記錄上設置一個獨占的next-key鎖,如果是唯一索引只鎖定記錄。
5.3 INSERT
INSERT
區別於UPDATE系列單獨列出,是因為它的處理方式較為特別。
插入行之前,會設置一種插入意向鎖,插入意向鎖表示插入的意圖。如果其它事務在要插入的位置上設置了X鎖,則無法獲取插入意向鎖,插入操作也因此阻塞。
INSERT
在插入的行上設置X鎖。該鎖是一個Record鎖,並不是next-key鎖,即只鎖定記錄本身,不鎖定間隙,因此不會阻止其他會話在這行記錄前的間隙中插入新的記錄。
具體的加鎖過程,見6.2。
6. 可能的死鎖場景
6.1 Duplicate key error引發的死鎖
併發條件下,唯一鍵索引衝突可能會導致死鎖,這種死鎖一般分為兩種,一種是rollback
引發,另一種是commit
引發。
6.1.1 rollback
引發的Duplicate key死鎖
我命名為insert-insert-insert-rollback死鎖
事務一 | 事務二 | 事務三 |
---|---|---|
mysql> begin; Query OK, 0 rows affected (0.00 sec) mysql> insert into test values (2,2); Query OK, 1 row affected (0.01 sec) |
||
mysql> begin; Query OK, 0 rows affected (0.00 sec) mysql> insert into test values (2,2); 執行之後被阻塞,等待事務一 |
||
mysql> begin; Query OK, 0 rows affected (0.00 sec) mysql> insert into test values (2,2); 執行之後被阻塞,等待事務一 |
||
mysql>rollback; Query OK, 0 rows affected (0.00 sec) |
||
ERROR 1213 (40001): Deadlock found when trying to get lock; try restarting transaction | ||
Query OK, 1 row affected (16.13 sec) |
當事務一執行回滾時,事務二和事務三發生了死鎖。InnoDB的死鎖檢測一旦檢測到死鎖發生,會自動失敗其中一個事務,因此看到的結果是一個失敗另一個成功。
為什麼會死鎖?
死鎖產生的原因是事務一插入記錄時,對(2,2)記錄加X鎖,此時事務二和事務三插入數據時檢測到了重覆鍵錯誤,此時事務二和事務三要在這條索引記錄上設置S鎖,由於X鎖的存在,S鎖的獲取被阻塞。
事務一回滾,由於S鎖和S鎖是可以相容的,因此事務二和事務三都獲得了這條記錄的S鎖,此時其中一個事務希望插入,則該事務期望在這條記錄上加上X鎖,然而另一個事務持有S鎖,S鎖和X鎖互相是不相容的,兩個事務就開始互相等待對方的鎖釋放,造成了死鎖。
事務二和事務三為什麼會加S鎖,而不是直接等待X鎖
事務一的insert語句加的是隱式鎖(隱式的Record鎖、X鎖),但是其他事務插入同一行記錄時,出現了唯一鍵衝突,事務一的隱式鎖升級為顯示鎖。
事務二和事務三在插入之前判斷到了唯一鍵衝突,是因為插入前的重覆索引檢查,這次檢查必須進行一次當前讀,於是非唯一索引就會被加上S模式的next-key鎖,唯一索引就被加上了S模式的Record鎖。
因為插入和更新之前都要進行重覆索引檢查而執行當前讀操作,所以RR隔離級別下,同一個事務內不連續的查詢,可能也會出現幻讀的效果(但個人並不認為RR級別下也會出現幻讀,幻讀的定義應該是連續的讀取)。而連續的查詢由於都是讀取快照,中間沒有當前讀的操作,所以不會出現幻讀。
6.1.2 commit
引發的Duplicate key死鎖
delete-insert-insert-commit死鎖
事務一 | 事務二 | 事務三 |
---|---|---|
mysql> begin; Query OK, 0 rows affected (0.00 sec) mysql> delete from test where id=2; Query OK, 1 row affected (0.01 sec) |
||
mysql> begin; Query OK, 0 rows affected (0.00 sec) mysql> insert into test values (2,2); 執行之後被阻塞,等待事務一 |
||
mysql> begin; Query OK, 0 rows affected (0.00 sec) mysql> insert into test values (2,2); 執行之後被阻塞,等待事務一 |
||
mysql>commit; Query OK, 0 rows affected (0.00 sec) |
||
ERROR 1213 (40001): Deadlock found when trying to get lock; try restarting transaction | ||
Query OK, 1 row affected (2.37 sec) |
這種情況下產生的死鎖和insert-insert-insert-rollback
死鎖產生的原理一致。
6.2 數據插入的過程
經過以上分析,一條數據在插入時經過以下幾個過程:
假設數據表test.test
中存在(1,1)、(5,5)和(10,10)三條記錄。
- 事務開啟,嘗試獲取插入意向鎖。例如,事務一執行了
select * from test where id>8 for update
,事務二要插入(9,9),此時先要獲取插入意向鎖,由於事務一已經在對應的記錄和間隙上加了X鎖,因此事務二被阻塞,並且阻塞的原因是獲取插入意向鎖時被事務一的X鎖阻塞。 - 獲取意向鎖之後,插入之前進行重覆索引檢查。重覆索引檢查為當前讀,需要添加S鎖。
- 如果是已經存在唯一索引,且索引未加鎖。直接拋出
Duplicate key
的錯誤。如果存在唯一索引,且索引加鎖,等待鎖釋放。 - 重覆檢查通過之後,加入X鎖,插入記錄
6.3 GAP與Insert Intention衝突引發死鎖
update-insert死鎖
仍然是表test,當前表中的記錄如下:
mysql> select * from test;
+----+------+
| id | code |
+----+------+
| 1 | 1 |
| 5 | 5 |
| 10 | 10 |
+----+------+
3 rows in set (0.01 sec)
事務一 | 事務二 |
---|---|
begin; | begin; |
select * from test where id=5 for update; | select * from test where id=10 for update; |
insert into test values(7,7); | |
insert into test values(7,7); | |
Query OK, 1 row affected (5.03 sec) | |
ERROR 1213 (40001): Deadlock found when trying to get lock; try restarting transaction |
使用show engine innodb status
查看死鎖狀態。先後出現lock_mode X locks gap before rec insert intention waiting
和lock_mode X locks gap before rec
字眼,是gap鎖和插入意向鎖的衝突導致的死鎖。
回顧select...for update的加鎖範圍
首先回顧一下兩個事務中的select ... for update
做了哪些加鎖操作。
code=5
時,首先會獲取code=5
的索引記錄鎖(Record鎖),根據之前gap鎖的介紹,會在前一個索引和當前索引之間的間隙加鎖,於是區間(1,5)之間被加上了X模式的gap鎖。除此之外RR模式下,還會加next-key鎖,於是區間(5,10]被加了next-key鎖;
- 因此,
code=5
的加鎖範圍是,區間(1,5)的gap鎖,{5}索引Record鎖,(5,10]的next-key鎖。即區間(1,10)上都被加上了X模式的鎖。 - 同理,
code=10
的加鎖範圍是,區間(5,10)的gap鎖,{10}索引Record鎖,(10,+∞)的next-key鎖。
由gap鎖的特性,相容矩陣中衝突的鎖也可以被不同的事務同時加在一個間隙上。上述兩個select ... for update
語句出現了間隙鎖的交集,code=5
的next-key鎖和code=10
的gap鎖有重疊的區域——(5,10)。
死鎖的成因
當事務一執行插入語句時,會先加X模式的插入意向鎖
,即相容矩陣中的IX鎖。
但是由於插入意向鎖要鎖定的位置存在X模式的gap鎖
。相容矩陣中IX和X鎖是不相容的,因此事務一的IX鎖會等待事務二的gap鎖釋放。
事務二也執行插入語句,與事務一同樣,事務二的插入意向鎖IX鎖會等待事務一的gap鎖釋放。
兩個事務互相等待對方先釋放鎖,因此出現死鎖。
7 總結
除了以上給出的幾種死鎖模式,還有很多其他死鎖的場景。
無論是哪種場景,萬變不離其宗,都是由於某個區間上或者某一個記錄上可以同時持有鎖,例如不同事務在同一個間隙gap上的鎖不衝突;不同事務中,S鎖可以阻塞X鎖的獲取,但是不會阻塞另一個事務獲取該S鎖。這樣才會出現兩個事務同時持有鎖,並互相等待,最終導致死鎖。
其中需要註意的點是,增、刪、改的操作都會進行一次當前讀操作,以此獲取最新版本的數據,並檢測是否有重覆的索引。
這個過程除了會導致RR隔離級別下出現死鎖之外還會導致其他兩個問題:
- 第一個是可重覆讀可能會因為這次的當前讀操作而中斷,(同樣,幻讀可能也會因此產生);
- 第二個是其他事務的更新可能會丟失(解決方式:悲觀鎖、樂觀鎖)。