1. 程式人生 > >資料庫併發處理 - 上的一把好"鎖"

資料庫併發處理 - 上的一把好"鎖"

為什麼要有鎖?

我們都是知道,資料庫中鎖的設計是解決多使用者同時訪問共享資源時的併發問題。在訪問共享資源時,鎖定義了使用者訪問的規則。根據加鎖的範圍,MySQL 中的鎖可大致分成全域性鎖,表級鎖和行鎖三類。在本篇文章中,會依次介紹三種類型的鎖。在閱讀本篇文章後,應該掌握如下的內容:

  1. 為什麼要在備份時使用全域性鎖?
  2. 為什麼推薦使用 InnoDB 作為引擎進行備份?
  3. 設定全域性只讀的方法
  4. 表級鎖的兩種型別
  5. MDL 導致資料庫掛掉的問題
  6. 如何利用兩段鎖協議減少鎖衝突
  7. 如何解決死鎖
  8. 對於熱點表,如何避免死鎖檢測的損耗?

全域性鎖

什麼是全域性鎖?

全域性鎖會讓整個庫處於只讀狀態,其他執行緒語句(DML,DDL,更新事務類)的語句都被會阻塞。

使用全域性鎖的場景

在做全庫邏輯備份時,會把整庫進行 select 然後儲存成文字。

為什麼要使用全域性鎖?

想象這樣一個場景,要備份一個購買系統,其中購買操作設計到更新賬號餘額表和使用者課程表。

現在進行邏輯備份,在備份過程中,一位使用者購買了一門課程,這時需要在餘額表扣掉餘額,然後在購買的課程中加上一門課。正確的順序肯定是先進行購買操作,減少餘額和增加課程然後在進行備份。但卻有可能出現這樣的問題:

  1. 如果在時間順序上先備份餘額表 (u_account),然後使用者購買(操作兩張表),再備份使用者課程表(u_course)?

    這時用備份的資料做恢復時,會發現使用者沒花錢卻買了一堂課。原因在於,先備份餘額表,說明使用者餘額不變。之後才進行購買操作,餘額表減錢,課程表增加一門課程。接著備份課程表,課程表課程加一。購買操作在已經備份完的餘額表後進行。

  2. 如果在時間順序上先備份使用者課程表(u_course),然後使用者購買(操作兩張表),再備份餘額表 (u_account)?

    同樣的,如果先備份課程表,課程沒有增加,因為沒有進行購買操作。之後進行購買操作後,餘額表減錢,然後被備份。就出現了,使用者花錢卻沒有購買成功的情況。

也就是說,不加鎖的話,備份系統的得到的庫不是一個邏輯時間點,這個檢視是邏輯不一致。

如何解決檢視邏輯不一致的問題?

對於不支援事務的引擎,像 MyISAM. 通過使用 Flush tables with read lock (FTWRL) 命令來開啟全域性鎖。

但使用 FTWRL 存在的問題是:

  1. 在主庫上備份時,備份期間不能執行更新,業務基本暫停。
  2. 在從庫上備份,備份期間從庫不能執行主庫同步過來的 binlog,導致主從延遲。

對於支援事務並且開啟一致性檢視(可重複讀級別)下配合上 MVCC 的功能的引擎(InnoDB),備份就很簡單了。

使用官方的 mysqldump 工具時,加上 --single-transaction 選項,再匯出資料前就會啟動一個事務,來確保拿到一致性檢視。並且由於 MVCC 的支援,同時可以進行更新操作。

全庫只讀設定方法的比較

為什麼不推薦使用 set global readonly=true ,要使用 FTWRL :

  1. 在有些系統中,readonly 的值會被用來做其他邏輯,比如用來判斷一個庫是主庫還是備庫。因此,修改 global 變數的方式影響面更大,不建議使用。

  2. 在異常處理機制上有差異。

    執行 FTWRL 命令之後由於客戶端發生異常斷開,那麼 MySQL 會自動釋放這個全域性鎖,整個庫回到可以正常更新的狀態。

    將整個庫設定為 readonly 之後,如果客戶端發生異常,則資料庫就會一直保持 readonly 狀態,這樣會導致整個庫長時間處於不可寫狀態,風險較高。

表級鎖

什麼是表級鎖?

表級鎖的作用域是對某張表進行加鎖,在 MySQL 中表級別的鎖有兩種,一種是表鎖,一種是元資料鎖(meta data lock,MDL)。

表鎖

與 FTWRL 類似,可以使用 lock tables … read/write 來鎖定某張表。在釋放時,可以使用 unlock tables 來釋放鎖或者斷開連線時,主動釋放。

需要注意的是,這樣方式的鎖表,不但會限制其他執行緒的讀寫,也限定了自己執行緒的操作物件。

假如,執行緒 A 執行 lock tables t1 read, t2 write; 操作。

這時對於表 t1 來說,其他執行緒只能只讀,執行緒 A 也只能只讀,不能寫。

對於表 t2 來說,只允許執行緒 A 讀寫,其他執行緒讀寫都會被阻塞。

元資料鎖

與表鎖手動加鎖不同,元資料鎖會自動加上。

為什麼要有 MDL?

MDL 保證的就是讀寫的正確性,比如在查詢一箇中的資料時,此時另一個執行緒改變了表結構,查詢的結果和表結構不一致肯定不行。簡單來說,**MDL 就是解決 DML 和 DDL 之間同時操作的問題

在 MySQL 5.5 引入了 MDL,在對一個進行 DML 時,會加 DML 讀鎖。進行 DDL 時,會加 MDL寫鎖。

讀鎖間不互斥,允許多個執行緒同時對同一張表進行 DML。

讀寫鎖之間、寫鎖之間是互斥的,用來保證變更表結構操作的安全性。

  1. 如果有兩個執行緒要同時給一個表加欄位,其中一個要等另一個執行完才能開始執行。
  2. 如果一個執行緒要讀,另一個執行緒要寫。根據訪問表的時間,一個操作進行完之後,另一個才可以進行。

MDL 引發的問題?

給表加欄位,卻導致庫掛了?

由於 MDL 是自動加的,並且在給表加欄位或者修改欄位或者加索引時,需要掃描全表的資料。所以在對大表操作時,要非常小心,以免對線上的服務造成影響。但實際上,操作小表時,也可能出問題。假設 t 是小表。按照下圖所示,開啟四個 session.

MySQL 5.7.27

假設有一張叫 sync_test 的表:

mysql> desc sync_test;
+-------+--------------+------+-----+---------+----------------+
| Field | Type         | Null | Key | Default | Extra          |
+-------+--------------+------+-----+---------+----------------+
| id    | int(11)      | NO   | PRI | NULL    | auto_increment |
| name  | varchar(255) | NO   |     | NULL    |                |
+-------+--------------+------+-----+---------+----------------+
2 rows in set (0.00 sec)

開啟事務1, 插入資料。對於事務 1 來說,自動申請了表 sync_test 的 MDL 讀鎖:

開啟事務2,插入資料。對於事務 2 來說,自動申請了表 sync_test 的 MDL 讀鎖:

開啟事務3,改變表結構。對於事務 3 來說,會申請表 sync_test 的 MDL 寫鎖,這時由於讀寫鎖互斥,被阻塞:

開啟事務 4,插入資料。對於事務 4 來說,會申請 sync_test 的 MDL 讀鎖,由於之前事務 3 提前申請了寫鎖,互斥所以被阻塞:

這時如果在這張表上的查詢語句很頻繁,而且客戶端有重連機制,在超時後會再起一個新 session 請求,這個庫的執行緒就很快會爆滿了。

如何安全的給表加資源

通過上面的例子也可以看到,MDL 會直到事務提交才釋放,在做表結構變更的時候,一定要小心不要導致鎖住線上查詢和更新。在開啟事務後,並沒有在短時間內結束,也就是由於所謂的長事務造成的。如果想對某個表進行 DDL 的操作時,可以先查詢下是否有長事務的執行(information_schema 下的 innodb_trx 表),可以先 kill 這個事務,然後做 DDL 操作。

但有時 kill 也未必可以,在表被頻繁使用時,新的事務可能馬上就來了。比較理想的情況,在 alter table 中設定等待時間,如果在時間內拿到最好,否則就放棄,不要阻塞語句。之後再重複這個操作。

MariaDB 已經合併了 AliSQL 的這個功能,所以這兩個開源分支目前都支援 DDL NOWAIT/WAIT n 這個語法。

ALTER TABLE tbl_name NOWAIT add column ...
ALTER TABLE tbl_name WAIT N add column ...

行級鎖

什麼是行級鎖?

MySQL 的行鎖是由引擎層自己實現的,所有不是所有的引擎都執行行鎖,比如在 MyISAM 引擎就不支援行鎖。不支援行鎖意味著併發控制只能用表鎖,這就造成了在同一時刻只有一個更新在執行,就影響到了業務的併發度。InnoDB 支援行鎖是讓 MyISAM 被取代的重要原因。

行鎖就是對資料庫表中行記錄的鎖。比如事務 A,B 同時想要更新一行資料,在更新時一定會按照一定的順序進行,而不能同時更新。

行鎖的目的就是減少像表級別的鎖衝突,來提升業務的併發度。

兩階段鎖協議

在 InnoDB 的事務中,行鎖是在需要的時候在加上,但並不是使用完就釋放,而是在事務結束後才釋放,這就是兩階段鎖協議。

假設有一個表 t,事務 A, B 操作表 t 的過程如下:

在事務 A 的兩條語句更新後,事務 B 更新操作會被阻塞。直到事務 A 中執行 commit 操作後才能執行。

兩階段鎖在事務上的幫助

由於兩階段鎖的特點,在事務結束時才會釋放鎖,所以需要遵循的一個原則是事務中需要鎖多個行時,把有可能造成鎖衝突,最可能影響併發度的鎖儘量向後放。

比如購買課程的例子,顧客 A 購買培訓機構 B 一門課程。涉及到操作:

  1. 顧客 A 的餘額減少
  2. 培訓機構 B 所在的餘額增加。
  3. 插入一條交易資訊的操作。

對於第二個操作,當有許多人同時購買時併發度就較高,出現鎖衝突的情況也較高。所以將操作 2 放置一個事務的最後就更好。

當有時併發度過大時,我們會發現一種現象 CPU 的使用率接近 100%,但事務執行數量卻很少。這就可能出現了死鎖。

死鎖的檢查

當併發系統中不同的執行緒出現迴圈的資源依賴,等待別的執行緒釋放資源時,就會讓涉及的執行緒處於一直等待的情況。這就稱為死鎖。

如上圖中,事務 A 對id =1 的所在行,加入了行鎖。等待 id=2 的行鎖。事務 B 對 id = 2 的行,加入了行鎖。等待 id=1 的行鎖。事務 A,B 等待對方資源的釋放。

如何解決死鎖

方式 一: 設定死鎖的等待時間 innodb_lock_wait_timeout

還是 sync_test 這張表,模擬簡單的鎖等待情況,注意這裡並不是死鎖。開啟兩個事務 A,B. 同時對 id=1 這行進行更新。

事務 A 更新操作:

mysql> begin;
Query OK, 0 rows affected (0.00 sec)

mysql> update sync_test set name="dead_lock_test" where id = 1;
Query OK, 1 row affected (0.00 sec)
Rows matched: 1  Changed: 1  Warnings: 0

事務 B 更新操作:

mysql> begin;
Query OK, 0 rows affected (0.00 sec)

mysql> update sync_test set name="dead_lock_test2" where id = 1;
ERROR 1205 (HY000): Lock wait timeout exceeded; try restarting transaction

可以看到事務 B 丟擲了死鎖等待的錯誤。

設定等待時間的問題

在 InnoDB 中,MySQL 預設的死鎖等待時間是 50s. 意味著在出現死鎖後,被鎖住的執行緒要過 50s 被能退出,這對於線上服務說,等待時間過長。但如果把值設定的過小,如果是像上述例子這樣是簡單的鎖等待呢,並不是死鎖怎麼辦,就會出現誤傷的情況。

方式二:發起死鎖檢測,發現死鎖後,主動回滾某個事務,讓其他事務繼續執行。

MySQL 中預設就是開啟狀態,能夠快速發現死鎖的情況。

set innodb_deadlock_detect=on

事務 A,B 互相依賴,造成死鎖的例子:

開啟事務 A:

mysql> begin;
mysql> update sync_test set name="dead_lock_test1" where id = 1;

開啟事務 A:

mysql> begin;
mysql> update sync_test set name="dead_lock_test3" where id = 3;

繼續操作事務 A:

mysql> update sync_test set name="dead_lock_test3_1" where id = 3;

# 會出現阻塞的情況

繼續操作事務 B:

mysql> update sync_test set name="dead_lock_test1_2" where id = 1;
ERROR 1213 (40001): Deadlock found when trying to get lock; try restarting transaction

此時事務 A 阻塞取消,執行成功。

不過檢測死鎖也是有額外負擔的,每當一個事務被鎖的時候,就要看看它所依賴的執行緒有沒有被別人鎖住,如此迴圈,最後判斷是否出現了迴圈等待,也就是死鎖。如果是所有事務都要更新同一行的場景呢?每個新來的被堵住的執行緒,都要判斷會不會由於自己的加入導致了死鎖,這是一個時間複雜度是 O(n) 的操作。假設有 1000 個併發執行緒要同時更新同一行,那麼死鎖檢測操作就是 1000*1000=100 萬這個量級的。

所以,對於更新頻繁併發量大的表,死鎖檢測會導致消耗大量的 CPU.

如何避免死鎖檢測的損耗

方法一:如果保證業務一定不會出現死鎖,可以臨時把死鎖檢查關掉。

但這樣存在一定的風險,因為業務設計時不會把死鎖當做嚴重的問題,出現死鎖後回滾後,再重試就沒有問題了。但關掉死鎖檢測後,可能出現大量超時的情況。

方法二:控制併發度。

如果對於併發量能控制,比如同一行同時最多隻有 10 個執行緒在更新,那麼死鎖檢測的成本很低,就不會出現這個問題。具體來說在客戶端做併發控制,但對於客戶端較多的應用,也無法控制。所以併發控制在資料庫服務端,如果有中介軟體,也可以考慮在中介軟體中實現。

方法三:降低死鎖的概率

將一行統計的結構,拆成多行累計的結構。比如將之前某個教學機構的金額由一行拆成 10 行,總收入就等於這 10 行資料的累計。這樣原來鎖衝突的概率變為原來的 1/10, 也就減少了死鎖檢測的 CPU 消耗。但在一部分行記錄變成0 時,程式碼需要特殊處理。

總結

本篇文章中,依次介紹了全域性鎖、表級鎖和行鎖的概念。

對於全域性鎖來說,使用 InnoDB 引擎 在 RR 級別和 MVCC 的幫助下,可以讓其在備份的同事更新資料。

對於表級鎖來說,對於更新熱點表的表結構時,要注意 MDL 讀寫鎖互斥的情況,造成資料庫掛掉。

對於行級鎖來說,合理的利用兩段鎖協議,降低鎖的衝突。並要注意死鎖發生的情況,採取合適的死鎖檢測手段。