什麼? 搞不定分佈式鎖?

分佈式鎖

若是你以爲我寫的不錯, 或者想和我多交流, 就掃一掃關注我吧, 本人公衆號: stormlingjava

1. 什麼是分佈式鎖

分佈式鎖是控制分佈式系統之間同步訪問共享資源的一種方式。在分佈式系統中,經常須要協調他們的動做。若是不一樣的系統或是同一個系統的不一樣主機之間共享了一個或一組資源,那麼訪問這些資源的時候,每每須要互斥來防止彼此干擾來保證一致性,在這種狀況下,便須要使用到分佈式鎖。node

2. 爲何要使用分佈式鎖

爲了保證一個方法或屬性在高併發狀況下的同一時間只能被同一個線程執行,在傳統單體應用單機部署的狀況下,可使用Java併發處理相關的API(如ReentrantLock或Synchronized)進行互斥控制。在單機環境中,Java中提供了不少併發處理相關的API。可是,隨着業務發展的須要,原單體單機部署的系統被演化成分佈式集羣系統後,因爲分佈式系統多線程、多進程而且分佈在不一樣機器上,這將使原單機部署狀況下的併發控制鎖策略失效,單純的Java API並不能提供分佈式鎖的能力。爲了解決這個問題就須要一種跨JVM的互斥機制來控制共享資源的訪問,這就是分佈式鎖要解決的問題!mysql

舉個例子:redis

機器A , 機器B是一個集羣, A, B兩臺機器上的程序都是同樣的, 具有高可用性能.sql

A, B機器都有一個定時任務, 天天晚上凌晨2點須要執行一個定時任務, 可是這個定時任務只能執行一遍, 不然的話就會報錯, 那A,B兩臺機器在執行的時候, 就須要搶鎖, 誰搶到鎖, 誰執行, 誰搶不到, 就不用執行了!shell

3. 鎖的處理

  • 單個應用中使用鎖: (單進程多線程)

synchronize
分佈式鎖控制分佈式系統之間同步訪問資源的一種方式數據庫

分佈式鎖是控制分佈式系統之間同步同問共享資源的一種方式apache

4. 分佈式鎖的實現

  • 基於數據的樂觀鎖實現分佈式鎖
  • 基於zookeeper臨時節點的分佈式鎖
  • 基於redis的分佈式鎖

5. redis的分佈式鎖

  • 獲取鎖:

在set命令中, 有不少選項能夠用來修改命令的行爲, 一下是set命令可用選項的基本語法緩存

redis 127.0.0.1:6379>SET KEY VALUE [EX seconds] [PX milliseconds] [NX|XX]

    - EX seconds  設置指定的到期時間(單位爲秒)
    - PX milliseconds 設置指定的到期時間(單位毫秒)
    - NX: 僅在鍵不存在時設置鍵
    - XX: 只有在鍵已存在時設置

方式1: 推介安全

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";

        public static boolean getLock(JedisCluster jedisCluster, String lockKey, String requestId, int expireTime) {
        // NX: 保證互斥性
        String result = jedisCluster.set(lockKey, requestId, SET_IF_NOT_EXIST, SET_WITH_EXPIRE_TIME, expireTime);
        if (LOCK_SUCCESS.equals(result)) {
            return true;
        }
        return false;
    }

方式2:

public static boolean getLock(String lockKey,String requestId,int expireTime) {
     Long result = jedis.setnx(lockKey, requestId);
     if(result == 1) {
         jedis.expire(lockKey, expireTime);
         return true;
     }
     return false;
 }

注意: 推介方式1, 由於方式2中setnx和expire是兩個操做, 並非一個原子操做, 若是setnx出現問題, 就是出現死鎖的狀況, 因此推薦方式1

  • 釋放鎖:

方式1: del命令實現

public static void releaseLock(String lockKey,String requestId) {
    if (requestId.equals(jedis.get(lockKey))) {
        jedis.del(lockKey);
    }
}

方式2: redis+lua腳本實現 推薦

public static boolean releaseLock(String lockKey, String 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 (result.equals(1L)) {
            return true;
}
        return false;
    }

6. zookeeper的分佈式鎖

6.1 zookeeper實現分佈式鎖的原理

理解了鎖的原理後,就會發現,Zookeeper 天生就是一副分佈式鎖的胚子。

首先,Zookeeper的每個節點,都是一個自然的順序發號器。

在每個節點下面建立子節點時,只要選擇的建立類型是有序(EPHEMERAL_SEQUENTIAL 臨時有序或者PERSISTENT_SEQUENTIAL 永久有序)類型,那麼,新的子節點後面,會加上一個次序編號。這個次序編號,是上一個生成的次序編號加一

好比,建立一個用於發號的節點「/test/lock」,而後以他爲父親節點,能夠在這個父節點下面建立相同前綴的子節點,假定相同的前綴爲「/test/lock/seq-」,在建立子節點時,同時指明是有序類型。若是是第一個建立的子節點,那麼生成的子節點爲/test/lock/seq-0000000000,下一個節點則爲/test/lock/seq-0000000001,依次類推,等等。

什麼? 搞不定分佈式鎖?

其次,Zookeeper節點的遞增性,能夠規定節點編號最小的那個得到鎖。

一個zookeeper分佈式鎖,首先須要建立一個父節點,儘可能是持久節點(PERSISTENT類型),而後每一個要得到鎖的線程都會在這個節點下建立個臨時順序節點,因爲序號的遞增性,能夠規定排號最小的那個得到鎖。因此,每一個線程在嘗試佔用鎖以前,首先判斷本身是排號是否是當前最小,若是是,則獲取鎖。

第三,Zookeeper的節點監聽機制,能夠保障佔有鎖的方式有序並且高效。

每一個線程搶佔鎖以前,先搶號建立本身的ZNode。一樣,釋放鎖的時候,就須要刪除搶號的Znode。搶號成功後,若是不是排號最小的節點,就處於等待通知的狀態。等誰的通知呢?不須要其餘人,只須要等前一個Znode 的通知就能夠了。當前一個Znode 刪除的時候,就是輪到了本身佔有鎖的時候。第一個通知第二個、第二個通知第三個,擊鼓傳花似的依次向後。

Zookeeper的節點監聽機制,能夠說可以很是完美的,實現這種擊鼓傳花似的信息傳遞。具體的方法是,每個等通知的Znode節點,只須要監聽linsten或者 watch 監視排號在本身前面那個,並且緊挨在本身前面的那個節點。 只要上一個節點被刪除了,就進行再一次判斷,看看本身是否是序號最小的那個節點,若是是,則得到鎖。

爲何說Zookeeper的節點監聽機制,能夠說是很是完美呢?

一條龍式的首尾相接,後面監視前面,就不怕中間截斷嗎?好比,在分佈式環境下,因爲網絡的緣由,或者服務器掛了或則其餘的緣由,若是前面的那個節點沒能被程序刪除成功,後面的節點不就永遠等待麼?

其實,Zookeeper的內部機制,能保證後面的節點可以正常的監聽到刪除和得到鎖。在建立取號節點的時候,儘可能建立臨時znode 節點而不是永久znode 節點,一旦這個 znode 的客戶端與Zookeeper集羣服務器失去聯繫,這個臨時 znode 也將自動刪除。排在它後面的那個節點,也能收到刪除事件,從而得到鎖。

說Zookeeper的節點監聽機制,是很是完美的。還有一個緣由。

Zookeeper這種首尾相接,後面監聽前面的方式,能夠避免羊羣效應。所謂羊羣效應就是每一個節點掛掉,全部節點都去監聽,而後作出反映,這樣會給服務器帶來巨大壓力,因此有了臨時順序節點,當一個節點掛掉,只有它後面的那一個節點才作出反映。

###6.2 zookeeper實現分佈式鎖的示例

zookeeper是經過臨時節點來實現分佈式鎖.

import org.apache.curator.RetryPolicy;
import org.apache.curator.framework.CuratorFramework;
import org.apache.curator.framework.CuratorFrameworkFactory;
import org.apache.curator.framework.recipes.locks.InterProcessMutex;
import org.apache.curator.retry.ExponentialBackoffRetry;
import org.junit.Before;
import org.junit.Test;

/**
 * @ClassName ZookeeperLock
 * @Description TODO
 * @Author lingxiangxiang
 * @Date 2:57 PM
 * @Version 1.0
 **/
public class ZookeeperLock {
    // 定義共享資源
    private static int NUMBER = 10;

    private static void printNumber() {
        // 業務邏輯: 秒殺
        System.out.println("*********業務方法開始************\n");
        System.out.println("當前的值: " + NUMBER);
        NUMBER--;
        try {
            Thread.sleep(2000);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
        System.out.println("*********業務方法結束************\n");

    }

    // 這裏使用@Test會報錯
    public static void main(String[] args) {
        // 定義重試的側策略 1000 等待的時間(毫秒) 10 重試的次數
        RetryPolicy policy = new ExponentialBackoffRetry(1000, 10);

        // 定義zookeeper的客戶端
        CuratorFramework client = CuratorFrameworkFactory.builder()
                .connectString("10.231.128.95:2181,10.231.128.96:2181,10.231.128.97:2181")
                .retryPolicy(policy)
                .build();
        // 啓動客戶端
        client.start();

        // 在zookeeper中定義一把鎖
        final InterProcessMutex lock = new InterProcessMutex(client, "/mylock");

        //啓動是個線程
        for (int i = 0; i <10; i++) {
            new Thread(new Runnable() {
                @Override
                public void run() {
                    try {
                        // 請求獲得的鎖
                        lock.acquire();
                        printNumber();
                    } catch (Exception e) {
                        e.printStackTrace();
                    } finally {
                        // 釋放鎖, 還鎖
                        try {
                            lock.release();
                        } catch (Exception e) {
                            e.printStackTrace();
                        }
                    }
                }
            }).start();
        }

    }
}

7. 基於數據的分佈式鎖

咱們在討論使用分佈式鎖的時候每每首先排除掉基於數據庫的方案,本能的會以爲這個方案不夠「高級」。從性能的角度考慮,基於數據庫的方案性能確實不夠優異,總體性能對比:緩存 > Zookeeper、etcd > 數據庫。也有人提出基於數據庫的方案問題不少,不太可靠。數據庫的方案可能並不適合於頻繁寫入的操做.

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

7.1 基於表記錄

要實現分佈式鎖,最簡單的方式可能就是直接建立一張鎖表,而後經過操做該表中的數據來實現了。當咱們想要得到鎖的時候,就能夠在該表中增長一條記錄,想要釋放鎖的時候就刪除這條記錄。

爲了更好的演示,咱們先建立一張數據庫表,參考以下:

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');

由於表database_lock中resource是惟一索引, 因此其餘請求提交到數據庫, 就會報錯, 並不會插入成功, 只有一個能夠插入. 插入成功, 咱們就獲取到鎖

  • 刪除鎖
INSERT INTO database_lock(resource, description) VALUES (1, 'lock');

這種實現方式很是的簡單,可是須要注意如下幾點:

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

7.2 樂觀鎖

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

什麼? 搞不定分佈式鎖?

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

爲了更好的理解數據庫樂觀鎖在實際項目中的使用,這裏也就舉了業界老生常談的庫存例子。一個電商平臺都會存在商品的庫存,當用戶進行購買的時候就會對庫存進行操做(庫存減1表明已經賣出了一件)。若是隻是一個用戶進行操做數據庫自己就能保證用戶操做的正確性,而在併發的狀況下就會產生一些意想不到的問題:
  好比兩個用戶同時購買一件商品,在數據庫層面實際操做應該是庫存進行減2操做,可是因爲高併發的狀況,第一個用戶購買完成進行數據讀取當前庫存並進行減1操做,因爲這個操做沒有徹底執行完成。第二個用戶就進入購買相同商品,此時查詢出的庫存多是未減1操做的庫存致使了髒數據的出現【線程不安全操做】,一般若是是單JVM狀況下使用JAVA內置的鎖就能保證線程安全,若是在多JVM的狀況下,使用分佈式鎖也能實現【後期會補】,而本篇着重的去講數據庫層面的。
針對上面的問題,數據庫樂觀鎖也能保證線程安全,一般哎代碼層面咱們都會這樣作:

select goods_num from goods where goods_name = "小本子";
update goods set goods_num = goods_num -1 where goods_name = "小本子";

上面的SQL是一組的,一般先查詢出當前的goods_num,而後再goods_num上進行減1的操做修改庫存,當併發的狀況下,這條語句可能致使本來庫存爲3的一個商品通過兩我的購買還剩下2庫存的狀況就會致使商品的多賣。那麼數據庫樂觀鎖是如何實現的呢?
首先定義一個version字段用來看成一個版本號,每次的操做就會變成這樣:

select goods_num,version from goods where goods_name = "小本子";
update goods set goods_num = goods_num -1,version =查詢的version值自增 where goods_name ="小本子" and version=查詢出來的version;

其實,藉助更新時間戳(updated_at)也能夠實現樂觀鎖,和採用version字段的方式類似:更新操做執行前線獲取記錄當前的更新時間,在提交更新時,檢測當前更新時間是否與更新開始時獲取的更新時間戳相等。

7.3 悲觀鎖

除了能夠經過增刪操做數據庫表中的記錄之外,咱們還能夠藉助數據庫中自帶的鎖來實現分佈式鎖。在查詢語句後面增長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。

若是你以爲我寫的不錯, 或者想和我多交流, 就掃一掃關注我吧, 本人公衆號: stormling
什麼? 搞不定分佈式鎖?

相關文章
相關標籤/搜索