前言
前陣子參與了位元組跳動後端青訓營,其中大專案編寫涉及到資料持久化一般選擇使用MySQL
。由於時間原因,資料庫使用我選擇了無腦三板斧:1. 建立了索引加速查詢、2. 關閉自動提交事務、3. 在需要確保原子性的資料庫操作之間手動建立和提交事務。
這麼一看,彷彿即使是實際開發也與你此前聽聞的一些MySQL
相關名詞:讀寫鎖、間隙鎖、多版本併發控制、redo log
、bin log
、undo log
毫不相干,在講本文的主題之前,我先引入一個真實場景。
在某次不夠規範的小組開發過程中,開發成員選擇測試程式的方式比較原始,大家共享一個測試資料庫,各自使用測試賬號進行介面的測試,這就意味著資料庫中的記錄在某一時刻有可能被多個事務訪問,甚至在其他人測試的同時,某張資料表的結構被另一位同學修改。
多事務併發訪問,反映到開發者這邊,就是查詢介面有時速度很慢。如果你是直接使用資料庫管理工具運算元據庫表資料/結構,對應的就是Navicat
不時的陷入較長時間的無響應狀態。
當然導致資料庫訪問速度變慢的原因有很多:sql語句編寫不規範、資料庫伺服器的效能差、網路狀況不佳等,但是本文所側重的點在於探究MySQL
的鎖機制,在其中發揮了什麼作用。
相信在完成本文的閱讀之後,你會明白上面的場景的發生,可能是MySQL
的鎖機制從中作祟。
MySQL的鎖有哪幾種
全域性鎖
MySQL
可以通過顯式命令對整個資料庫例項加全域性讀鎖:
此時整個資料庫處於只讀狀態,所有資料記錄的更新、資料庫/表結構的改動提交都會被阻塞,這可以用於全庫的資料備份。
表級鎖
表鎖
表鎖可以通過以下顯式命令實現對一個表新增讀/寫鎖:
如果A執行緒為t1表新增了讀鎖,為t2表新增了寫鎖。則其他執行緒將只能讀t1,寫t1被阻塞;讀/寫t2都會被阻塞。而A執行緒在執行unlock tables
之前,也只能執行讀t1、讀/寫t2的操作。
後設資料鎖(metadata lock)
MDL鎖不需要顯式使用,在訪問一個表的時候會被自動加上,並且當事務完成提交時釋放。當對一個表資料做CRUD操作的時候,自動加MDL讀鎖;當對該表結構作出改動的時候,自動加MDL寫鎖。
- 讀鎖之間不互斥,因此多個執行緒才可以同時訪問一張資料表。
- 讀寫鎖之間、寫鎖之間是互斥的(被讀鎖佔用時,加寫鎖的執行緒被阻塞/被寫鎖佔用時,加讀鎖/寫鎖的執行緒都被阻塞),這也是為了確保表結構的修改和表的資料的操作不發生衝突。
這裡展示一個多執行緒併發操作同一個資料表的案例:
這裡執行緒B
會因為執行緒A
的事務還沒有提交,而新增列的操作需要獲取MDL寫鎖因此被阻塞,同時執行緒C
申請MDL讀鎖的請求又被阻塞在了執行緒B
申請MDL寫鎖的請求之後,此時表t在執行緒A
事務提交之前,完全喪失了讀寫能力。
或許此時你已經對於為什麼多人除錯程式時資料庫訪問不時出現卡頓有了一些自己的想法,當然這只是鎖機制的冰山一角。
行級鎖
通過上面的講解,我們明白了,所謂的讀寫鎖並不是單指一個鎖叫讀鎖/寫鎖,而是指不同粒度的鎖有讀鎖和寫鎖兩種狀態,允許的併發程度也有所不同。行級鎖也是如此(針對記錄行的鎖,鎖粒度進一步縮小),行鎖的存在也使得事務併發訪問資料庫的效能進一步的提高,並且依舊有讀寫鎖之分,下面介紹。
但區別於全域性鎖和表級鎖,MySQL行鎖是由各個儲存引擎自己實現的,並不是所有的儲存引擎都支援行鎖(MyISAM不支援),由於現在MySQL使用者大多選擇使用InnoDB儲存引擎,所以本文將以InnoDB引擎為預設選擇。
兩階段鎖協議
在InnoDB事務當中,行鎖在需要的時候新增,並且直到事務提交才釋放(鎖的新增和釋放分兩個階段進行),舉個例子:
事務A(執行緒A)在提交之前,佔有id=1這條行記錄的寫鎖,事務B(執行緒B)修改同一行的操作將被阻塞。
死鎖與檢測
死鎖原本是作業系統當中的概念,意思是多個執行緒都在等待其他執行緒釋放自己需要的資源,使得這些執行緒陷入無限制的等待。
在這個例子當中,執行緒A的事務和執行緒B的事物分別佔有id=1
和id=2
這兩條記錄的寫鎖,使得兩個執行緒在試圖獲取其他執行緒佔用的鎖資源時陷入死鎖。
InnoDB儲存引擎預設開啟了死鎖檢測,每個新來的被阻塞的執行緒,都會主動判斷是否是自己的加入導致死鎖(檢測邏輯就是判斷自己需要的行資源是否被別的執行緒的事務佔有),時間複雜度O(n),一旦檢測到,則回滾當前執行緒的事務,確保其他執行緒可以得到執行。
這裡你會發現,如果同時有多個執行緒修改同一條記錄,一旦併發度很高,則需要消耗O(n^2)時間去完成死鎖檢測,就會消耗大量CPU資源在死鎖檢測上,而使得資料庫IO的效能下降。
此時你是否又對我最初給出的小組開發時訪問資料庫慢的場景有了自己的思考,其實在高QPS情況下,發生死鎖檢測的概率是大大高於小組開發場景的
因此控制熱點記錄的併發訪問數量,是提升資料庫IO效能的重要前提。
多版本併發控制(MVCC)
上面講述了InnoDB的update操作會佔用行記錄的寫鎖,那麼你自然而然就想到,select查詢操作是否就佔用了行記錄的讀鎖呢?不完全正確,這就不得不提及MySQL的InnoDB引擎的用於控制事務隔離級別的多版本併發控制機制。
簡言之就是每條行記錄值的變化是由一個鏈式的結構組織的,存放在undo log
檔案當中,undo log
在事務發生回滾的時候,用於回溯事務對行記錄的修改過程。
而InnoDB儲存引擎預設的事務隔離級別是可重複讀(Read Repeatable),簡單來說:就是當事務A啟動期間,普通的select查詢將無法訪問到其他事務在此期間對錶記錄的改動。
關於多版本併發控制(MVCC)這裡我沒有過多深入講解,詳情給出我的另一篇文章:https://juejin.cn/post/7085185961239248927
快照讀
對於普通的查詢操作,你大致瞭解InnoDB引擎管理的表的行記錄變更是鏈式組織的,那麼每一條記錄就相當於一個個的快照,因此普通的select查詢操作被稱為快照讀,會讀取到自己可見的最近一個版本(但不一定是最新版本),快照讀並不加鎖(也就是沒有獲取讀鎖)。
至於具體讀到哪個版本的快照,在上面連結給出的文章中有詳細講解。
當前讀
這裡給出了兩種不同的當前讀方式,當前讀可以讀取到undo log
版本鏈上的最新記錄,不同之處在於,第一條sql獲取了id=1這條行記錄的讀鎖(在其他事務已經持有id=1行記錄的寫鎖時將被阻塞);第二條select查詢雖然也是當前讀,但是獲取了id=1這條記錄的寫鎖(在其他事物已經持有id=1行記錄的讀/寫鎖時將被阻塞)。
上面講解死鎖檢測的時候我用更新語句獲得了行記錄的寫鎖,而這裡,通過增加for update
字尾,可以使得當前讀操作也獲取行記錄的寫鎖。
間隙鎖
間隙鎖的出現解決了幻讀問題,那麼先簡述一下幻讀的概念,以及幻讀有什麼問題。
幻讀概述
- 在InnoDB引擎的可重複讀隔離級別下,普通查詢是快照讀,不會看到其他併發事務插入的資料,因此幻讀在當前讀情況下才會出現。
- 幻讀指當前讀場景下,查詢到了其他併發事務新插入的行(讀到其他事務對行記錄的修改,並不屬於幻讀,因為當前讀就是會讀取到行記錄的最新版本)。
幻讀的問題
這裡用一張表t的操作來描述幻讀帶來的問題。
以下的分析建立在沒有間隙鎖的情況下(只是為了分析所作的假設):
- 事務A的第一個sql查詢c=1的記錄,獲得(1,1),此時新增了
for update
,從語義上就是希望鎖住所有c=1的行記錄。 - 並且在RR隔離級別下,所有掃描到的行資料都會加行鎖,因為c欄位沒有索引,比較c=1的操作需要全表掃描,因此事務A的第一條sql在當前讀的情況下,為整張表的3條行記錄都新增了寫鎖。
- 此時事務B併發插入了一條(2,1)的記錄,並且成功。
- 事務A的第二個sql依舊查詢c=1的記錄,獲得(1,1)、(2,1)兩條記錄,從語義上違背了第一條sql的目的。(原本打算鎖定所有c=1的記錄,但是突然又冒出一條記錄)
這裡的核心問題就在於:即使所有掃描到的行記錄都加上了鎖,依舊無法阻止新記錄的插入(因為要插入的記錄不可能提前鎖定),要避免幻讀,就需要將記錄之間的間隙鎖定——間隙鎖。
Gap Lock
間隙鎖在可重複讀隔離級別下才有效,所以本文的描述都是基於RR級別(InnoDB儲存引擎事務預設隔離級別),這裡給出間隙鎖配合行鎖工作的一些規則:
- 所有的鎖是新增在索引上的
- 加間隙鎖的基本單位是next-key lock(前開後閉區間)
- 查詢過程中訪問到的記錄和區間才會加鎖
- 索引上的等值查詢,給唯一索引加鎖的時候,next-key lock退化為行鎖
- 索引上的等值查詢,向右遍歷時且最後一個值不滿足等值條件的時候,next-key lock退化為間隙鎖
- 唯一索引上的範圍查詢會訪問到不滿足條件的第一個值為止
小結
本文概述了MySQL鎖機制的工作情況,明確了鎖有讀/寫之分。以及給出了一些會觸發表鎖、行鎖的案例,同時,InnoDB儲存引擎為了解決幻讀問題,引入了間隙鎖,用於鎖定索引之間的間隙,防止當前讀的出錯。
還記得文章開頭我丟擲的實際開發案例嗎,相信通過這篇文章的講解,你對於多事務併發運算元據庫時資料庫訪問效能下降的原因,已經有了不少自己的思考。