redis使用范圍廣泛,分布式鎖就是其中之一,面試官也最喜歡問的裝逼問題之一。今天通過簡單剖析源碼,分析為啥redis可以用作分布式鎖的實現(xiàn)
鎖。根據(jù)維基百科上的說明:每個線程在訪問對應(yīng)資源前都需獲取鎖的信息,再根據(jù)信息決定是否可以訪問。若訪問對應(yīng)信息,鎖的狀態(tài)會改變?yōu)殒i定,因此其他線程此時不會訪問該資源,當資源結(jié)束后,會恢復(fù)鎖的狀態(tài),允許其他線程的訪問。
為啥需要鎖?
一段代碼(甲)正在分步修改一塊數(shù)據(jù)。這時,另一條線程(乙)由于一些原因被喚醒。如果乙此時去讀取甲正在修改的數(shù)據(jù),而甲碰巧還沒有完成整個修改過程,這個時候這塊數(shù)據(jù)的狀態(tài)就處在極大的不確定狀態(tài)中,讀取到的數(shù)據(jù)當然也是有問題的。更嚴重的情況是乙也往這塊地方寫數(shù)據(jù),這樣的一來,后果將變得不可收拾。因此,多個線程間共享的數(shù)據(jù)必須被保護。達到這個目的的方法,就是確保同一時間只有一個臨界區(qū)域處于運行狀態(tài),而其他的臨界區(qū)域,無論是讀是寫,都必須被掛起并且不能獲得運行機會。
說白了就是應(yīng)用進行邏輯處理時經(jīng)常會遇到并發(fā)問題,如果不對共享數(shù)據(jù)進行保護,那么將導(dǎo)致數(shù)據(jù)不一致。在同一個JVM中,通過JUC包下lock或者Java類庫自帶的synchronized可以實現(xiàn)加鎖操作。但對于不同的JVM的,這種方式就不適合了,所以就引出分布式鎖的概念。
分布式鎖:是控制分布式系統(tǒng)之間同步訪問共享資源的一種方式
實現(xiàn)分布式鎖的方式有很多,比如通過redis、zookeeper、數(shù)據(jù)庫樂觀鎖等。網(wǎng)上提到最多的就是前面兩個,由于今天主題是redis,所以今天著重分析redis是如何實現(xiàn)分布式鎖的以及為啥redis適合分布式鎖實現(xiàn)
1、實現(xiàn)分布式鎖
客戶端。網(wǎng)上扒的一段代碼,代碼都差不多
//加鎖
public String lock(String key) {
Jedis jedis = null;
try {
jedis = redisConnection.getJedis();
jedis.select(dbIndex);
key = KEY_PRE + key;
String value = fetchLockValue();
if (SET_SUCCESS.equals(jedis.set(key, value, "NX", "EX", lockExpirseTime))) {
return value;
}
} catch (Exception e) {
e.printStackTrace();
} finally {
if (jedis != null) {
jedis.close();
}
}
return null;
}
//解鎖
public boolean unLock(String key, String value) {
Long RELEASE_SUCCESS = 1L;
Jedis jedis = null;
try {
jedis = redisConnection.getJedis();
jedis.select(dbIndex);
key = KEY_PRE + key;
String command = "if redis.call('get',KEYS[1])==ARGV[1] then return redis.call('del',KEYS[1]) else return 0 end";
if (RELEASE_SUCCESS.equals(jedis.eval(command, Collections.singletonList(key), Collections.singletonList(value)))) {
return true;
}
} catch (Exception e) {
e.printStackTrace();
} finally {
if (jedis != null) {
jedis.close();
}
}
return false;
}
- 加鎖使用setnx或者setex命令,這兩個命令可以確保set和expire指令原子執(zhí)行。setnx與setex命令有些不同:前者是鍵存在不做任何操作,后者是鍵存在就覆蓋。所以加鎖最好用setnx
- 解鎖時先判斷該鎖是不是當前線程持有的,如果是才調(diào)用del指令刪掉鍵。注意這里也必須需要原子性執(zhí)行。因為判斷和del是兩個指令,假如不原子執(zhí)行,線程1剛好執(zhí)行判斷完,過期時間到期了,線程2搶到鎖,結(jié)果線程1執(zhí)行刪除鍵操作,導(dǎo)致把線程2加的鎖給刪掉了
以上就是加鎖解鎖的邏輯,很簡單。但是背后的redis執(zhí)行過程可是不簡單,經(jīng)過一序列復(fù)雜的執(zhí)行流程。下面簡單過一遍源碼
setnx
這個指令包含兩個指令:set和expire。具體執(zhí)行過程如下:
- 1、當多路復(fù)用函數(shù)監(jiān)聽到客戶端文件事件,交給事件分派器進行分派,最終交給文件事件讀readQueryFromClient處理器處理
- 2、readQueryFromClient收到響應(yīng)后,開始從緩沖區(qū)里讀數(shù)據(jù)進行分析,提取出命令請求中包含的命令參數(shù), 以及命令參數(shù)的個數(shù),然后分別將參數(shù)和參數(shù)個數(shù)保存到客戶端狀態(tài)的argv屬性和 argc屬性里面
- 3、調(diào)用命令執(zhí)行器,執(zhí)行客戶端指定的命令
setnx命令的命令執(zhí)行器是setnxCommand,具體實現(xiàn)在t_string.c中
void setGenericCommand(redisClient *c, int flags, robj *key, robj *val, robj *expire, int unit, robj *ok_reply, robj *abort_reply) {
long long milliseconds = 0; /* initialized to avoid any harmness warning */
// 取出過期時間
if (expire) {
// 取出 expire 參數(shù)的值
// T = O(N)
if (getLongLongFromObjectOrReply(c, expire, &milliseconds, NULL) != REDIS_OK)
return;
// expire 參數(shù)的值不正確時報錯
if (milliseconds <= 0) {
addReplyError(c,"invalid expire time in SETEX");
return;
}
// 不論輸入的過期時間是秒還是毫秒
// Redis 實際都以毫秒的形式保存過期時間
// 如果輸入的過期時間為秒,那么將它轉(zhuǎn)換為毫秒
if (unit == UNIT_SECONDS) milliseconds *= 1000;
}
// 如果設(shè)置了 NX 或者 XX 參數(shù),那么檢查條件是否不符合這兩個設(shè)置
// 在條件不符合時報錯,報錯的內(nèi)容由 abort_reply 參數(shù)決定
if ((flags & REDIS_SET_NX && lookupKeyWrite(c->db,key) != NULL) ||
(flags & REDIS_SET_XX && lookupKeyWrite(c->db,key) == NULL))
{
addReply(c, abort_reply ? abort_reply : shared.nullbulk);
return;
}
// 將鍵值關(guān)聯(lián)到數(shù)據(jù)庫
setKey(c->db,key,val);
// 將數(shù)據(jù)庫設(shè)為臟
server.dirty++;
// 為鍵設(shè)置過期時間
if (expire) setExpire(c->db,key,mstime()+milliseconds);
// 發(fā)送事件通知
notifyKeyspaceEvent(REDIS_NOTIFY_STRING,"set",key,c->db->id);
// 發(fā)送事件通知
if (expire) notifyKeyspaceEvent(REDIS_NOTIFY_GENERIC,
"expire",key,c->db->id);
// 設(shè)置成功,向客戶端發(fā)送回復(fù)
// 回復(fù)的內(nèi)容由 ok_reply 決定
addReply(c, ok_reply ? ok_reply : shared.ok);
}
從源碼中可以看到,
1、鍵值是保存到dict字典數(shù)據(jù)結(jié)構(gòu)中
2、過期時間也專門用一個dict字典數(shù)據(jù)結(jié)構(gòu)來保存:key是鎖的鍵,v是過期時間。為啥要用專門的一個dict來保存這些過期時間?
其實這里的key和第一點提到的key是同一個指針,也就是共享同一片內(nèi)存,所以把過期時間單獨抽出來保存到另一個dict里跟保存到當前dict,占用的空間是一樣的。但是查詢效率卻不一樣了,假如想獲取所有添加了過期時間的key,這種方式則只需要o(1)就可以實現(xiàn)。
3、當使用setnx指令時,首先根據(jù)key到dict中查詢是否已經(jīng)存在,如果已經(jīng)存在,則根據(jù)abort_reply參數(shù)報錯指定的內(nèi)容。setnx指令abort_reply參數(shù)是shared.czero,也就是-1。所以當加鎖不成功就返回-1,這個值的具體實現(xiàn)在server.c#createSharedObjects函數(shù)中
//是一個為-1的字符串
shared.cnegone = createObject(REDIS_STRING,sdsnew(":-1\r\n"));
以上就是redis加鎖的簡單過程。解鎖也差不多,就不分析了
總結(jié)
1、在上一篇文章中提到過,redis執(zhí)行大部分指令是單線程執(zhí)行的。除了個別磁盤IO比較多的指令外。setnx是純內(nèi)存操作,所以在redis中自然是單線程執(zhí)行,這種執(zhí)行有先后順序,自然最適合實現(xiàn)分布式鎖了
2、redis在集群條件下,上面所說的方法是有缺陷的。因為redis的主從同步是異步執(zhí)行的,無法保證從節(jié)點及時更新。當主節(jié)點掛掉時,如果從節(jié)點的數(shù)據(jù)不是最新的,還有同步線程1的加鎖信息,那么另外一個線程來加鎖,立即就被批準了。這樣就會導(dǎo)致系統(tǒng)中同樣一把鎖被兩個線程持有,導(dǎo)致不安全。
不過redis也提供了redlock算法用于解決這個問題。這個用起來有點復(fù)雜,具體原理我暫時也不太清楚,待后面看源碼再分析
3、總體來說,單機分布式鎖采用redis是比較簡單的,但容易有不安全問題,這種不安全也僅僅是在主從發(fā)生 failover 的情況下才會產(chǎn)生,而且持續(xù)時間極短,業(yè)務(wù)系統(tǒng)多數(shù)情況下可以容忍。涉及到集群時,采用redis實現(xiàn)起來就有點麻煩了