今天我要跟你聊聊 MySQL 的鎖。數(shù)據(jù)庫鎖設(shè)計的初衷是處理并發(fā)問題。作為多用戶共享的資源,當(dāng)出現(xiàn)并發(fā)訪問的時候,數(shù)據(jù)庫需要合理地控制資源的訪問規(guī)則。而鎖就是用來實現(xiàn)這些訪問規(guī)則的重要數(shù)據(jù)結(jié)構(gòu)。
根據(jù)加鎖的范圍,MySQL 里面的鎖大致可以分成全局鎖、表級鎖和行鎖三類。今天這篇文章,我會和你分享全局鎖和表級鎖。而關(guān)于行鎖的內(nèi)容,我會留著在下一篇文章中再和你詳細介紹。
這里需要說明的是,鎖的設(shè)計比較復(fù)雜,這兩篇文章不會涉及鎖的具體實現(xiàn)細節(jié),主要介紹的是碰到鎖時的現(xiàn)象和其背后的原理。
全局鎖
顧名思義,全局鎖就是對整個數(shù)據(jù)庫實例加鎖。MySQL 提供了一個加全局讀鎖的方法,命令是 Flush tables with read lock (FTWRL)。當(dāng)你需要讓整個庫處于只讀狀態(tài)的時候,可以使用這個命令,之后其他線程的以下語句會被阻塞:數(shù)據(jù)更新語句(數(shù)據(jù)的增刪改)、數(shù)據(jù)定義語句(包括建表、修改表結(jié)構(gòu)等)和更新類事務(wù)的提交語句。
全局鎖的典型使用場景是,做全庫邏輯備份。也就是把整庫每個表都 select 出來存成文本。
以前有一種做法,是通過 FTWRL 確保不會有其他線程對數(shù)據(jù)庫做更新,然后對整個庫做備份。注意,在備份過程中整個庫完全處于只讀狀態(tài)。
但是讓整庫都只讀,聽上去就很危險:
- 如果你在主庫上備份,那么在備份期間都不能執(zhí)行更新,業(yè)務(wù)基本上就得停擺;
- 如果你在從庫上備份,那么備份期間從庫不能執(zhí)行主庫同步過來的 binlog,會導(dǎo)致主從延遲。
看來加全局鎖不太好。但是細想一下,備份為什么要加鎖呢?我們來看一下不加鎖會有什么問題。
假設(shè)你現(xiàn)在要維護“極客時間”的購買系統(tǒng),關(guān)注的是用戶賬戶余額表和用戶課程表。
現(xiàn)在發(fā)起一個邏輯備份。假設(shè)備份期間,有一個用戶,他購買了一門課程,業(yè)務(wù)邏輯里就要扣掉他的余額,然后往已購課程里面加上一門課。
如果時間順序上是先備份賬戶余額表 (u_account),然后用戶購買,然后備份用戶課程表 (u_course),會怎么樣呢?你可以看一下這個圖:

可以看到,這個備份結(jié)果里,用戶 A 的數(shù)據(jù)狀態(tài)是“賬戶余額沒扣,但是用戶課程表里面已經(jīng)多了一門課”。如果后面用這個備份來恢復(fù)數(shù)據(jù)的話,用戶 A 就發(fā)現(xiàn),自己賺了。
作為用戶可別覺得這樣可真好啊,你可以試想一下:如果備份表的順序反過來,先備份用戶課程表再備份賬戶余額表,又可能會出現(xiàn)什么結(jié)果?
也就是說,不加鎖的話,備份系統(tǒng)備份的得到的庫不是一個邏輯時間點,這個視圖是邏輯不一致的。
說到視圖你肯定想起來了,我們在前面講事務(wù)隔離的時候,其實是有一個方法能夠拿到一致性視圖的,對吧?
是的,就是在可重復(fù)讀隔離級別下開啟一個事務(wù)。
官方自帶的邏輯備份工具是 mysqldump。當(dāng) mysqldump 使用參數(shù)–single-transaction 的時候,導(dǎo)數(shù)據(jù)之前就會啟動一個事務(wù),來確保拿到一致性視圖。而由于 MVCC 的支持,這個過程中數(shù)據(jù)是可以正常更新的。
你一定在疑惑,有了這個功能,為什么還需要 FTWRL 呢?一致性讀是好,但前提是引擎要支持這個隔離級別。比如,對于 MyISAM 這種不支持事務(wù)的引擎,如果備份過程中有更新,總是只能取到最新的數(shù)據(jù),那么就破壞了備份的一致性。這時,我們就需要使用 FTWRL 命令了。
所以,single-transaction 方法只適用于所有的表使用事務(wù)引擎的庫。如果有的表使用了不支持事務(wù)的引擎,那么備份就只能通過 FTWRL 方法。這往往是 DBA 要求業(yè)務(wù)開發(fā)人員使用 InnoDB 替代 MyISAM 的原因之一。
你也許會問,既然要全庫只讀,為什么不使用 set global readonly=true 的方式呢?確實 readonly 方式也可以讓全庫進入只讀狀態(tài),但我還是會建議你用 FTWRL 方式,主要有兩個原因:
- 一是,在有些系統(tǒng)中,readonly 的值會被用來做其他邏輯,比如用來判斷一個庫是主庫還是備庫。因此,修改 global 變量的方式影響面更大,我不建議你使用。
- 二是,在異常處理機制上有差異。如果執(zhí)行 FTWRL 命令之后由于客戶端發(fā)生異常斷開,那么 MySQL 會自動釋放這個全局鎖,整個庫回到可以正常更新的狀態(tài)。而將整個庫設(shè)置為 readonly 之后,如果客戶端發(fā)生異常,則數(shù)據(jù)庫就會一直保持 readonly 狀態(tài),這樣會導(dǎo)致整個庫長時間處于不可寫狀態(tài),風(fēng)險較高。
業(yè)務(wù)的更新不只是增刪改數(shù)據(jù)(DML),還有可能是加字段等修改表結(jié)構(gòu)的操作(DDL)。不論是哪種方法,一個庫被全局鎖上以后,你要對里面任何一個表做加字段操作,都是會被鎖住的。
但是,即使沒有被全局鎖住,加字段也不是就能一帆風(fēng)順的,因為你還會碰到接下來我們要介紹的表級鎖。
表級鎖
MySQL 里面表級別的鎖有兩種:一種是表鎖,一種是元數(shù)據(jù)鎖(meta data lock,MDL)。
表鎖的語法是 lock tables … read/write。與 FTWRL 類似,可以用 unlock tables 主動釋放鎖,也可以在客戶端斷開的時候自動釋放。需要注意,lock tables 語法除了會限制別的線程的讀寫外,也限定了本線程接下來的操作對象。
舉個例子, 如果在某個線程 A 中執(zhí)行 lock tables t1 read, t2 write; 這個語句,則其他線程寫 t1、讀寫 t2 的語句都會被阻塞。同時,線程 A 在執(zhí)行 unlock tables 之前,也只能執(zhí)行讀 t1、讀寫 t2 的操作。連寫 t1 都不允許,自然也不能訪問其他表。
在還沒有出現(xiàn)更細粒度的鎖的時候,表鎖是最常用的處理并發(fā)的方式。而對于 InnoDB 這種支持行鎖的引擎,一般不使用 lock tables 命令來控制并發(fā),畢竟鎖住整個表的影響面還是太大。
另一類表級的鎖是 MDL(metadata lock)。MDL 不需要顯式使用,在訪問一個表的時候會被自動加上。MDL 的作用是,保證讀寫的正確性。你可以想象一下,如果一個查詢正在遍歷一個表中的數(shù)據(jù),而執(zhí)行期間另一個線程對這個表結(jié)構(gòu)做變更,刪了一列,那么查詢線程拿到的結(jié)果跟表結(jié)構(gòu)對不上,肯定是不行的。
因此,在 MySQL 5.5 版本中引入了 MDL,當(dāng)對一個表做增刪改查操作的時候,加 MDL 讀鎖;當(dāng)要對表做結(jié)構(gòu)變更操作的時候,加 MDL 寫鎖。
讀鎖之間不互斥,因此你可以有多個線程同時對一張表增刪改查。
讀寫鎖之間、寫鎖之間是互斥的,用來保證變更表結(jié)構(gòu)操作的安全性。因此,如果有兩個線程要同時給一個表加字段,其中一個要等另一個執(zhí)行完才能開始執(zhí)行。
雖然 MDL 鎖是系統(tǒng)默認會加的,但卻是你不能忽略的一個機制。比如下面這個例子,我經(jīng)常看到有人掉到這個坑里:給一個小表加個字段,導(dǎo)致整個庫掛了。
你肯定知道,給一個表加字段,或者修改字段,或者加索引,需要掃描全表的數(shù)據(jù)。在對大表操作的時候,你肯定會特別小心,以免對線上服務(wù)造成影響。而實際上,即使是小表,操作不慎也會出問題。我們來看一下下面的操作序列,假設(shè)表 t 是一個小表。
備注:這里的實驗環(huán)境是 MySQL 5.6。

我們可以看到 session A 先啟動,這時候會對表 t 加一個 MDL 讀鎖。由于 session B 需要的也是 MDL 讀鎖,因此可以正常執(zhí)行。
之后 session C 會被 blocked,是因為 session A 的 MDL 讀鎖還沒有釋放,而 session C 需要 MDL 寫鎖,因此只能被阻塞。
如果只有 session C 自己被阻塞還沒什么關(guān)系,但是之后所有要在表 t 上新申請 MDL 讀鎖的請求也會被 session C 阻塞。前面我們說了,所有對表的增刪改查操作都需要先申請 MDL 讀鎖,就都被鎖住,等于這個表現(xiàn)在完全不可讀寫了。
如果某個表上的查詢語句頻繁,而且客戶端有重試機制,也就是說超時后會再起一個新 session 再請求的話,這個庫的線程很快就會爆滿。
你現(xiàn)在應(yīng)該知道了,事務(wù)中的 MDL 鎖,在語句執(zhí)行開始時申請,但是語句結(jié)束后并不會馬上釋放,而會等到整個事務(wù)提交后再釋放。
基于上面的分析,我們來討論一個問題,如何安全地給小表加字段?
首先我們要解決長事務(wù),事務(wù)不提交,就會一直占著 MDL 鎖。在 MySQL 的 information_schema 庫的 innodb_trx 表中,你可以查到當(dāng)前執(zhí)行中的事務(wù)。如果你要做 DDL 變更的表剛好有長事務(wù)在執(zhí)行,要考慮先暫停 DDL,或者 kill 掉這個長事務(wù)。
但考慮一下這個場景。如果你要變更的表是一個熱點表,雖然數(shù)據(jù)量不大,但是上面的請求很頻繁,而你不得不加個字段,你該怎么做呢?
這時候 kill 可能未必管用,因為新的請求馬上就來了。比較理想的機制是,在 alter table 語句里面設(shè)定等待時間,如果在這個指定的等待時間里面能夠拿到 MDL 寫鎖最好,拿不到也不要阻塞后面的業(yè)務(wù)語句,先放棄。之后開發(fā)人員或者 DBA 再通過重試命令重復(fù)這個過程。
MariaDB 已經(jīng)合并了 AliSQL 的這個功能,所以這兩個開源分支目前都支持 DDL NOWAIT/WAIT n 這個語法。
ALTER TABLE tbl_name NOWAIT add column ...
ALTER TABLE tbl_name WAIT N add column ...
小結(jié)
介紹了 MySQL 的全局鎖和表級鎖。
全局鎖主要用在邏輯備份過程中。對于全部是 InnoDB 引擎的庫,我建議你選擇使用–single-transaction 參數(shù),對應(yīng)用會更友好。
表鎖一般是在數(shù)據(jù)庫引擎不支持行鎖的時候才會被用到的。如果你發(fā)現(xiàn)你的應(yīng)用程序里有 lock tables 這樣的語句,你需要追查一下,比較可能的情況是:
要么是你的系統(tǒng)現(xiàn)在還在用 MyISAM 這類不支持事務(wù)的引擎,那要安排升級換引擎;
要么是你的引擎升級了,但是代碼還沒升級。我見過這樣的情況,最后業(yè)務(wù)開發(fā)就是把 lock tables 和 unlock tables 改成 begin 和 commit,問題就解決了。
MDL 會直到事務(wù)提交才釋放,在做表結(jié)構(gòu)變更的時候,你一定要小心不要導(dǎo)致鎖住線上查詢和更新。