讀redis源碼筆記2-分布式鎖

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)起來就有點麻煩了

最后編輯于
?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請聯(lián)系作者
【社區(qū)內(nèi)容提示】社區(qū)部分內(nèi)容疑似由AI輔助生成,瀏覽時請結(jié)合常識與多方信息審慎甄別。
平臺聲明:文章內(nèi)容(如有圖片或視頻亦包括在內(nèi))由作者上傳并發(fā)布,文章內(nèi)容僅代表作者本人觀點,簡書系信息發(fā)布平臺,僅提供信息存儲服務(wù)。

友情鏈接更多精彩內(nèi)容