分布式 - 分布式鎖的場(chǎng)景與實(shí)現(xiàn)

學(xué)習(xí)完整課程請(qǐng)移步 互聯(lián)網(wǎng) Java 全棧工程師

使用場(chǎng)景

首先呈驶,我們看這樣一個(gè)場(chǎng)景:客戶下單的時(shí)候窖式,我們調(diào)用庫存中心進(jìn)行減庫存扫责,那我們一般的操作都是:

update store set num = $num where id = $id

這種通過設(shè)置庫存的修改方式吃谣,我們知道在并發(fā)量高的時(shí)候會(huì)存在數(shù)據(jù)庫的丟失更新撕予,比如 a, b 當(dāng)前兩個(gè)事務(wù)鲫惶,查詢出來的庫存都是 5,a 買了 3 個(gè)單子要把庫存設(shè)置為 2实抡,而 b 買了 1 個(gè)單子要把庫存設(shè)置為 4欠母,那這個(gè)時(shí)候就會(huì)出現(xiàn) a 會(huì)覆蓋 b 的更新,所以我們更多的都是會(huì)加個(gè)條件:

update store set num = $num where id = $id and num = $query_num

即樂觀鎖的方式來處理吆寨,當(dāng)然也可以通過版本號(hào)來處理樂觀鎖赏淌,都是一樣的,但是這是更新一個(gè)表鸟废,如果我們牽扯到多個(gè)表呢猜敢,我們希望和這個(gè)單子關(guān)聯(lián)的所有的表同一時(shí)間只能被一個(gè)線程來處理更新,多個(gè)線程按照不同的順序去更新同一個(gè)單子關(guān)聯(lián)的不同數(shù)據(jù)盒延,出現(xiàn)死鎖的概率比較大缩擂。對(duì)于非敏感的數(shù)據(jù),我們也沒有必要去都加樂觀鎖處理添寺,我們的服務(wù)都是多機(jī)器部署的胯盯,要保證多進(jìn)程多線程同時(shí)只能有一個(gè)進(jìn)程的一個(gè)線程去處理,這個(gè)時(shí)候我們就需要用到分布式鎖计露。分布式鎖的實(shí)現(xiàn)方式有很多博脑,我們今天分別通過數(shù)據(jù)庫,Zookeeper, Redis 以及 Tair 的實(shí)現(xiàn)邏輯票罐。

數(shù)據(jù)庫實(shí)現(xiàn)

加 xx 鎖

更新一個(gè)單子關(guān)聯(lián)的所有的數(shù)據(jù)叉趣,先查詢出這個(gè)單子,并加上排他鎖该押,在進(jìn)行一系列的更新操作

begin transaction疗杉;
select ...for update;
doSomething()蚕礼;
commit();

這種處理主要依靠排他鎖來阻塞其他線程烟具,不過這個(gè)需要注意幾點(diǎn):

  1. 查詢的數(shù)據(jù)一定要在數(shù)據(jù)庫里存在梢什,如果不存在的話,數(shù)據(jù)庫會(huì)加 gap 鎖朝聋,而 gap 鎖之間是兼容的嗡午,這種如果兩個(gè)線程都加了gap 鎖,另一個(gè)再更新的話會(huì)出現(xiàn)死鎖冀痕。不過一般能更新的數(shù)據(jù)都是存在的
  2. 后續(xù)的處理流程需要盡可能的時(shí)間短荔睹,即在更新的時(shí)候提前準(zhǔn)備好數(shù)據(jù),保證事務(wù)處理的時(shí)間足夠的短言蛇,流程足夠的短应媚,因?yàn)殚_啟事務(wù)是一直占著連接的,如果流程比較長會(huì)消耗過多的數(shù)據(jù)庫連接的

唯一鍵

通過在一張表里創(chuàng)建唯一鍵來獲取鎖猜极,比如執(zhí)行 saveStore 這個(gè)方法

insert table lock_store ('method_name') values($method_name)

其中 method_name 是個(gè)唯一鍵,通過這種方式也可以做到消玄,解鎖的時(shí)候直接刪除改行記錄就行跟伏。不過這種方式,鎖就不會(huì)是阻塞式的翩瓜,因?yàn)椴迦霐?shù)據(jù)是立馬可以得到返回結(jié)果的受扳。

那針對(duì)以上數(shù)據(jù)庫實(shí)現(xiàn)的兩種分布式鎖,存在什么樣的優(yōu)缺點(diǎn)呢兔跌?

優(yōu)點(diǎn)

簡單勘高,方便,快速實(shí)現(xiàn)

缺點(diǎn)

  • 基于數(shù)據(jù)庫坟桅,開銷比較大华望,性能可能會(huì)存在影響
  • 基于數(shù)據(jù)庫的當(dāng)前讀來實(shí)現(xiàn),數(shù)據(jù)庫會(huì)在底層做優(yōu)化仅乓,可能用到索引赖舟,可能不用到索引,這個(gè)依賴于查詢計(jì)劃的分析

Zookeeper 實(shí)現(xiàn)

獲取鎖

  1. 先有一個(gè)鎖跟節(jié)點(diǎn)夸楣,lockRootNode宾抓,這可以是一個(gè)永久的節(jié)點(diǎn)
  2. 客戶端獲取鎖,先在 lockRootNode 下創(chuàng)建一個(gè)順序的瞬時(shí)節(jié)點(diǎn)豫喧,保證客戶端斷開連接石洗,節(jié)點(diǎn)也自動(dòng)刪除
  3. 調(diào)用 lockRootNode 父節(jié)點(diǎn)的 getChildren() 方法,獲取所有的節(jié)點(diǎn)紧显,并從小到大排序讲衫,如果創(chuàng)建的最小的節(jié)點(diǎn)是當(dāng)前節(jié)點(diǎn),則返回 true,獲取鎖成功鸟妙,否則焦人,關(guān)注比自己序號(hào)小的節(jié)點(diǎn)的釋放動(dòng)作(exist watch)挥吵,這樣可以保證每一個(gè)客戶端只需要關(guān)注一個(gè)節(jié)點(diǎn),不需要關(guān)注所有的節(jié)點(diǎn)花椭,避免羊群效應(yīng)忽匈。
  4. 如果有節(jié)點(diǎn)釋放操作,重復(fù)步驟 3

釋放鎖

只需要?jiǎng)h除步驟 2 中創(chuàng)建的節(jié)點(diǎn)即可

使用 Zookeeper 的分布式鎖存在什么樣的優(yōu)缺點(diǎn)呢矿辽?

優(yōu)點(diǎn)

  • 客戶端如果出現(xiàn)宕機(jī)故障的話丹允,鎖可以馬上釋放
  • 可以實(shí)現(xiàn)阻塞式鎖,通過 watcher 監(jiān)聽袋倔,實(shí)現(xiàn)起來也比較簡單
  • 集群模式雕蔽,穩(wěn)定性比較高

缺點(diǎn)

  • 一旦網(wǎng)絡(luò)有任何的抖動(dòng),Zookeeper 就會(huì)認(rèn)為客戶端已經(jīng)宕機(jī)宾娜,就會(huì)斷掉連接批狐,其他客戶端就可以獲取到鎖。當(dāng)然 Zookeeper 有重試機(jī)制前塔,這個(gè)就比較依賴于其重試機(jī)制的策略了
  • 性能上不如緩存

Redis 實(shí)現(xiàn)

我們先舉個(gè)例子嚣艇,比如現(xiàn)在我要更新產(chǎn)品的信息,產(chǎn)品的唯一鍵就是 productId

簡單實(shí)現(xiàn) 1

public boolean lock(String key, V v, int expireTime){
        int retry = 0;
        //獲取鎖失敗最多嘗試10次
        while (retry < failRetryTimes){
            //獲取鎖
            Boolean result = redis.setNx(key, v, expireTime);
            if (result){
                return true;
            }

            try {
                //獲取鎖失敗間隔一段時(shí)間重試
                TimeUnit.MILLISECONDS.sleep(sleepInterval);
            } catch (InterruptedException e) {
                Thread.currentThread().interrupt();
                return false;
            }

        }

        return false;
    }
    public boolean unlock(String key){
        return redis.delete(key);
    }
    public static void main(String[] args) {
        Integer productId = 324324;
        RedisLock<Integer> redisLock = new RedisLock<Integer>();
        redisLock.lock(productId+"", productId, 1000);
    }
}

這是一個(gè)簡單的實(shí)現(xiàn)华弓,存在的問題:

  1. 可能會(huì)導(dǎo)致當(dāng)前線程的鎖誤被其他線程釋放食零,比如 a 線程獲取到了鎖正在執(zhí)行,但是由于內(nèi)部流程處理超時(shí)或者 gc 導(dǎo)致鎖過期寂屏,這個(gè)時(shí)候b線程獲取到了鎖贰谣,a 和 b 線程處理的是同一個(gè) productId,b還在處理的過程中迁霎,這個(gè)時(shí)候 a 處理完了吱抚,a 去釋放鎖,可能就會(huì)導(dǎo)致 a 把 b 獲取的鎖釋放了考廉。
  2. 不能實(shí)現(xiàn)可重入
  3. 客戶端如果第一次已經(jīng)設(shè)置成功频伤,但是由于超時(shí)返回失敗,此后客戶端嘗試會(huì)一直失敗

針對(duì)以上問題我們改進(jìn)下:

  1. v 傳 requestId芝此,然后我們?cè)卺尫沛i的時(shí)候判斷一下憋肖,如果是當(dāng)前 requestId,那就可以釋放婚苹,否則不允許釋放
  2. 加入 count 的鎖計(jì)數(shù)岸更,在獲取鎖的時(shí)候查詢一次,如果是當(dāng)前線程已經(jīng)持有的鎖膊升,那鎖技術(shù)加 1怎炊,直接返回 true

簡單實(shí)現(xiàn) 2

private static volatile int count = 0;
public boolean lock(String key, V v, int expireTime){
    int retry = 0;
    //獲取鎖失敗最多嘗試10次
    while (retry < failRetryTimes){
        //1.先獲取鎖,如果是當(dāng)前線程已經(jīng)持有,則直接返回
        //2.防止后面設(shè)置鎖超時(shí),其實(shí)是設(shè)置成功评肆,而網(wǎng)絡(luò)超時(shí)導(dǎo)致客戶端返回失敗债查,所以獲取鎖之前需要查詢一下
        V value = redis.get(key);
        //如果當(dāng)前鎖存在,并且屬于當(dāng)前線程持有瓜挽,則鎖計(jì)數(shù)+1盹廷,直接返回
        if (null != value && value.equals(v)){
            count ++;
            return true;
        }

        //如果鎖已經(jīng)被持有了,那需要等待鎖的釋放
        if (value == null || count <= 0){
            //獲取鎖
            Boolean result = redis.setNx(key, v, expireTime);
            if (result){
                count = 1;
                return true;
            }
        }

        try {
            //獲取鎖失敗間隔一段時(shí)間重試
            TimeUnit.MILLISECONDS.sleep(sleepInterval);
        } catch (InterruptedException e) {
            Thread.currentThread().interrupt();
            return false;
        }

    }

    return false;
}
public boolean unlock(String key, String requestId){
    String value = redis.get(key);
    if (Strings.isNullOrEmpty(value)){
        count = 0;
        return true;
    }
    //判斷當(dāng)前鎖的持有者是否是當(dāng)前線程久橙,如果是的話釋放鎖俄占,不是的話返回false
    if (value.equals(requestId)){
        if (count > 1){
            count -- ;
            return true;
        }
        
        boolean delete = redis.delete(key);
        if (delete){
            count = 0;
        }
        return delete;
    }

    return false;
}
public static void main(String[] args) {
    Integer productId = 324324;
    RedisLock<String> redisLock = new RedisLock<String>();
    String requestId = UUID.randomUUID().toString();
    redisLock.lock(productId+"", requestId, 1000);
}

這種實(shí)現(xiàn)基本解決了誤釋放和可重入的問題啃奴,這里說明幾點(diǎn):

  1. 引入 count 實(shí)現(xiàn)重入的話虎囚,看業(yè)務(wù)需要,并且在釋放鎖的時(shí)候泛释,其實(shí)也可以直接就把鎖刪除了祝拯,一次釋放搞定甚带,不需要在通過 count 數(shù)量釋放多次,看業(yè)務(wù)需要吧
  2. 關(guān)于要考慮設(shè)置鎖超時(shí)佳头,所以需要在設(shè)置鎖的時(shí)候查詢一次欲低,可能會(huì)有性能的考量,看具體業(yè)務(wù)吧
  3. 目前獲取鎖失敗的等待時(shí)間是在代碼里面設(shè)置的畜晰,可以提出來,修改下等待的邏輯即可

錯(cuò)誤實(shí)現(xiàn)

獲取到鎖之后要檢查下鎖的過期時(shí)間瑞筐,如果鎖過期了要重新設(shè)置下時(shí)間,大致代碼如下:

public boolean tryLock2(String key, int expireTime){
    long expires = System.currentTimeMillis() + expireTime;

    // 獲取鎖
    Boolean result = redis.setNx(key, expires, expireTime);
    if (result){
        return true;
    }

    V value = redis.get(key);
    if (value != null && (Long)value < System.currentTimeMillis()){
        // 鎖已經(jīng)過期
        String oldValue = redis.getSet(key, expireTime);
        if (oldValue != null && oldValue.equals(value)){
            return true;
        }
    }
    
    return false;
}

這種實(shí)現(xiàn)存在的問題凄鼻,過度依賴當(dāng)前服務(wù)器的時(shí)間了,如果在大量的并發(fā)請(qǐng)求下聚假,都判斷出了鎖過期块蚌,而這個(gè)時(shí)候再去設(shè)置鎖的時(shí)候,最終是會(huì)只有一個(gè)線程膘格,但是可能會(huì)導(dǎo)致不同服務(wù)器根據(jù)自身不同的時(shí)間覆蓋掉最終獲取鎖的那個(gè)線程設(shè)置的時(shí)間峭范。

Tair 實(shí)現(xiàn)

通過 Tair 來實(shí)現(xiàn)分布式鎖和 Redis 的實(shí)現(xiàn)核心差不多,不過 Tair 有個(gè)很方便的 api瘪贱,感覺是實(shí)現(xiàn)分布式鎖的最佳配置纱控,就是 Put api 調(diào)用的時(shí)候需要傳入一個(gè) version,就和數(shù)據(jù)庫的樂觀鎖一樣菜秦,修改數(shù)據(jù)之后甜害,版本會(huì)自動(dòng)累加,如果傳入的版本和當(dāng)前數(shù)據(jù)版本不一致球昨,就不允許修改尔店。

?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請(qǐng)聯(lián)系作者
  • 序言:七十年代末,一起剝皮案震驚了整個(gè)濱河市,隨后出現(xiàn)的幾起案子嚣州,更是在濱河造成了極大的恐慌鲫售,老刑警劉巖,帶你破解...
    沈念sama閱讀 217,826評(píng)論 6 506
  • 序言:濱河連續(xù)發(fā)生了三起死亡事件该肴,死亡現(xiàn)場(chǎng)離奇詭異情竹,居然都是意外死亡,警方通過查閱死者的電腦和手機(jī)沙庐,發(fā)現(xiàn)死者居然都...
    沈念sama閱讀 92,968評(píng)論 3 395
  • 文/潘曉璐 我一進(jìn)店門鲤妥,熙熙樓的掌柜王于貴愁眉苦臉地迎上來,“玉大人拱雏,你說我怎么就攤上這事棉安。” “怎么了铸抑?”我有些...
    開封第一講書人閱讀 164,234評(píng)論 0 354
  • 文/不壞的土叔 我叫張陵贡耽,是天一觀的道長。 經(jīng)常有香客問我鹊汛,道長蒲赂,這世上最難降的妖魔是什么? 我笑而不...
    開封第一講書人閱讀 58,562評(píng)論 1 293
  • 正文 為了忘掉前任刁憋,我火速辦了婚禮滥嘴,結(jié)果婚禮上,老公的妹妹穿的比我還像新娘至耻。我一直安慰自己若皱,他們只是感情好,可當(dāng)我...
    茶點(diǎn)故事閱讀 67,611評(píng)論 6 392
  • 文/花漫 我一把揭開白布尘颓。 她就那樣靜靜地躺著走触,像睡著了一般。 火紅的嫁衣襯著肌膚如雪疤苹。 梳的紋絲不亂的頭發(fā)上互广,一...
    開封第一講書人閱讀 51,482評(píng)論 1 302
  • 那天,我揣著相機(jī)與錄音卧土,去河邊找鬼惫皱。 笑死,一個(gè)胖子當(dāng)著我的面吹牛尤莺,可吹牛的內(nèi)容都是我干的逸吵。 我是一名探鬼主播,決...
    沈念sama閱讀 40,271評(píng)論 3 418
  • 文/蒼蘭香墨 我猛地睜開眼缝裁,長吁一口氣:“原來是場(chǎng)噩夢(mèng)啊……” “哼扫皱!你這毒婦竟也來了足绅?” 一聲冷哼從身側(cè)響起,我...
    開封第一講書人閱讀 39,166評(píng)論 0 276
  • 序言:老撾萬榮一對(duì)情侶失蹤韩脑,失蹤者是張志新(化名)和其女友劉穎氢妈,沒想到半個(gè)月后,有當(dāng)?shù)厝嗽跇淞掷锇l(fā)現(xiàn)了一具尸體段多,經(jīng)...
    沈念sama閱讀 45,608評(píng)論 1 314
  • 正文 獨(dú)居荒郊野嶺守林人離奇死亡首量,尸身上長有42處帶血的膿包…… 初始之章·張勛 以下內(nèi)容為張勛視角 年9月15日...
    茶點(diǎn)故事閱讀 37,814評(píng)論 3 336
  • 正文 我和宋清朗相戀三年,在試婚紗的時(shí)候發(fā)現(xiàn)自己被綠了进苍。 大學(xué)時(shí)的朋友給我發(fā)了我未婚夫和他白月光在一起吃飯的照片加缘。...
    茶點(diǎn)故事閱讀 39,926評(píng)論 1 348
  • 序言:一個(gè)原本活蹦亂跳的男人離奇死亡,死狀恐怖觉啊,靈堂內(nèi)的尸體忽然破棺而出拣宏,到底是詐尸還是另有隱情,我是刑警寧澤杠人,帶...
    沈念sama閱讀 35,644評(píng)論 5 346
  • 正文 年R本政府宣布勋乾,位于F島的核電站,受9級(jí)特大地震影響嗡善,放射性物質(zhì)發(fā)生泄漏辑莫。R本人自食惡果不足惜,卻給世界環(huán)境...
    茶點(diǎn)故事閱讀 41,249評(píng)論 3 329
  • 文/蒙蒙 一罩引、第九天 我趴在偏房一處隱蔽的房頂上張望各吨。 院中可真熱鬧,春花似錦袁铐、人聲如沸揭蜒。這莊子的主人今日做“春日...
    開封第一講書人閱讀 31,866評(píng)論 0 22
  • 文/蒼蘭香墨 我抬頭看了看天上的太陽。三九已至伪嫁,卻和暖如春领炫,著一層夾襖步出監(jiān)牢的瞬間,已是汗流浹背张咳。 一陣腳步聲響...
    開封第一講書人閱讀 32,991評(píng)論 1 269
  • 我被黑心中介騙來泰國打工帝洪, 沒想到剛下飛機(jī)就差點(diǎn)兒被人妖公主榨干…… 1. 我叫王不留,地道東北人脚猾。 一個(gè)月前我還...
    沈念sama閱讀 48,063評(píng)論 3 370
  • 正文 我出身青樓葱峡,卻偏偏與公主長得像,于是被迫代替她去往敵國和親龙助。 傳聞我的和親對(duì)象是個(gè)殘疾皇子砰奕,可洞房花燭夜當(dāng)晚...
    茶點(diǎn)故事閱讀 44,871評(píng)論 2 354

推薦閱讀更多精彩內(nèi)容