是不是精神續作?《喋血復仇》開發商和肯德基推特互懟
MySqL的鎖
資料庫鎖設計的初衷是處理併發問題。作為多使用者共享的資源,當出現併發訪問的時候,資料庫需要合理地控制資源的訪問規則。而鎖就是用來實現這些訪問規則的重要資料結構。
根據加鎖的範圍,MySQL 裡面的鎖大致可以分成全域性鎖、表級鎖和行鎖三類。
全域性鎖
全域性鎖就是對整個資料庫例項加鎖。MySQL 提供了一個加全域性讀鎖的方法,命令是Flush tables with read lock (FTWRL)。當你需要讓整個庫處於只讀狀態的時候,可以使用這個命令,之後其他執行緒的以下語句會被阻塞:資料更新語句(資料的增刪改)、資料定義語句(包括建表、修改表結構等)和更新類事務的提交語句
全域性鎖的使用場景
全域性鎖的典型使用場景,是做資料庫的邏輯備份。也就是把整庫每個表都 select 出來存成文字。注意,在備份過程中整個庫完全處於只讀狀態。 但是讓整庫都只讀,會存在以下問題:
- 如果你在主庫上備份,那麼在備份期間都不能執行更新,業務基本上就得停擺;
- 如果你在從庫上備份,那麼備份期間從庫不能執行主庫同步過來的 binlog,會導致主從延遲。
看來加全域性鎖不太好。但是細想一下,備份為什麼要加鎖呢?我們來看一下不加鎖會有什麼問題。 假設,資料庫裡有兩張表,賬戶餘額表和使用者課程表:
如果在備份的時候,不加上全域性鎖,考慮如下情況:
- 先備份使用者餘額表,再備份使用者課程表
如圖:
在這個備份結果裡,資料的狀態是使用者的餘額沒有扣除,但是使用者課程表裡卻多了一門課。這顯然是不合理的
反過來呢?也會產生類似的效果。
先備份使用者餘額表,再備份使用者課程表
也就是說,不加鎖的話,備份系統備份的得到的庫不是一個邏輯時間點,這個檢視是邏輯不一致的。
事實上,在介紹事務隔離的時候,其實是有一個方法能夠拿到一致性檢視的,就是在可重複讀隔離級別下開啟一個事務。
官方自帶的邏輯備份工具是mysqldump。當 mysqldump 使用引數–single-transaction的時候,導資料之前就會啟動一個事務,來確保拿到一致性檢視。而由於 MVCC 的支援,這個過程中資料是可以正常更新的。
你一定在疑惑,有了這個功能,為什麼還需要 FTWRL 呢?一致性讀是好,但前提是引擎要支援這個隔離級別。比如,對於 MyISAM 這種不支援事務的引擎,如果備份過程中有更新,總是隻能取到最新的資料,那麼就破壞了備份的一致性。這時,我們就需要使用 FTWRL 命令了。 所以,single-transaction
- 一是,在有些系統中,readonly 的值會被用來做其他邏輯,比如用來判斷一個庫是主庫還是備庫。因此,修改 global 變數的方式影響面更大,我不建議你使用。
- 二是,在異常處理機制上有差異。如果執行 FTWRL 命令之後由於客戶端發生異常斷開,那麼 MySQL 會自動釋放這個全域性鎖,整個庫回到可以正常更新的狀態。而將整個庫設定為 readonly 之後,如果客戶端發生異常,則資料庫就會一直保持 readonly 狀態,這樣會導致整個庫長時間處於不可寫狀態,風險較高。
業務的更新不只是增刪改資料(DML),還有可能是加欄位等修改表結構的操作(DDL)。不論是哪種方法,一個庫被全域性鎖上以後,你要對裡面任何一個表做加欄位操作,都是會被鎖住的。
但是,即使沒有被全域性鎖住,加欄位也不是就能一帆風順的,因為你還會碰到接下來我們要介紹的表級鎖。
表級鎖
MySQL 裡面表級別的鎖有兩種:一種是表鎖,一種是元資料鎖(meta data lock,MDL)。
表鎖的語法是lock tables … read/write。與 FTWRL 類似,可以用 unlock tables 主動釋放鎖,也可以在客戶端斷開的時候自動釋放。需要注意,lock tables 語法除了會限制別的執行緒的讀寫外,也限定了本執行緒接下來的操作物件。
舉個例子, 如果在某個執行緒 A 中執行 lock tables t1 read, t2 write; 這個語句,則其他執行緒寫 t1、讀寫 t2 的語句都會被阻塞。同時,執行緒 A 在執行 unlock tables 之前,也只能執行讀 t1、讀寫 t2 的操作。連寫 t1 都不允許,自然也不能訪問其他表
在還沒有出現更細粒度的鎖的時候,表鎖是最常用的處理併發的方式。而對於 InnoDB 這種支援行鎖的引擎,一般不使用 lock tables 命令來控制併發,畢竟鎖住整個表的影響面還是太大。
另一類表級的鎖是 MDL(metadata lock)。MDL 不需要顯式使用,在訪問一個表的時候會被自動加上。MDL 的作用是,保證讀寫的正確性。你可以想象一下,如果一個查詢正在遍歷一個表中的資料,而執行期間另一個執行緒對這個表結構做變更,刪了一列,那麼查詢執行緒拿到的結果跟表結構對不上,肯定是不行的。
因此,在 MySQL 5.5 版本中引入了 MDL,當對一個表做增刪改查操作的時候,加 MDL 讀鎖;當要對錶做結構變更操作的時候,加 MDL 寫鎖。
- 讀鎖之間不互斥,因此你可以有多個執行緒同時對一張表增刪改查。
- 讀寫鎖之間、寫鎖之間是互斥的,用來保證變更表結構操作的安全性。因此,如果有兩個執行緒要同時給一個表加欄位,其中一個要等另一個執行完才能開始執行。
雖然 MDL 鎖是系統預設會加的,但卻是你不能忽略的一個機制。
給一個表加欄位,或者修改欄位,或者加索引,需要掃描全表的資料。在對大表操作的時候,你肯定會特別小心,以免對線上服務造成影響。而實際上,即使是小表,操作不慎也會出問題。我們來看一下下面的操作序列,假設表 t 是一個小表。
我們可以看到 session A 先啟動,這時候會對錶 t 加一個 MDL 讀鎖。由於 session B 需要的也是 MDL 讀鎖,因此可以正常執行。
之後 session C 會被 blocked,是因為 session A 的 MDL 讀鎖還沒有釋放,而 session C 需要 MDL 寫鎖,因此只能被阻塞。
如果只有 session C 自己被阻塞還沒什麼關係,但是之後所有要在表 t 上新申請 MDL 讀鎖的請求也會被 session C 阻塞。前面我們說了,所有對錶的增刪改查操作都需要先申請 MDL 讀鎖,就都被鎖住,等於這個表現在完全不可讀寫了。
如果某個表上的查詢語句頻繁,而且客戶端有重試機制,也就是說超時後會再起一個新 session 再請求的話,這個庫的執行緒很快就會爆滿。
也就是是說,事務中的 MDL 鎖,在語句執行開始時申請,但是語句結束後並不會馬上釋放,而會等到整個事務提交後再釋放。
如何安全的給小表加欄位?
首先我們要解決長事務,事務不提交,就會一直佔著 MDL 鎖。在 MySQL 的 information_schema 庫的 innodb_trx 表中,你可以查到當前執行中的事務。如果你要做 DDL 變更的表剛好有長事務在執行,要考慮先暫停 DDL,或者 kill 掉這個長事務。
但考慮一下這個場景。如果你要變更的表是一個熱點表,雖然資料量不大,但是上面的請求很頻繁,而你不得不加個欄位,你該怎麼做呢?
這時候 kill 可能未必管用,因為新的請求馬上就來了。比較理想的機制是,在 alter table 語句裡面設定等待時間,如果在這個指定的等待時間裡面能夠拿到 MDL 寫鎖最好,拿不到也不要阻塞後面的業務語句,先放棄。之後開發人員或者 DBA 再通過重試命令重複這個過程。
行級鎖
MySQL 的行鎖是在引擎層由各個引擎自己實現的。但並不是所有的引擎都支援行鎖,比如 MyISAM 引擎就不支援行鎖。不支援行鎖意味著併發控制只能使用表鎖,對於這種引擎的表,同一張表上任何時刻只能有一個更新在執行,這就會影響到業務併發度。InnoDB 是支援行鎖的,這也是 MyISAM 被 InnoDB 替代的重要原因之一。
顧名思義,行鎖就是針對資料表中行記錄的鎖。這很好理解,比如事務 A 更新了一行,而這時候事務 B 也要更新同一行,則必須等事務 A 的操作完成後才能進行更新。
兩階段鎖協議
如圖:
這個問題的結論取決於事務 A 在執行完兩條 update 語句後,持有哪些鎖,以及在什麼時候釋放。你可以驗證一下:實際上事務 B 的 update 語句會被阻塞,直到事務 A 執行 commit 之後,事務 B 才能繼續執行。
也就是說,在 InnoDB 事務中,行鎖是在需要的時候才加上的,但並不是不需要了就立刻釋放,而是要等到事務結束時才釋放。
這個就是兩階段鎖協議。知道了這個設定,對我們使用事務有什麼幫助呢?那就是,如果你的事務中需要鎖多個行,要把最可能造成鎖衝突、最可能影響併發度的鎖儘量往後放。我給你舉個例子。
假設你負責實現一個電影票線上交易業務,顧客 A 要在影院 B 購買電影票。我們簡化一點,這個業務需要涉及到以下操作:
- 從顧客 A 賬戶餘額中扣除電影票價;
- 給影院 B 的賬戶餘額增加這張電影票價;
- 記錄一條交易日誌。也就是說,
要完成這個交易,我們需要 update 兩條記錄,並 insert 一條記錄。當然,為了保證交易的原子性,我們要把這三個操作放在一個事務中。那麼,你會怎樣安排這三個語句在事務中的順序呢?
試想如果同時有另外一個顧客 C 要在影院 B 買票,那麼這兩個事務衝突的部分就是語句 2 了。因為它們要更新同一個影院賬戶的餘額,需要修改同一行資料。
根據兩階段鎖協議,不論你怎樣安排語句順序,所有的操作需要的行鎖都是在事務提交的時候才釋放的。所以,如果你把語句 2 安排在最後,比如按照 3、1、2 這樣的順序,那麼影院賬戶餘額這一行的鎖時間就最少。這就最大程度地減少了事務之間的鎖等待,提升了併發度。
但是即使正確設計,伺服器也有可能會出現問題,這裡就要說到死鎖和死鎖檢測了。
死鎖和死鎖檢測
當併發系統中不同執行緒出現迴圈資源依賴,涉及的執行緒都在等待別的執行緒釋放資源時,就會導致這幾個執行緒都進入無限等待的狀態,稱為死鎖。使用資料庫的行鎖舉個例子:
這時候,事務 A 在等待事務 B 釋放 id=2 的行鎖,而事務 B 在等待事務 A 釋放 id=1 的行鎖。 事務 A 和事務 B 在互相等待對方的資源釋放,就是進入了死鎖狀態。
當出現死鎖以後,有兩種策略:
- 一種策略是,直接進入等待,直到超時。這個超時時間可以通過引數 innodb_lock_wait_timeout 來設定。
- 另一種策略是,發起死鎖檢測,發現死鎖後,主動回滾死鎖鏈條中的某一個事務,讓其他事務得以繼續執行。將引數 innodb_deadlock_detect 設定為 on,表示開啟這個邏輯。
在 InnoDB 中,innodb_lock_wait_timeout 的預設值是 50s,意味著如果採用第一個策略,當出現死鎖以後,第一個被鎖住的執行緒要過 50s 才會超時退出,然後其他執行緒才有可能繼續執行。對於線上服務來說,這個等待時間往往是無法接受的。
但是,我們又不可能直接把這個時間設定成一個很小的值,比如 1s。這樣當出現死鎖的時候,確實很快就可以解開,但如果不是死鎖,而是簡單的鎖等待呢?所以,超時時間設定太短的話,會出現很多誤傷。
所以,正常情況下我們還是要採用第二種策略,即:主動死鎖檢測,而且 innodb_deadlock_detect 的預設值本身就是 on。主動死鎖檢測在發生死鎖的時候,是能夠快速發現並進行處理的,但是它也是有額外負擔的。
你可以想象一下這個過程:每當一個事務被鎖的時候,就要看看它所依賴的執行緒有沒有被別人鎖住,如此迴圈,最後判斷是否出現了迴圈等待,也就是死鎖。
那如果是我們上面說到的所有事務都要更新同一行的場景呢?
每個新來的被堵住的執行緒,都要判斷會不會由於自己的加入導致了死鎖,這是一個時間複雜度是 O(n) 的操作。假設有 1000 個併發執行緒要同時更新同一行,那麼死鎖檢測操作就是 100 萬這個量級的。雖然最終檢測的結果是沒有死鎖,但是這期間要消耗大量的 CPU 資源。因此,你就會看到 CPU 利用率很高,但是每秒卻執行不了幾個事務。
怎麼解決由這種熱點行更新導致的效能問題呢?問題的癥結在於,死鎖檢測要耗費大量的 CPU 資源。
一種頭痛醫頭的方法,就是如果你能確保這個業務一定不會出現死鎖,可以臨時把死鎖檢測關掉。但是這種操作本身帶有一定的風險,因為業務設計的時候一般不會把死鎖當做一個嚴重錯誤,畢竟出現死鎖了,就回滾,然後通過業務重試一般就沒問題了,這是業務無損的。而關掉死鎖檢測意味著可能會出現大量的超時,這是業務有損的。
另一個思路是控制併發度。根據上面的分析,你會發現如果併發能夠控制住,比如同一行同時最多隻有 10 個執行緒在更新,那麼死鎖檢測的成本很低,就不會出現這個問題。一個直接的想法就是,在客戶端做併發控制。但是,你會很快發現這個方法不太可行,因為客戶端很多。我見過一個應用,有 600 個客戶端,這樣即使每個客戶端控制到只有 5 個併發執行緒,彙總到資料庫服務端以後,峰值併發數也可能要達到 3000。
因此,這個併發控制要做在資料庫服務端。如果你有中介軟體,可以考慮在中介軟體實現;如果你的團隊有能修改 MySQL 原始碼的人,也可以做在 MySQL 裡面。基本思路就是,對於相同行的更新,在進入引擎之前排隊。這樣在 InnoDB 內部就不會有大量的死鎖檢測工作了。
可能你會問,如果團隊裡暫時沒有資料庫方面的專家,不能實現這樣的方案,能不能從設計上優化這個問題呢?
你可以考慮通過將一行改成邏輯上的多行來減少鎖衝突。還是以影院賬戶為例,可以考慮放在多條記錄上,比如 10 個記錄,影院的賬戶總額等於這 10 個記錄的值的總和。這樣每次要給影院賬戶加金額的時候,隨機選其中一條記錄來加。這樣每次衝突概率變成原來的 1/10,可以減少鎖等待個數,也就減少了死鎖檢測的 CPU 消耗。
這個方案看上去是無損的,但其實這類方案需要根據業務邏輯做詳細設計。如果賬戶餘額可能會減少,比如退票邏輯,那麼這時候就需要考慮當一部分行記錄變成 0 的時候,程式碼要有特殊處理。