文章轉載自:http://www.fanyilun.me/2017/04/20/MySQL加鎖分析/
以下實驗數據基於MySQL 5.7。
假設已知一張表my_table,id列為主鍵
id | name | num |
---|---|---|
1 | aaa | 100 |
5 | bbb | 200 |
8 | bbb | 300 |
10 | ccc | 400 |
1. 查詢命中聚簇索引(主鍵索引)
1.1 如果是精確查詢,那么會在命中的索引上加record lock
// 在id=1的聚簇索引上加X鎖
update my_table set name='a' where id=1;
// 在id=1的聚簇索引上加S鎖
select * from my_table where id=1 lock in share mode;
1.2 如果是范圍查詢,那么
- 1.2.1 在RC隔離級別下,會在所有命中的行的聚簇索引上加record locks(只鎖行)
// 在id=8和10的聚簇索引上加X鎖
update my_table set name='a' where id>7;
// 在id=1的聚簇索引上加X鎖
update my_table set name='a' where id<=1;
- 1.2.2 在RR隔離級別下,會在所有命中的行的聚簇索引上加next-key locks(鎖住行和間隙)。最后命中的索引的后一條記錄,也會被加上next-key lock。
// 在id=8、10(、+∞)的聚簇索引上加X鎖
// 在(5,8)(8,10)(10,+∞)加gap lock
update my_table set name='a' where id>7;
// 在id=1、5的聚簇索引上加X鎖
// 在(-∞,1)(1,5)加gap lock
update my_table set name='a' where id<=1;
1.3 如果查詢結果為空,那么
- 1.3.1 在RC隔離級別下,什么也不會鎖
- 1.3.2 在RR隔離級別下,會鎖住查詢目標所在的間隙。
// 在(1,5)加gap lock
update my_table set name='a' where id=2;
2. 查詢命中唯一索引
假設上述表中,num列加了唯一索引
2.1 如果是精確查詢,那么會在命中的唯一索引,和對應的聚簇索引上加record lock。
// 在num=100的唯一索引上加X鎖
// 並在id=1的聚簇索引上加X鎖
update my_table set name='a' where num=100;
2.2 如果是范圍查詢,那么
- 2.2.1 在RC隔離級別下,會在所有命中的唯一索引和聚簇索引上加record lock。同2.1
- 2.2.2 在RR隔離級別下,會在所有命中的行的唯一索引上加next-key locks。最后命中的索引的后一條記錄,也會被加上next-key lock。
// 在num=100和num=200的唯一索引上加X鎖
// 並在id=1和id=5的聚簇索引上加X鎖
// 並在唯一索引的間隙(-∞,100)(100,200)加gap lock
update my_table set name='a' where num<150;
3. 查詢命中二級索引(非唯一索引)
3.1 如果是精確查詢,那么
- 3.1.1 在RC隔離級別下,同2.1,對命中的二級索引和聚簇索引加record lock
// 在name='bbb'的兩條索引記錄上加X鎖
// 並在id=5和id=8的聚簇索引上加X鎖
update my_table set num=10 where name='bbb';
- 3.1.2 在RR隔離級別下,會在命中的二級索引上加next-key lock,最后命中的索引的后面的間隙會加上gap lock。對應的聚簇索引上加record lock。
// 在name='bbb'的兩條索引記錄上加X鎖
// 並在id=5和id=8的聚簇索引上加X鎖
// 並在二級索引的間隙('aaa','bbb')('bbb','bbb')('bbb','ccc')加gap lock
update my_table set num=10 where name='bbb';
3.2 范圍查詢、模糊查詢的情況比較復雜,此處不詳述。可以用上述方法自己實驗。
4.查詢沒有命中索引
假設上述表中,name列加了普通二級索引,num列沒有索引
4.1 如果查詢條件沒有命中索引
- 4.1.1 在RC隔離級別下,對命中的數據的聚簇索引加X鎖。根據MySQL官方手冊[4],對於update和delete操作,RC只會鎖住真正執行了寫操作的記錄,這是因為盡管innodb會鎖住所有記錄,MySQL Server層會進行過濾並把不符合條件的鎖當即釋放掉[5]。同時對於UPDATE語句,如果出現了鎖沖突(要加鎖的記錄上已經有鎖),innodb不會立即鎖等待,而是執行semi-consistent read:返回改數據上一次提交的快照版本,供MySQL Server層判斷是否命中,如果命中了才會交給innodb鎖等待。因此加鎖情況可以這樣來認為:
// 在id=5的聚簇索引上加X鎖
update my_table set num=1 where num=200;
// 先在id=1,5,8,10(全表所有記錄)的聚簇索引上加X鎖
// 然后馬上釋放id=1,8,10的鎖,只保留id=5的鎖
delete from my_table where num=200;
- 4.1.2 在RR隔離級別下,事情就很糟糕了,對全表的所有聚簇索引數據加next-key lock
// 在id=1,5,8,10(全表所有記錄)的聚簇索引上加X鎖
// 並在聚簇索引的所有間隙(-∞,1)(1,5)(5,8)(8,10)(10,+∞)加gap lock
update my_table set num=100 where num=200;
// 盡管name列有索引,但是like '%%'查詢不使用索引,因此此時也是鎖住所有聚簇索引,情況和上面一模一樣
update my_table set num=100 where name like '%b%';
5. 對索引鍵值有修改
假設上述表中,name列加了二級索引
如果一條update語句,對索引鍵值有修改,那么修改前后的數據如何加鎖呢。這點要結合數據多版本的可見性來考慮:無論是聚簇索引,還是二級索引,只要其鍵值更新,就會產生新版本。將老版本數據deleted bti設置為1;同時插入新版本[6]。因此可以認為,一次索引鍵值的修改實際上操作了兩條索引數據:原索引和修改后的新索引。
從innodb的事務的角度來看,如果一個事務操作(寫)了一條數據,那么這條數據一定要加鎖。因此可以認為,如果修改了索引鍵值,那么修改前和修改后的索引都會加鎖。另外,由於修改的數據並沒有被作為查詢條件,那么也不會有“不可重復讀”和“幻讀”的問題,因此無需加gap lock,索引修改只會加X record lock。
如果被修改的數據也作為查詢條件,那么加鎖方式與上面提及的相同。
示例(RC和RR級別效果一樣):
// 在id=1的聚簇索引上加X鎖
// 並在name='aaa'(name列索引原鍵值)和name='eee'(新鍵值)的索引上加鎖
update my_table set name='eee' where id=1;
6. 插入數據
假設上述表中,name列加了二級索引
insert加鎖過程:
- 唯一索引沖突檢查:表中一定有至少一個唯一索引,那么首先會做唯一索引的沖突檢查。innodb檢查唯一索引沖突的方式是,對目標的索引項加S鎖(因為不能依賴快照讀,需要一個徹底的當前讀),讀到數據則唯一索引沖突,返回異常,否則檢查通過。
- 對插入的間隙加上插入意向鎖(Insert Intention Lock)
- 對插入記錄的所有索引項加X鎖
示例:
// 先對id=15加S鎖
// 再對間隙id(10,+∞)和name('ccc',+∞)加Insert Intention Lock
// 然后在id=15的聚簇索引上加X鎖(S鎖升級為X鎖)
// 並在name='fff'的索引上加X鎖
insert into my_table (`id`, `name`, `num`) values ('15', 'fff', '800');
注意,如果有其他事務同時插入
insert into my_table (`id`, `name`, `num`) values ('16', 'fff', '900');
也是可以插入的,因為雖然name列的索引值相同,但是聚集索引列不同,這並不算同一位置。
還有一個有趣的問題,如果插入的二級索引鍵值已經存在,那么這個插入意向鎖會加在哪個間隙中呢?
顧名思義,插入意向鎖鎖定的間隙一定是將要插入的索引的位置,如果二級索引鍵值相同,默認會按照聚簇索引的大小來排序(二級索引在存儲上其實就是{索引值,主鍵值})。例如:
/ 插入意向鎖加在間隙 ({'aaa',1},{'bbb',5}) 上
insert into my_table (`id`, `name`, `num`) values ('4', 'bbb', '800');
// 插入意向鎖加在間隙 ({'bbb',5},{'bbb',8}) 上
insert into my_table (`id`, `name`, `num`) values ('6', 'bbb', '800');
// 插入意向鎖加在間隙 ({'bbb',8},{'ccc',10}) 上
insert into my_table (`id`, `name`, `num`) values ('11', 'bbb', '800');
7.隱式鎖
為了降低鎖的開銷,innodb采用了延遲加鎖機制,即隱式鎖(implicit lock)[7]。
從數據存儲結構上看,每張表的數據都是掛在聚簇索引的B+樹下面的葉子節點上(每個節點代表一個page,每個page存放着多行數據)。每行存儲的信息項中都會存有一隱藏列事務id。當有事務對這條記錄進行修改時,需要先判斷該行記錄是否有隱式鎖(原記錄的事務id是否是活動的事務),如果有則為其真正創建鎖並等待,否則直接更新數據並寫入自己的事務id。
二級索引雖然存儲上沒有記錄事務id,但同樣可以存在隱式鎖,只不過判斷邏輯復雜一些,需要依賴對應的聚簇索引做計算。
當然,隱式鎖只是一個實現細節,顯示還是隱式加鎖並不影響上文對加鎖的判斷。
另外,聚簇索引每行記錄的事務id,還有一個重要作用就是實現MVCC快照讀:由於事務id是全局遞增的,那么進行快照讀的時候,如果數據的事務id小於當前事務id並且不在活躍事務列表內(活躍事務指尚未提交的事務),則直接返回當前行數據。否則需要根據roll pointer(和事務id一樣,也在每行的隱藏列中)去查找undo日志.
8.一個RC隔離級別下的死鎖
其實可以看到,RC隔離級別下的加鎖已經很少了,用官方文檔的話說”greatly reduces the probability of deadlocks”。因此盡管MySQL的默認隔離級別是RR,但是互聯網應用更傾向與使用RC來避免死鎖+提高並發能力。例如阿里電商的MySQL默認級別就是RC。
還是以這個表來舉例,假設id為主鍵,num列無索引。
id | name | num |
---|---|---|
1 | aaa | 100 |
5 | bbb | 200 |
8 | bbb | 300 |
按以下順序執行事務:
trx1 | trx2 |
---|---|
insert into my_table (id, name, num) values (‘16’, ‘rrr’, ‘888’); | - |
- | insert into my_table (id, name, num) values (‘17’, ‘ttt’, ‘999’); |
delete from sys.my_table where num=300; // waiting | - |
- | delete from sys.my_table where num=400; // deadlock |
對照上文的加鎖邏輯,insert會對聚簇索引加X鎖,因此trx1和trx2首先會分別持有id=16和id=17的X鎖。
接下來坑爹的事情來了,對於無索引字段,delete操作不會執行semi-consistent read,而是先直接鎖住所有數據的聚簇索引(盡管后面會馬上釋放,但也需要先獲取鎖)。這樣一來,事務1的delete需要鎖住所有記錄,等待事務2持有的id=17的X鎖,而事務2的delete需要等待事務1的id=16的X鎖。死鎖就產生了。
在這個例子中,如果insert和delete的順序都顛倒一下,或者delete都變為update,死鎖都不會發生。
9.小結
- 索引記錄的間隙上用來避免幻讀。
- Select(Serializable隔離級別除外)不會加鎖,而是執行快照讀。
- 寫操作都會加鎖,具體加鎖方式取決於隔離級別、索引命中情況以及修改的索引情況。
- 為了減少鎖的范圍,避免死鎖的發生,應該盡量讓查詢條件命中索引,而且命中的越精確加鎖越少。同時如果能接受RC級別對一致性的破壞,可以將隔離級別調整成RC。
10.參考資料
[1] 蕭美陽, 葉曉俊. 並發控制實現方法的比較研究[J]. 計算機應用研究, 2006, 23(6):19-22.
[2] MySQL 5.7 Reference Manual :: 15.5.1 InnoDB Locking
[3] MySQL 5.7 Reference Manual :: 15.5.4 Phantom Rows
[4] MySQL 5.7 Reference Manual :: 15.5.2.1 Transaction Isolation Levels
[5] MySQL 加鎖處理分析
[6] InnoDB多版本(MVCC)實現簡要分析
[7] Introduction to Transaction Locks in InnoDB Storage Engine