原理分析
最近看到好多博主都在推分布式鎖,實(shí)現(xiàn)方式很多,基于db、redis、zookeeper。zookeeper方式實(shí)現(xiàn)起來比較繁瑣,這里我們就談?wù)劵趓edis實(shí)現(xiàn)分布式鎖的正確實(shí)現(xiàn)方式。
背景
在很多互聯(lián)網(wǎng)產(chǎn)品應(yīng)用中,有些場(chǎng)景需要加鎖處理,比如:秒殺,全局遞增ID,樓層生成等等。大部分的解決方案是基于DB實(shí)現(xiàn)的,Redis為單進(jìn)程單線程模式,采用隊(duì)列模式將并發(fā)訪問變成串行訪問,且多客戶端對(duì)Redis的連接并不存在競(jìng)爭(zhēng)關(guān)系。 其次Redis提供一些命令SETNX,GETSET,可以方便實(shí)現(xiàn)分布式鎖機(jī)制。
Redis命令介紹
使用Redis實(shí)現(xiàn)分布式鎖,有兩個(gè)重要函數(shù)需要介紹。
SETNX命令(SET if Not Exists)
- 語法:
SETNX key value
- 功能:
當(dāng)且僅當(dāng) key 不存在,將 key 的值設(shè)為 value ,并返回1; 若給定的 key 已經(jīng)存在,則 SETNX 不做任何動(dòng)作,并返回0。
GETSET命令
- 語法:
GETSET key value
- 功能:
將給定 key 的值設(shè)為 value ,并返回 key 的舊值 (old value), 當(dāng) key 存在但不是字符串類型時(shí),返回一個(gè)錯(cuò)誤,當(dāng)key不存在時(shí),返回nil。
GET命令
- 語法:
GET key
- 功能:
返回 key 所關(guān)聯(lián)的字符串值,如果 key 不存在那么返回特殊值 nil 。
DEL命令
- 語法:
DEL key [KEY …]
- 功能:
刪除給定的一個(gè)或多個(gè) key ,不存在的 key 會(huì)被忽略。
兵貴精,不在多。分布式鎖,我們就依靠這四個(gè)命令。但在具體實(shí)現(xiàn),還有很多細(xì)節(jié),需要仔細(xì)斟酌,因?yàn)樵诜植际讲l(fā)多進(jìn)程中,任何一點(diǎn)出現(xiàn)差錯(cuò),都會(huì)導(dǎo)致死鎖,hold住所有進(jìn)程。
加鎖實(shí)現(xiàn)
SETNX 可以直接加鎖操作,比如說對(duì)某個(gè)關(guān)鍵詞foo加鎖,客戶端可以嘗試 SETNX foo.lock <current unix time>。
如果返回1,表示客戶端已經(jīng)獲取鎖,可以往下操作,操作完成后,通過
DEL foo.lock命令來釋放鎖。如果返回0,說明foo已經(jīng)被其他客戶端上鎖,如果鎖是非堵塞的,可以選擇返回調(diào)用。如果是堵塞調(diào)用,就需要進(jìn)入下一個(gè)重試循環(huán),直至成功獲得鎖或者重試超時(shí)。
理想是美好的,現(xiàn)實(shí)是殘酷的。僅僅使用SETNX加鎖帶有競(jìng)爭(zhēng)條件的,在某些特定的情況會(huì)造成死鎖錯(cuò)誤。
處理死鎖
在上面的處理方式中,如果獲取鎖的客戶端執(zhí)行時(shí)間過長(zhǎng),進(jìn)程被kill掉,或者因?yàn)槠渌惓1罎?,?dǎo)致無法釋放鎖,就會(huì)造成死鎖。所以,需要對(duì)加鎖要做時(shí)效性檢測(cè)。
因此,我們?cè)诩渔i時(shí),把當(dāng)前時(shí)間戳作為value存入此鎖中,通過當(dāng)前時(shí)間戳和redis中的時(shí)間戳進(jìn)行對(duì)比,如果超過一定差值,認(rèn)為鎖已經(jīng)時(shí)效,防止鎖無限期的鎖下去。
但是,在大并發(fā)情況,如果同時(shí)檢測(cè)鎖失效,并簡(jiǎn)單粗暴的刪除死鎖,再通過SETNX上鎖,可能會(huì)導(dǎo)致競(jìng)爭(zhēng)條件的產(chǎn)生,即多個(gè)客戶端同時(shí)獲取鎖。
情景描述如下:
C1獲取鎖,并崩潰。C2和C3調(diào)用SETNX上鎖返回0后,獲得foo.lock的時(shí)間戳,通過比對(duì)時(shí)間戳,發(fā)現(xiàn)鎖超時(shí)。
C2 向foo.lock發(fā)送DEL命令。
C2 向foo.lock發(fā)送SETNX獲取鎖。
C3 向foo.lock發(fā)送DEL命令,此時(shí)C3發(fā)送DEL時(shí),其實(shí)DEL掉的是C2的鎖。
C3 向foo.lock發(fā)送SETNX獲取鎖。
此時(shí)C2和C3都獲取了鎖,產(chǎn)生競(jìng)爭(zhēng)條件,如果在更高并發(fā)的情況,可能會(huì)有更多客戶端獲取鎖。
所以,DEL鎖的操作,不能直接使用在鎖超時(shí)的情況下,幸好我們有GETSET方法,假設(shè)我們現(xiàn)在有另外一個(gè)客戶端C4,看看如何使用GETSET方式,避免這種情況產(chǎn)生。
C1獲取鎖,并崩潰。C2和C3調(diào)用SETNX上鎖返回0后,調(diào)用GET命令獲得foo.lock的時(shí)間戳T1,通過比對(duì)時(shí)間戳,發(fā)現(xiàn)鎖超時(shí)。
C4(調(diào)用SETNX上鎖返回0后,調(diào)用GET命令獲得foo.lock的時(shí)間戳T1,通過比對(duì)時(shí)間戳,發(fā)現(xiàn)鎖超時(shí))向foo.lock發(fā)送GESET命令,
GETSET foo.lock并得到foo.lock中老的時(shí)間戳T2。
如果T1=T2,說明C4獲得鎖。
如果T1!=T2,說明C4之前有另外一個(gè)客戶端C5通過調(diào)用GETSET方式獲取并更改了時(shí)間戳,C4未獲得鎖。只能進(jìn)入下次循環(huán)中。
時(shí)間戳問題
我們看到foo.lock的value值為時(shí)間戳,所以要在多客戶端情況下,保證鎖有效,一定要同步各服務(wù)器的時(shí)間。如果各服務(wù)器間,時(shí)間有差異,時(shí)間不一致的客戶端,在判斷鎖超時(shí),就會(huì)出現(xiàn)偏差,從而產(chǎn)生競(jìng)爭(zhēng)條件。鎖的超時(shí)與否,嚴(yán)格依賴時(shí)間戳。
鎖覆蓋問題
現(xiàn)在唯一的問題是,C4設(shè)置foo.lock的新時(shí)間戳,是否會(huì)對(duì)C5獲取得鎖產(chǎn)生影響?
其實(shí)我們可以看到C4和C5只有在調(diào)用GET命令獲得foo.lock的時(shí)間戳,通過比對(duì)時(shí)間戳,發(fā)現(xiàn)鎖超時(shí)后,幾乎同時(shí)調(diào)用GETSET方式獲取鎖,執(zhí)行的時(shí)間差值極小,并且寫入foo.lock中的都是有效時(shí)間戳,所以對(duì)鎖并沒有影響。
為了讓這個(gè)鎖更加強(qiáng)壯,獲取鎖的客戶端,應(yīng)該在調(diào)用關(guān)鍵業(yè)務(wù)時(shí),再次調(diào)用GET方法獲取T1,和寫入的T0時(shí)間戳進(jìn)行對(duì)比,以免鎖因其他情況被執(zhí)行DEL意外解開而不知。****但是如果遇到上面描述得問題,則T0則會(huì)與T1不一致,當(dāng)然差別一般會(huì)很小。這就是鎖覆蓋問題****。
鎖覆蓋會(huì)導(dǎo)致什么問題呢?
當(dāng)客戶端的鎖過期時(shí)間被覆蓋,會(huì)造成鎖不具有標(biāo)識(shí)性,會(huì)造成客戶端無法釋放鎖(客戶端只能釋放明確自己持有的鎖)。
nil 問題
GET返回nil時(shí)應(yīng)該走哪種邏輯?
一、第一種走循環(huán)走setnx邏輯
C1客戶端獲取鎖,并且處理完后,DEL掉鎖。
在DEL鎖之前,C2通過SETNX向foo.lock設(shè)置時(shí)間戳T0失敗,發(fā)現(xiàn)有客戶端獲取鎖,進(jìn)入GET操作。C2 向foo.lock發(fā)送GET命令,獲取返回值T1(nil)(因?yàn)榇藭r(shí)C1執(zhí)行DEL刪除鎖)。
C2 循環(huán),進(jìn)入下一次SETNX邏輯。
二、第二種走超時(shí)邏輯
C1客戶端獲取鎖,并且處理完后,DEL掉鎖。
在DEL鎖之前,C2通過SETNX向foo.lock設(shè)置時(shí)間戳T0發(fā)現(xiàn)有客戶端獲取鎖,進(jìn)入GET操作。C2 向foo.lock發(fā)送GET命令,獲取返回值T1(nil)(因?yàn)榇藭r(shí)C1執(zhí)行DEL刪除鎖)。
C2 通過
T0 > T1 + expire對(duì)比,進(jìn)入GETSET流程。C2調(diào)用GETSET向foo.lock發(fā)送T0時(shí)間戳,返回foo.lock的原值T2,C2判斷如果T2=T1相等,獲得鎖,如果T2!=T1,未獲得鎖。
分析
兩種邏輯貌似都是OK,但是從邏輯處理上來說,當(dāng)GET返回nil,表示鎖是被刪除的,而不是超時(shí),應(yīng)該走SETNX邏輯加鎖。
對(duì)于"第二種走超時(shí)邏輯"是否會(huì)造成死鎖,尚不清楚,不過推薦采用第一種方式。
GETSET返回nil時(shí)應(yīng)該怎么處理?
前提:假設(shè)C4客戶端獲取鎖后由于異常退出等原因未正常釋放鎖,導(dǎo)致鎖超時(shí)。此時(shí),C1、C2和C3客戶端同時(shí)請(qǐng)求獲取鎖。C1、C2和C3客戶端調(diào)用GET接口,C1返回T1,此時(shí)C3網(wǎng)絡(luò)情況更好,快速進(jìn)入獲取鎖,并執(zhí)行DEL刪除鎖,C2返回T2(nil)。C1進(jìn)入超時(shí)處理邏輯。C2面臨上面提到「GET返回nil時(shí)應(yīng)該走哪種邏輯?」的兩種選擇:1. 也進(jìn)入超時(shí)處理邏輯;2. 繼續(xù)循環(huán)走setnx邏輯(推薦);
C1向foo.lock發(fā)送GETSET命令,獲取返回值T11(nil)。C1比對(duì)C1和C11發(fā)現(xiàn)兩者不同,處理邏輯認(rèn)為未獲取鎖,然后繼續(xù)循環(huán)走setnx邏輯。
C2有兩種選擇:
進(jìn)入超時(shí)處理邏輯;
C2 向foo.lock發(fā)送GETSET命令,獲取返回值T22(C1寫入的時(shí)間戳)。C2比對(duì)T2和T22發(fā)現(xiàn)兩者不同,處理邏輯認(rèn)為未獲取鎖,然后繼續(xù)循環(huán)走setnx邏輯。繼續(xù)循環(huán)走setnx邏輯;
- 很明顯,C1和C2最終都會(huì)繼續(xù)循環(huán)走setnx邏輯,然后通過SETNX向foo.lock設(shè)置時(shí)間戳T0會(huì)失敗,這其實(shí)是因?yàn)樵诓襟E1中C1執(zhí)行GETSET命令導(dǎo)致的。此時(shí)C1和C2都認(rèn)為未獲取鎖,其實(shí)C1是已經(jīng)獲取鎖了,但是他的處理邏輯沒有考慮GETSET返回nil的情況,只是單純的用GET和GETSET值進(jìn)行對(duì)比。
分析
至于為什么會(huì)出現(xiàn)這種情況?就如上面設(shè)想的場(chǎng)景那樣,多客戶端時(shí),每個(gè)客戶端連接redis后,發(fā)出的命令并不是連續(xù)的,導(dǎo)致從單客戶端看到的好像連續(xù)的命令,到redis server后,這兩條命令之間可能已經(jīng)插入大量的其他客戶端發(fā)出的命令,比如DEL,SETNX等。
正確的處理方式就是GETSET返回nil時(shí),獲取鎖成功。
總結(jié)
必要的超時(shí)機(jī)制:獲取鎖的客戶端一旦崩潰,一定要有過期機(jī)制,否則其他客戶端都降無法獲取鎖,造成死鎖問題。
分布式鎖,多客戶端的時(shí)間戳不能保證嚴(yán)格意義的一致性,所以在某些特定因素下,有可能存在問題。要適度的機(jī)制,可以承受小概率的事件產(chǎn)生。
只對(duì)關(guān)鍵處理節(jié)點(diǎn)加鎖,良好的習(xí)慣是,把相關(guān)的資源準(zhǔn)備好,比如連接數(shù)據(jù)庫后,調(diào)用加鎖機(jī)制獲取鎖,直接進(jìn)行操作,然后釋放,盡量減少持有鎖的時(shí)間。
在持有鎖期間要不要CHECK鎖,如果需要嚴(yán)格依賴鎖的狀態(tài),最好在關(guān)鍵步驟中做鎖的CHECK檢查機(jī)制,但是根據(jù)我們的測(cè)試發(fā)現(xiàn),在大并發(fā)時(shí),每一次CHECK鎖操作,都要消耗掉幾個(gè)毫秒,而我們的整個(gè)持鎖處理邏輯才不到10毫秒,玩客沒有選擇做鎖的檢查。
sleep學(xué)問,為了減少對(duì)redis的壓力,獲取鎖嘗試時(shí),循環(huán)之間一定要做sleep操作。但是sleep時(shí)間是多少是門學(xué)問。需要根據(jù)自己的redis的QPS,加上持鎖處理時(shí)間等進(jìn)行合理計(jì)算。如果redis的QPS足夠高,也可以考慮循環(huán)之間不sleep,循環(huán)一定次數(shù)/時(shí)間執(zhí)行yeild,提高響應(yīng)速度。
至于為什么不使用Redis的muti,expire,watch等機(jī)制,可以查下參考資料,找下原因。
代碼實(shí)現(xiàn)
代碼庫
https://github.com/HuTu92/distributed-lock
源碼
package com.github.hutu92.concurrent.locks;
import com.alibaba.fastjson.JSON;
import redis.clients.jedis.Jedis;
import redis.clients.jedis.JedisPool;
/**
* Created by liuchunlong on 2018/8/31.
* <p>
* 基于redis的分布式鎖 v1
*
* 需要客戶端時(shí)間同步
*/
public class DistributedLock {
private static final long RETRY_BARRIER = 3 * 1000; // 請(qǐng)求鎖重試屏障,單位毫秒
private final JedisPool jedisPool; // redis連接池
private final String lockKey; // lock Key
private final long lockExpiryInNanos; // 鎖的過期時(shí)長(zhǎng),單位納秒
private static final ThreadLocal<Lock> lockThreadLocal = new ThreadLocal<Lock>();
/**
* 構(gòu)造方法
*
* @param jedisPool redis連接池
* @param lockKey 鎖的Key
* @param lockExpiryInMillis 鎖的過期時(shí)長(zhǎng),單位毫秒
*/
public DistributedLock(JedisPool jedisPool, String lockKey, long lockExpiryInMillis) {
this.jedisPool = jedisPool;
this.lockKey = lockKey;
this.lockExpiryInNanos = lockExpiryInMillis * 1000;
}
/**
* 構(gòu)造方法
* <p>
* 使用鎖默認(rèn)的過期時(shí)長(zhǎng)Integer.MAX_VALUE,即鎖永遠(yuǎn)不會(huì)過期
*
* @param jedisPool redis連接池
* @param lockKey 鎖的Key
*/
public DistributedLock(JedisPool jedisPool, String lockKey) {
this(jedisPool, lockKey, Integer.MAX_VALUE);
}
/**
* 獲取鎖在redis中的Key標(biāo)記
*
* @return locks key
*/
public String getLockKey() {
return this.lockKey;
}
/**
* 鎖的過期時(shí)長(zhǎng)
*
* @return
*/
public long getLockExpiryInNanos() {
return lockExpiryInNanos;
}
/**
* 請(qǐng)求分布式鎖,不會(huì)阻塞,直接返回
*
* @param jedis redis 連接
* @return 成功獲取鎖返回true, 否則返回false
*/
private boolean tryAcquire(Jedis jedis) {
final Lock newLock = new Lock(System.nanoTime() + this.lockExpiryInNanos);
/**
* 將新鎖(newLock)寫入redis中。如果成功寫入,redis中不存在鎖,獲取鎖成功;否則,redis中已存在鎖,獲取鎖失??;
*/
if (jedis.setnx(this.lockKey, newLock.toString()) == 1) {
lockThreadLocal.set(newLock);
return true;
}
/**
* 至此,說明redis中已存在鎖,獲取鎖失敗,則需要進(jìn)行如下操作:
* 1. 判斷redis中已存在的鎖是否過期,如果過期則直接獲取鎖;
* 2. 否則,獲取鎖失??;
*/
final String currentLockValue = jedis.get(lockKey);
// 特別的,當(dāng)jedis.get()獲取已存在的鎖currentLockValue為空時(shí),應(yīng)該重新SETNX
if (currentLockValue == null || currentLockValue.length() == 0) {
tryAcquire(jedis);
}
final Lock currentLock = Lock.fromJson(currentLockValue); // redis中已存在的鎖
// 如果redis中已存在的鎖已超時(shí),則重新獲取鎖
if (isExpired(currentLock)) {
String originLockValue = jedis.getSet(lockKey, newLock.toString());
/**
* 這里還有個(gè)前置條件:
* 會(huì)對(duì)已存在的鎖進(jìn)行校驗(yàn),jedis.get()和jedis.getSet()獲取的鎖必須是同一鎖,重新獲取鎖才成功
*/
// 特別的,當(dāng)jedis.getSet()獲取已存在的鎖originLockValue為空時(shí),則認(rèn)定獲取鎖成功
if (originLockValue == null || originLockValue.length() == 0) {
lockThreadLocal.set(newLock);
return true;
}
if (originLockValue.equals(currentLockValue)) {
lockThreadLocal.set(newLock);
return true;
}
}
return false;
}
/**
* 請(qǐng)求分布式鎖,不會(huì)阻塞,直接返回
*
* @return 成功獲取鎖返回true, 否則返回false
*/
public boolean tryAcquire() {
Jedis jedis = null;
try {
jedis = jedisPool.getResource();
return tryAcquire(jedis);
} finally {
if (jedis != null) {
jedis.close();
}
}
}
/**
* 超時(shí)請(qǐng)求分布式鎖,會(huì)阻塞
*
* 采用"自旋獲取鎖"的方式,直至獲取鎖成功或者請(qǐng)求鎖超時(shí)
*
* @param acquireTimeoutInMillis 鎖的請(qǐng)求超時(shí)時(shí)長(zhǎng)
* @return
*/
public boolean acquire(long acquireTimeoutInMillis) {
Jedis jedis = null;
try {
jedis = jedisPool.getResource();
long acquireTime = System.currentTimeMillis();
// 鎖的請(qǐng)求到期時(shí)間
long expiryTime = System.currentTimeMillis() + acquireTimeoutInMillis;
while (expiryTime >= System.currentTimeMillis()) {
boolean result = tryAcquire(jedis);
if (result) { // 獲取鎖成功直接返回,否則循環(huán)重試
return true;
}
if ((System.currentTimeMillis() - acquireTime) > RETRY_BARRIER) {
Thread.yield();
}
}
} finally {
if (jedis != null) {
jedis.close();
}
}
return false;
}
/**
* 釋放鎖
*/
public void release() {
Jedis jedis = null;
try {
jedis = jedisPool.getResource();
release(jedis);
} finally {
if (jedis != null) {
jedis.close();
}
}
}
/**
* 釋放鎖
*
* @param jedis
*/
private void release(Jedis jedis) {
Lock currlock = lockThreadLocal.get();
if (currlock != null) {
final String currentLockValue = jedis.get(lockKey);
if (currentLockValue != null && currentLockValue.length() != 0) {
final Lock currentLock = Lock.fromJson(currentLockValue); // redis中已存在的鎖
if (currlock.equals(currentLock)) {
lockThreadLocal.remove();
jedis.del(lockKey);
}
}
}
}
/**
* 判斷當(dāng)前線程是否持有鎖
*
* 未持有鎖或者鎖超時(shí),返回false
*
* @return
*/
public boolean isLocked() {
Lock currlock = lockThreadLocal.get();
// 如果當(dāng)前線程保存的lock不為null,并且未超時(shí),則當(dāng)前線程必然持有鎖,鎖未被意外釋放
return currlock != null && !currlock.isExpired();
}
/**
* 判斷指定的lock是否是當(dāng)前線程持有的鎖
*
* @return
*/
boolean isMine(final Lock lock) {
Lock currlock = lockThreadLocal.get();
return currlock != null && currlock.equals(lock);
}
/**
* 判斷鎖是否超時(shí)
*
* @param lock
* @return
*/
boolean isExpired(final Lock lock) {
return lock.isExpired();
}
/**
* 鎖
*/
protected static class Lock {
private long expiryTime; // 鎖的過期時(shí)間,注意,不是過期時(shí)長(zhǎng),單位納秒
Lock(long expiryTime) {
this.expiryTime = expiryTime;
}
/**
* 解析字符串,根據(jù)解析出的過期時(shí)間構(gòu)造Lock
*
* @param json
* @return
*/
static Lock fromJson(String json) {
return JSON.parseObject(json, Lock.class);
}
@Override
public String toString() {
return JSON.toJSONString(this, false);
}
public long getExpiryTime() {
return expiryTime;
}
/**
* 判斷鎖是否超時(shí),如果鎖的過期時(shí)間小于當(dāng)前系統(tǒng)時(shí)間,則判定鎖超時(shí)
*
* @return
*/
boolean isExpired() {
return this.expiryTime < System.nanoTime();
}
@Override
public boolean equals(Object obj) {
return obj != null
&& obj instanceof Lock
&& this.expiryTime == ((Lock) obj).getExpiryTime();
}
}
}
優(yōu)化
上面存在的鎖覆蓋問題是不可避免的,還有就是要求客戶端時(shí)間同步。下面我們進(jìn)一步優(yōu)化這一問題。
Redis命令介紹
SET
- 語法:
SET key value [EX seconds] [PX milliseconds] [NX|XX]
功能:
將字符串值 value 關(guān)聯(lián)到 key 。
如果 key 已經(jīng)持有其他值, SET 就覆寫舊值,無視類型。
對(duì)于某個(gè)原本帶有生存時(shí)間(TTL)的鍵來說, 當(dāng) SET 命令成功在這個(gè)鍵上執(zhí)行時(shí),這個(gè)鍵原有的 TTL 將被清除。可選參數(shù)
從 Redis 2.6.12 版本開始,SET 命令的行為可以通過一系列參數(shù)來修改:EX second :設(shè)置鍵的過期時(shí)間為 second 秒。
SET key value EX second效果等同于SETEX key second value。PX millisecond :設(shè)置鍵的過期時(shí)間為 millisecond 毫秒。
SET key value PX millisecond效果等同于PSETEX key millisecond value。NX :只在鍵不存在時(shí),才對(duì)鍵進(jìn)行設(shè)置操作。
SET key value NX效果等同于SETNX key value。XX :只在鍵已經(jīng)存在時(shí),才對(duì)鍵進(jìn)行設(shè)置操作。
因?yàn)?SET 命令可以通過參數(shù)來實(shí)現(xiàn)和 SETNX 、 SETEX 和 PSETEX 三個(gè)命令的效果,所以將來的 Redis 版本可能會(huì)廢棄并最終移除 SETNX 、 SETEX 和 PSETEX 這三個(gè)命令。
- 返回值:
在 Redis 2.6.12 版本以前, SET 命令總是返回 OK 。
從 Redis 2.6.12 版本開始, SET 在設(shè)置操作成功完成時(shí),才返回 OK 。
如果設(shè)置了 NX 或者 XX ,但因?yàn)闂l件沒達(dá)到而造成設(shè)置操作未執(zhí)行,那么命令返回空批量回復(fù)(NULL Bulk Reply)。
使用模式
命令 SET resource-name anystring NX EX max-lock-time 是一種在 Redis 中實(shí)現(xiàn)鎖的簡(jiǎn)單方法。
客戶端執(zhí)行以上的命令:
如果服務(wù)器返回 OK ,那么這個(gè)客戶端獲得鎖。
如果服務(wù)器返回 NIL ,那么客戶端獲取鎖失敗,可以在稍后再重試。
設(shè)置的過期時(shí)間到達(dá)之后,鎖將自動(dòng)釋放。
可以通過以下修改,讓這個(gè)鎖實(shí)現(xiàn)更健壯:
不使用固定的字符串作為鍵的值,而是設(shè)置一個(gè)不可猜測(cè)(non-guessable)的長(zhǎng)隨機(jī)字符串,作為口令串(token)。
不使用 DEL 命令來釋放鎖,而是發(fā)送一個(gè) Lua 腳本,這個(gè)腳本只在客戶端傳入的值和鍵的口令串相匹配時(shí),才對(duì)鍵進(jìn)行刪除。
這兩個(gè)改動(dòng)可以防止持有過期鎖的客戶端誤刪現(xiàn)有鎖的情況出現(xiàn)。
以下是一個(gè)簡(jiǎn)單的解鎖腳本示例:
if redis.call("get",KEYS[1]) == ARGV[1]
then
return redis.call("del",KEYS[1])
else
return 0
end
源碼
package com.github.hutu92;
import com.alibaba.fastjson.JSON;
import redis.clients.jedis.Jedis;
import redis.clients.jedis.JedisPool;
import java.util.Collections;
import java.util.UUID;
import java.util.concurrent.locks.ReentrantLock;
/**
* Created by liuchunlong on 2018/9/4.
* <p>
* 基于redis的分布式鎖 v2
* <p>
* 不需要客戶端時(shí)間同步
*/
public class DistributedLock {
private static final long RETRY_BARRIER = 600; // 重試屏障,單位毫秒
private static final long INTERVAL_TIMES = 200; // 下一次重試等待,單位毫秒
private final JedisPool jedisPool; // redis連接池
private final String lockKey; // lock Key
private final long lockExpiryInMillis; // 鎖的過期時(shí)長(zhǎng),單位納秒
private final ThreadLocal<Lock> lockThreadLocal = new ThreadLocal<Lock>();
/**
* 構(gòu)造方法
*
* @param jedisPool redis連接池
* @param lockKey 鎖的Key
* @param lockExpiryInMillis 鎖的過期時(shí)長(zhǎng),單位毫秒
*/
public DistributedLock(JedisPool jedisPool, String lockKey, long lockExpiryInMillis) {
this.jedisPool = jedisPool;
this.lockKey = lockKey;
this.lockExpiryInMillis = lockExpiryInMillis;
}
/**
* 構(gòu)造方法
* <p>
* 使用鎖默認(rèn)的過期時(shí)長(zhǎng)Integer.MAX_VALUE,即鎖永遠(yuǎn)不會(huì)過期
*
* @param jedisPool redis連接池
* @param lockKey 鎖的Key
*/
public DistributedLock(JedisPool jedisPool, String lockKey) {
this(jedisPool, lockKey, Integer.MAX_VALUE);
}
/**
* 獲取鎖在redis中的Key標(biāo)記
*
* @return locks key
*/
public String getLockKey() {
return this.lockKey;
}
/**
* 鎖的過期時(shí)長(zhǎng)
*
* @return
*/
public long getLockExpiryInMillis() {
return lockExpiryInMillis;
}
/**
* can override
*
* @param jedis
* @return
*/
private String nextUid(Jedis jedis) {
// 可以考慮雪花算法..
return UUID.randomUUID().toString();
}
private synchronized Jedis getClient() {
return jedisPool.getResource();
}
private synchronized void closeClient(Jedis jedis) {
jedis.close();
}
/**
* 請(qǐng)求分布式鎖,不會(huì)阻塞,直接返回
*
* @param jedis redis 連接
* @return 成功獲取鎖返回true, 否則返回false
*/
private boolean tryAcquire(Jedis jedis) {
final Lock nLock = new Lock(nextUid(jedis));
String result = jedis.set(this.lockKey, nLock.toString(), "NX", "PX", this.lockExpiryInMillis);
if ("OK".equals(result)) {
lockThreadLocal.set(nLock);
return true;
}
return false;
}
/**
* 請(qǐng)求分布式鎖,不會(huì)阻塞,直接返回
*
* @return 成功獲取鎖返回true, 否則返回false
*/
public boolean tryAcquire() {
Jedis jedis = null;
try {
jedis = getClient();
return tryAcquire(jedis);
} finally {
if (jedis != null) {
closeClient(jedis);
}
}
}
/**
* 超時(shí)請(qǐng)求分布式鎖,會(huì)阻塞
*
* 采用"自旋獲取鎖"的方式,直至獲取鎖成功或者請(qǐng)求鎖超時(shí)
*
* @param acquireTimeoutInMillis 鎖的請(qǐng)求超時(shí)時(shí)長(zhǎng)
* @return
*/
public boolean acquire(long acquireTimeoutInMillis) throws InterruptedException {
Jedis jedis = null;
try {
jedis = getClient();
long acquireTime = System.currentTimeMillis();
long expiryTime = System.currentTimeMillis() + acquireTimeoutInMillis; // 鎖的請(qǐng)求到期時(shí)間
while (expiryTime >= System.currentTimeMillis()) {
boolean result = tryAcquire(jedis);
if (result) { // 獲取鎖成功直接返回,否則循環(huán)重試
return true;
}
Thread.sleep(INTERVAL_TIMES);
}
} finally {
if (jedis != null) {
closeClient(jedis);
}
}
return false;
}
/**
* 釋放鎖
*
* @return
*/
public boolean release() throws InterruptedException {
return release(Integer.MAX_VALUE);
}
/**
* 釋放鎖
*
* @return
*/
public boolean release(long releaseTimeoutInMillis) throws InterruptedException {
Jedis jedis = null;
try {
jedis = getClient();
return release(jedis, releaseTimeoutInMillis);
} finally {
if (jedis != null) {
closeClient(jedis);
}
}
}
/**
* 釋放鎖
*
* @param jedis
* @param releaseTimeoutInMillis
* @return
*/
private boolean release(Jedis jedis, long releaseTimeoutInMillis) throws InterruptedException {
Lock cLock = lockThreadLocal.get();
if (cLock == null) {
System.out.println("lock is null!");
}
if (cLock != null) {
String luaScript = "if redis.call('get', KEYS[1]) == ARGV[1] then return redis.call('del', KEYS[1]) else return 0 end";
long releaseTime = System.currentTimeMillis();
long expiryTime = System.currentTimeMillis() + releaseTimeoutInMillis; // 鎖的釋放到期時(shí)間
while (expiryTime >= System.currentTimeMillis()) {
Object result = jedis.eval(luaScript, Collections.singletonList(this.lockKey),
Collections.singletonList(cLock.toString()));
if (((Long) result) == 1L) {
lockThreadLocal.remove();
return true;
}
Thread.sleep(INTERVAL_TIMES);
}
}
return false;
}
/**
* 鎖
*/
protected static class Lock {
private String uid; // lock 唯一標(biāo)識(shí)
Lock(String uid) {
this.uid = uid;
}
public String getUid() {
return uid;
}
@Override
public String toString() {
return JSON.toJSONString(this, false);
}
}
}
性能調(diào)優(yōu)
這里我們使用ab性能測(cè)試工具來模擬測(cè)試。
由于沒有使用隊(duì)列,對(duì)高并發(fā)請(qǐng)求進(jìn)行削峰,所以所有的壓力都會(huì)被打到redis上。為了測(cè)試方便我這里只是本地啟動(dòng)了單機(jī)redis,沒有做其它的調(diào)優(yōu)配置。
我們并發(fā)測(cè)試場(chǎng)景是1000個(gè)并發(fā)請(qǐng)求,總共2000個(gè)請(qǐng)求。
ab -n 2000 -c 1000 "localhost:8080/lock/v2/seckill"
上述的地址是一個(gè)接口,接口代碼如下:
@RestController
@RequestMapping("/lock")
public class LockController {
private static LongAdder longAdder = new LongAdder();
private static Long ACQUIRE_TIMEOUT_IN_MILLIS = (long) Integer.MAX_VALUE;
private static Long stock = 100000L;
private static DistributedLock lock;
static {
longAdder.add(stock);
}
private final JedisPool jedisPool;
@Autowired
public LockController(JedisPool jedisPool) {
this.jedisPool = jedisPool;
lock = new DistributedLock(jedisPool, "seckillV2_" + UUID.randomUUID().toString());
}
@GetMapping("/v2/seckill")
public String seckillV2() throws InterruptedException {
boolean acquireResult = false;
try {
acquireResult = lock.acquire(ACQUIRE_TIMEOUT_IN_MILLIS);
if (!acquireResult) {
return "人太多了,換個(gè)姿勢(shì)操作一下!";
}
if (longAdder.longValue() == 0L) {
return "已搶光!";
}
doSomeThing(jedisPool);
longAdder.decrement();
System.out.println("已搶: " + (stock - longAdder.longValue()) + ", 還剩下: " + longAdder.longValue());
} finally {
if (acquireResult) {
boolean releaseResult = lock.release();
if (!releaseResult) {
System.out.println("釋放鎖失?。?);
}
}
}
return "OK";
}
private void doSomeThing(JedisPool jedisPool) {
Jedis jedis = null;
try {
jedis = jedisPool.getResource();
jedis.incr("already_bought");
} finally {
if (jedis != null) {
jedis.close();
}
}
}
}
那么我們這里說的性能調(diào)優(yōu)指的是什么呢?
仔細(xì)分析上面的源碼你會(huì)發(fā)現(xiàn),獲取鎖的邏輯是循環(huán)獲取的,再每次循環(huán)之間,應(yīng)該怎么去處理?如果不做任何處理,直接繼續(xù)下一個(gè)循環(huán),表面上看能夠及時(shí)的獲取鎖,但這會(huì)給redis更大的壓力,如果redis扛不住,到最后只會(huì)適得其反;而如果sleep等待,那么等待多久呢?等待久了,鎖的獲取和釋放就會(huì)不及時(shí);使用yield如何?等等
No1
if ((System.currentTimeMillis() - acquireTime) > RETRY_BARRIER) {
Thread.yield();
}
請(qǐng)求獲取鎖的前600毫秒內(nèi)直接循環(huán)重試,如果超過600毫秒還未獲取到鎖則每次循環(huán)都將線程推遲到下一個(gè)時(shí)間片執(zhí)行。
主要參數(shù)說明:
Failed requests:失敗的請(qǐng)求
Time per request:每個(gè)請(qǐng)求的平均耗時(shí)
No2
if ((System.currentTimeMillis() - acquireTime) > RETRY_BARRIER) {
Thread.sleep(INTERVAL_TIMES);
} else {
Thread.yield();
}
請(qǐng)求獲取鎖的前600毫秒內(nèi)每次循環(huán)重試都先將線程推遲到下一個(gè)時(shí)間片,如果超過600毫秒還未獲取到鎖則每次循環(huán)都將線程休眠200毫秒。
<figure style="margin: 0px; padding: 0px; max-width: 100%; box-sizing: border-box !important; word-wrap: break-word !important; color: inherit; font-family: -apple-system-font, BlinkMacSystemFont, "Helvetica Neue", "PingFang SC", "Hiragino Sans GB", "Microsoft YaHei UI", "Microsoft YaHei", Arial, sans-serif; font-size: inherit; font-style: normal; font-variant-ligatures: normal; font-variant-caps: normal; font-weight: 400; letter-spacing: 2px; orphans: 2; text-align: justify; text-indent: 0px; text-transform: none; white-space: normal; widows: 2; word-spacing: 2px; -webkit-text-stroke-width: 0px; background-color: rgb(255, 255, 255); text-decoration-style: initial; text-decoration-color: initial; line-height: inherit;"><figcaption style="margin: 10px 0px 0px; padding: 0px; max-width: 100%; box-sizing: border-box !important; word-wrap: break-word !important; line-height: inherit; text-align: center; color: rgb(153, 153, 153); font-size: 0.7em;">carbon -1-</figcaption>
</figure>
很明顯,出錯(cuò)率降低了很多,每個(gè)請(qǐng)求的耗時(shí)也減少了一半,這是因?yàn)?,No1中在600毫秒內(nèi)的直接循環(huán)重試,會(huì)產(chǎn)生很多意義的請(qǐng)求,給redis造成了巨大的壓力,無法響應(yīng)請(qǐng)求。
No3
Thread.sleep(INTERVAL_TIMES);
請(qǐng)求獲取鎖的每次循環(huán)重試都將線程休眠200毫秒。
<figure style="margin: 0px; padding: 0px; max-width: 100%; box-sizing: border-box !important; word-wrap: break-word !important; color: inherit; font-family: -apple-system-font, BlinkMacSystemFont, "Helvetica Neue", "PingFang SC", "Hiragino Sans GB", "Microsoft YaHei UI", "Microsoft YaHei", Arial, sans-serif; font-size: inherit; font-style: normal; font-variant-ligatures: normal; font-variant-caps: normal; font-weight: 400; letter-spacing: 2px; orphans: 2; text-align: justify; text-indent: 0px; text-transform: none; white-space: normal; widows: 2; word-spacing: 2px; -webkit-text-stroke-width: 0px; background-color: rgb(255, 255, 255); text-decoration-style: initial; text-decoration-color: initial; line-height: inherit;"><figcaption style="margin: 10px 0px 0px; padding: 0px; max-width: 100%; box-sizing: border-box !important; word-wrap: break-word !important; line-height: inherit; text-align: center; color: rgb(153, 153, 153); font-size: 0.7em;">carbon -2-</figcaption>
</figure>
No4
Thread.sleep(INTERVAL_TIMES * 10);
請(qǐng)求獲取鎖的每次循環(huán)重試都將線程休眠2秒。
<figure style="margin: 0px; padding: 0px; max-width: 100%; box-sizing: border-box !important; word-wrap: break-word !important; color: inherit; font-family: -apple-system-font, BlinkMacSystemFont, "Helvetica Neue", "PingFang SC", "Hiragino Sans GB", "Microsoft YaHei UI", "Microsoft YaHei", Arial, sans-serif; font-size: inherit; font-style: normal; font-variant-ligatures: normal; font-variant-caps: normal; font-weight: 400; letter-spacing: 2px; orphans: 2; text-align: justify; text-indent: 0px; text-transform: none; white-space: normal; widows: 2; word-spacing: 2px; -webkit-text-stroke-width: 0px; background-color: rgb(255, 255, 255); text-decoration-style: initial; text-decoration-color: initial; line-height: inherit;"><figcaption style="margin: 10px 0px 0px; padding: 0px; max-width: 100%; box-sizing: border-box !important; word-wrap: break-word !important; line-height: inherit; text-align: center; color: rgb(153, 153, 153); font-size: 0.7em;">carbon -3-</figcaption>
</figure>
很明顯,休眠時(shí)間過長(zhǎng),會(huì)使部分線程請(qǐng)求鎖的時(shí)間變長(zhǎng),不能夠及時(shí)獲取到鎖。
No5
Thread.yield();
請(qǐng)求獲取鎖的每次循環(huán)重試都將線程推遲到下一個(gè)時(shí)間片執(zhí)行。
<figure style="margin: 0px; padding: 0px; max-width: 100%; box-sizing: border-box !important; word-wrap: break-word !important; color: inherit; font-family: -apple-system-font, BlinkMacSystemFont, "Helvetica Neue", "PingFang SC", "Hiragino Sans GB", "Microsoft YaHei UI", "Microsoft YaHei", Arial, sans-serif; font-size: inherit; font-style: normal; font-variant-ligatures: normal; font-variant-caps: normal; font-weight: 400; letter-spacing: 2px; orphans: 2; text-align: justify; text-indent: 0px; text-transform: none; white-space: normal; widows: 2; word-spacing: 2px; -webkit-text-stroke-width: 0px; background-color: rgb(255, 255, 255); text-decoration-style: initial; text-decoration-color: initial; line-height: inherit;"><figcaption style="margin: 10px 0px 0px; padding: 0px; max-width: 100%; box-sizing: border-box !important; word-wrap: break-word !important; line-height: inherit; text-align: center; color: rgb(153, 153, 153); font-size: 0.7em;">carbon -4-</figcaption>
</figure>
總結(jié)
總的來說,No2與No3表現(xiàn)的都還可以。但是No2使用了Thread.yield();也會(huì)給redis造成壓力,我可以對(duì)比下兩者的 Percentage of the requests served within a certain time (ms) 數(shù)據(jù)??梢钥吹絅o3的90%以下請(qǐng)求的用戶平均時(shí)間要明顯低于No2的。所以最終我們選擇No3策略。
當(dāng)然你也可以根據(jù)你的redis的QPS自行調(diào)整策略。