
由于具體業(yè)務(wù)場景的需求,需要保證數(shù)據(jù)在分布式環(huán)境下的正確更新,所以研究了一下Java中分布式鎖的實(shí)現(xiàn)。
Java分布式鎖的實(shí)現(xiàn)方式主要有以下三種:
- 數(shù)據(jù)庫實(shí)現(xiàn)的樂觀鎖
- Redis實(shí)現(xiàn)的分布式鎖
- Zookeeper實(shí)現(xiàn)的分布式鎖
其中,較常用的是前兩種方式,但是數(shù)據(jù)庫實(shí)現(xiàn)方式需要較多的數(shù)據(jù)庫操作,所以最終選擇的是用Redis實(shí)現(xiàn)分布式鎖。
最初考慮分布式鎖的數(shù)據(jù)安全性的時候,只考慮到兩點(diǎn)。第一,Redis鎖需要有一個超時時間,這樣即便某個持有鎖的節(jié)點(diǎn)掛了,也不到導(dǎo)致其他節(jié)點(diǎn)死鎖,保證每個鎖有一個UniqueId;第二,每個鎖需要有一個UniqueId,確保當(dāng)一個線程執(zhí)行完一個任務(wù)去釋放鎖的時候釋放的一定是自己的鎖,否則可能存在一種場景,就是一個線程釋放鎖的時候,它的鎖可能已經(jīng)超時被釋放了,而因?yàn)槿鄙僖粋€UniqueId,它卻釋放了另一個線程的鎖
基于以上兩點(diǎn)的考慮,分別設(shè)計了獲取鎖和釋放鎖的api。
public interface DistributionLockService {
/**
* @param lockName the name of the lock
* @param uniqueCode uniqueCode for the lock
* @param expireTime expire time of lock(MILLISECONDS)
* @return the result of get lock
* */
boolean getLock(String lockName, String uniqueCode, int expireTime);
/**
* @param lockName the name of the lock
* @param uniqueCode uniqueCode for the lock
* */
void releaseLock(String lockName, String uniqueCode);
}
具體的實(shí)現(xiàn)代碼如下:
/**
* @param lockName the name of the lock
* @param uniqueCode uniqueCode for the lock
* @param expireTime expire time of lock(MILLISECONDS)
* @return the result of get lock
* */
@Override
public boolean getLock(String lockName, String uniqueCode, int expireTime) {
boolean isLock = false;
try {
Long result = jedis.setnx(lockName, uniqueCode);
isLock = result == 1 ? true : false;
if (isLock) {
jedis.expire(lockName, expireTime);
}
} catch (Exception e){
logger.error("DistributionLockService/getLock", e);
}
return isLock;
}
/**
* @param lockName the name of the lock
* @param uniqueCode uniqueCode for the lock
* */
@Override
public void releaseLock(String lockName, String uniqueCode) {
try {
String tag = jedis.get(getKey(lockName));
if (tag != null && tag.equals(uniqueCode))
jedis.del(getKey(lockName));
} catch (Exception e) {
logger.error("DistributionLockService/releaseLock", e);
}
}
上述的代碼用setnx+expire實(shí)現(xiàn)分布式鎖。調(diào)用setnx,當(dāng)傳入的key未被占用時,就在redis中插入一條該key的記錄,返回值為1,此時為其設(shè)置超時時間。而當(dāng)這個key在redis中已有記錄時,則不會重新插入記錄,這樣的話,便可以實(shí)現(xiàn)分布式鎖的基本功能。且為其設(shè)置過期時間,并加入UniqueId的check,避免了上述提及的兩個問題。
但是,上述代碼仍然存在問題,就是忽略了操作的原子性。獲取鎖的時候,調(diào)sexnx方法與設(shè)置超時時間expire不是原子操作,如果在sexnx方法執(zhí)行成功后,節(jié)點(diǎn)突然down掉,沒有執(zhí)行expire方法,而之后的釋放鎖操作也沒有執(zhí)行,那么這個節(jié)點(diǎn)便會長期持有鎖,盡管這種可能性很小,但是依然存在死鎖的風(fēng)險。為了避免這種風(fēng)險,修正代碼如下:
private static final String SET_IF_NOT_EXIST = "NX";
private static final String SET_WITH_EXPIRE_TIME = "PX";
private static final String IS_LOCKED = "OK";
/**
* @param lockName the name of the lock
* @param uniqueCode uniqueCode for the lock
* @param expireTime expire time of lock
* @return the result of get lock
* */
@Override
public boolean getLock(String lockName, String uniqueCode, int expireTime) {
boolean isLock = false;
try {
String result = jedis.set(lockName, uniqueCode, SET_IF_NOT_EXIST, SET_WITH_EXPIRE_TIME, expireTime);
isLock = IS_LOCKED.equalsIgnoreCase(result) ? true : false;
} catch (Exception e){
logger.error("DistributionLockService/getLock", e);
}
return isLock;
}
Redis較高的版本中,有一個有五個參數(shù)的set方法,其中前兩個參數(shù)就是key和value,最后一個參數(shù)是過期時間,中間兩個參數(shù)表示setnx和setex,實(shí)際上就是一個可以設(shè)置過期時間的setnx方法。這個方法可以保證加鎖和設(shè)置過期時間兩者是作為一個請求傳送到Redis服務(wù)器的,所以不會出現(xiàn)上述的死鎖場景。
加鎖的問題解決了,解鎖的問題依然在。上述的解鎖代碼中,在解鎖之前先驗(yàn)證了UniqueId,然后采用del方法來釋放鎖,但是由于get和del是兩次請求,而不是一個原子操作,所以這之間仍存在并發(fā)的問題。若做check的時候,檢查得到確實(shí)是這個鎖的UniqueId,但是在執(zhí)行del方法之前,這個鎖已經(jīng)超時,然后新的線程也已經(jīng)獲取到鎖了,那么del刪掉的鎖,便不是自己的鎖,而是下一個線程的鎖。
Redis中沒有直接的api處理這個問題。解決這個問題,需要使用lua腳本,來確保整個操作的原子性。代碼如下:
/**
* @param lockName the name of the lock
* @param uniqueCode uniqueCode for the lock
* */
@Override
public void releaseLock(String lockName, String uniqueCode) {
try {
String script = "if redis.call('get', KEYS[1]) == ARGV[1] " +
"then return redis.call('del', KEYS[1]) " +
"else return 0 end";
jedis.eval(script, Arrays.asList(lockName), Arrays.asList(uniqueCode));
} catch (Exception e) {
logger.error("DistributionLockService/releaseLock", e);
}
}
jedis的eval方法支持執(zhí)行l(wèi)ua腳本方法,所以便可利用這個方法來實(shí)現(xiàn)釋放鎖的原子操作,具體邏輯和之前的代碼其實(shí)是一致的,但是由于是原子操作,所以可以避免上文中存在的問題。
至此,簡單Redis鎖的實(shí)現(xiàn)便算是成功了。但是其中依然存在許多問題,如果Redis不是單機(jī)的,而是集群分布的,那么其中的數(shù)據(jù)同步該怎么做?在有些較看重數(shù)據(jù)的正確性的場景中,即使Redis鎖超時,只要檢測到機(jī)器仍在正常運(yùn)行Redis鎖就不應(yīng)該被釋放,而應(yīng)該被續(xù)期,這些,都是redis鎖在更復(fù)雜的場景中所需要考慮的。留待以后繼續(xù)研究。