簡(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));
}
}
思路:
- 通過key字段標(biāo)識(shí)持有鎖的進(jìn)程和線程况褪。
- 通過count字段記錄鎖重入的次數(shù)。
- 通過循環(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)分布式鎖目胡,如下圖所示:
上圖所示锯七,client1現(xiàn)在持有鎖。zookeeper獲取鎖的整個(gè)過程:
- 首先選取一個(gè)目錄(如/root/lock/)用作實(shí)現(xiàn)分布式鎖的根目錄讶隐。
- 客戶端嘗試在資源目錄下創(chuàng)建臨時(shí)順序節(jié)點(diǎn)起胰。
- 然后,嘗試獲取競(jìng)爭(zhēng)的資源目錄下的所有子節(jié)點(diǎn)巫延。
- 判斷子節(jié)點(diǎn)中序號(hào)最小的節(jié)點(diǎn)是不是自己所創(chuàng)建的效五,如果是的話則獲取鎖成功。
- 如果沒有獲取鎖成功炉峰,則在/root/lock/目錄上添加watcher畏妖,監(jiān)聽其子節(jié)點(diǎn)是否有新增或者刪除。
- 如果字節(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)后如下圖所示:
讀寫鎖實(shí)現(xiàn)
讀寫鎖的實(shí)現(xiàn)邏輯跟互斥鎖差不多,針對(duì)讀寫節(jié)點(diǎn)我們可以在其節(jié)點(diǎn)名字前增加前綴進(jìn)行區(qū)分麻献。如下圖所示:
- client1和client2獲取了讀鎖呼巷。
- client3需要獲取寫鎖,所以對(duì)client2進(jìn)行了監(jiān)聽赎瑰;client4也需要獲取寫鎖,但是它的前面有一個(gè)節(jié)點(diǎn)也需要獲取寫鎖破镰,所以它監(jiān)聽client3節(jié)點(diǎn)
- 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);
}
}
}
整體的流程圖如下:
下列是公平鎖獲取鎖的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ī)器獲取成功則獲取鎖成功肆糕,下面是客戶端獲取鎖的操作步驟:
- 獲取當(dāng)前Unix時(shí)間般堆,以毫秒為單位。
- 依次嘗試從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í)例办斑。
- 客戶端使用當(dāng)前時(shí)間減去開始獲取鎖時(shí)間(步驟1記錄的時(shí)間)就得到獲取鎖使用的時(shí)間外恕。當(dāng)且僅當(dāng)從大多數(shù)(這里是3個(gè)節(jié)點(diǎn))的Redis節(jié)點(diǎn)都取到鎖,并且使用的時(shí)間小于鎖失效時(shí)間時(shí)乡翅,鎖才算獲取成功鳞疲。
- 如果取到了鎖,key的真正有效時(shí)間等于有效時(shí)間減去獲取鎖所使用的時(shí)間(步驟3計(jì)算的結(jié)果)蠕蚜。
- 如果因?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è)問題:
如果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ā)生阻塞,造成同樣的問題咒劲。
作者給出了一種解決上述問題的辦法顷蟆,如下圖所示:
- 鎖服務(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è)上的:
- 假設(shè)所有Redis節(jié)點(diǎn)在密鑰過期之前持有密鑰的時(shí)間大約是正確的;
- 網(wǎng)絡(luò)延時(shí)與失效時(shí)間相比較小;
- 而且該進(jìn)程暫停的時(shí)間比過期時(shí)間短得多摇肌。
舉個(gè)例子:假設(shè)系統(tǒng)有五個(gè)Redis節(jié)點(diǎn)(A、B仪际、C围小、D和E)和兩個(gè)客戶機(jī)(clien1和client2)
- Client1獲取節(jié)點(diǎn)A、B树碱、C上的鎖肯适。由于網(wǎng)絡(luò)問題,無法到達(dá)D和E成榜。
- 節(jié)點(diǎn)C上的時(shí)鐘向前跳轉(zhuǎn)框舔,導(dǎo)致鎖過期。
- Client2獲取節(jié)點(diǎn)C赎婚、D刘绣、E上的鎖,由于網(wǎng)絡(luò)問題挣输,無法到達(dá)A和B纬凤。
- 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提供的鎖工具汞幢。