分佈式鎖的三種實現方式

一、基於數據庫實現分佈式鎖

大致思路:需要在數據庫中建一張鎖表,當我們需要鎖住某個資源或者方法時,就向該表中添加一條數據,如果需要釋放鎖則刪除數據即可。
以方法鎖爲例

 create table method_lock
(
   id                   int not null auto_increment,
   method_name          varchar(100) not null comment '鎖定的方法名',
   desc                 varchar(100) comment '描述',
   update_time          datetime not null default CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
   holder               varchar(50) comment '鎖持有者',
   primary key (id),
   unique key `idx_method_name` (`method_name`) using BTREE
) ENGINE=InnoDB DEFAULT CHARSET=utf8 comment='方法鎖表';

如果需要鎖定某個方法,則向method_lock表中插一條數據

insert into method_lock(method_name, desc, holder) values ('function1', 'desc1', 'holder1');

如果需要釋放鎖,則從method_lock表中刪除數據

delete from method_lock where method_name = 'function1';

由於method_name列有唯一索引,若多個應用同時獲取function1的鎖,數據庫會保證只有一條數據插入成功,我們可以認爲插入成功的那個應用獲得了該方法鎖,就可以執行方法體。

以上的實現有如下的問題:

這把鎖強依賴於數據庫,數據庫單點,一旦數據庫掛掉,會導致所有需要獲取方法鎖的業務不可用;
鎖沒有加有效時間,一旦釋放鎖失敗了,則導致鎖數據一直存在數據庫中,其他應用無法再獲得該鎖;
這把鎖是非阻塞式的,一旦insert失敗就會直接報錯,嘗試獲得鎖的線程無法進入排隊隊列,如果想重新獲取鎖,只能再次觸發獲得鎖操作。

針對以上問題,有如下的解決方案:

數據庫單點?搞兩個數據庫,主主同步,掛了一個另一個也可以頂上;
鎖沒有有效時間?搞一個定時任務,隔一段時間就掃描一次表,將超時的數據刪除(需要設置一個合理的超時時間);
鎖非阻塞?如果希望獲取鎖失敗的應用進入排隊隊列,搞一個while循環反覆嘗試insert,直到insert成功(但大部分的業務場景是不需要這樣的)。

二、基於緩存實現分佈式鎖

相較於基於數據庫實現分佈式鎖,基於緩存實現分佈式鎖的性能會好一些(畢竟操作緩存的開銷小於操作數據庫的開銷),而且緩存可以實現分佈式部署,解決了基於數據庫實現分佈式鎖的單點問題。
常用的分佈式緩存有memcached和redis,下面分開講一下。

memcached:

可以利用memcached的add命令,這個命令在key不存在的時候纔會執行成功,併發下多個應用同時add同一個key,只有一個可以add成功,add成功的那個應用就獲取了鎖。處理完業務後需要將鎖釋放掉,用delete命令將key刪除掉即可(有些文章會先判斷一下緩存是否失效,沒失效的情況下刪除,失效了就不管它了,我個人覺得多此一舉,直接刪除沒那麼多麻煩事)
僞碼如下:

String key = 'method_lock_' + methodName;
try {
    String value = holder;//鎖持有者的信息
    long expireTime = xxx;//鎖的有效時長
    if(mc.add(key, value, expireTime)) {
        //do business things

        //業務處理結束後,將緩存刪除
        mc.delete(key);
    }
} catch(Exception e) {
    mc.delete(key); 
}

redis:

redis沒有add這個命令,但它有一個SETNX(SET IF NOT EXISTS)可以實現一樣的效果,只有當key不存在時纔會設置成功並返回1,設置不成功時返回0。
但遺憾的是,SETNX這個命令不能像mc.add那樣直接設置失效時間,需要在設置成功後再通過EXPIRE命令追加設置失效時間。

僞碼如下:

String key = 'method_lock_' + methodName;
String value = holder;//鎖持有者的信息
long expireTime = xxx;//鎖的有效時長
int lockedRes = redis.SETNX(key,value);
if(lockedRes == 1) {
    //[1]獲取鎖成功

    //[2]追加設置失效時間
    redis.EXPIRE(key, expireTime);

    try {
        //[3]do business things

        //[4]業務處理結束後,將緩存刪除
        redis.DEL(key);
    } catch(Exception e) {
        //業務處理過程中發生了異常,也需要將緩存刪除
        redis.DEL(key);
    }
}

以上僞碼基本上實現了分佈式鎖的功能,但是有一個問題,在[1]和[2]之間(即已經成功獲取了鎖但是還沒來得及設置失效時間時)應用掛掉了,那麼這個緩存將永久存在,其他應用再也無法獲取這個鎖。雖說這種場景是很少見的,但是也得處理啊,改進思路如下:
1.把value從holder變成expiredDate,即令value=expiredDate.toString(),嘗試通過SETNX(key,value)獲取鎖。若返回1,獲取鎖成功;返回0,獲取鎖失敗,進入下一步;
2.通過GET(key)命令,拿到已有的value(即expiredDate),判斷是否已經超時,若未超時,說明某個應用正在持有鎖;若已經超時,則進入下一步;
3.通過GETSET(key,newValue)命令(將給定key的值設爲newValue ,並返回key的舊值(oldValue)),拿到oldValue,再次解析出oldValue中包含的失效時間,判斷是否已經超時,若已經超時,說明獲取鎖成功。

爲什麼第三步中要再一次判斷是否超時呢?第二步裏不是判斷過了嗎?

原因是,可能有多個應用併發執行到第三步,比如應用A和應用B都執行到第三步了,應用A早於應用B一點點的時間執行了GETSET方法,對應用A來說,拿到的oldValue是第二步中的value,肯定是超時的,那麼應用A實際上獲得了這個鎖;對應用B來說,因爲晚了一步,GETSET拿到的oldValue實際上是應用A設置的newValue,理論上它此刻是不會超時的,那麼應用B獲取鎖就失敗了。

有人要問了,應用B通過GETSET再次更新了失效時間(覆蓋了已經獲取鎖的應用A設置的失效時間),有影響嗎?不影響,因爲無論是應用A還是應用B,它們的失效時長都是固定的,應用B的expiredDate會晚於應用A的expiredDate,應用B的GETSET不會影響應用A已經獲取鎖的這個事實,它只是將失效時間延長了一點點。

僞碼如下:

long expireTime = 300 * 1000; //失效時長(單位ms) 300秒即5分鐘
String expiredDateStr = //當前時間加上expireTime時長計算得到的時間並轉成String類型;
String key = 'method_lock_' + methodName;
int lockResult = redis.SETNX(key, expiredDateStr);
bool getLock = false;
if (lockResult == 1) {
    //得到鎖
    getLock = true;
} else {
    String oldExpiredDateStr = redis.GET(key);

    //檢查鎖是否超時
    if (CheckedLockTimeOut(oldExpiredDateStr)) {
        String newExpiredDateStr = //當前時間加上expireTime時長計算得到的時間並轉成String類型;
        string oldValue = redis.GETSET(key, newExpiredDateStr);
        if (CheckedLockTimeOut(oldValue)) {
            //得到鎖
            getLock = true;
        }
    }
}
//[1]獲得鎖
if (getLock)
{
    try {
        //[2]do business  function

        //[3]釋放鎖
        redis.DEL(key);
    } catch (Exception e) {
        //業務處理過程中發生了異常,需要釋放鎖
        redis.DEL(key);
    }
}

改進後的代碼解決了死鎖的問題,但是,這種方式實現的鎖無法重入,即獲得鎖的應用再重新進入的時候,無法判斷出鎖的持有者是自己。改進前的代碼是可以的,因爲改進前存儲的value是holder,可以GET(key)後判斷holder是不是自己。有人要說了,是不是可以把改進後代碼中的value中加進holder信息?答案是,不可以。因爲改進後的代碼用到了GETSET,如果應用A和應用B幾乎同時進入第三步,A先GETSET,B後GETSET,最終結果是A拿到了鎖B沒拿到,但是存儲的信息卻被B更新了(鎖的holder變成了B但實際上A拿到了鎖),這就尷尬了。

所以,改進後的方法也不是完美的。

三、基於zookeeper實現分佈式鎖

zookeeper臨時有序節點可以實現分佈式鎖
大致思路:每個客戶端嘗試獲取某個方法鎖時,就在zookeeper的該方法對應的節點目錄下,生成一個臨時有序節點。如果生成的有序節點是該目錄下最小的,即認爲獲得了該方法鎖,可以執行方法體。如果需要釋放某個方法鎖,則只要刪除之前生成的節點就可以。

這種實現方式的優點:

如果某個應用掛掉,與zookeeper斷連,則該應用生成的臨時節點全部自動刪除掉,即它所擁有的鎖會自動釋放。這樣避免了基於數據庫和基於緩存實現分佈式鎖時,單個應用掛掉無法自動釋放其持有鎖的問題;
可以實現阻塞式的鎖(如果有這個需要的話)。具體實現方式是:如果某個客戶端嘗試獲取鎖失敗,那麼它可以在當前目錄下的最小節點上綁定一個監聽器,如果該節點發生了變化(被刪除),監聽器可以通知到之前嘗試獲取鎖的客戶端,該客戶端可以再次檢查自己生成的節點是否是當前最小的節點,如果是的話,即獲取了方法鎖;
不存在單點問題。zookeeper是集羣部署的,只要集羣中有半數以上機器存活,就可以對外提供服務;
生成節點的時候,將鎖持有者的信息保存下來,同一客戶端重入時,判斷持有者是否是自己,即可重入。

基於zookeeper臨時有序節點的方式實現分佈式鎖優點很多,缺點只有一個,效率不如基於緩存實現分佈式鎖,因爲zookeeper在生成節點、銷燬節點時的消耗大於緩存操作的消耗。

四、三種方式的比較

理解難易程度(從易到難):

數據庫>緩存>zookeeper

實現難易程度(從易到難):

zookeeper≈緩存>數據庫(數據庫需要建表,其他兩個相對簡單)

性能(從高到低):

緩存>zookeeper>數據庫(數據庫最低毫無疑問,zookeeper居中,緩存最高)

可靠性(從高到低):

zookeeper>緩存>數據庫


本文參考了http://www.hollischuang.com/archives/1716,在原文基礎上理解並重組,其中基於緩存實現的分佈式鎖有較大改變。

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章