只要學計算機,「鎖」永遠是一個繞不過的話題。MySQL鎖也是一樣。
一句話解釋MySQL鎖:
MySQL鎖是解決資源競爭的一種方案。
短短一句話卻包含了3點值得我們注意的事情:
- 對什么資源進行競爭?
- 競爭的方式(或者說情形)有哪些?
- 鎖是如何解決競爭的?
這篇文章開始帶你循序漸進地理解這幾個問題。

1. 資源的競爭方式
MySQL對資源的操作無非就是讀、寫兩種方式,但是由于事務并發(fā)執(zhí)行的存在,因此對同一資源的并發(fā)訪問存在3種形式:
- 讀—讀:并發(fā)事務同時讀取相同資源。由于讀操作不會改變資源本身,因此這種情況下并不存在并發(fā)安全性問題。
- 讀—寫/寫—讀:一個事務對資源進行讀操作,另一個事務對資源進行寫操作。
- 寫—寫:并發(fā)事務同時對同一個資源進行寫操作。
2. 讀—寫/寫—讀下的問題
假設一種情形,一個事務先對某個資源進行讀操作,然后另一個事務再對該資源進行寫操作,如果兩個事務到此為止,必然不會導致并發(fā)問題。
可是事務這種東西,一般情況下就是包含有很多個子操作啊。
2.1. 幻讀
想象一下啊,假設事務T1和T2并發(fā)執(zhí)行,T1先查找了所有name為「王剛蛋」的用戶信息,此時發(fā)現(xiàn)擁有這個硬漢名字的用戶只有一個。然后T2插入了一個同樣叫做「王剛蛋」的用戶的信息,并且提交了。

2.2. 不可重復讀
再來,同樣是T1和T2兩個事務,T1通過id = 1查詢到了一條數(shù)據(jù),然后T2緊接著UPDATE(DELETE也可以)了該條記錄,不同的是,T2緊接著通過COMMIT提交了事務。

此時,T1再次執(zhí)行相同的查詢操作,會發(fā)現(xiàn)數(shù)據(jù)發(fā)生了變化,name字段由「王剛蛋」變成了「蟬沐風」。
如果一個事務讀到了另一個已提交事務修改過的(或者是刪除的)數(shù)據(jù),而導致了前后兩次讀取的數(shù)據(jù)不一致的情況,這種事務并發(fā)問題叫做不可重復讀。
2.3. 臟讀
事情還沒結束,假設T1和T2都要訪問user_innodb表中id為1的數(shù)據(jù),不同的是T1先讀取數(shù)據(jù),緊接著T2修改了數(shù)據(jù)的name字段,需要注意的是,T2并沒有提交!
此時,T1再次執(zhí)行相同的查詢操作,會發(fā)現(xiàn)數(shù)據(jù)發(fā)生了變化,name字段由「王剛蛋」變成了「蟬沐風」。
如果一個事務讀到了另一個未提交事務修改過的數(shù)據(jù),而導致了前后兩次讀取的數(shù)據(jù)不一致的情況,這種事務并發(fā)問題叫做臟讀。
2.4. 鎖與MVCC的關系
總結一下:我們在讀—寫,寫—讀的情況下會遇到3種讀不一致性的問題,臟讀、不可重復讀以及幻讀。
那寫—寫呢?很顯然,在不做任何措施的情況下,并發(fā)會出現(xiàn)更大的問題。那該怎么辦呢?
一切的并發(fā)問題都可以通過串行化解決,但是串行化效率太低了!
再優(yōu)化一下,一切并發(fā)問題都可以通過加鎖來解決,這種方案我們稱為基于鎖的并發(fā)控制(Lock Bases Concurrency Control, LBCC)!但是在讀多寫少的環(huán)境下,客戶端連讀取幾條記錄都需要排隊,效率還是太低了!
因此,MySQL的設計者為事務之間的隔離性提供了不同的級別,使得開發(fā)者可以根據(jù)自己的業(yè)務場景設置不同的隔離級別,來解決(或者部分解決)讀—寫/寫—讀下的讀一致性問題,而不是一上來就加鎖。
那有了MVCC是不是在讀—寫/寫—讀的情況下就不需要鎖了呢?那也不是。
MVCC解決的是讀—寫/寫—讀中“比較純粹的讀”遇到的一致性問題,原諒我,這是我自己編的詞兒。那什么是不純粹的?拿存款業(yè)務舉個例子。
假設陀螺要存一筆錢,系統(tǒng)需要先把陀螺的余額讀出來,然后在余額的基礎上加上本次存款的金額,最后再寫入到數(shù)據(jù)庫中。在將余額讀出來之后,如果不想讓其他事務繼續(xù)訪問該余額,直到整個存款事務完成之后,其他事務才可以對該余額繼續(xù)進行操作,這種情況下就必須為余額的讀取操作添加鎖。
再總結一下:MVCC是MySQL默認的解決讀—寫/寫—讀下一致性問題的方式,不需要加鎖。而鎖是實現(xiàn)一致性的最終兜底方案,在某些特殊場景下,鎖的使用不可避免。
說得更準確一點,MVCC是MySQL在READ COMMITTED、REPEATABLE READ這兩種隔離級別之下執(zhí)行普通SELECT操作時默認解決一致性問題的方式。
2.5. 鎖與事務的關系
事務是多個操作的集合,比如我們可以把「把大象裝冰箱」這件事情作為一個事務。

事務有A(原子性)、C(一致性)、I(隔離性)、D(持久性)4大特性,而鎖就是實現(xiàn)隔離性的其中一種方案(比如還有MVCC等方案)。
事務的隔離性針對不同場景需求又實現(xiàn)了不同的隔離級別,不同的隔離級別下,事務使用鎖的方式又會有所不同。舉個例子。
在READ COMMITTED、REPEATABLE READ這兩種隔離級別之下,SELECT操作是不需要加鎖的,直接使用MVCC機制即可滿足當前隔離級別的需求。但是在SERIALIZABLE隔離級別,并且在禁用自動提交時(autocommit=0),MySQL會將普通的SELECT語句轉化為SELECT ... LOCK IN SHARE MODE這樣的加鎖語句,如果你看不懂這句話也沒關系,你只需要知道MySQL自動加鎖了就行,更詳細的下文再說。
另外,一個事務可能會加很多個鎖,但是某個鎖一定只屬于一個事務。這就好比一個管理員可以管理多個保險柜,一個保險柜一定只被一個管理員管理。
3. 寫—寫情況
寫—寫的情況下肯定要加鎖的了,所以接下來終于要聊一聊鎖了。
我們首先研究一下鎖住的東西的大小,也就是鎖的粒度。
4. 鎖的粒度
舉一個非常應景的例子。疫情防控的時候,是封鎖整個小區(qū)還是封鎖某棟樓的某個單元,這完全是兩種概念。
對應到MySQL鎖的粒度,那就是表鎖和行鎖。
很容易想到,封鎖小區(qū)的行為遠比封鎖某棟樓某單元的行為粗曠,因此,
從鎖定粒度上來看,表鎖 > 行鎖
直接堵住小區(qū)的門口要比進入小區(qū)找到具體某棟樓的某個單元要快不少,因此,
從加鎖效率上來看,表鎖 > 行鎖
直接鎖住小區(qū)大概率會影響其他樓居民的正常生活和各種社會活動的開展,而鎖住某棟樓某單元頂多影響這一個單元的居民的生活,因此,
從沖突概率來看,表鎖 > 行鎖
從并發(fā)性能來看,表鎖 < 行鎖
MySQL支持很多存儲引擎,而不同的存儲引擎對鎖的支持也不盡相同。對于MyISAM、MERGE、MEMORY這些存儲引擎而言,只支持表鎖;而InnoDB存儲引擎既支持表鎖也支持行鎖,下文討論的所有內容均針對InnoDB存儲引擎。
說完鎖的粒度,還有一件事情需要我們仔細考慮一下。上文說過,READ COMMITTED、REPEATABLE READ這兩種隔離級別之下,SELECT操作默認采用MVCC機制就可以了,壓根兒不需要加鎖,那么問題來了,萬一我就是想加鎖呢?
你可能會說,“簡單啊,那就加鎖!把數(shù)據(jù)鎖死!除了我誰也別動!”
很好,但是對于大部分讀—讀而言,由于不會出現(xiàn)讀一致性問題,所以不讓其他事務進行讀操作并不合理。
你可能又說,“那行吧,那就讓讀操作加鎖的時候允許其他事務對鎖住的數(shù)據(jù)進行讀操作,但是不允許寫操作?!?/p>
嗯,想得確實更細致了一些。但是再想想我上文中舉過的陀螺存錢的例子,有時候SELECT操作需要獨占數(shù)據(jù),其他事務既不能讀,更不能寫。
我們把這種共享和排他的性質稱為鎖的基本模式。
5. 鎖的基本模式
5.1. 共享鎖
共享鎖(Shared Lock),簡稱S鎖,可以同時被多個事務共享,也就是說,如果一個事務給某個數(shù)據(jù)資源添加了S鎖,其他事務也被允許獲取該數(shù)據(jù)資源的S鎖。
由于S鎖通常被用于讀取數(shù)據(jù),因此也被稱為讀鎖。
那怎么給數(shù)據(jù)添加S鎖呢?
我們可以用 SELECT ... LOCK IN SHARE MODE;的方式,在讀取數(shù)據(jù)之前就為數(shù)據(jù)添加一把S鎖。如果當前事務執(zhí)行了該語句,那么會為讀取到的記錄添加S鎖,同時其他事務也可以使用SELECT ... LOCK IN SHARE MODE;方式繼續(xù)獲取這些數(shù)據(jù)的S鎖。
我們通過以下的例子驗證一下S鎖是否可以重復獲取。

5.2. 排他鎖
排他鎖(Exclusive Lock),簡稱X鎖。只要一個事務獲取了某數(shù)據(jù)資源的X鎖,其他的事務就不能再獲取該數(shù)據(jù)的X鎖和S鎖。
由于X鎖通常被用于修改數(shù)據(jù),因此也被稱為寫鎖。
X鎖的添加方式有兩種,
- 自動添加X鎖我們對記錄進行增刪改時,通常情況下會自動對其添加X鎖。
- 手動加鎖我們可以用 SELECT ... FOR UPDATE;的方式,在讀取數(shù)據(jù)之前就為數(shù)據(jù)添加一把X鎖。如果當前事務執(zhí)行了該語句,那么會為讀取到的記錄添加X鎖,這樣既不允許其他事務獲取這些記錄的S鎖,也不允許獲取這些記錄的X鎖。

通常情況下,事務提交或結束事務時,鎖會被釋放。
6. 意向鎖
6.1. 背景
前面提到的S鎖和X鎖的語法規(guī)則其實是針對記錄的,也就是行鎖,原因是InnoDB中行鎖用的最多。如果將鎖的粒度和鎖的基本模式排列組合一下,就會出現(xiàn)如下4種情況:
- 行級S鎖
- 行級X鎖
- 表級S鎖
- 表級X鎖
那么接下來的描述,也就順理成章了。
如果事務給一個表添加了表級S鎖,則:
- 其他事務可以繼續(xù)獲得該表的S鎖,但是無法獲取該表的X鎖;
- 其他事務可以繼續(xù)獲得該表某些行的S鎖,但是無法獲取該表某些行的X鎖。
如果事務給一個表添加了表級X鎖,則:
- 不論是該表的S鎖、X鎖,還是該表某些行的S鎖、X鎖,其他事務都只能干瞪眼兒,啥也獲取不了。
挺好理解的吧,總之就是S鎖只能和S鎖相容,X鎖和其他任何鎖都互斥。問題來了,雖然用的不多,但是萬一我真的想給整個表添加一個S鎖或者X鎖怎么辦?
假如我要給表user添加一個S鎖,那就必須保證user在表級別上和行級別上都不能有X鎖,表級別上還好說一點,無非就是1個內存結構罷了,但是行X鎖呢?必須得逐行遍歷是否有行X鎖嗎?
同理,假如我要給表user添加一個X鎖,那就必須保證user在表級別上和行級別上都不能有任何鎖(S和X都不能有),難不成得逐行遍歷是否有S或X鎖嗎?
遍歷是不可能遍歷的!這輩子都不可能遍歷的!于是,意向鎖(Intension Lock)誕生了。
6.2. 概念
我們要避免遍歷,那最好的辦法就是在給行加鎖時,先在表級別上添加一個標識。
- 意向共享鎖(Intension Shared Lock):簡稱IS鎖,當事務試圖給行添加S鎖時,需要先在表級別上添加一個IS鎖;
- 意向排他鎖(Intension Exclusive Lock):簡稱IX鎖,當事務試圖給行添加X鎖時,需要先在表級別上添加一個IX鎖。
這樣一來:
- 如果想給user表添加一個S鎖(表級鎖),就先看一下user表有沒有IX鎖;如果有,就說明user表的某些行被加了X鎖(行鎖),需要等到行的X鎖釋放,隨即IX鎖被釋放,才可以在user表中添加S鎖;
- 如果想給user表添加一個X鎖(表級鎖),就先看一下user有沒有IS鎖或IX鎖;如果有,就說明user表的某些行被加了S鎖或X鎖(行鎖),需要等到所有行鎖被釋放,隨即IS鎖或IX鎖被釋放,才可以在user表中添加X鎖。
需要注意的是,意向鎖和意向鎖之間是不沖突的,意向鎖和行鎖之間也不沖突。
只有在對表添加S鎖或X鎖時才需要判斷當前表是否被添加了IS鎖或IX鎖,當為表添加IS鎖或IX鎖時,不需要關心當前表是否已經被添加了其他IS鎖或IX鎖。
目前為止MySQL鎖的基本模式就介紹完了,接下來回到這片文章的題目,MySQL鎖,鎖住的到底是什么?由于InnoDB的行鎖用的最多,這里的鎖自然指的是行鎖。
7. 行鎖的原理
既然都叫行鎖了,我們姑且猜測一下,行鎖鎖住的是一行數(shù)據(jù)。我們做個實驗。
7.1. 沒有任何索引的表
我們先創(chuàng)建一張沒有任何索引的普通表,語句如下
CREATE TABLE `user_t1` (
`id` int DEFAULT NULL,
`name` varchar(255) DEFAULT NULL
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
表中數(shù)據(jù)如下:
mysql> SELECT * FROM user_t1;
+------+-------------+
| id | name |
+------+-------------+
| 1 | chanmufeng |
| 2 | wanggangdan |
| 3 | wangshangju |
| 4 | zhaotiechui |
+------+-------------+
接下來我們在兩個session中開啟兩個事務。
- 事務1,我們通過WHERE id = 1“鎖住”第1行數(shù)據(jù);
- 事務2,我們通過WHERE id = 2"鎖住"第2行數(shù)據(jù)。

一件詭異的事情是,第2個加鎖的操作被阻塞了。實際上,T2中不管我們要給user_t1中哪行數(shù)據(jù)加鎖,都會失?。?/p>
為什么我SELECT一條數(shù)據(jù),卻給我鎖住了整個表?這個實驗直接推翻了我們的猜測,InnoDB的行鎖并非直接鎖定Record行。
為什么沒有索引的情況下,給某條語句加鎖會鎖住整個表呢?別急,我們繼續(xù)。
7.2. 有主鍵索引的表
我們再創(chuàng)建一個表user_t2,語句如下:
CREATE TABLE `user_t2` (
`id` int NOT NULL,
`name` varchar(255) DEFAULT NULL,
PRIMARY KEY (`id`)
) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4;
和user_t1的不同之處在于為id創(chuàng)建了一個主鍵索引。表中數(shù)據(jù)依然如下:
mysql> SELECT * FROM user_t2;
+------+-------------+
| id | name |
+------+-------------+
| 1 | chanmufeng |
| 2 | wanggangdan |
| 3 | wangshangju |
| 4 | zhaotiechui |
+------+-------------+
同樣開啟兩個事務:
- 事務1,通過WHERE id = 1“鎖住”第1行數(shù)據(jù);
- 事務2依然使用WHERE id = 1嘗試加鎖,加鎖失??;使用WHERE id = 2嘗試加鎖,加鎖成功。

既然鎖的不是Record行,難不成鎖的是id這一列嗎?
我們再做最后一個實驗。
7.3. 有唯一索引的表
我們再創(chuàng)建一個表user_t3,語句如下:
CREATE TABLE `user_t3` (
`id` int NOT NULL,
`name` varchar(255) DEFAULT NULL,
PRIMARY KEY (`id`),
UNIQUE KEY (`uk_name`) (`name`)
) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4;

和user_t2的不同之處在于為name列創(chuàng)建了一個唯一索引。表中數(shù)據(jù)依然如下:
mysql> SELECT * FROM user_t3;
+------+-------------+
| id | name |
+------+-------------+
| 1 | chanmufeng |
| 2 | wanggangdan |
| 3 | wangshangju |
| 4 | zhaotiechui |
+------+-------------+

兩個事務:
- 事務1,通過name字段 “鎖住”name為“chanmufeng”的數(shù)據(jù);
- 事務2依然使用WHERE name = “chanmufeng” 嘗試加鎖,可以預料,加鎖失敗;使用WHERE id = 1嘗試給同樣的行加鎖,加鎖失敗。
通過3個實驗我們發(fā)現(xiàn),行鎖鎖住的既不是Record行,也不是Column列,那到底鎖住的是什么?我們對比一下,上文的3張表的不同點在于索引不同,其實InnoDB的行鎖,就是通過鎖住索引來實現(xiàn)的。
接下來回答3個問題。
8. 三個問題
8.1. 鎖住索引?沒有索引怎么辦?
你說鎖住索引?如果我不創(chuàng)建索引,MySQL鎖定個啥?
如果我們沒有設置主鍵,InnoDB會優(yōu)先選取一個不包含NULL值的Unique鍵作為主鍵,如果表中連Unique鍵也沒有的話,就會自動為每一條記錄添加一個叫做DB_ROW_ID的列作為默認主鍵,只不過這個主鍵我們看不到罷了。
下圖是數(shù)據(jù)的行格式??床欢脑拸娏彝扑]看一下我上面給出的兩篇文章,說得非常明白。

8.2. 為什么第一個實驗會鎖表?
因為SELECT沒有用到索引,會進行全表掃描,然后把DB_ROW_ID作為默認主鍵的聚簇索引都給鎖住了。
8.3. 為什么通過唯一索引給數(shù)據(jù)加鎖,主鍵索引也會被鎖???
不管是Unique索引還是普通索引,它們的葉子結點中存儲的數(shù)據(jù)都不完整,其中只是存儲了作為索引并且排序好的列數(shù)據(jù)以及對應的主鍵值。
因此我們通過索引查找數(shù)據(jù)數(shù)據(jù)實際上是在索引的B+樹中先找到對應的主鍵,然后根據(jù)主鍵再去主鍵索引的B+樹的葉子結點中找到完整數(shù)據(jù),最后返回。所以雖然是兩個索引樹,但實際上是同一行數(shù)據(jù),必須全部鎖住。
下面給了一張圖,讓不了解索引的朋友大致了解一下。上半部分是name列創(chuàng)建的唯一索引的B+樹,下半部分是主鍵索引(也叫聚簇索引)。
假如我們通過WHERE name = '王鋼蛋'對數(shù)據(jù)進行查詢,會先用到name列的唯一索引,最終定位到主鍵值為1,然后再到主鍵索引中查詢id = 1的數(shù)據(jù),最終拿到完整的行數(shù)據(jù)。

9. 總結
至此,我已經回答了文章開頭的絕大多數(shù)問題。
MySQL鎖,是解決資源競爭問題的一種手段。有哪些競爭呢?讀—寫/寫—讀,寫—寫中都會出現(xiàn)資源競爭問題,不同的是前者可以通過MVCC的方式來解決,但是某些情況下你也不得不用鎖,因此我也順便解釋了鎖和MVCC的關系。
然后介紹了MySQL鎖的基本模式,包括共享鎖(S鎖)和排他鎖(X鎖),還引入了意向鎖。
最后解釋了鎖到底鎖的是什么的問題。通過3個實驗,最終解釋了InnoDB鎖本質上鎖的是索引。