作者:benjamim,騰訊TEG后臺開發工程師
背景
在上一篇《淺談MySQL數據可靠性》的文章中,簡單地聊了一下鎖在實現數據可靠性中的具體作用,這篇文章我想繼續來聊聊mysql的鎖是怎么加上的,為啥想聊這個呢?主要是因為業務中我們或多或少都會使用到鎖,畢竟鎖是保障我們數據安全性的關鍵法寶。 但是由于不了解原理,往往可能導致我們在”刻意“或者”無意“的使用場景下,帶來潛在的性能問題,輕則導致處理能力降低,重則可能會拖垮我們的DB,因此需要對鎖的原理以及使用場景有比較全面的了解,才能更好地駕馭,避免給我們帶來不必要的業務隱患。我們主要從三個方面來討論這個問題:
- 啥時候加?
- 如何加?
- 什么時候該加什么時候不該加?
顯式鎖
- select ... for update
- select ... in share mode
兩者的區別在于前者加的是排它鎖,后者加的是共享鎖。加了排他鎖之后,后續對該范圍數據的寫和讀操作都將被阻塞,另外一個共享鎖不會阻塞讀取,而是阻塞寫入,但是這往往會帶來一些問題,比如電商場景下更新庫存時候,我們為了保障數據的一致性更新往往需要先將該商品數據鎖住,如果此時兩個線程并發更新庫存,就可能會導致數據更新出現異常,如圖所示:
所以我們在業務上往往會使用select ... for update對數據進行加鎖。另外還有些咱們比較不常用的加鎖方式,比如
- 全局鎖:Flush tables with read lock,主要在進行邏輯備份的時候會用到
- 表鎖:lock tables … read/write
隱式鎖是我們需要特別關注的,因為很多的”坑“就是因為隱式鎖的存在導致的,無形往往最為致命。
表級鎖除了表鎖以外,還有元數據鎖,
- 在進行增刪改查的時候會加MDL讀鎖;
- 在對表結構進行變更的時候,會加MDL寫鎖;
這個會帶來的問題就是當我們想給表添加索引或者修改表結構的時候,由于加了MDL寫鎖,會阻塞我們線上正常的讀寫請求,這個時候可能會觸發上游的失敗重試機制,那很可能就會出現請求雪崩導致DB被打掛。
另外的就是與我們日常業務息息相關行鎖以及間隙鎖,當我們在進行增刪改的時候,會根據當前的隔離級別加上行鎖或者間隙鎖,那么這時候需要注意的是是否會影響正常業務的讀寫性能,另外帶來的風險就是可能出現加鎖范圍過大阻塞請求,并觸發上游重試,導致服務雪崩,DB打掛。
會不會加鎖呢?
談到這里有的同學可能有疑問,你這增刪改都加鎖了,那我讀的時候豈不是性能很差,特別是在讀多寫多的業務場景下,我的讀請求一上來的話,DB不是分分鐘被我查掛了?其實這里innodb引擎用到了一個mvcc的技術即多版本并發控制,其原理就是在數據更新的同時在undolog中記錄更新的事務id以及相應的數據,并且維護一個readview的活躍事務id,這樣當一個事務執行的時候,很容易能知道自己能看見什么數據,不能看見什么數據,這時候讀取數據自然也就不會受到鎖的影響能夠正常的讀取啦。
怎么加
這里討論怎么加其實就是了解加鎖的類型以及范圍,即用了什么鎖且加在哪里了?在討論這個問題之前我們先來看看事務隔離級別:
- 讀未提交
- 讀已提交
- 可重復讀
- 串行化
為啥要說這個呢?因為隔離級別也影響著咱們的加鎖,讀已提交解決了臟讀的問題,但是未解決幻讀問題;可重復讀通過引入間隙鎖解決了幻讀問題,因此意味著不同的隔離級別用到的鎖還不一樣,但是有一點明確的是,越高隔離級別鎖的使用更加嚴格??芍貜妥x是默認的事務隔離級別,但是線上設置的隔離級別往往都是讀已提交,主要是因為這個級別夠用并且能夠有更好的并發性能。接下來我們討論的范圍也主要是在讀已提交(RC)和可重復讀(RR)。
這里根據《mysql45講》總結的規則來具體分析:
- 原則1:加鎖的基本單位是next-key lock。希望你還記得,next-key lock是前開后閉區間。
- 原則2:查找過程中訪問到的對象才會加鎖。
- 優化1:索引上的等值查詢,給唯一索引加鎖的時候,next-key lock退化為行鎖。
- 優化2:索引上的等值查詢,向右遍歷時且最后一個值不滿足等值條件的時候,next-key lock退化為間隙鎖。
- 一個bug:唯一索引上的范圍查詢會訪問到不滿足條件的第一個值為止。
另外有兩點需要注意的是:
- 鎖是加在索引上的
- gap鎖是共享的而非獨占的
接下來是分別進行討論,可能有些冗長,需要你耐心看完。
首先是RC級別,這個級別下的加鎖規則是比較簡單的,因為只涉及到行鎖,首先我們先設計一張表
CREATE TABLE `t_db_lock` ( `id` int(11) NOT NULL, `a` int(11) DEFAULT NULL, `b` int(11) DEFAULT NULL, PRIMARY KEY (`id`), KEY `a` (`a`)) ENGINE=InnoDB;insert into t_db_lock values(0,0,0),(5,5,5),(10,10,10); 主鍵等值存在
sessionA |
sessionB |
sessionC |
begin;update t_db_lock set id=id+1 where id = 0; |
||
insert into t_db_lock values(1,1,1) [block] |
||
update t_db_lock set id=id+1 where id = 5;[success] |
- 可以看到此時sessionA在做主鍵上的數據更新,將當前的記錄的主鍵值更新為1,此時db會在id=1和0上加上行鎖,即此時針對該id的更新會被阻塞;
- 因此當sessionB想插入id=1的記錄時會被阻塞??;
- 但是由于sessionC更新的是id=5的記錄,因此可以執行成功
sessionA |
sessionB |
sessionC |
begin;update t_db_lock set b=b+1 where a = 0; |
||
update t_db_lock set b=b+1 where id = 0; [block] |
||
update t_db_lock set b=b+1 where b = 0;[block] |
- sessionA根據普通索引的判斷條件更新數據,由于行鎖是加在索引上,因此這時候a列相關索引數據上了鎖;
- 但是為啥這時候我更新id=0的數據也被阻塞了呢?因為這時除了加a上的索引,還有回表更新的操作,此時訪問到的主鍵上的索引也會被加鎖,因為是同一行,所以此時更新同樣被阻塞??;
- 同樣的道理,當我們去更新的b=0的數據對應的主鍵索引上也是同一條數據,所以此時更新也被阻塞,但是如果我們此時是更新b=5的這條數據的話就能更新成功;
sessionA |
sessionB |
sessionC |
begin;select * from t_db_lock where id = 2 for update; |
||
update t_db_lock set b=b+1 where a = 0; [success] |
||
update t_db_lock set b=b+1 where b = 0;[success] |
- sessionA加了一個id為2的鎖,此時這行記錄不存在,此時行鎖沒有加成功,因此不會阻塞其他session的請求
- sessionB執行成功
- sessionC執行成功
sessionA |
sessionB |
sessionC |
begin;select * from t_db_lock where b=3 for update; |
||
update t_db_lock set b=b+1 where a = 0; [success] |
||
update t_db_lock set b=b+1 where b = 5;[success] |
- 這種情況和主鍵等值不存在一致,由于未找到對應的加鎖記錄,則后續的更新操作都能夠執行成功。
sessionA |
sessionB |
sessionC |
begin;select * from t_db_lock where id >= 0 and id <= 5 for update; |
||
update t_db_lock set b=b+1 where a = 0; [block] |
||
insert into t_db_lock values(1,1,1) [success] |
- sessionA根據范圍加鎖,鎖了id=0和5這兩行數據;
- sessionB由于更新id=0這行已經上鎖的數據,所以被阻塞住;
- sessionC由于之前id=1這行記錄并不存在,所以可以正常插入,這個場景是不是有點熟悉,就是咱們所說的幻讀,如果這時候在sessionA中再執行select * from t_db_lock where id >= 0 and id <= 5就會發現多了一條數據;
這里可重復讀級別下主要是討論間隙鎖的加鎖場景,這種加鎖情況會比讀已提交的隔離級別復雜的多; set session transaction isolation level repeatable read;
主鍵等值存在
sessionA |
sessionB |
sessionC |
begin;select * from t_db_lock where id = 5 for update; |
||
insert into t_db_lock values(2,2,2); [success] |
||
insert into t_db_lock values(6,6,6); [success] |
- sessionA在已經存在的id=5這行加鎖,根據加鎖規則,唯一索引會退化為行鎖,因此僅在id=5這行加鎖;其實這也好理解,既然已經是唯一索引了,那么就不會會出現幻讀的情況,因此幻讀僅僅取決于這行是否存在,因此我只要給該行加鎖保證不再寫入即可;
- sessionB和sessionC均不在鎖范圍內則插入成功;
sessionA |
sessionB |
sessionC |
begin;select * from t_db_lock where a = 5 for update; |
||
insert into t_db_lock values(6,11,6); [block] |
||
insert into t_db_lock values(11,10,6); [success] |
- sessionA在已經存在的a=5這行記錄上加鎖,由于是非唯一索引,根據加鎖規則,首先掃描a索引加上next-key lock (0,5] ,接著向右遍歷到第一個不滿足條件的(根據規則五,唯一索引上的范圍查詢會訪問到不滿足條件的第一個值為止),并退化為間隙鎖,因此加鎖范圍為(5,10),總體加鎖范圍為(0,10);并且for update,因此也會對應在主鍵的索引范圍內加上鎖,即(0,10);
- sessionB在主鍵索引的鎖范圍內,因此被阻塞;
- sessionC此時不在普通索引和主鍵索引的范圍上,因此執行成功;
這里可以看到,對于非唯一等值查詢的情況下,加鎖的范圍要比主鍵等值存在更大,因此我們在對非唯一索引加鎖的時候需要注意這個范圍。
sessionA |
sessionB |
sessionC |
begin;select * from t_db_lock where id = 3 for update; |
||
insert into t_db_lock values(2,2,2); [block] |
||
insert into t_db_lock values(6,6,6); [success] |
- sessionA此時對id=3的記錄加上了行鎖,但是由于此時3這行的記錄不存在,會對此范圍加鎖,按照加鎖原則,向右遍歷且最后一個值不滿足等值條件,next-key lock退化為間隙鎖,此時加鎖范圍為(0,5);
- sessionB屬于加鎖范圍內,因此被阻塞;
- sessionC不在此加鎖范圍內,加鎖成功;
為啥這里要加的是范圍鎖呢,其實主要解決的是幻讀問題,假設這里如果沒有在此范圍內加鎖,那么T1時刻sessionB執行成功,T2時刻再次執行select * from t_db_lock where id = 3的話,就會發現原先查詢不到的結果現在竟然可以查詢到了,就像出現幻覺一樣;因為為了避免出現這種幻讀的情況,需要在此范圍內加鎖。
非唯一等值不存在
sessionA |
sessionB |
sessionC |
begin;select * from t_db_lock where a = 3 for update; |
||
insert into t_db_lock values(3,5,5); [block] |
||
insert into t_db_lock values(6,5,5); [success] |
- sessionA在a=3這行上加鎖的,由于db中不存在該行,所以同樣會加next-key lock,并且因為鎖都是加在索引上的,因此會在a索引上加上(0,5]的范圍鎖。但是這里有個奇怪的現象,當a=5時,如果id<5會阻塞,如果id>5則會成功,從結果看來,此時a上的鎖似乎是有偏向性的,并不是嚴格意義上的a=5時就會鎖住相應的插入記錄
sessionA |
sessionB |
sessionC |
select * from t_db_lock where id >= 5 and id < 6 for update; |
||
insert into t_db_lock values(3,3,3); [success] |
||
insert into t_db_lock values(10,10,10); [block] |
- sessionA進行范圍查詢加鎖,在語義上等價于select * from t_db_lock where id = 5 for update,但是實際加鎖情況還是有很大的區別,首先id >= 5根據等值查詢查詢到id=5這行加鎖為(0,5],由于是唯一索引,退化為行鎖,因此在id=5這行上加了鎖,接著向右查詢,找到第一個不滿足條件的值,即id=10這行,所以加next-key lock(5,10],這里因為并不是等值查詢,不會有退化為間隙鎖的過程,所以整體加鎖范圍[5,10]
- sessionB不在鎖范圍內,插入成功
- sessionC在所謂中,插入失敗,注意這里是被阻塞住,而不是報主鍵沖突
sessionA |
sessionB |
sessionC |
begin;select * from t_db_lock where a >= 5 and a < 6 for update; |
||
insert into t_db_lock values(3,3,3); [block] |
||
insert into t_db_lock values(10,10,10); [block] |
- sessionA加鎖范圍區別于主鍵索引主要是在(0, 5]這個范圍下并未退化為行鎖,因此總體加鎖范圍為(0, 10]
sessionA |
sessionB |
sessionC |
begin;select * from t_db_lock where b = 6 for update; |
||
insert into t_db_lock values(3,3,3); [block] |
||
insert into t_db_lock values(10,10,10); [block] |
- sessionA中加鎖記錄為b=6這行,由于b未創建索引,因此會將所有b索引上的記錄都加鎖,由于是for update加鎖,認為還回去主表上更新,因此主表的相關記錄也都被上了鎖,這就會導致加鎖期間處于鎖表的狀態,任何的更新操作都沒辦法成功,這在線上會是非常危險的操作,可能會導致db被打垮。
通過上述的分析我們應該對鎖的類型以及語句中加鎖的范圍有一個大致的了解,可以知道悲觀鎖是需要我們謹慎使用的,因為很可能簡單的sql就會拖垮db的性能,影響線上服務的質量,那么什么時候該加什么時候不該加呢?
我認為對于db的并發場景,我們可以這么去考慮:
- 盡可能優先考慮使用樂觀鎖的方式解決;
- 如果需要用到悲觀鎖,則務必在加鎖的鍵上加索引;
- 確認db的隔離級別,分析sql中可能存在導致沖突或者死鎖的原因,避免sql被長時間阻塞;