分佈式鎖(4)-基於Mysql實現
1.使用場景
在分佈式系統裏,我們有時執行定時任務,或者處理某些併發請求,需要確保多點系統裏同時只有一個執行線程進行處理。
分佈式鎖就是在分佈式系統裏互斥訪問資源的解決方案。
通常我們會更多地使用Redis分佈式鎖、Zookeeper分佈式鎖的解決方案。
本篇文章介紹的是基於MySQL實現的分佈式鎖方案,性能上肯定是不如Redis、Zookeeper。
所以,基於Mysql實現分佈式鎖,適用於對性能要求不高,並且不希望因爲要使用分佈式鎖而引入新組件。
2.基於唯一索引(insert
)實現
2.1 實現方式
- 獲取鎖時在數據庫中insert一條數據,包括id、方法名(唯一索引)、線程名(用於重入)、重入計數
- 獲取鎖如果成功則返回true
- 獲取鎖的動作放在while循環中,週期性嘗試獲取鎖直到結束或者可以定義方法來限定時間內獲取鎖
- 釋放鎖的時候,delete對應的數據
2.2 優點:
- 實現簡單、易於理解
2.3 缺點
- 沒有線程喚醒,獲取失敗就被丟掉了;
- 沒有超時保護,一旦解鎖操作失敗,就會導致鎖記錄一直在數據庫中,其他線程無法再獲得到鎖;
- 這把鎖強依賴數據庫的可用性,數據庫是一個單點,一旦數據庫掛掉,會導致業務系統不可用;
- 併發量大的時候請求量大,獲取鎖的間隔,如果較小會給系統和數據庫造成壓力;
- 這把鎖只能是非阻塞的,因爲數據的insert操作,一旦插入失敗就會直接報錯,沒有獲得鎖的線程並不會進入排隊隊列,要想再次獲得鎖就要再次觸發獲得鎖操作;
- 這把鎖是非重入的,同一個線程在沒有釋放鎖之前無法再次獲得該鎖,因爲數據中數據已經存在了;
- 這把鎖是非公平鎖,所有等待鎖的線程憑運氣去爭奪鎖。
2.4 簡單實現方案
新建一張表,用於存儲鎖的信息,需要加鎖的時候就插入一條記錄,釋放鎖的時候就刪除這條記錄
新建一張最簡單的表
CREATE TABLE `t_lock` (
`lock_key` varchar(64) NOT NULL COMMENT '鎖的標識',
PRIMARY KEY (`lock_key`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='分佈式鎖'
根據插入sql返回受影響的行數,大於0表示成功佔有鎖
insert ignore into t_lock(lock_key) values(:lockKey)
釋放鎖的時候就刪除記錄
delete from t_lock where lock_key = :lockKey
2.5 完善實現方案
上面這種簡單的實現有以下幾個問題:
- 這把鎖強依賴數據庫的可用性,數據庫是一個單點,一旦數據庫掛掉,會導致業務系統不可用。
- 這把鎖沒有失效時間,一旦解鎖操作失敗,就會導致鎖記錄一直在數據庫中,其他線程無法再獲得到鎖。
- 這把鎖只能是非阻塞的,因爲數據的insert操作,一旦插入失敗就會直接報錯。沒有獲得鎖的線程並不會進入排隊隊列,要想再次獲得鎖就要再次觸發獲得鎖操作。
- 這把鎖是非重入的,同一個線程在沒有釋放鎖之前無法再次獲得該鎖。因爲數據中數據已經存在了。
當然,我們也可以有其他方式解決上面的問題。
- 數據庫是單點?搞兩個數據庫,數據之前雙向同步。一旦掛掉快速切換到備庫上。
- 沒有失效時間?只要做一個定時任務,每隔一定時間把數據庫中的超時數據清理一遍。
- 非阻塞的?搞一個while循環,直到insert成功再返回成功。
- 非重入的?在數據庫表中加個字段,記錄當前獲得鎖的機器的主機信息和線程信息,那麼下次再獲取鎖的時候先查詢數據庫,如果當前機器的主機信息和線程信息在數據庫可以查到的話,直接把鎖分配給他就可以了。
3.基於排他鎖(for update
)實現
3.1 實現方式
- 獲取鎖可以通過,在select語句後增加
for update
,數據庫會在查詢過程中給數據庫表增加排他鎖。當某條記錄被加上排他鎖之後,其他線程無法再在該行記錄上增加排他鎖,我們可以認爲獲得排它鎖的線程即可獲得分佈式鎖; - 其餘實現與使用唯一索引相同;
- 釋放鎖通過
connection.commit();
操作,提交事務來實現。
for update
具體可參考數據庫-MySQL中for update的作用和用法一文。
3.2 優點
- 實現簡單、易於理解。
3.3 缺點
- 排他鎖會佔用連接,產生連接爆滿的問題;
- 如果表不大,可能並不會使用行鎖;
- 同樣存在單點問題、併發量問題。
3.4 僞代碼
CREATE TABLE `methodLock` (
`id` INT ( 11 ) NOT NULL AUTO_INCREMENT COMMENT '主鍵',
`method_name` VARCHAR ( 64 ) NOT NULL DEFAULT '' COMMENT '鎖定的方法名',
`desc` VARCHAR ( 1024 ) NOT NULL DEFAULT '備註信息',
`update_time` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '保存數據時間,自動生成',
PRIMARY KEY ( `id` ),
UNIQUE KEY `uidx_method_name` ( `method_name ` ) USING BTREE
) ENGINE = INNODB DEFAULT CHARSET = utf8 COMMENT = '鎖定中的方法';
/**
* 加鎖
*/
public boolean lock() {
// 開啓事務
connection.setAutoCommit(false);
// 循環阻塞,等待獲取鎖
while (true) {
// 執行獲取鎖的sql
result = select * from methodLock where method_name = xxx for update;
// 結果非空,加鎖成功
if (result != null) {
return true;
}
}
// 加鎖失敗
return false;
}
/**
* 解鎖
*/
public void unlock() {
// 提交事務,解鎖
connection.commit();
}
4.樂觀鎖實現
一般是通過爲數據庫表添加一個 version
字段來實現讀取出數據時,將此版本號一同讀出.
之後更新時,對此版本號加1,在更新過程中,會對版本號進行比較,如果是一致的,沒有發生改變,則會成功執行本次操作;如果版本號不一致,則會更新失敗。
實際就是個CAS
過程。
缺點:
- 這種操作方式,使原本一次的update操作,必須變爲2次操作: select版本號一次;update一次。增加了數據庫操作的次數。
- 如果業務場景中的一次業務流程中,多個資源都需要用保證數據一致性,那麼如果全部使用基於數據庫資源表的樂觀鎖,就要讓每個資源都有一張資源表,這個在實際使用場景中肯定是無法滿足的。而且這些都基於數據庫操作,在高併發的要求下,對數據庫連接的開銷一定是無法忍受的。
- 樂觀鎖機制往往基於系統中的數據存儲邏輯,因此可能會造成髒數據被更新到數據庫中。
5.總結
數據庫鎖現在使用較多的就上面說的3種方式,排他鎖(悲觀鎖),版本號(樂觀鎖),記錄鎖,各有優缺點。
注意點:
- 使用mysql分佈式鎖,必須保證多個服務節點使用的是同一個mysql庫。
優點
- 直接藉助DB簡單易懂。
- 方便快捷,因爲基本每個服務都會連接,但是不是每個服務都會使用redis或者zookeeper;
- 如果客戶端斷線了會自動釋放鎖,不會造成鎖一直被佔用;
- mysql分佈式鎖是可重入鎖,對於舊代碼的改造成本低;
缺點
- 加鎖直接打到數據庫,增加了數據庫的壓力;
- 加鎖的線程會佔用一個session,也就是一個連接數,如果併發量大可能會導致正常執行的sql語句獲取不到連接;
- 服務拆分後如果每個服務使用自己的數據庫,則不合適;
- 鎖的可用性和數據庫強關聯,一旦數據庫掛了,則整個分佈式鎖不可用;
- 如果需要考慮極限情況,會有超時等各種問題,在解決問題的過程中會使整個方案變得越來越複雜;
- 數據庫的性能瓶頸相較於
redis
、zk
要低很多,當調用量大的時候,性能問題將成爲關鍵; - 還需要考慮超時等問題。