分布式鎖

簡(jiǎn)述

使用分布式鎖的目的:

  • 提高效率:使用分布式鎖避免不同節(jié)點(diǎn)重復(fù)性的操作亮隙,比如:推送绪商、定時(shí)任務(wù)等
  • 保證正確性:避免多個(gè)節(jié)點(diǎn)破壞操作的正確性谊路,比如多個(gè)節(jié)點(diǎn)同時(shí)更新共享資源的問題其做。

分布式鎖需要的特性:

  • 互斥性
  • 可重入
  • 鎖超時(shí)
  • 阻塞碉克、非阻塞
  • 公平凌唬、非公平鎖

Mysql方式

我們可以通過數(shù)據(jù)庫(kù)的來實(shí)現(xiàn)鎖,具體的實(shí)現(xiàn)方式如下:

互斥鎖

首先建立一張表用來存儲(chǔ)鎖信息漏麦,表結(jié)構(gòu)如下:

CREATE TABLE `tb_lock` (
    `id` bigint(20) NOT NULL COMMENT '主鍵',
        `key` VARCHAR(100) not null COMMENT '鎖持鎖對(duì)象的標(biāo)識(shí)',
        `resource_id` bigint(20) not null comment '競(jìng)爭(zhēng)的資源id',
        `count` int(10) not null comment '鎖重入次數(shù)',
        `insert_time` datetime not null comment '插入時(shí)間',
        `update_time` datetime not null comment '更新時(shí)間',
         PRIMARY key (`id`),
         UNIQUE(`resource_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8 comment '鎖';

獲取鎖代碼:

    private boolean acquire(long resourceId) throws Exception {
        String key = String.format("%s-%s",getUUID(),Thread.currentThread().getId());
        List<Lock> locks = daoHelper.sql.getListBySQL(Lock.class,"resource_id="+resourceId);
        if(CollectionUtils.isNotEmpty(locks)){
            Lock lock = locks.get(0);
            if(key.equals(lock.getKey())){
                lock.setCount(lock.getCount()+1);
                daoHelper.sql.upateEntity(lock);
                return true;
            }else{
                return false;
            }
        }
        return insert(resourceId,key);
    }

釋放鎖的代碼:

    private int release(long resourceId) throws Exception {
        String key = String.format("%s-%s",getUUID(),Thread.currentThread().getId());
        List<Lock> locks = daoHelper.sql.getListBySQL(Lock.class,"resource_id="+resourceId);
        if(CollectionUtils.isNotEmpty(locks)){
            Lock lock = locks.get(0);
            if(key.equals(lock.getKey())){
                if(lock.getCount()==1){
                    daoHelper.sql.deleteByID(Lock.class,lock.getId());
                }else{
                    lock.setCount(lock.getCount()-1);
                    daoHelper.sql.upateEntity(lock);
                    return lock.getCount();
                }
            }else{
                return 0;
            }
        }
        return 0;
    }

阻塞的實(shí)現(xiàn):

    /**
     * 不斷的重試客税。
     */
    public boolean tryLock(long resourceId,long time) throws Exception {
        while(true){
            long endTime = System.currentTimeMillis() + time;
            if(acquire(resourceId)){
                return true;
            }
            if(endTime>System.currentTimeMillis()){
                return false;
            }
            LockSupport.parkNanos(TimeUnit.SECONDS.toNanos(1));
        }
    }
    /**
     * 重試
     */
    public boolean lock(long resourceId) throws Exception {
        while(true){
            if(acquire(resourceId)){
                return true;
            }
            LockSupport.parkNanos(TimeUnit.SECONDS.toNanos(1));
        }
    }

思路:

  1. 通過key字段標(biāo)識(shí)持有鎖的進(jìn)程和線程况褪。
  2. 通過count字段記錄鎖重入的次數(shù)。
  3. 通過循環(huán)加線程掛起的方式實(shí)現(xiàn)阻塞更耻。

小結(jié)

一般我們很少會(huì)使用數(shù)據(jù)庫(kù)實(shí)現(xiàn)分布式测垛,如果數(shù)據(jù)本身存儲(chǔ)在Mysql中且有資源競(jìng)爭(zhēng)的話,可以采用事物或者樂觀鎖來解決秧均。

Zookeeper方案

ZooKeeper是以Paxos算法為基礎(chǔ)的分布式應(yīng)用程序協(xié)調(diào)服務(wù)食侮。它的數(shù)據(jù)結(jié)構(gòu)和文件目錄類似,它的數(shù)據(jù)節(jié)點(diǎn)類型包括四種類型:

  • 持久化節(jié)點(diǎn)
  • 持久化順序節(jié)點(diǎn)
  • 臨時(shí)節(jié)點(diǎn)
  • 臨時(shí)順序節(jié)點(diǎn)

互斥鎖實(shí)現(xiàn)

我們可以利用它的臨時(shí)順序性節(jié)點(diǎn)和它的watch機(jī)制實(shí)現(xiàn)分布式鎖目胡,如下圖所示:


image

上圖所示锯七,client1現(xiàn)在持有鎖。zookeeper獲取鎖的整個(gè)過程:

  1. 首先選取一個(gè)目錄(如/root/lock/)用作實(shí)現(xiàn)分布式鎖的根目錄讶隐。
  2. 客戶端嘗試在資源目錄下創(chuàng)建臨時(shí)順序節(jié)點(diǎn)起胰。
  3. 然后,嘗試獲取競(jìng)爭(zhēng)的資源目錄下的所有子節(jié)點(diǎn)巫延。
  4. 判斷子節(jié)點(diǎn)中序號(hào)最小的節(jié)點(diǎn)是不是自己所創(chuàng)建的效五,如果是的話則獲取鎖成功。
  5. 如果沒有獲取鎖成功炉峰,則在/root/lock/目錄上添加watcher畏妖,監(jiān)聽其子節(jié)點(diǎn)是否有新增或者刪除。
  6. 如果字節(jié)點(diǎn)發(fā)生改變疼阔,重復(fù)3步驟戒劫。

zookeeper也可以理解為一個(gè)小型的數(shù)據(jù)庫(kù),它的每個(gè)節(jié)點(diǎn)都可以存儲(chǔ)數(shù)據(jù)婆廊,默認(rèn)存儲(chǔ)數(shù)據(jù)大小為1M迅细。所以我們可以在每個(gè)節(jié)點(diǎn)上存競(jìng)爭(zhēng)鎖的線程標(biāo)識(shí),重入次數(shù)等信息淘邻,用來實(shí)現(xiàn)鎖重入等功能茵典。

上述過程中有一個(gè)問題:所有客戶端都把watcher注冊(cè)到資源節(jié)點(diǎn)上,一旦資源節(jié)點(diǎn)的子節(jié)點(diǎn)發(fā)生改變宾舅,會(huì)通知所有的客戶端统阿,但實(shí)際上能夠成功獲取鎖的客戶端只有一個(gè)开皿,就是序號(hào)最小的那個(gè)節(jié)點(diǎn)蛹头。這樣會(huì)造成網(wǎng)絡(luò)資源的浪費(fèi)品嚣,嚴(yán)重時(shí)可能會(huì)打滿網(wǎng)卡楷兽,這種現(xiàn)象在zookeeper使用中稱作“羊群效應(yīng)”。

為了解決上述問題晰筛,采用最小監(jiān)聽的模式赞辩,每個(gè)客戶端只對(duì)排在它前面的節(jié)點(diǎn)注入watcher棺榔,改進(jìn)后如下圖所示:


image

讀寫鎖實(shí)現(xiàn)

讀寫鎖的實(shí)現(xiàn)邏輯跟互斥鎖差不多,針對(duì)讀寫節(jié)點(diǎn)我們可以在其節(jié)點(diǎn)名字前增加前綴進(jìn)行區(qū)分麻献。如下圖所示:


image
  1. client1和client2獲取了讀鎖呼巷。
  2. client3需要獲取寫鎖,所以對(duì)client2進(jìn)行了監(jiān)聽赎瑰;client4也需要獲取寫鎖,但是它的前面有一個(gè)節(jié)點(diǎn)也需要獲取寫鎖破镰,所以它監(jiān)聽client3節(jié)點(diǎn)
  3. client5節(jié)點(diǎn)是為了獲取讀鎖餐曼,雖然鎖目前被讀鎖占領(lǐng),但是它的請(qǐng)求比client4和client5晚鲜漩,為了保證避免‘鎖饑餓’現(xiàn)象的發(fā)生源譬,它需要等待前面的client3和client4釋放鎖之后才能獲取讀鎖,所以它對(duì)client4節(jié)點(diǎn)進(jìn)行監(jiān)聽孕似。

curator

Curator是Netflix公司開源的一個(gè)Zookeeper客戶端踩娘,相比較Zookeeper原生的客戶端,它提供了更人性化的API風(fēng)格喉祭,同時(shí)它也提供了一些工具類的封裝养渴,比如上述的互斥鎖和讀寫鎖它都有實(shí)現(xiàn),如下所示:

  • InterProcessMutex 實(shí)現(xiàn)可重入互斥鎖
  • InterProcessReadWriteLock 實(shí)現(xiàn)讀寫鎖

小結(jié)

Zookeeper的數(shù)據(jù)特點(diǎn)比較容易實(shí)現(xiàn)CLH(Craig, Landin, and Hagersten)隊(duì)列泛烙,可以很輕松實(shí)現(xiàn)公平鎖理卑。它的session通過周期性的keepAlive來維護(hù),一旦session失效后蔽氨,臨時(shí)節(jié)點(diǎn)會(huì)在Zookeeper中刪除藐唠,從而避免了因?yàn)榭蛻舳隋礄C(jī)而導(dǎo)致的死鎖。Zookeeper本身屬于CP型的鹉究,在數(shù)據(jù)一致性方面做的很好宇立,所以使用它實(shí)現(xiàn)分布式鎖比較安全。但是使用它需要維護(hù)額外的Zookeeper集群自赔,使用成本上相對(duì)來說高一些妈嘹。

Redis方案

setnx版本

使用Redis實(shí)現(xiàn)分布式鎖,最普遍的做法是使用setNX指令匿级,為了避免客戶端宕機(jī)導(dǎo)致死鎖蟋滴,通常在加鎖成功之后對(duì)key設(shè)置過期時(shí)間,如下所示:

    Long result = jedis.setnx(lockKey, requestId);
    if (result!=null&&result == 1) {
        jedis.expire(lockKey, expireTime);
    }

但是上述過程有問題痘绎,setNX和expire是分開的兩步操作津函,整個(gè)加鎖過程不是原子性的,如果在setNx后客戶端宕機(jī)了孤页,也會(huì)導(dǎo)致死鎖尔苦。

在Redis 2.6.12 版本開始, SET命令提供了多個(gè)參數(shù),如NX允坚,EX等魂那。setNX和expire操作可以使用一條指令完成:

    //value中可以保存線程標(biāo)識(shí)。
    jedis.set(lockKey, requestId, "NX", "EX", expireTime);

鎖的釋放操作稠项,可以簡(jiǎn)單的使用刪除key的方式進(jìn)行釋放涯雅,但是這樣做不安全,如果執(zhí)行時(shí)間大于過期時(shí)間的話展运,鎖可能會(huì)被勿刪活逆。舉個(gè)例子:客戶端A取得資源鎖,但是緊接著被一個(gè)其他操作阻塞了拗胜,當(dāng)客戶端A運(yùn)行完畢其他操作后要釋放鎖時(shí)蔗候,原來的鎖早已超時(shí)并且被Redis自動(dòng)釋放,并且在這期間資源鎖又被客戶端B再次獲取到埂软。如果僅使用DEL命令將key刪除锈遥,那么這種情況就會(huì)把客戶端B的鎖給刪除掉。比較安全的做法是采用lua腳本勘畔,告訴redis只有key存在且其value等于指定的值才刪除成功所灸,lua腳本如下所示:

if redis.call("get",KEYS[1]) == ARGV[1] then
    return redis.call("del",KEYS[1])
else
    return 0
end

可以使用jedis執(zhí)行l(wèi)ua腳本:

    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 (1L.equals(result)) {
        return true;
    }
    return false;

使用setNX只能實(shí)現(xiàn)簡(jiǎn)單的互斥操作,如果想要實(shí)現(xiàn)更復(fù)雜的功能可以使用Redisson咖杂。

Redisson版本

Redisson提供了豐富鎖工具和線程同步工具:

  • RLock:互斥庆寺、公平/非公平、可重入鎖
  • MultiLock:聯(lián)合鎖
  • RedLock:紅鎖
  • ReadWriteLock:讀寫鎖
  • Semaphore:信號(hào)量
  • PermitExpirableSemaphore:支持過期的信號(hào)量
  • CountDownLatch:柵欄

RLock

RLock鎖的特點(diǎn):互斥可重入鎖诉字,支持公平/非公平模式懦尝。RLock實(shí)現(xiàn)了java的Lock接口,它的用法和ReentrantLock一樣壤圃,如下所示:

RLock lock = redisson.getLock("anyLock");
RLock fairLock = redisson.getFairLock("anyLock");
// Most familiar locking method
lock.lock();
lock.unlock();

它使用lua腳本實(shí)現(xiàn)陵霉,先來看它的非公平模式下獲取鎖流程:

  • KEYS[1] :需要加鎖的key,這里需要是字符串類型伍绳。
  • ARGV[1] :鎖的超時(shí)時(shí)間踊挠,防止死鎖
  • ARGV[2] :鎖的唯一標(biāo)識(shí),id(UUID.randomUUID()) + “:” + threadId
//不存在初始化為1
if (redis.call('exists', KEYS[1]) == 0) then 
    redis.call('hset', KEYS[1], ARGV[2], 1); 
    redis.call('pexpire', KEYS[1], ARGV[1]); 
    return nil; 
end; 
//如果是重入冲杀,value則加1
if (redis.call('hexists', KEYS[1], ARGV[2]) == 1)then 
    redis.call('hincrby', KEYS[1], ARGV[2], 1); 
    redis.call('pexpire', KEYS[1], ARGV[1]); 
    return nil; 
end; 
return redis.call('pttl', KEYS[1]);

鎖的狀態(tài)采用hash結(jié)構(gòu)存儲(chǔ)效床,hash的item為線程標(biāo)識(shí),value為鎖的重入次數(shù)权谁,同時(shí)lock接口中還訂閱解鎖的channel剩檀,用于競(jìng)爭(zhēng)鎖資源。

它解鎖的流程:

  • KEYS[1] :需要加鎖的key旺芽,這里需要是字符串類型沪猴。
  • KEYS[2] :redis消息的ChannelName,一個(gè)分布式鎖對(duì)應(yīng)唯一的一個(gè)channelName:“redisson_lock__channel__{” + getName() + “}”
  • ARGV[1] :reids消息體辐啄,這里只需要一個(gè)字節(jié)的標(biāo)記就可以,主要標(biāo)記redis的key已經(jīng)解鎖运嗜,再結(jié)合redis的Subscribe壶辜,能喚醒其他訂閱解鎖消息的客戶端線程申請(qǐng)鎖。
  • ARGV[2] :鎖的超時(shí)時(shí)間担租,防止死鎖
  • ARGV[3] :鎖的唯一標(biāo)識(shí)砸民, id(UUID.randomUUID()) + “:” + threadId
if (redis.call('exists', KEYS[1]) == 0) then 
    redis.call('publish', KEYS[2], ARGV[1]); 
    return 1; 
end; 
if (redis.call('hexists', KEYS[1], ARGV[3]) == 0) then 
    return nil; 
end; 
local counter = redis.call('hincrby', KEYS[1], ARGV[3], -1); 
if (counter > 0) then 
    redis.call('pexpire', KEYS[1], ARGV[2]);
    return 0;
else redis.call('del', KEYS[1]);
    redis.call('publish', KEYS[2], ARGV[1]);
    return 1;
end;
return nil;

lock接口源碼:

    public void lockInterruptibly(long leaseTime, TimeUnit unit) throws InterruptedException {
        long threadId = Thread.currentThread().getId();
        Long ttl = this.tryAcquire(leaseTime, unit, threadId);
        if (ttl != null) {
            //訂閱對(duì)應(yīng)key的channel。
            RFuture<RedissonLockEntry> future = this.subscribe(threadId);
            this.commandExecutor.syncSubscription(future);
            try {
                while(true) {
                    //嘗試執(zhí)行l(wèi)ua腳本奋救,去獲取鎖阱洪。
                    ttl = this.tryAcquire(leaseTime, unit, threadId);
                    if (ttl == null) {
                        return;
                    }
                    //如果ttl大于0,則最大等待ttl時(shí)間菠镇,再去嘗試獲取鎖。
                    if (ttl >= 0L) {
                        //此處采用信號(hào)量實(shí)現(xiàn)的承璃,如果沒有鎖釋放利耍,則阻塞等待通知。
                        this.getEntry(threadId).getLatch().tryAcquire(ttl, TimeUnit.MILLISECONDS);
                    } else {
                        this.getEntry(threadId).getLatch().acquire();
                    }
                }
            } finally {
                this.unsubscribe(future, threadId);
            }
        }
    }

整體的流程圖如下:


image

下列是公平鎖獲取鎖的lua腳本:

  • KEYS[1]:需要加鎖的key
  • KEYS[2]:Redisson的redisson_lock_queue_key 使用list存儲(chǔ)等待鎖的客戶端盔粹,相當(dāng)于CLH隊(duì)列隘梨,保證節(jié)點(diǎn)獲取鎖的公平性。
  • KEYS[3]:Redisson的redisson_lock_timeout_key 使用zset存儲(chǔ)鎖的等待時(shí)間和list元素的去重處理舷嗡。
  • ARGV[1]: 鎖的超時(shí)時(shí)間轴猎,防止死鎖
  • ARGV[2]: 鎖的唯一標(biāo)識(shí),id(UUID.randomUUID()) + “:” + threadId
  • ARGV[3]: currentTime+5000L(ms)
  • ARGV[4]:currentTime
while true
//檢查redisson_lock_queue_key里面元素进萄,刪除過期的元素捻脖。
do local firstThreadId2 = redis.call('lindex', KEYS[2], 0);
    if firstThreadId2 == false then
        break;
    end;
    local timeout = tonumber(redis.call('zscore', KEYS[3], firstThreadId2));
    if timeout <= tonumber(ARGV[4]) then
        redis.call('zrem', KEYS[3], firstThreadId2);
        redis.call('lpop', KEYS[2]);
    else break;
    end;
end;
//如果key不存在,且redisson_lock_queue_key為空中鼠,或者redisson_lock_queue_key里面第一個(gè)元素是請(qǐng)求者可婶,則獲取鎖成功,并返回null援雇。
if (redis.call('exists', KEYS[1]) == 0) and ((redis.call('exists', KEYS[2]) == 0) or (redis.call('lindex', KEYS[2], 0) == ARGV[2])) then
    redis.call('lpop', KEYS[2]);
    redis.call('zrem', KEYS[3], ARGV[2]);
    redis.call('hset', KEYS[1], ARGV[2], 1);
    redis.call('pexpire', KEYS[1], ARGV[1]);
    return nil;
end;

//如果判斷持有鎖的線程是自己矛渴,則進(jìn)行鎖重入處理,count++惫搏,并返回null具温。
if (redis.call('hexists', KEYS[1], ARGV[2]) == 1) then
    redis.call('hincrby', KEYS[1], ARGV[2], 1);
    redis.call('pexpire', KEYS[1], ARGV[1]);
    return nil;
end;

//設(shè)置ttl時(shí)間,用于重試獲取鎖的時(shí)間筐赔。
local firstThreadId = redis.call('lindex', KEYS[2], 0);
local ttl;
//獲取ttl铣猩,如果隊(duì)列中第一個(gè)元素不是請(qǐng)求者,則取第一個(gè)元素的ttl川陆,否則取當(dāng)前鎖的ttl剂习。
if firstThreadId ~= false and firstThreadId ~= ARGV[2] then
    ttl = tonumber(redis.call('zscore', KEYS[3], firstThreadId)) - tonumber(ARGV[4]);
else 
    ttl = redis.call('pttl', KEYS[1]);
end;
local timeout = ttl + tonumber(ARGV[3]);

//將請(qǐng)求者加入隊(duì)列中蛮位。
if redis.call('zadd', KEYS[3], timeout, ARGV[2]) == 1 then
redis.call('rpush', KEYS[2], ARGV[2]);
end;
return ttl;

公平鎖實(shí)現(xiàn)要比非公平鎖復(fù)雜一些,為了保證公平鎖的公平性鳞绕,維護(hù)了一個(gè)list結(jié)構(gòu)失仁,當(dāng)做FIFO隊(duì)列,鎖爭(zhēng)用發(fā)生時(shí)们何,只有l(wèi)ist中的第一個(gè)元素能去獲取鎖萄焦。同時(shí)還維護(hù)了一個(gè)zset結(jié)構(gòu),用于存儲(chǔ)每個(gè)客戶端獲取鎖的超時(shí)時(shí)間冤竹,避免list中存在大量失效的數(shù)據(jù)拂封,同時(shí)也可以用來對(duì)list進(jìn)行去重。

公平鎖的釋放邏輯和非公平鎖也不一樣鹦蠕,具體代碼細(xì)節(jié)冒签,我還沒有去看,感興趣同學(xué)可以自己去翻閱Redisson的源碼钟病。

使用Redis鎖安全問題

如果Redis掛了怎么辦萧恕?你可能會(huì)說,可以通過增加一個(gè)slave節(jié)點(diǎn)解決這個(gè)問題肠阱。但這通常是行不通的票唆。這樣做,我們不能實(shí)現(xiàn)資源的獨(dú)享,因?yàn)镽edis的主從同步通常是異步的屹徘。

在這種場(chǎng)景(主從結(jié)構(gòu))中存在明顯的競(jìng)態(tài):

  • 客戶端A從master獲取到鎖
  • 在master將鎖同步到slave之前走趋,master宕掉了。
  • slave節(jié)點(diǎn)被晉級(jí)為master節(jié)點(diǎn)
  • 客戶端B取得了同一個(gè)資源被客戶端A已經(jīng)獲取到的另外一個(gè)鎖噪伊。安全失效簿煌!

為了解決這個(gè)問題,Redis的作者提出了一種解決方案:RedLock鉴吹。

RedLock

它的基本思想是使用多個(gè)Redis實(shí)例啦吧,比如說使用5個(gè)獨(dú)立的Redis實(shí)例,讓他們部署在不同的機(jī)器上拙寡,避免同時(shí)宕掉授滓,然后使用五個(gè)Redis分別去獲取鎖,如果超過一半的機(jī)器獲取成功則獲取鎖成功肆糕,下面是客戶端獲取鎖的操作步驟:

  1. 獲取當(dāng)前Unix時(shí)間般堆,以毫秒為單位。
  2. 依次嘗試從N個(gè)實(shí)例诚啃,使用相同的key和隨機(jī)值獲取鎖淮摔。在步驟2,當(dāng)向Redis設(shè)置鎖時(shí),客戶端應(yīng)該設(shè)置一個(gè)網(wǎng)絡(luò)連接和響應(yīng)超時(shí)時(shí)間始赎,這個(gè)超時(shí)時(shí)間應(yīng)該小于鎖的失效時(shí)間和橙。例如你的鎖自動(dòng)失效時(shí)間為10秒仔燕,則超時(shí)時(shí)間應(yīng)該在5-50毫秒之間。這樣可以避免服務(wù)器端Redis已經(jīng)掛掉的情況下魔招,客戶端還在死死地等待響應(yīng)結(jié)果晰搀。如果服務(wù)器端沒有在規(guī)定時(shí)間內(nèi)響應(yīng),客戶端應(yīng)該盡快嘗試另外一個(gè)Redis實(shí)例办斑。
  3. 客戶端使用當(dāng)前時(shí)間減去開始獲取鎖時(shí)間(步驟1記錄的時(shí)間)就得到獲取鎖使用的時(shí)間外恕。當(dāng)且僅當(dāng)從大多數(shù)(這里是3個(gè)節(jié)點(diǎn))的Redis節(jié)點(diǎn)都取到鎖,并且使用的時(shí)間小于鎖失效時(shí)間時(shí)乡翅,鎖才算獲取成功鳞疲。
  4. 如果取到了鎖,key的真正有效時(shí)間等于有效時(shí)間減去獲取鎖所使用的時(shí)間(步驟3計(jì)算的結(jié)果)蠕蚜。
  5. 如果因?yàn)槟承┰蛏星ⅲ@取鎖失敗(沒有在至少N/2+1個(gè)Redis實(shí)例取到鎖或者取鎖時(shí)間已經(jīng)超過了有效時(shí)間)靶累,客戶端應(yīng)該在所有的Redis實(shí)例上進(jìn)行解鎖(即便某些Redis實(shí)例根本就沒有加鎖成功)翎朱。

釋放鎖比較簡(jiǎn)單,向所有的Redis實(shí)例發(fā)送釋放鎖命令即可尺铣,不用關(guān)心之前有沒有從Redis實(shí)例成功獲取到鎖.

Redisson提供了RedLock的實(shí)現(xiàn)

    RedissonClient client1 = Redisson.create();
    RedissonClient client2 = Redisson.create();
    RLock lock1 = client1.getLock("lock1");
    RLock lock2 = client1.getLock("lock2");
    RLock lock3 = client2.getLock("lock3");
    RedissonMultiLock lock = new RedissonRedLock(lock1, lock2, lock3);
    lock.lock();
    lock.unlock();
    client1.shutdown();
    client2.shutdown();

有關(guān)RedLock的爭(zhēng)論

關(guān)于RedLock是否安全的問題,Martin Kleppmann和Redis之父Antirez有過激烈的討論争舞。Martin Kleppmann是劍橋大學(xué)分布式系統(tǒng)研究員凛忿,他寫了一本書《Designing Data-Intensive Applications》,他對(duì)RedLock算法提出了質(zhì)疑竞川,認(rèn)為RedLock算法沒卵用店溢,下面是它原文給出的結(jié)論:

I think the Redlock algorithm is a poor choice because it is “neither fish nor fowl”: it is unnecessarily heavyweight and expensive for efficiency-optimization locks, but it is not sufficiently safe for situations in which correctness depends on the lock.

Martin認(rèn)為分布式鎖普遍存在一個(gè)問題:


image

如果Client1在獲取鎖之后,由于GC-STW導(dǎo)致線程阻塞時(shí)間大于鎖的過期時(shí)間委乌,鎖被提前釋放掉了床牧。這時(shí)如果Client2也過來獲取鎖,也能夠獲取成功遭贸,造成了錯(cuò)誤戈咳。

這種情況并不只是理論上的,現(xiàn)實(shí)中有真實(shí)的案例壕吹。HBase曾經(jīng)就遇到過這個(gè)問題:https://www.slideshare.net/enissoz/hbase-and-hdfs-understanding-filesystem-usage

除了上述的GC-STW著蛙,還有可能別的原因造成上述問題。比如說在真正執(zhí)行更新操作之前耳贬,需要網(wǎng)絡(luò)請(qǐng)求外部資源或者讀取文件等操作踏堡,也會(huì)發(fā)生阻塞,造成同樣的問題咒劲。

作者給出了一種解決上述問題的辦法顷蟆,如下圖所示:


image
  • 鎖服務(wù)需要一個(gè)遞增的token
  • 客戶端獲取鎖成功后需要使用這個(gè)token進(jìn)行更新資源
  • 資源數(shù)據(jù)庫(kù)诫隅,更新時(shí)需要對(duì)token進(jìn)行檢查,如果token小于等于已保存的token帐偎,則拒絕更新逐纬。

上述的整個(gè)過程有點(diǎn)類似CAS。如果使用Zookeeper實(shí)現(xiàn)分布式鎖的話可以使用其事物id(zxid)或者node的版本號(hào)實(shí)現(xiàn)token肮街。

對(duì)于RedLock而言风题,因?yàn)樗牡讓硬捎枚鄠€(gè)Redis實(shí)例,它沒法生成遞增的token嫉父,所以無法用token的方式解決作者描述的問題沛硅。Martin認(rèn)為RedLock除了不能生成遞增token外,還有其它的問題绕辖,因?yàn)镽edLock是建立在一些假設(shè)上的:

  1. 假設(shè)所有Redis節(jié)點(diǎn)在密鑰過期之前持有密鑰的時(shí)間大約是正確的;
  2. 網(wǎng)絡(luò)延時(shí)與失效時(shí)間相比較小;
  3. 而且該進(jìn)程暫停的時(shí)間比過期時(shí)間短得多摇肌。

舉個(gè)例子:假設(shè)系統(tǒng)有五個(gè)Redis節(jié)點(diǎn)(A、B仪际、C围小、D和E)和兩個(gè)客戶機(jī)(clien1和client2)

  1. Client1獲取節(jié)點(diǎn)A、B树碱、C上的鎖肯适。由于網(wǎng)絡(luò)問題,無法到達(dá)D和E成榜。
  2. 節(jié)點(diǎn)C上的時(shí)鐘向前跳轉(zhuǎn)框舔,導(dǎo)致鎖過期。
  3. Client2獲取節(jié)點(diǎn)C赎婚、D刘绣、E上的鎖,由于網(wǎng)絡(luò)問題挣输,無法到達(dá)A和B纬凤。
  4. Client1 和 Client2現(xiàn)在都持有了鎖。

步驟2的原因是因?yàn)镽edis的過期策略是建立在當(dāng)前物理機(jī)的時(shí)鐘上的撩嚼,如果物理機(jī)的時(shí)間發(fā)生跳轉(zhuǎn)停士,那么key的過期時(shí)間也會(huì)更變。如果C在將鎖持久化到磁盤之前崩潰完丽,并立即重啟向瓷,也會(huì)發(fā)生類似的問題。出于這個(gè)原因舰涌,Redlock文檔建議延遲重啟崩潰節(jié)點(diǎn)的時(shí)間猖任,至少要達(dá)到最長(zhǎng)鎖的生存時(shí)間。但是這種又產(chǎn)生了一種前提瓷耙,需要保證重啟的延時(shí)時(shí)間大于鎖的生存時(shí)間朱躺。

Martin覺得前面這個(gè)時(shí)鐘跳躍的例子還不夠刁赖,又給出了一個(gè)由客戶端GC pause引發(fā)Redlock失效的例子。如下:

  • Client1向Redis節(jié)點(diǎn)A, B, C, D, E發(fā)起鎖請(qǐng)求长搀。
  • 各個(gè)Redis節(jié)點(diǎn)已經(jīng)把請(qǐng)求結(jié)果返回給了Client端1宇弛,但Client1在收到請(qǐng)求結(jié)果之前進(jìn)入了長(zhǎng)時(shí)間的GC pause。
  • 在所有的Redis節(jié)點(diǎn)上源请,鎖過期了枪芒。
  • Client2在A, B, C, D, E上獲取到了鎖。
  • Client1從GC pause從恢復(fù)谁尸,收到了前面第2步來自各個(gè)Redis節(jié)點(diǎn)的請(qǐng)求結(jié)果舅踪。客戶端1認(rèn)為自己成功獲取到了鎖良蛮。
  • Client1和Client2現(xiàn)在都認(rèn)為自己持有了鎖抽碌。

Martin給的這個(gè)例子其實(shí)有一些問題,因?yàn)镽edLcok在獲取鎖之后决瞳,會(huì)拿獲取鎖的時(shí)間和鎖的過期時(shí)間進(jìn)行比較货徙,如果A、B皮胡、C痴颊、D、E都過期了屡贺,那么RedLock實(shí)際上是獲取鎖失敗蠢棱。關(guān)于這一點(diǎn),Redis之父在他的反駁文中也進(jìn)行了闡述烹笔。

最后,Martin得出了如下的結(jié)論:

  • 如果是為了效率(efficiency)而使用分布式鎖抛丽,允許鎖的偶爾失效谤职,那么使用單Redis節(jié)點(diǎn)的鎖方案就足夠了,簡(jiǎn)單而且效率高亿鲜。Redlock則是個(gè)過重的實(shí)現(xiàn)(heavyweight)允蜈。
  • 如果是為了正確性(correctness)在很嚴(yán)肅的場(chǎng)合使用分布式鎖,那么不要使用Redlock蒿柳。它不是建立在異步模型上的一個(gè)足夠強(qiáng)的算法饶套,它對(duì)于系統(tǒng)模型的假設(shè)中包含很多危險(xiǎn)的成分(對(duì)于timing)。而且垒探,它沒有一個(gè)機(jī)制能夠提供token妓蛮。那應(yīng)該使用什么技術(shù)呢?Martin認(rèn)為圾叼,應(yīng)該考慮類似Zookeeper的方案蛤克,或者支持事務(wù)的數(shù)據(jù)庫(kù)捺癞。

有關(guān)他們之間的爭(zhēng)論,我沒有做過多的分析构挤,網(wǎng)上有一篇文章總結(jié)的很好:http://www.reibang.com/p/dd66bdd18a56

總結(jié)

具體使用哪一種分布式鎖最好呢髓介?沒有最好的,應(yīng)該根據(jù)不同的業(yè)務(wù)場(chǎng)景選取最合適的算法筋现。Martin給出的結(jié)論值得參考唐础,如果使用分布式鎖是為了效率問題,那么直接使用Redis的setNX實(shí)現(xiàn)就可以了矾飞。如果想要保證正確性一膨,推薦使用Zookeeper或者支持事務(wù)的數(shù)據(jù)庫(kù)。如果想要使用分布式鎖實(shí)現(xiàn)一些比較復(fù)雜的功能且能夠接受小概率的不準(zhǔn)確性凰慈,可以采用Reisson提供的鎖工具汞幢。

參考資料

最后編輯于
?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請(qǐng)聯(lián)系作者
  • 序言:七十年代末,一起剝皮案震驚了整個(gè)濱河市微谓,隨后出現(xiàn)的幾起案子森篷,更是在濱河造成了極大的恐慌,老刑警劉巖豺型,帶你破解...
    沈念sama閱讀 218,640評(píng)論 6 507
  • 序言:濱河連續(xù)發(fā)生了三起死亡事件仲智,死亡現(xiàn)場(chǎng)離奇詭異,居然都是意外死亡姻氨,警方通過查閱死者的電腦和手機(jī)钓辆,發(fā)現(xiàn)死者居然都...
    沈念sama閱讀 93,254評(píng)論 3 395
  • 文/潘曉璐 我一進(jìn)店門,熙熙樓的掌柜王于貴愁眉苦臉地迎上來肴焊,“玉大人前联,你說我怎么就攤上這事∪⒕欤” “怎么了似嗤?”我有些...
    開封第一講書人閱讀 165,011評(píng)論 0 355
  • 文/不壞的土叔 我叫張陵,是天一觀的道長(zhǎng)届宠。 經(jīng)常有香客問我烁落,道長(zhǎng),這世上最難降的妖魔是什么豌注? 我笑而不...
    開封第一講書人閱讀 58,755評(píng)論 1 294
  • 正文 為了忘掉前任伤塌,我火速辦了婚禮,結(jié)果婚禮上轧铁,老公的妹妹穿的比我還像新娘每聪。我一直安慰自己,他們只是感情好,可當(dāng)我...
    茶點(diǎn)故事閱讀 67,774評(píng)論 6 392
  • 文/花漫 我一把揭開白布熊痴。 她就那樣靜靜地躺著他爸,像睡著了一般。 火紅的嫁衣襯著肌膚如雪果善。 梳的紋絲不亂的頭發(fā)上诊笤,一...
    開封第一講書人閱讀 51,610評(píng)論 1 305
  • 那天,我揣著相機(jī)與錄音巾陕,去河邊找鬼讨跟。 笑死,一個(gè)胖子當(dāng)著我的面吹牛鄙煤,可吹牛的內(nèi)容都是我干的晾匠。 我是一名探鬼主播,決...
    沈念sama閱讀 40,352評(píng)論 3 418
  • 文/蒼蘭香墨 我猛地睜開眼梯刚,長(zhǎng)吁一口氣:“原來是場(chǎng)噩夢(mèng)啊……” “哼凉馆!你這毒婦竟也來了?” 一聲冷哼從身側(cè)響起亡资,我...
    開封第一講書人閱讀 39,257評(píng)論 0 276
  • 序言:老撾萬榮一對(duì)情侶失蹤澜共,失蹤者是張志新(化名)和其女友劉穎,沒想到半個(gè)月后锥腻,有當(dāng)?shù)厝嗽跇淞掷锇l(fā)現(xiàn)了一具尸體嗦董,經(jīng)...
    沈念sama閱讀 45,717評(píng)論 1 315
  • 正文 獨(dú)居荒郊野嶺守林人離奇死亡,尸身上長(zhǎng)有42處帶血的膿包…… 初始之章·張勛 以下內(nèi)容為張勛視角 年9月15日...
    茶點(diǎn)故事閱讀 37,894評(píng)論 3 336
  • 正文 我和宋清朗相戀三年瘦黑,在試婚紗的時(shí)候發(fā)現(xiàn)自己被綠了京革。 大學(xué)時(shí)的朋友給我發(fā)了我未婚夫和他白月光在一起吃飯的照片。...
    茶點(diǎn)故事閱讀 40,021評(píng)論 1 350
  • 序言:一個(gè)原本活蹦亂跳的男人離奇死亡幸斥,死狀恐怖匹摇,靈堂內(nèi)的尸體忽然破棺而出,到底是詐尸還是另有隱情甲葬,我是刑警寧澤廊勃,帶...
    沈念sama閱讀 35,735評(píng)論 5 346
  • 正文 年R本政府宣布,位于F島的核電站演顾,受9級(jí)特大地震影響供搀,放射性物質(zhì)發(fā)生泄漏隅居。R本人自食惡果不足惜钠至,卻給世界環(huán)境...
    茶點(diǎn)故事閱讀 41,354評(píng)論 3 330
  • 文/蒙蒙 一、第九天 我趴在偏房一處隱蔽的房頂上張望胎源。 院中可真熱鬧棉钧,春花似錦、人聲如沸涕蚤。這莊子的主人今日做“春日...
    開封第一講書人閱讀 31,936評(píng)論 0 22
  • 文/蒼蘭香墨 我抬頭看了看天上的太陽(yáng)。三九已至佑钾,卻和暖如春西疤,著一層夾襖步出監(jiān)牢的瞬間,已是汗流浹背休溶。 一陣腳步聲響...
    開封第一講書人閱讀 33,054評(píng)論 1 270
  • 我被黑心中介騙來泰國(guó)打工代赁, 沒想到剛下飛機(jī)就差點(diǎn)兒被人妖公主榨干…… 1. 我叫王不留,地道東北人兽掰。 一個(gè)月前我還...
    沈念sama閱讀 48,224評(píng)論 3 371
  • 正文 我出身青樓芭碍,卻偏偏與公主長(zhǎng)得像,于是被迫代替她去往敵國(guó)和親孽尽。 傳聞我的和親對(duì)象是個(gè)殘疾皇子窖壕,可洞房花燭夜當(dāng)晚...
    茶點(diǎn)故事閱讀 44,974評(píng)論 2 355