基於MySQL 5.6.16
記錄數
,沒法鎖定不存在的記錄,因此沒法阻止插入,會出現幻讀。session1 | session2 |
---|---|
begin tx | |
select name from user where id = 7 | - |
- | begin tx |
- | update user set name = 'chen' where id = 7 |
- | commit tx |
select name from user where id = 7 // 數據不一致 | - |
commit tx | - |
session1 | session2 |
---|---|
begin tx | - |
select name from user where id = 7 | - |
- | begin tx |
- | update user set name = 'chen' where id = 7 |
select name from user where id = 7 // 髒數據 | - |
commit tx | - |
- | commit tx |
以上爲已過期的處理事務的方式(92年被批准的標準),列出來是爲了引出共享鎖
和排它鎖
的概念!html
select * from user where name = 'lin' for update
以上SQL是否有加鎖,對那些記錄加鎖?mysql
主鍵
時,鎖的是聚簇索引
對應的記錄。惟一索引
時,鎖的是惟一索引
對應的記錄、聚簇索引
對應的記錄。普通索引
時,鎖的是普通索引
對應的記錄和間隙
,聚簇索引
對應的記錄。表級別
的,當對記錄加鎖時,同時會在表上加上對應的意向鎖(共享鎖 -> 意向共享鎖,排它鎖 -> 意向排它鎖)。排他鎖
時,發現表上若是已經有了意向鎖,就會被阻塞。- | 共享鎖(S) | 排它鎖(X) | 意向共享鎖(IS) | 意向排它鎖(IX) |
---|---|---|---|---|
共享鎖(S) | 兼容 | 不兼容 | 兼容 | 不兼容 |
排它鎖(X) | 不兼容 | 不兼容 | 不兼容 | 不兼容 |
意向共享鎖(IS) | 兼容 | 不兼容 | 兼容 | 兼容 |
意向排它鎖(IX) | 不兼容 | 不兼容 | 兼容 | 兼容 |
還有其餘的「自增鎖」、「insert時候的隱式鎖」,本文不會說明,詳細可參考:解決死鎖之路 - 常見 SQL 語句的加鎖分析git
select * from user where name = 'lin'
以上是否有加鎖?
在讀已提交
和可重複讀
級別下,查詢使用了MVCC
方式,是不加鎖
的。
InnoDB 每一個彙集索引都有 4 個隱藏字段,分別是行ID
(DB_ROW_ID,隱含的自增ID,若是數據表沒有主鍵,InnoDB會自動以DB_ROW_ID產生一個聚簇索引),最近更改的事務ID
(DB_TRX_ID,每條記錄有獨立的事務ID,數據修改並提交成功的同時,會將事務ID修改爲當前事務ID,新ID確定會大於舊ID),undo Log 的指針
(回滾指針DB_ROLL_PTR,記錄刪除的時候全局事務ID號,指向這條記錄在undo log上的回滾數據),刪除標記
(記錄頭信息有專門的bit標誌,用來表示當前記錄是否已經被刪除,當刪除時,不會當即刪除,而是打標記,而後異步刪除)。
數據庫每次對數據進行更新操做時,會將修改前
的數據保存到undo log
中,經過undo log
能夠實現事務回滾
,而且能夠根據undo log
回溯到某個特定的版本
的數據,實現MVCC
。undo log
分爲insert
和update
,delete
與update
操做被歸成一類。
其中DB_ROLL_PTR長度爲7個字節(56個字節),數據結構以下:github
UNIV_INLINE void trx_undo_decode_roll_ptr( /*=====================*/ roll_ptr_t roll_ptr, /*!< in: roll pointer */ ibool* is_insert, /*!< out: TRUE if insert undo log */ ulint* rseg_id, /*!< out: rollback segment id */ ulint* page_no, /*!< out: page number */ ulint* offset) /*!< out: offset of the undo entry within page */ { #if DATA_ROLL_PTR_LEN != 7 # error "DATA_ROLL_PTR_LEN != 7" #endif #if TRUE != 1 # error "TRUE != 1" #endif ut_ad(roll_ptr < (1ULL << 56)); *offset = (ulint) roll_ptr & 0xFFFF; //獲取低16位 爲OFFSET roll_ptr >>= 16; //右移16位 *page_no = (ulint) roll_ptr & 0xFFFFFFFF;//獲取32位爲 page no roll_ptr >>= 32;//右移32位 *rseg_id = (ulint) roll_ptr & 0x7F;//獲取7位爲segment id roll_ptr >>= 7;//右移7位 *is_insert = (ibool) roll_ptr; // TRUE==1 ,最高位,標識修改或插入 }
字節位置 | 字節長度 | 做用 |
---|---|---|
55 | 1 | 操做類型:1=INSERT,0=UPDATE |
48-54 | 7 | undolog segment id |
16-47 | 32 | undolog 頁編號 |
0-15 | 16 | undolog 頁上的偏移量 |
查詢:返回的記錄須要知足兩個條件。sql
修改:分爲兩種狀況,update的列是不是主鍵列。數據庫
舊版本信息
記錄在undo log中,設置當前記錄的事務ID爲當前事務ID。若是是主鍵列,update分兩部執行:安全
mysql有後臺purge進程
來刪除無用的undo log,按順序從老到新定時掃描undo log,直到徹底清除或者遇到一個不能清除的undo log。purge進程有本身的read view(等同於進程開始時最老的活動事務以前的view,trx_sys->mvcc->clone_oldest_view),保證清除的數據對任何事務來講都是不可見的。session
MySQL在RC級別下經過MVCC解決了髒讀,在RR級別下經過MVCC方案解決了髒讀、不可重複讀。數據結構
事務級
快照、RC是語句級
快照。RR:在一個事務內同一快照讀執行任意次數,獲得的數據一致;且只能讀到第一次執行前已經提交的數據或本事務內更改的數據。併發
設該行的當前事務id爲trx_id,read view中最先的事務id爲trx_id_min, 最遲的事務id爲trx_id_max(trx_id_max是當前全部已提交的事務中最大XID+1)。
trx_id < trx_id_min
的話,那麼代表該行記錄所在的事務已經在本次新事務建立以前就提交了,因此該行記錄的當前值是可見
的。trx_id > trx_id_max
的話,那麼代表該行記錄所在的事務在本次新事務建立以後纔開啓,因此該行記錄的當前值不可見
。若是trx_id_min <= trx_id <= trx_id_max
,遍歷read view
,查找trx_id
是否在read view
列表中:
trx_id
在read view
列表中,此記錄的最後一次修改在read_view建立時還沒有commit
,不可見
。trx_id
不在read view
列表中,此記錄在read_view
建立以前已經commit
,可見
。不可見
,則從該行記錄的回滾指針DB_ROLL_PTR
指向的Undo Log
中取出對應的數據,而後從新從第一步開始判斷。read view
。讀數據時,要不要開啓「讀事務」
RR隔離級別下,MySQL經過MVCC + next-key
(記錄鎖 + 間隙鎖(gap鎖))解決了幻讀,gap只跟insert衝突,gap之間不衝突。
當前讀:全部的鎖定讀都是當前讀,也就是讀取當前記錄的最新版本,不會利用 undo log 讀取鏡像。
select * from user where age = 10 for update;
當age爲普通索引
時,age索引加鎖以下:
1 | 5 | 10 | 12 | 15 |
---|
「10」上會加上排它鎖,(5, 10) 和 (10, 12)間會加上間隙鎖。
RR模式是否解決了幻讀?這一點還存在爭議,好比github上的這一個爭議:https://github.com/Yhzhtk/not...。
session1 | session2 |
---|---|
begin tx | |
select * from user where id = 7 | - |
- | begin tx |
- | inset into user(id) values(7) |
- | commit tx |
select * from user where id = 7 for update | - |
commit tx | - |
session1的兩次select查詢結果不一致。
對於這個爭議,要看對幻讀的定義,「快照讀
和當前讀
的結果不一致」屬不屬於幻讀的範圍。官網定義的「The so-called phantom problem occurs within a transaction when the same query produces different sets of rows at different times. 」,在我看來「same query」應該是表示徹底同樣
的sql,因此要麼都是當前讀,要麼都是快照讀,若是按這個理解來看,RR級別下就解決了幻讀。
delete from t1 where id = 10;
這個SQL會加什麼鎖?
回答這個問題,咱們須要知道如下前提:
結論:id是主鍵時,此SQL只須要在id=10這條記錄上加X鎖便可。
結論:若id列是unique列,其上有unique索引。那麼SQL須要加兩個X鎖,一個對應於id unique索引上的id = 10的記錄,另外一把鎖對應於聚簇索引上的[name=’d’,id=10]的記錄。
結論:若id列上有非惟一索引,那麼對應的全部知足SQL查詢條件的記錄,都會被加鎖。同時,這些記錄在主鍵索引上的記錄,也會被加鎖。
因爲id列上沒有索引,所以只能走聚簇索引,進行所有掃描。從圖中能夠看到,知足刪除條件的記錄有兩條,可是,聚簇索引上全部的記錄,都被加上了X鎖。不管記錄是否知足條件,所有被加上X鎖。既不是加表鎖,也不是在知足條件的記錄上加記錄鎖。
有人可能會問?爲何不是隻在知足條件的記錄上加鎖呢?這是因爲MySQL的實現決定的。若是一個條件沒法經過索引快速過濾,那麼存儲引擎層面就會將全部記錄加鎖後返回,而後由MySQL Server層進行過濾。所以也就把全部的記錄,都鎖上了。
注:在實際的實現中,MySQL有一些改進(semi-consistent read),在MySQL Server過濾條件,發現不知足後,會調用unlock_row方法,把不知足條件的記錄放鎖 (違背了2PL的約束)。這樣作,保證了最後只會持有知足條件記錄上的鎖,可是每條記錄的加鎖操做仍是不能省略的。
結論:若id列上沒有索引,SQL會走聚簇索引的全掃描進行過濾,因爲過濾是由MySQL Server層面進行的。所以每條記錄,不管是否知足條件,都會被加上X鎖。可是,爲了效率考量,MySQL作了優化,對於不知足條件的記錄,會在判斷後放鎖,最終持有的,是知足條件的記錄上的鎖,可是不知足條件的記錄上的加鎖/放鎖動做不會省略。同時,優化也違背了2PL的約束。
結論:Repeatable Read隔離級別下,id列上有一個非惟一索引,對應SQL:delete from t1 where id = 10; 首先,經過id索引定位到第一條知足查詢條件的記錄,加記錄上的X鎖,加GAP上的GAP鎖,而後加主鍵聚簇索引上的記錄X鎖,而後返回;而後讀取下一條,重複進行。直至進行到第一條不知足條件的記錄[11,f],此時,不須要加記錄X鎖,可是仍舊須要加GAP鎖,最後返回結束。
如圖,這是一個很恐怖的現象。首先,聚簇索引上的全部記錄,都被加上了X鎖。其次,聚簇索引每條記錄間的間隙(GAP),也同時被加上了GAP鎖。這個示例表,只有6條記錄,一共須要6個記錄鎖,7個GAP鎖。試想,若是表上有1000萬條記錄呢?
在這種狀況下,這個表上,除了不加鎖的快照度,其餘任何加鎖的併發SQL,均不能執行,不能更新,不能刪除,不能插入,全表被鎖死。
結論:在Repeatable Read隔離級別下,若是進行全表掃描的當前讀,那麼會鎖上表中的全部記錄,同時會鎖上聚簇索引內的全部GAP,杜絕全部的併發 更新/刪除/插入 操做。固然,跟組合四:[id無索引, Read Committed]相似,這個狀況下,也能夠開啓semi-consistent read,來緩解加鎖開銷與併發影響,可是semi-consistent read自己也會帶來其餘問題,不建議使用。
注:(我在:MySQL版本號5.6.16,RR級別,表數據量爲223條記錄的狀況下作測試「update t set a = 'b'」,其中a是非索引字段,結果爲:在事務結束前,會鎖住全部的記錄,能夠經過「select * from information_schema.innodb_locks」看到,lock_model爲「X」,lock_type爲「RECORD」)。
注:就是所謂的semi-consistent read。semi-consistent read開啓的狀況下,對於不知足查詢條件的記錄,MySQL會提早放鎖。針對上面的這個用例,就是除了記錄[d,10],[g,10]以外,全部的記錄鎖都會被釋放,同時不加GAP鎖。semi-consistent read如何觸發:要麼是read committed隔離級別;要麼是Repeatable Read隔離級別,同時設置了 innodb_locks_unsafe_for_binlog 參數。詳細見:MySQL+InnoDB semi-consitent read原理及實現分析
結果:會鎖住小於5到大於5這段的間隙,例如:{一、三、七、九、10}數據中會鎖住(3, 7)這段間隙。
結論:在Repeatable Read隔離級別下,針對一個複雜的SQL,首先須要提取其where條件。Index Key肯定的範圍,須要加上GAP鎖;Index Filter過濾條件,視MySQL版本是否支持ICP,若支持ICP,則不知足Index Filter的記錄,不加X鎖,不然須要X鎖;Table Filter過濾條件,不管是否知足,都須要加X鎖。
注:一個SQL中的where條件如何拆分?具體的介紹,建議閱讀SQL中的where條件,在數據庫中提取與應用淺析。
從圖中能夠看出,在Repeatable Read隔離級別下,由Index Key所肯定的範圍,被加上了GAP鎖;Index Filter鎖給定的條件 (userid = ‘hdc’)什麼時候過濾,視MySQL的版本而定:
pubtime > 1 and pubtime < 20
的數據都經過回表
查出來以後,才作userid = ’hdc‘
過濾。MySQL加鎖規則裏面,包含了五個原則。
加鎖的基本單位是next-key lock
,next-key lock 是前開後閉區間。
select * from t where c > 12 and c < 16 for update;
當c
爲索引
時,假設c
的值有「1,3,11,15,17,20」,則加的鎖爲:(11, 15],(15, 17],即在(11,15)和(15, 17)上加間隙鎖,15和17上加記錄鎖。
訪問到的對象纔會加鎖。
select id from t where c = 15 lock in share mode;
c爲索引,id爲主鍵,加讀鎖時, 覆蓋索引優化狀況下, 不會訪問主鍵索引, 所以若是要經過 lock in share mode 給行加鎖避免數據被修改, 那就須要繞過索引優化, 如 select 一個不在索引中的值。
但若是改爲 for update , 則 mysql 認爲接下來會更新數據, 所以會將對應主鍵索引也一塊兒鎖了。
索引上的等值查詢,給惟一索引加鎖的時候,next-key lock 退化爲記錄鎖。
當c是惟一索引或主鍵,假設c
的值有「1,3,11,15,17,20」。
select * from t where c = 15 for update;
只會在15上加記錄鎖。
select * from t where c >= 15 and c < 17 for update;
雖然查出來的結果跟=15
是同樣的,可是加的鎖卻不同。Mysql定位到第一個符合條件的數據15
( 查詢第一個符合條件的數據是,經過樹搜索的方式定位記錄,用的是「等值查詢」的方法),因爲在(11, 15]上是等值查詢,因此退化成記錄鎖,總體加鎖是:記錄鎖15和next-key (15, 17]
索引上的等值查詢,向右遍歷時且最後一個值不知足等值條件的時候,next-key lock 退化爲間隙鎖。
當c是普通索引,假設c
的值有「1,3,11,15,17,20」。
select * from t where c = 11 for update;
引擎在找到c=11
這一條索引項後繼續向右遍歷到c=15
這一條, 此時鎖範圍是 (3, 11], (11, 15)
範圍查詢會訪問到不知足條件的第一個值爲止。
select * from t where c >= 11 and c <= 15;
當c是惟一索引,假設c
的值有「1,3,11,15,17,20」。從常理上看,c是惟一索引,不會出現重複的數據,因此加的鎖應該爲爲「11,(11, 15]」。
可是mysql在這種場景上,處理方式跟普通索引同樣,會繼續向後找第一個不知足條件的記錄,最終加的鎖是「11,(11, 15],(15, 17]」
RR
級別下,列id
有如下幾條數據
id |
---|
5 |
10 |
15 |
20 |
25 |
30 |
條件 | 說明 | 鎖 |
---|---|---|
id = 1 | 因爲不存在1 的值,會在第一個大於1的值上加next-key,根據規則4,等值查詢會退化成間隙鎖 |
(-∞, 5) |
id < 5 | 根據規則5,向後查詢第一個不符合條件的值,在找到的值上加next-key | (-∞, 5] |
id = 5 | 根據規則3,惟一索引/主鍵上等值查詢,退化成記錄鎖 | 5 |
id <= 5 | 根據規則五,即便id是主鍵,也會繼續向後查找 | (-∞, 5](5, 10] |
id > 5 and id < 10 | (5, 10] | |
id >= 5 and id < 10 | 規則三,惟一索引上等值查找,退化成記錄鎖 | 5, (5, 10] |
id >= 5 and id <= 10 | 5, (5, 10], (10, 15] | |
id = 8 | 8 記錄不存在,同第一個條件 |
(5, 10) |
id = 10 | 10 | |
id > 25 and id < 30 | (25, 30] | |
id > 25 and id <= 30 | 數據末尾,會對正無窮大加鎖 | (25, 30], (30, +∞] |
id >= 30 | 數據末尾,會對正無窮大加鎖 | 30, (30, +∞] |
條件 | 說明 | 鎖 |
---|---|---|
id = 10 | 普通索引跟惟一索引不同的點在於,普通索引是存在重複的可能性,<br/>因此即便等值查詢,也是按next-key加鎖,<br/>根據規則4,繼續向後查詢時,退化成間隙鎖 | (5, 10], (10, 15) |
id = 12 | 12 記錄不存在 |
(10, 15) |
id > 10 and id <= 15 | (10, 15], (15, 20] | |
id >= 10 and id <= 15 | (5, 10], (10, 15], (15, 20] |
條件 | 說明 | 鎖 |
---|---|---|
id =15 | 因爲id不是索引,在沒有開啓semi-consistent read 狀況下會鎖住所有數據(RR級別默認不開啓) |
(-∞, 5], (5, 10], (10, 15], (15, 20], (20, 25], (25, 30], (30, +∞] |
RR
級別下,普通索引id
有如下幾條數據
id |
---|
5 |
10 |
10 |
10 |
15 |
30 |
delete from t where id = 10
鎖的是(5, 10], (10, 10], (10, 10], (10, 15)
delete from t where id = 10 limit 2
鎖的是(5, 10], (10, 10]。
在刪除數據的時候儘可能加 limit。這樣不只能夠控制刪除數據的條數,讓操做更安全,還能夠減少加鎖的範圍。
索引搜索就是:找到第一個值,而後向左或向右遍歷。
RR
級別下
id |
---|
5 |
10 |
15 |
20 |
25 |
30 |
select * from t where id >= 15 and id <= 20 order by id desc for update
若是id
是主鍵索引,因爲order by id desc
,因此從id <= 20
開始等值查詢第一個符合id=20
條件的數據,查找到20
以後,按道理根據規則3
,會退化成記錄鎖,但測試發現,還多鎖了個間隙鎖,這一點目前還沒在哪一個資料上找到對這個的說明,繼續向左查詢,找到最後符合id >= 15
的數據15
,根據規則5還會繼續查找到第一個不符合條件的數據10
,因此最終的加鎖是(5, 10], (10, 15], (15, 20], (20, 25)。
若是id
是普通索引,以上SQL查找到20
以後,根據規則4,會退化成間隙鎖,繼續向左查詢,找到最後符合id >= 15
的數據15
,根據規則5還會繼續查找到第一個不符合條件的數據10
,因此最終的加鎖是(5, 10], (10, 15], (15, 20], (20,25)
select * from t where id >= 15 and id < 22 order by id desc for update
若是id
是主鍵索引,因爲id=22
不存在,找到(20, 25)這個間隙,因爲MySQL定位第一個值用的是等值查找,根據規則4,會遍歷到25
且退化成間隙鎖,因此最終的加鎖是(5, 10], (10, 15], (15, 20], (20,25)
RR
級別下,普通索引id
有如下幾條數據
id |
---|
5 |
10 |
10 |
10 |
15 |
30 |
select * from t where id in (5, 10, 15) for update
MySQL是先加鎖id=5
,在繼續id=10
,id=15
,一個個加鎖上去的。
session1 | session2 |
---|---|
begin tx | begin tx |
select * from t where id in (5, 10, 15) for update | select * from t where id in (5, 10, 15) order by id desc for update |
commit tx | commit tx |
order by id desc
致使session2是按順序從15, 10, 5
加鎖,session1和session2加鎖順序相反,致使這兩條SQL可能會發生死鎖。
RR
級別下,主鍵索引id
有如下幾條數據
id |
---|
5 |
10 |
15 |
20 |
25 |
30 |
select * from t where id > 15 and id <= 20 for update
以上SQL的鎖是(15, 20], (20, 25],若是這時候記錄15
被刪除(delete from t where id = 15
),以上SQL的鎖會動態變成(10, 20], (20, 25],
RR級別下:
數據:
idx |
---|
2 |
5 |
9 |
6 |
14 |
15 |
事務:
session1 | session2 |
---|---|
begin tx | begin tx |
select * from user where idx = 3 for update | select * from user where idx < 3 for update |
commit tx | commit tx |
以上兩個事物互相不干擾,session1
的鎖範圍是(2, 5),session2
的鎖範圍是(2, 5],間隙鎖只會阻塞插入操做。
session1 | session2 |
---|---|
begin tx | begin tx |
delete from user where idx = 7 | delete from user where idx = 8 |
insert into user(idx) values (7) | insert into user(idx) values (8) |
commit tx | commit tx |
因爲idx不存在值爲7和8的記錄,session1和session2都持有(5, 9)間隙鎖,鎖只有在事務提以後的時候纔會釋放,此時出現兩個事務互相等待對方持有的間隙鎖而沒法插入,出現死鎖。
避免更新或者刪除不存在的記錄,容易致使死鎖問題。
既然RR級別下已經不會出現幻讀,那爲何還須要Serializable:
防止數據丟失(被覆蓋):
session1 | session2 |
---|---|
begin tx | |
select name from user where id = 7 | - |
- | begin tx |
- | update user set name = 'chen' where id = 7 |
- | commit tx |
update user set name = 'lin' where id = 7 | - |
commit tx | - |
防止出現幻覺(RR級別,id爲主鍵):
session1 | session2 |
---|---|
begin tx | |
select * from user where id = 7 // 發現數據不存在 | - |
- | begin tx |
- | inset into user(id) values(7) |
- | commit tx |
inset into user(id) values(7) // 報錯,主鍵衝突 | - |
select * from user where id = 7 // 仍然發現數據不存在 | - |
commit tx | - |
參考:
InnoDB多版本(MVCC)實現簡要分析
MySQL 加鎖處理分析
MySQL · 引擎特性 · InnoDB undo log 漫遊
SQL中的where條件,在數據庫中提取與應用淺析
MySQL 在 RC 隔離級別下是如何實現讀不阻塞的?
查看Mysql正在執行的事務、鎖、等待
數據庫分析手記 —— InnoDB鎖機制分析
爲何我只改一行的語句,鎖這麼多?
關於 MySQL 中 InnoDB 行鎖的理解及案例