明明只改了一行語句,為啥鎖有這麼多?

Linksla發表於2023-12-07

我們主要從三個方面來討論這個問題:

  • 啥時候加?

  • 如何加?

  • 什麼時候該加什麼時候不該加?

01 啥時候加

1.1 顯示鎖

MySQL 的加鎖可以分為顯示加鎖和隱式加鎖,顯示加鎖我們比較好識別的,因為他往往直接體現在 SQL 中,常見的顯示加鎖語句主要有:
    ▶︎ select ... for update;▶︎ select ... in share mode;
    兩者的區別在於前者加的是排它鎖,後者加的是共享鎖。加了排他鎖之後,後續對該範圍資料的寫和讀操作都將被阻塞,另外一個共享鎖不會阻塞讀取,而是阻塞寫入,但是這往往會帶來一些問題,比如電商場景下更新庫存時候,我們為了保障資料的一致性更新往往需要先將該商品資料鎖住,如果此時兩個執行緒併發更新庫存,就可能會導致資料更新出現異常。
    所以我們在業務上往往會使用 select … for update 對資料進行加鎖。另外還有些我們們比較不常用的加鎖方式,比如:

    • 全域性鎖: Flush tables with read lock,主要在進行邏輯備份的時候會用到
    • 表鎖: lock tables … read/write

    1.2 隱式鎖

    隱式鎖是我們需要特別關注的,很多的“坑”就是因為隱式鎖的存在導致的,無形往往最為致命。
    表級鎖除了表鎖以外,還有後設資料鎖:
    ▶︎ 在進行增刪改查的時候會加 MDL 讀鎖;
    ▶︎ 在對錶結構進行變更的時候,會加 MDL 寫鎖;
    這個會帶來的問題就是當我們想給表新增索引或者修改表結構的時候,由於加了 MDL 寫鎖,會阻塞我們線上正常的讀寫請求,這個時候可能會觸發上游的失敗重試機制,那很可能就會出現請求雪崩導致 DB 被打掛。
    另外的就是與我們日常業務息息相關的行鎖以及間隙鎖,當我們在進行增刪改的時候,會根據當前的隔離級別加上行鎖或者間隙鎖,那麼這時候需要注意是否會影響正常業務的讀寫效能,另外帶來的風險就是可能出現加鎖範圍過大阻塞請求,並觸發上游重試,導致服務雪崩,DB 打掛。

    1.3 會不會加鎖呢?

    談到這裡有的同學可能有疑問,你這增刪改都加鎖了,那我讀的時候豈不是效能很差,特別是在讀多寫多的業務場景下,我的讀請求一上來的話,DB 不是分分鐘被我查掛了?其實這裡 innodb 引擎用到了一個 mvcc 的技術即多版本併發控制,其原理就是在資料更新的同時在 undolog 中記錄更新的事務 id 以及相應的資料,並且維護一個 Readview 的活躍事務 id,這樣當一個事務執行的時候,很容易能知道自己能看見什麼資料,不能看見什麼資料,這時候讀取資料自然也就不會受到鎖的影響能夠正常地讀取啦。

    02 怎麼加

    這裡討論怎麼加其實就是了解加鎖的型別以及範圍,即用了什麼鎖且加在哪裡了?在討論這個問題之前我們先來看看事務隔離級別:
    ▶︎ 讀未提交;
    ▶︎ 讀已提交;
    ▶︎ 可重複讀;
    ▶︎ 序列化;
    為啥要說這個呢?因為隔離級別也影響著我們們的加鎖,讀已提交解決了髒讀的問題,但是未解決幻讀問題;可重複讀透過引入間隙鎖解決了幻讀問題,因此意味著不同的隔離級別用到的鎖還不一樣,但是有一點明確的是,越高隔離級別鎖的使用更加嚴格。可重複讀是預設的事務隔離級別,但是線上設定的隔離級別往往都是讀已提交,主要是因為這個級別夠用並且能夠有更好的併發效能。接下來我們討論的範圍也主要是在讀已提交( RC)和可重複讀( RR)。
    這裡根據相應規則來具體分析:
    ▶︎ 原則1:加鎖的基本單位是 next-key lock。希望你還記得,next-key lock 是前開後閉區間。
    ▶︎ 原則2:查詢過程中訪問到的物件才會加鎖。
    ▶︎ 最佳化1:索引上的等值查詢,給唯索引加鎖的時候,next-key lock 退化為行鎖。
    ▶︎ 最佳化2:索引上的等值查詢,向右遍歷時且最後一個值不滿足等值條件的時候,next-key lock 退化為間隙鎖。
    ▶︎ 一個 bug:唯索引上的範圍查詢會訪問到不滿足條件的第一個值為止。
    另外有兩點需要注意的是:
    ▶︎ 鎖是加在索引上的;
    ▶︎ gap鎖是共享的而非獨佔的。

    2.1 RC

    接下來分別進行討論,可能有些冗長,需要你耐心看完。
    首先是 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);

      2.2 主建等值存在

      ▶︎  可以看到此時 sessionA 在做主鍵上的資料更新,將當前的記錄的主鍵值更新為1,此時 db 會在 id=1 和 0 上加上行鎖,即此時針對該id的更新會被阻塞;
      ▶︎ 因此當 sessionB 想插入 id=1 的記錄時會被阻塞住;
      ▶︎ 但是由於 sessionC 更新的是 id=5 的記錄,因此可以執行成功。

      2.3 非唯 一等值

      ▶︎  sessionA 根據普通索引的判斷條件更新資料,由於行鎖是加在索引上,因此這時候 a 列相關索引資料上了鎖;
      ▶︎  但是為啥這時候我更新 id=0 的資料也被阻塞了呢?因為這時除了加 a 上的索引,還有回表更新的操作,此時訪問到的主鍵上的索引也會被加鎖,因為是同一行,所以此時更新同樣被阻塞住;
      ▶︎  同樣的道理,當我們去更新的 b=0 的資料對應的主鍵索引上也是同一條資料,所以此時更新也被阻塞,但是如果我們此時是更新 b=5 的這條資料的話就能更新成功。

      2.4 主鍵等值不存在

      ▶︎  sessionA 加了一個 id 為2的鎖,此時這行記錄不存在,行鎖沒有加成功,因此不會阻塞其他 session 的請求;
      ▶︎ sessionB 執行成功;
      ▶︎ sessionC 執行成功。

      2.5 無索引等值不存在

      ▶︎  這種情況和主鍵等值不存在一致,由於未找到對應的加鎖記錄,則後續的更新操作都能夠執行成功。

      2.6 主鍵範圍

      ▶︎  sessionA 根據範圍加鎖,鎖了 id=0 和 5 這兩行資料;
      ▶︎  sessionB 由於更新 id=0 這行已經上鎖的資料,所以被阻塞住;
      ▶︎  sessionC 由於之前 id=1 這行記錄並不存在,所以可以正常插入,這個場景是不是有點熟悉,就是我們們所說的幻讀,如果這時候在 sessionA 中再執行 select * from t_db_lock where id >= 0 and id <= 5 就會發現多了一條資料;

      2.7 RR

      這裡可重複讀級別下主要是討論間隙鎖的加鎖場景,這種加鎖情況會比讀已提交的隔離級別複雜的多;set session transaction isolation level repeatable read。

      2.8 主鍵等值存在

      ▶︎  sessionA 在已經存在的 id=5 這行加鎖,根據加鎖規則,唯 一索引會退化為行鎖,因此僅在 id=5 這行加鎖;其實這也好理解,既然已經是唯 一索引了,那麼就不會會出現幻讀的情況,因此幻讀僅僅取決於這行是否存在,因此我只要給該行加鎖保證不再寫入即可;
      ▶︎  sessionB 和 sessionC 均不在鎖範圍內則插入成功.

      2.9 非唯 一等值

      ▶︎  sessionA 在已經存在的 a=5 這行記錄上加鎖,由於是非唯 一索引,根據加鎖規則,首先掃描 a 索引加上 next-key lock (0,5] ,接著向右遍歷到第一個不滿足條件的( 根據規則五,唯 一索引上的範圍查詢會訪問到不滿足條件的第一個值為止 ),並退化為間隙鎖,因此加鎖範圍為(5,10),總體加鎖範圍為(0,10);並且 for update,也會對應在主鍵的索引範圍內加上鎖,即(0,10);
      ▶︎  sessionB 在主鍵索引的鎖範圍內,因此被阻塞;
      ▶︎  sessionC 此時不在普通索引和主鍵索引的範圍上,因此執行成功;
      這裡可以看到,對於非唯 一等值查詢的情況下,加鎖的範圍要比主鍵等值存在更大,因此我們在對非唯 一索引加鎖的時候需要注意這個範圍。

      2.10 主鍵等值不存在

      ▶︎  sessionA 此時對 id=3 的記錄加上了行鎖,但是由於此時3這行的記錄不存在,會對此範圍加鎖,按照加鎖原則,向右遍歷且最後一個值不滿足等值條件,next-key lock 退化為間隙鎖,此時加鎖範圍為(0,5);
      ▶︎  sessionB 屬於加鎖範圍內,因此被阻塞;
      ▶︎  sessionC 不在此加鎖範圍內,加鎖成功。
      為啥這裡要加的是範圍鎖呢,其實主要解決的是幻讀問題,假設這裡沒有在此範圍內加鎖,那麼 T1 時刻 sessionB 執行成功,T2 時刻再次執行 select * from t_db_lock where id = 3 的話,就會發現原先查詢不到的結果現在竟然可以查詢到了,就像出現幻覺一樣;為了避免出現這種幻讀的情況,需要在此範圍內加鎖。

      2.11 非唯  一等值不存在

      ▶︎  sessionA 在 a=3 這行上加鎖的,由於 db 中不存在該行,所以同樣會加next-key lock,並且因為鎖都是加在索引上的,因此會在 a 索引上加上(0,5)的範圍鎖。但是這裡有個奇怪的現象,當 a=5 時,如果 id<5 會阻塞,如果 id>5 則會成功,從結果看來,此時 a 上的鎖似乎是有偏向性的,並不是嚴格意義上的 a=5 時就會鎖住相應的插入記錄

      2.12 主鍵範圍

      ▶︎  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 在鎖中,插入失敗,注意這裡是被阻塞住,而不是報主鍵衝突。

      2.13 非唯 一範圍

      ▶︎  sessionA 加鎖範圍區別於主鍵索引主要是在(0, 5]這個範圍下並未退化為行鎖,因此總體加鎖範圍為(0, 10]

      2.14 無索引等值不存在

      ▶︎  sessionA 中加鎖記錄為 b=6 這行,由於 b 未建立索引,因此會將所有 b 索引上的記錄都加鎖,由於是 for update 加鎖,認為還回去主表上更新,因此主表的相關記錄也都被上了鎖,這就會導致加鎖期間處於鎖表的狀態,任何的更新操作都沒辦法成功,這線上上會是非常危險的操作,可能會導致 db 被打垮。

      03 什麼時候該加什麼時候不該加

      透過上述的分析我們應該對鎖的型別以及語句中加鎖的範圍有一個大致的瞭解,可以知道悲觀鎖是需要我們謹慎使用的,因為很可能簡單的 SQL 就會拖垮 db 的效能,影響線上服務的質量,那麼什麼時候該加什麼時候不該加呢?
      我認為對於 db 的併發場景,我們可以這麼去考慮:
      ▶︎  儘可能優先考慮使用樂觀鎖的方式解決;
      ▶︎  如果需要用到悲觀鎖,則務必在加鎖的鍵上加索引;
      ▶︎  確認 db 的隔離級別,分析 SQL 中可能存在導致衝突或者死鎖的原因,避免 SQL 被長時間阻塞;
      其實對於 db 的互斥方案並沒有銀彈,要根據具體的業務場景去針對性的制定解決方案,只是在可能出現的一些坑中,我們能夠提前識別到,避免低階錯誤,並且有能力去最佳化他,這就是能讓自己不斷進步提升的好方法啦。
      來源:本文轉自公眾號騰訊雲開發者


      來自 “ ITPUB部落格 ” ,連結:http://blog.itpub.net/70013542/viewspace-2999117/,如需轉載,請註明出處,否則將追究法律責任。

      相關文章