基於資料庫、redis和zookeeper實現的分散式鎖

曹自標發表於2020-12-30

基於資料庫

基於資料庫(MySQL)的方案,一般分為3類:基於表記錄、樂觀鎖和悲觀鎖

基於表記錄

用表主鍵或表欄位加唯一性索引便可實現,如下;

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='資料庫分散式鎖表';

想獲得鎖插入一條資料

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

解鎖刪除資料:

DELETE FROM database_lock WHERE resource=1;

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

  • 這種鎖沒有失效時間,一旦釋放鎖的操作失敗就會導致鎖記錄一直在資料庫中,其它執行緒無法獲得鎖。這個缺陷也很好解決,比如可以做一個定時任務去定時清理。
  • 這種鎖的可靠性依賴於資料庫。建議設定備庫,避免單點,進一步提高可靠性。
  • 這種鎖是非阻塞的,因為插入資料失敗之後會直接報錯,想要獲得鎖就需要再次操作。如果需要阻塞式的,可以弄個for迴圈、while迴圈之類的,直至INSERT成功再返回。
  • 這種鎖也是非可重入的,因為同一個執行緒在沒有釋放鎖之前無法再次獲得鎖,因為資料庫中已經存在同一份記錄了。想要實現可重入鎖,可以在資料庫中新增一些欄位,比如獲得鎖的主機資訊、執行緒資訊等,那麼在再次獲得鎖的時候可以先查詢資料,如果當前的主機資訊和執行緒資訊等能被查到的話,可以直接把鎖分配給它。
  • 在 MySQL 資料庫中採用主鍵衝突防重,在大併發情況下有可能會造成鎖表現象
基於樂觀鎖

可基於MVCC機制實現

  • 優點:在檢測資料衝突時並不依賴資料庫本身的鎖機制,不會影響請求的效能,當產生併發且併發量較小的時候只有少部分請求會失敗

  • 缺點: 唯一癿問題就是對資料表侵入較大,我們
    要為每個表設計一個版本號欄位,然後寫一條判斷 sql 每次進行判斷,增加了資料庫操作的次數,在高併發要求下,對資料庫連線的開銷也是無法忍受的。

基於悲觀鎖

在查詢語句後面增加for update, 資料庫會在查詢過程中給資料庫表增加排他鎖, 當某條記錄被加上排他鎖之後,其他執行緒無法再在該行記錄上增加排他鎖。

我們可以任務獲得排他鎖的執行緒即可獲得分散式鎖,當獲取到鎖之後,可以執行方法的業務邏輯,執行完方法後,通過connection.commit()操作來釋放鎖

注意:在加鎖的時候,只有明確地指定主鍵(或索引)的才會執行行鎖,否則MySQL 將會執行表鎖

加鎖前注意取消自動提交

優點:

  • 簡單易於理解
  • 嚴格保證資料訪問的安全

缺點:

  • MySQL會對查詢進行優化,如果任務全表掃描效率更高,便使用表鎖,導致效能問題
  • 如果一個排他鎖長時間不提交,就會佔用資料庫連線,類似連線變多,就可能把連線池撐爆
  • 悲觀鎖使用不當還可能產生死鎖的情況
  • 每次請求都會額外產生加鎖的開銷且未獲取到鎖的請求將會阻塞等待鎖的獲取,在高併發環境下,容易造成大量請求阻塞,影響系統可用性

基於redis

Java jedis分散式鎖例子

依賴(注意版本2.9.0後,但3以上不支援)

<dependency>
    <groupId>redis.clients</groupId>
    <artifactId>jedis</artifactId>
    <version>2.9.0</version>
</dependency>
public class RedisTool {

    private static final String LOCK_SUCCESS = "OK";
    private static final String SET_IF_NOT_EXIST = "NX";
    private static final String SET_WITH_EXPIRE_TIME = "PX";

    /**
     * 嘗試獲取分散式鎖
     * @param jedis Redis客戶端
     * @param lockKey 鎖
     * @param requestId 請求標識
     * @param expireTime 超期時間
     * @return 是否獲取成功
     */
    public static boolean tryGetDistributedLock(Jedis jedis, String lockKey, String requestId, int expireTime) {
        /**
         * 1. 使用key來當鎖,因為key是唯一的
         * 2. value,傳的是requestId。通過給value賦值為requestId,我們就知道這把鎖是哪個請求加的了,在解鎖的時候就可以有依據
         * 3. NX,意思是SET IF NOT EXIST,即當key不存在時,我們進行set操作;若key已經存在,則不做任何操作;
         * 4. PX,意思是我們要給這個key加一個過期的設定,具體時間由第五個引數決定。
         * 5. time,代表key的過期時間
         */
        String result = jedis.set(lockKey, requestId, SET_IF_NOT_EXIST, SET_WITH_EXPIRE_TIME, expireTime);

        if (LOCK_SUCCESS.equals(result)) {
            return true;
        }
        return false;

    }
    
    private static final Long RELEASE_SUCCESS = 1L;

    /**
     * 釋放分散式鎖
     * @param jedis Redis客戶端
     * @param lockKey 鎖
     * @param requestId 請求標識
     * @return 是否釋放成功
     */
    public static boolean releaseDistributedLock(Jedis jedis, String lockKey, String requestId) {
        /**
         * 使用Lua語言來實現,來確保上述操作是原子性。在eval命令執行Lua程式碼的時候,Lua程式碼將被當成一個命令去執行,並且直到eval命令執行完成,Redis才會執行其他命令。
         * 引數KEYS[1]賦值為lockKey,ARGV[1]賦值為requestId
         */
        String script = "if redis.call('get', KEYS[1]) == ARGV[1] then return redis.call('del', KEYS[1]) else return 0 end";
        Object result = jedis.eval(script, Collections.singletonList(lockKey), Collections.singletonList(requestId));

        if (RELEASE_SUCCESS.equals(result)) {
            return true;
        }
        return false;

    }

}

執行上面的set()方法就只會導致兩種結果:

  • 當前沒有鎖(key不存在),那麼就進行加鎖操作,並對鎖設定個有效期,同時value表示加鎖的客戶端。
  • 已有鎖存在,不做任何操作。
Redisson實現分散式鎖

使用流程如下,建立Redisson例項(單機或哨兵模式),然後通過getLock獲取鎖,後續是進行lock和unlock操作。

// 1. Create config object
Config config = new Config();
config.useClusterServers()
       // use "rediss://" for SSL connection
      .addNodeAddress("redis://127.0.0.1:7181");
// 2. Create Redisson instance
// Sync and Async API
RedissonClient redisson = Redisson.create(config);
// 3. Get Redis based implementation of java.util.concurrent.locks.Lock
RLock lock = redisson.getLock("myLock");

具體使用例子可參考:https://www.cnblogs.com/milicool/p/9201271.html

基於zookeeper

zookeeper基本鎖原理

利用臨時節點與watch機制,每個鎖佔用一個普通節點/lock,當需要獲取鎖時,在/lock目錄下建立一個臨時節點,建立成功則表示獲取鎖成功,失敗則watch /lock節點,有刪除操作後再去爭鎖。

臨時節點

  • 好處:在於當程式掛掉後能自動上鎖的節點自動刪除,即取消鎖
  • 缺點: 所有取鎖失敗的程式都監聽父節點,很容易發生羊群效應,即當釋放鎖後所有等待程式一起來建立節點,併發量很大
zookeeper鎖優化原理

上鎖改為建立臨時有序節點,每個上鎖的節點均能建立節點成功,只是其序號不同,只有序號最小的可以擁有鎖,如果這個節點序號不是最小的則watch序號比本身小的前一個節點。

步驟:

  • 在/lock節點下建立一個有序臨時節點(EPHEMERAL_SEQUENTIAL)
  • 判斷建立的節點序號是否最小,如果是則獲取鎖成功。不是則獲取鎖失敗,watch序號比本身小的前一個節點(避免很多執行緒watch同一個node,導致羊群效應)
  • 當獲取鎖失敗,設定watch後則等待watch事件到來後,再次判斷是否序號最小
  • 取鎖成功則執行程式碼,最後釋放鎖(刪除該節點)

優缺點:

  • 優點:有效的解決單點問題,不可重入問題,非阻塞問題,以及鎖無法釋放問題。實現簡單
  • 缺點:效能上可能沒有快取服務高,因為每次在建立鎖和釋放鎖過程中,都要動態建立、銷燬臨時節點來實現鎖功能。zookeeper中建立和刪除節點只能通過Leader伺服器來執行,然後將資料同步到所有follower機器上。

(圖片來自https://mp.weixin.qq.com/s/jn4LkPKlWJhfUwIKkp3KpQ)

開源框架Curator

Curator開源框架對zookeeper分散式鎖進行了實現。具體例子可參考:https://www.jianshu.com/p/31335efec309

參考:
https://mp.weixin.qq.com/s/jn4LkPKlWJhfUwIKkp3KpQ
https://blog.csdn.net/u013256816/article/details/92854794
https://www.cnblogs.com/milicool/p/9201271.html
https://mp.weixin.qq.com/s/y_Uw3P2Ll7wvk_j5Fdlusw
https://mp.weixin.qq.com/s/ovBtKTs-ycOWXSpZqRm6BA
https://mp.weixin.qq.com/s/iOtnIEPlEM1crBIgHXDZsg
https://mp.weixin.qq.com/s/95N8mKRreeOwaXLttYCbcQ

相關文章