淺析“分布式鎖”的實現(xiàn)方式

前言

我們在開發(fā)應用時,如果需要對一個共享變量進行多線程同步訪問的時候,我們可以使用Java多線程的各個技能點來處理,保證完美運行無BUG。
但是這里的都只是單機應用,即在同一個JVM中;然后隨著業(yè)務發(fā)展、微服務化,一個應用需要部署到多臺服務器上然后做負載均衡,大概的架構圖如下:

在上圖可以看到,變量A在JVM1、JVM2、JVM3三個JVM內存中(這個變量A主要體現(xiàn)是在一個類中的一個成員變量,是一個有狀態(tài)的對象),如果我們不加任何控制的話,變量A同進都會在JVM分配一塊內存,三個請求發(fā)過來同時對這個變量進行操作,顯然結果不是我們想要的。

如果我們業(yè)務中存在這樣的場景的話,就需要找到一種方法來解決。

為了保證一個方法或屬性在高并發(fā)的情況下同一時間只能被同一個線程執(zhí)行,在傳統(tǒng)單機部署的情況下,可以使用Java并發(fā)處理相關的API(如ReentrantLockSynchronized)進行互斥控制。但是,隨之業(yè)務發(fā)展的需要,原單機部署的系統(tǒng)演化成分布式集群系統(tǒng)后,由于分布式系統(tǒng)多線程、多進程并且分布在不同的機器上,這將原來的單機部署情況下的并發(fā)控制鎖策略失效,單純的Java API并不能提供分布式鎖的能力。
為了解決這個問題,就需要一種跨JVM的互斥機制來控制共享資源的訪問,這就是分布式鎖要解決的問題!

分布式鎖應該具備哪些條件

  • 在分布式系統(tǒng)環(huán)境下,一個方法在同一時間只能被一個機器的一個線程執(zhí)行;
  • 高可用、高性能的獲取鎖與釋放鎖;
  • 具備可重入特性;
  • 具備鎖失效機制、防止死鎖;
  • 具備非阻塞鎖特性,即沒有獲取到鎖直接返回獲取鎖失??;

分布式鎖的實現(xiàn)方式

目前幾乎所有大型網(wǎng)站及應用都是分布式部署,分布式場景中的數(shù)據(jù)一致性問題一直是一個比較重要的話題,分布式的CAP理論告訴我們任何一個分布式系統(tǒng)都無法同時滿足一致性(Consistency)、可用性(Availability)和分區(qū)容錯性(Partition tolerance),最多只能同時滿足兩項
一般情況下,都需要犧牲強一致性來換取系統(tǒng)的高可用性,系統(tǒng)往往只需要保證最終一致性,只要這個最終時間是在用戶可以接受的范圍內即可。
在很多時候,為了保證數(shù)據(jù)的最終一致性,需要很多的技術方案來支持,比如分布式事務、分布式鎖等。有的時候,我們需要保證一信方法在同一時間內只能被同一個線程執(zhí)行。
而分布式鎖的具體實現(xiàn)方案有如下三種:

基于數(shù)據(jù)庫實現(xiàn);
基于緩存(Redis等)實現(xiàn);
基于Zookeeper實現(xiàn);

以上盡管有三種方案,但是我們需要根據(jù)不同的業(yè)務進行選型。

基于數(shù)據(jù)庫的實現(xiàn)方式

基于數(shù)據(jù)庫的實現(xiàn)方式的思想核心為:

在數(shù)據(jù)庫中創(chuàng)建一個表,表中包含方法名等字段,并在方法名字段上創(chuàng)建唯一索引,想要執(zhí)行某個方法,就使用這個方法名向表中插入數(shù)據(jù),成功插入則獲取鎖,執(zhí)行完成后刪除對應的行數(shù)據(jù)釋放鎖。

一、創(chuàng)建一個表

DROP TABLE IF EXISTS `method_lock`;
CREATE TABLE `method_lock` (
  `id`          INT(11) UNSIGNED NOT NULL AUTO_INCREMENT
  COMMENT '主鍵',
  `method_name` VARCHAR(64)      NOT NULL
  COMMENT '鎖定的方法名',
  `desc`        VARCHAR(255)     NOT NULL
  COMMENT '備注信息',
  `update_time` TIMESTAMP        NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
  PRIMARY KEY (`id`),
  UNIQUE KEY `uidx_method_name` (`method_name`) USING BTREE
)
  ENGINE = InnoDB
  AUTO_INCREMENT = 3
  DEFAULT CHARSET = utf8
  COMMENT = '鎖定中的方法';

二、想要執(zhí)行某個方法,就使用這個方法名向表中插入數(shù)據(jù)

INSERT INTO method_lock (method_name, desc) VALUES ('methodName', '測試的methodName');

由于我們對method_name做了唯一性約束,如果有多個請求同時提交插入操作時,數(shù)據(jù)庫能確保只有一個操作可以成功,那么我們就可以認為操作成功的那個線程獲得了該方法的鎖,可以執(zhí)行方法體中的內容。

三、執(zhí)行完成后,刪除對應的行數(shù)據(jù)釋放鎖

delete from method_lock where method_name ='methodName';

這里只是基于數(shù)據(jù)庫實現(xiàn)的一種方法(比較粗的一種)。
但是對于分布式鎖應該具備的條件來說,還有一些問題需要解決及優(yōu)化

  • 因為是基于數(shù)據(jù)庫實現(xiàn)的,數(shù)據(jù)庫的可用性和性能將直接影響分布式鎖的可用性及性能。所以,數(shù)據(jù)庫需要雙機部署、數(shù)據(jù)同步、主備切換;
  • 它不具備可重入的特性,因為同一個線程在釋放鎖之前,行數(shù)據(jù)一直存在,無法再次成功插入數(shù)據(jù)。所以,需要在表中新增一列,用于記錄當前獲取到鎖的機器和線程信息,在再次獲取鎖的時候,先查詢表中機器和線程信息是否和當前機器線程相同,若相同則直接獲取鎖。
  • 沒有鎖失效機制,因為有可能出現(xiàn)成功插入數(shù)據(jù)后,服務器宕機了,對應的數(shù)據(jù)沒有被刪除,當服務恢復后一直獲取不到鎖,所以,需要在表中新增一列,用于記錄失效時間,并且需要有定時任務清除這些失效的數(shù)據(jù);
  • 不具備阻塞鎖特性,獲取不到鎖直接返回失敗,所以需要優(yōu)化獲取邏輯,循環(huán)多次去獲?。?/li>
  • 依賴數(shù)據(jù)庫需要一定的資源開銷,性能問題需要考慮;

基于緩存(Redis)的實現(xiàn)方式

使用Redis實現(xiàn)分布式鎖的理由:

  1. Redis具有很高的性能;
  2. Redis的命令對此支持較好,實現(xiàn)起來很方便;

Redis命令介紹:

SETNX

// 當且僅當key不存在時,set一個key為val的字符串,返回1;
// 若key存在,則什么都不做,返回0。
SETNX key val;

expire

// 為key設置一個超時時間,單位為second,超過這個時間鎖會自動釋放,避免死鎖。
expire key timeout;

delete

// 刪除key
delete key;

我們通過Redis實現(xiàn)分布式鎖時,主要通過上面的這三個命令。

通過Redis實現(xiàn)分布式的核心思想為:

  1. 獲取鎖的時候,使用setnx加鎖,并使用expire命令為鎖添加一個超時時間,超過該時間自動釋放鎖,鎖的value值為一個隨機生成的UUID,通過這個value值,在釋放鎖的時候進行判斷。
  2. 獲取鎖的時候還設置一個獲取的超時時間,若超過這個時間則放棄獲取鎖。
    3.釋放鎖的時候,通過UUID判斷是不是當前持有的鎖,若時該鎖,則執(zhí)行delete進行鎖釋放。

具體實現(xiàn)代碼如下:

public class DistributedLock {

    private final JedisPool jedisPool;
    private final static String KEY_PREF = "lock:"; // 鎖的前綴

    public DistributedLock(JedisPool jedisPool) {
        this.jedisPool = jedisPool;
    }

    /**
     * 加鎖
     *
     * @param lockName       String 鎖的名稱(key)
     * @param acquireTimeout long 獲取超時時間
     * @param timeout        long 鎖的超時時間
     * @return 鎖標識
     */
    public String lockWithTimeout(String lockName, long acquireTimeout, long timeout) {
        Jedis conn = null;

        try {
            // 獲取連接
            conn = jedisPool.getResource();
            // 隨機生成一個value
            String identifier = UUID.randomUUID().toString();
            // 鎖名,即 key值
            String lockKey = KEY_PREF + lockName;
            // 超時時間, 上鎖后超過此時間則自動釋放鎖
            int lockExpire = (int) (timeout / 1000);

            // 獲取鎖的超時時間,超過這個時間則放棄獲取鎖
            long end = System.currentTimeMillis() + acquireTimeout;
            while (System.currentTimeMillis() < end) {
                if (conn.setnx(lockKey, identifier) == 1) {
                    conn.expire(lockKey, lockExpire);
                    // 返回value值,用于釋放鎖時間確認
                    return identifier;
                }

                // 返回-1代表key沒有設置超時時間,為key設置一個超時時間
                if (conn.ttl(lockKey) == -1) {
                    conn.expire(lockKey, lockExpire);
                }

                try {
                    Thread.sleep(10);
                } catch (InterruptedException ie) {
                    Thread.currentThread().interrupt();
                }
            }
        } catch (JedisException e) {
            e.printStackTrace();
        } finally {
            if (conn != null) {
                conn.close();
            }
        }
        return null;
    }

    /**
     * 釋放鎖
     *
     * @param lockName   String 鎖key
     * @param identifier String 釋放鎖的標識
     * @return boolean
     */
    public boolean releaseLock(String lockName, String identifier) {
        Jedis conn = null;
        String lockKey = KEY_PREF + lockName;
        boolean retFlag = false;
        try {
            conn = jedisPool.getResource();
            while (true) {
                // 監(jiān)視lock, 準備開始事務
                conn.watch(lockKey);
                // 通過前面返回的value值判斷是不是該鎖,若時該鎖,則刪除釋放鎖
                if (identifier.equals(conn.get(lockKey))) {
                    Transaction transaction = conn.multi();
                    transaction.del(lockKey);
                    List<Object> results = transaction.exec();
                    if (results == null) continue;

                    retFlag = true;
                }

                conn.unwatch();
                break;
            }
        } catch (Exception e) {
            e.printStackTrace();
        } finally {
            if (conn != null) {
                conn.close();
            }
        }
        return retFlag;
    }
}

基于 Zookeeper 實現(xiàn)分布式鎖

基于Zookeeper臨時有序節(jié)點同樣可以實現(xiàn)分布式鎖。Zookeeper分布式鎖應用了臨時順序節(jié)點(在創(chuàng)建節(jié)點時,Zookeeper根據(jù)創(chuàng)建的時間順序給該節(jié)點名稱進行編號)。

具體實現(xiàn)步驟:

獲取鎖

首先,在Zookeeper當中創(chuàng)建一個持久節(jié)點ParentLock。當?shù)谝粋€客戶端想要獲得鎖時,需要在ParentLock這個節(jié)點下面創(chuàng)建一個臨時順序節(jié)點 Lock1。

之后,Client1查找ParentLock下面所有的臨時順序節(jié)點并排序,判斷自己所創(chuàng)建的節(jié)點Lock1是不是順序最靠前的一個。如果是第一個節(jié)點,則成功獲得鎖。

這時候,如果再有一個客戶端 Client2 前來獲取鎖,則在ParentLock下載再創(chuàng)建一個臨時順序節(jié)點Lock2。Client2查找ParentLock下面所有的臨時順序節(jié)點并排序,判斷自己所創(chuàng)建的節(jié)點Lock2是不是順序最靠前的一個,結果發(fā)現(xiàn)節(jié)點Lock2并不是最小的。

于是,Client2向排序僅比它靠前的節(jié)點Lock1注冊Watcher,用于監(jiān)聽Lock1節(jié)點是否存在。這意味著Client2搶鎖失敗,進入了等待狀態(tài)。

這時候,如果又有一個客戶端Client3前來獲取鎖,則在ParentLock下載再創(chuàng)建一個臨時順序節(jié)點Lock3。

Client3查找ParentLock下面所有的臨時順序節(jié)點并排序,判斷自己所創(chuàng)建的節(jié)點Lock3是不是順序最靠前的一個,結果同樣發(fā)現(xiàn)節(jié)點Lock3并不是最小的。

于是,Client3向排序僅比它靠前的節(jié)點Lock2注冊Watcher,用于監(jiān)聽Lock2節(jié)點是否存在。這意味著Client3同樣搶鎖失敗,進入了等待狀態(tài)。

這樣一來,Client1得到了鎖,Client2監(jiān)聽了Lock1,Client3監(jiān)聽了Lock2。這恰恰形成了一個等待隊列,很像是Java當中ReentrantLock所依賴的 AQS 。

釋放鎖

釋放鎖分為兩種情況:

1.任務完成,客戶端顯示釋放

當任務完成時,Client1會顯示調用刪除節(jié)點Lock1的指令。

2.任務執(zhí)行過程中,客戶端崩潰

獲得鎖的Client1在任務執(zhí)行過程中,如果Duang的一聲崩潰,則會斷開與Zookeeper服務端的鏈接。根據(jù)臨時節(jié)點的特性,相關聯(lián)的節(jié)點Lock1會隨之自動刪除。

由于Client2一直監(jiān)聽著Lock1的存在狀態(tài),當Lock1節(jié)點被刪除,Client2會立刻收到通知。這時候Client2會再次查詢ParentLock下面的所有節(jié)點,確認自己創(chuàng)建的節(jié)點Lock2是不是目前最小的節(jié)點。如果是最小,則Client2順理成章獲得了鎖。

同理,如果Client2也因為任務完成或者節(jié)點崩潰而刪除了節(jié)點Lock2,那么Client3就會接到通知。

最終,Client3成功得到了鎖。

基于 Zookeeper 實現(xiàn)分布式鎖優(yōu)缺點:

優(yōu)點
具備高可用、可重入、阻塞鎖特性、可解決失效死鎖問題。
缺點
因為需要頻繁的創(chuàng)建和刪除節(jié)點,性能上不如Redis方式。因為每次在創(chuàng)建鎖和釋放鎖的過程中,都要動態(tài)創(chuàng)建、銷毀瞬時節(jié)點來實現(xiàn)鎖功能。ZK中創(chuàng)建和刪除節(jié)點只能通過Leader服務器來執(zhí)行,然后將數(shù)據(jù)同不到所有的Follower機器上。

PS: 可以直接使用zookeeper第三方庫Curator客戶端,這個客戶端中封裝了一個可重入的鎖服務。Curator提供的InterProcessMutex是分布式鎖的實現(xiàn)。acquire方法用戶獲取鎖,release方法用于釋放鎖。

https://github.com/apache/curator/

參考鏈接:

http://m.itdecent.cn/p/9055ca856aaf
https://blog.csdn.net/wuzhiwei549/article/details/80692278

最后編輯于
?著作權歸作者所有,轉載或內容合作請聯(lián)系作者
【社區(qū)內容提示】社區(qū)部分內容疑似由AI輔助生成,瀏覽時請結合常識與多方信息審慎甄別。
平臺聲明:文章內容(如有圖片或視頻亦包括在內)由作者上傳并發(fā)布,文章內容僅代表作者本人觀點,簡書系信息發(fā)布平臺,僅提供信息存儲服務。

相關閱讀更多精彩內容

友情鏈接更多精彩內容