【MYSQL】 數據庫實現分佈式鎖

下面我們來了解一下基於數據庫(MySQL)的方案,一般分爲3類:基於表記錄、樂觀鎖和悲觀鎖。

基於表記錄,可以通過UNIQUE KEY實現鎖

我們可以創建這樣一張表

CREATE TABLE `database_lock` (
	`id` BIGINT NOT NULL AUTO_INCREMENT,
	`resource` int NOT NULL COMMENT '鎖定的資源',
	`description` varchar(1024) NOT NULL DEFAULT "" COMMENT '描述',
	PRIMARY KEY (`id`),
	UNIQUE KEY `uiq_idx_resource` (`resource`) 
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='數據庫分佈式鎖表';

如果要鎖住某方法,只要執行以下sql:

INSERT INTO database_lock(resource, description) VALUES (1, 'lock');

注意:在表database_lock中,resource字段做了唯一性約束,這樣如果有多個請求同時提交到數據庫的話,數據庫可以保證只有一個操作可以成功(其它的會報錯:ERROR 1062 (23000): Duplicate entry ‘1’ for key ‘uiq_idx_resource’),那麼那麼我們就可以認爲操作成功的那個請求獲得了鎖。

當需要釋放鎖的時,可以刪除這條數據:

這種實現方式非常的簡單,但是需要注意以下幾點:

這種鎖沒有失效時間,一旦釋放鎖的操作失敗就會導致鎖記錄一直在數據庫中,其它線程無法獲得鎖。這個缺陷也很好解決,比如可以做一個定時任務去定時清理。
這種鎖的可靠性依賴於數據庫。建議設置備庫,避免單點,進一步提高可靠性。
這種鎖是非阻塞的,因爲插入數據失敗之後會直接報錯,想要獲得鎖就需要再次操作。如果需要阻塞式的,可以弄個for循環、while循環之類的,直至INSERT成功再返回。
這種鎖也是非可重入的,因爲同一個線程在沒有釋放鎖之前無法再次獲得鎖,因爲數據庫中已經存在同一份記錄了。想要實現可重入鎖,可以在數據庫中添加一些字段,比如獲得鎖的主機信息、線程信息等,那麼在再次獲得鎖的時候可以先查詢數據,如果當前的主機信息和線程信息等能被查到的話,可以直接把鎖分配給它。

總結
讓我們總結分佈式鎖需要具備的幾個條件:

互斥(必須):同一時刻,分佈式部署的應用中,同一個方法/資源只能被一臺機器上的一個線程佔用。

鎖失效保護(必須):出現客戶端斷電等異常情況,鎖仍然能被其他客戶端獲取,防止死鎖。

可重入(可選):同一個線程在沒有釋放鎖之前,如果想再次操作,可以直接獲得鎖。

阻塞/非阻塞(可選):若沒有獲取到鎖,返回獲取失敗

高可用、高性能(可選):獲取釋放鎖最好是原子操作,獲取釋放鎖的性能要好

除了互斥,其他條件都存在一些問題:

  1. 目前沒有鎖失效時間,如果解鎖失敗,就會導致鎖記錄永遠留在數據庫中,無法被其他線程獲取,該方法就會永久不可用。

  2. 該鎖不可重入,因爲它不認識請求方是不是當前佔用鎖的線程。

  3. 該鎖是非阻塞的,因爲數據庫插入操作失敗會直接報錯,線程只能再次請求獲得鎖。

  4. 當前數據庫是單點,一旦宕機,鎖機制就會完全崩壞。

解決方案:

  1. 針對鎖失效問題,我們可以新增一個expire超時字段,在加鎖時設置。

然後另起一個線程,負責輪詢刪除表中超時的數據。

  1. 針對不可重入問題,我們可以再新增一個request_info字段,記錄當前獲取鎖的線程的機器和線程信息,當相同的線程再次訪問時,就可以識別放行了。

  2. 針對非阻塞,還是和redis篇一樣,寫一個while死循環,失敗了不斷重拾,直到獲取鎖成功爲止。

  3. 針對單點問題,通用方案就是配置主從數據庫。

數據庫樂觀鎖實現

顧名思義,系統認爲數據的更新在大多數情況下是不會產生衝突的,只在數據庫更新操作提交的時候纔對數據作衝突檢測。如果檢測的結果出現了與預期數據不一致的情況,則返回失敗信息。

樂觀鎖大多數是基於數據版本(version)的記錄機制實現的。何謂數據版本號?即爲數據增加一個版本標識,在基於數據庫表的版本解決方案中,一般是通過爲數據庫表添加一個 “version”字段來實現讀取出數據時,將此版本號一同讀出,之後更新時,對此版本號加1。在更新過程中,會對版本號進行比較,如果是一致的,沒有發生改變,則會成功執行本次操作;如果版本號不一致,則會更新失敗。

爲了更好的理解數據庫樂觀鎖在實際項目中的使用,這裏就列舉一個典型的電商庫存的例子。一個電商平臺都會存在商品的庫存,當用戶進行購買的時候就會對庫存進行操作(庫存減1代表已經賣出了一件)。我們將這個庫存模型用下面的一張表optimistic_lock來表述,參考如下:

CREATE TABLE `optimistic_lock` (
	`id` BIGINT NOT NULL AUTO_INCREMENT,
	`resource` int NOT NULL COMMENT '鎖定的資源',
	`version` int NOT NULL COMMENT '版本信息',
	`created_at` datetime COMMENT '創建時間',
	`updated_at` datetime COMMENT '更新時間',
	`deleted_at` datetime COMMENT '刪除時間', 
	PRIMARY KEY (`id`),
	UNIQUE KEY `uiq_idx_resource` (`resource`) 
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='數據庫分佈式鎖表';

其中:id表示主鍵;resource表示具體操作的資源,在這裏也就是特指庫存;version表示版本號。

在使用樂觀鎖之前要確保表中有相應的數據,比如

INSERT INTO optimistic_lock(resource, version, created_at, updated_at) VALUES(20, 1, CURTIME(), CURTIME());

如果只是一個線程進行操作,數據庫本身就能保證操作的正確性。主要步驟如下:

STEP1 - 獲取資源:SELECT resource FROM optimistic_lock WHERE id = 1
STEP2 - 執行業務邏輯
STEP3 - 更新資源:UPDATE optimistic_lock SET resource = resource -1 WHERE id = 1
然而在併發的情況下就會產生一些意想不到的問題:比如兩個線程同時購買一件商品,在數據庫層面實際操作應該是庫存(resource)減2,但是由於是高併發的情況,第一個線程執行之後(執行了STEP1、STEP2但是還沒有完成STEP3),第二個線程在購買相同的商品(執行STEP1),此時查詢出的庫存並沒有完成減1的動作,那麼最終會導致2個線程購買的商品卻出現庫存只減1的情況。

在引入了version字段之後,那麼具體的操作就會演變成下面的內容:

STEP1 - 獲取資源: SELECT resource, version FROM optimistic_lock WHERE id = 1
STEP2 - 執行業務邏輯
STEP3 - 更新資源:UPDATE optimistic_lock SET resource = resource -1, version = version + 1 WHERE id = 1 AND version = oldVersion
其實,藉助更新時間戳(updated_at)也可以實現樂觀鎖,和採用version字段的方式相似:更新操作執行前線獲取記錄當前的更新時間,在提交更新時,檢測當前更新時間是否與更新開始時獲取的更新時間戳相等。

樂觀鎖的優點比較明顯,由於在檢測數據衝突時並不依賴數據庫本身的鎖機制,不會影響請求的性能,當產生併發且併發量較小的時候只有少部分請求會失敗。缺點是需要對錶的設計增加額外的字段,增加了數據庫的冗餘,另外,當應用併發量高的時候,version值在頻繁變化,則會導致大量請求失敗,影響系統的可用性。我們通過上述sql語句還可以看到,數據庫鎖都是作用於同一行數據記錄上,這就導致一個明顯的缺點,在一些特殊場景,如大促、秒殺等活動開展的時候,大量的請求同時請求同一條記錄的行鎖,會對數據庫產生很大的寫壓力。所以綜合數據庫樂觀鎖的優缺點,樂觀鎖比較適合併發量不高,並且寫操作不頻繁的場景。

數據庫悲觀鎖

除了可以通過增刪操作數據庫表中的記錄以外,我們還可以藉助數據庫中自帶的鎖來實現分佈式鎖。在查詢語句後面增加FOR UPDATE,數據庫會在查詢過程中給數據庫表增加悲觀鎖,也稱排他鎖。當某條記錄被加上悲觀鎖之後,其它線程也就無法再改行上增加悲觀鎖。

悲觀鎖,與樂觀鎖相反,總是假設最壞的情況,它認爲數據的更新在大多數情況下是會產生衝突的。

在使用悲觀鎖的同時,我們需要注意一下鎖的級別。MySQL InnoDB引起在加鎖的時候,只有明確地指定主鍵(或索引)的纔會執行行鎖 (只鎖住被選取的數據),否則MySQL 將會執行表鎖(將整個數據表單給鎖住)。

在使用悲觀鎖時,我們必須關閉MySQL數據庫的自動提交屬性(參考下面的示例),因爲MySQL默認使用autocommit模式,也就是說,當你執行一個更新操作後,MySQL會立刻將結果進行提交。

mysql> SET AUTOCOMMIT = 0;
Query OK, 0 rows affected (0.00 sec)

這樣在使用FOR UPDATE獲得鎖之後可以執行相應的業務邏輯,執行完之後再使用COMMIT來釋放鎖。

我們不妨沿用前面的database_lock表來具體表述一下用法。假設有一線程A需要獲得鎖並執行相應的操作,那麼它的具體步驟如下:

STEP1 - 獲取鎖:SELECT * FROM database_lock WHERE id = 1 FOR UPDATE;。
STEP2 - 執行業務邏輯。
STEP3 - 釋放鎖:COMMIT。
如果另一個線程B在線程A釋放鎖之前執行STEP1,那麼它會被阻塞,直至線程A釋放鎖之後才能繼續。注意,如果線程A長時間未釋放鎖,那麼線程B會報錯,參考如下(lock wait time可以通過innodb_lock_wait_timeout來進行配置):

ERROR 1205 (HY000): Lock wait timeout exceeded; try restarting transaction

上面的示例中演示了指定主鍵並且能查詢到數據的過程(觸發行鎖),如果查不到數據那麼也就無從“鎖”起了。

如果未指定主鍵(或者索引)且能查詢到數據,那麼就會觸發表鎖,比如STEP1改爲執行(這裏的version只是當做一個普通的字段來使用,與上面的樂觀鎖無關):

SELECT * FROM database_lock WHERE description='lock' FOR UPDATE;

或者主鍵不明確也會觸發表鎖,又比如STEP1改爲執行:

SELECT * FROM database_lock WHERE id>0 FOR UPDATE;

注意,雖然我們可以顯示使用行級鎖(指定可查詢的主鍵或索引),但是MySQL會對查詢進行優化,即便在條件中使用了索引字段,但是否真的使用索引來檢索數據是由MySQL通過判斷不同執行計劃的代價來決定的,如果MySQL認爲全表掃描效率更高,比如對一些很小的表,它有可能不會使用索引,在這種情況下InnoDB將使用表鎖,而不是行鎖。

在悲觀鎖中,每一次行數據的訪問都是獨佔的,只有當正在訪問該行數據的請求事務提交以後,其他請求才能依次訪問該數據,否則將阻塞等待鎖的獲取。悲觀鎖可以嚴格保證數據訪問的安全。但是缺點也明顯,即每次請求都會額外產生加鎖的開銷且未獲取到鎖的請求將會阻塞等待鎖的獲取,在高併發環境下,容易造成大量請求阻塞,影響系統可用性。另外,悲觀鎖使用不當還可能產生死鎖的情況。

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章