Redis分布式鎖的一點小理解


1. 為何要分布式鎖

現在假設一個場景,同時有十個請求需要對資源進行訪問和修改,為了保證數據的正確性,那么你的程序可能是這么寫的:

/** 用於鎖的對象*/
public static final Object lock = new Object();
​
/** 模擬業務的資源*/
public static volatile int source;
​
public static void main(String[] args) throws InterruptedException {
    ExecutorService executorService = Executors.newFixedThreadPool(10);
    // 模擬有十個請求同時請求同一個資源
    for (int i = 0; i < 10; i++) {
        executorService.execute(() -> {
            System.err.println("[" + Thread.currentThread().getName() + "]正在爭奪鎖...");
            synchronized (lock) {
                System.err.println("okay![" + Thread.currentThread().getName() + "]拿到鎖了,現在執行業務操作,執行后資源值為:" + ++source);
                try {
                    TimeUnit.SECONDS.sleep(3);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
            }
        });
    }
}

結果圖:

synchronized鎖不住程序結果0

從結果來看就算同時有多個請求,確實保證了一次只有一個請求訪問的,拋去性能的問題不講,這樣寫似乎確實能實現。但是真的沒問題嗎?對於單機程序來說這樣確實是能保證正確性,但是如果服務器用的是多台機器,這些請求會被負載均衡到不同的機器,由於synchronized只能作用於當前的JVM,所以對於其他JVM就鎖不住了,這樣對於資源的訪問也就亂套了(當然不同JVM上方的source也是只在當前JVM生效,source只是一種資源的象征,實際可能是DB中某條數據的值)。如下圖所示:

synchronized鎖1

所以為了解決這個問題,分布式鎖就這樣誕生了。分布式鎖這名字聽起來很大氣,但是仔細想想我們現在的問題只是不同機器不能訪問同一個鎖,那么如果我們將這個鎖放到第三方(如redis)中,所有機器在訪問的時候去這個第三方拿,由於第三方的鎖只有一個,這樣又能保證鎖住了。分布式鎖2

2. redis如何實現分布式鎖

那來看下使用redis如何實現分布式鎖。

2.1 setnx+expire存在的問題以及更好的實現

舊一點的版本(2.6之前)使用的是setnx+expire的組合來實現。

  setnx:當key不存在的時候設置成功,返回1,若存在的話返回0表示失敗。但是這樣的組合存在一個問題,先來看一段偽代碼。

try {
        if (redisclient. setnx(key 1) == 1) { //1
            redisClient.expire(key, 1000);//2
        }
    } finally {
        redisClient.del(key);
    }

如上代碼,看上去沒什么問題,但是極端情況下如果在1處執行完畢2處還沒執行這時候這台機器宕機了,由於命令已經在redis執行了,那么這個鎖將是無期限的,且不會被刪除,也就是說設置setnx和expire是兩個命令,不具備原子性。 針對這個問題,可以使用 redis2.6版本之后的命令

set key value NX EX timeOut(過期秒數)

來解決,這個命令跟 setnx一樣,但是多了過期時間,可以很好的解決這個問題。

如果沒有代碼的redisClient沒有set這五個參數的命令,也可以采用lua腳本的方式來保證原子性,如下。

    String luascript = "if redis.call('setnx',KEYS[1],ARGV[1])==1 then return redis.call('expire',KEYS[1],ARGV[2]) else return 0 end";
    redisClient.eval(luaScript, Collections.singletonList(key), Arrays.asList(uuid.toString(),"過期秒數"));

2.2 如何正確的釋放分布式鎖

解決原子性的問題之后,還存在着一個問題:如果在過期時間內程序代碼沒執行完,那么其他其他機器線程獲得這個鎖,這樣會造成同時有兩個線程執行一段代碼,並且A機器(過期還沒執行完)中finally會刪除key,導致誤刪到B機器鎖(當前獲得鎖的機器)的情況。

這個問題這樣解決:

  1. 我們可以在相同的機器上開一個守護線程(如上面例子就在A機器再開一個守護線程),這個線程主要作用是在key快過期的時候進行續命操作,保證代碼執行完畢。

  2. 關於誤刪,我們可以把value設成當前線程獨一無二的ID(可以使用uuid),刪除前判斷一下是否是自己的ID,是的話再執行刪除,如下面代碼:

 try {
     ...
 } finally {
     if (uuid.equals(redisClient.get(key)) {//1
         redisClient.del(key);//2
     }
 }

這時一般情況都沒問題,但是這里的1和2又跟前面的問題類似---不具備原子性,所以還是有出錯的可能,但是 redis中沒有支持獲取刪除的原子性命令,該怎么解決呢? 我們可以通過Lua腳本來解決,例如本例中可以像下面這么寫

   String luascript = "if redis.call('get',KEYS[1])==ARGV[l] then return redis.call('del',KEYS[1]) else return 0 end";
     redisClient.eval(luaScript, Collections.singletonList(key), Collections.singletonList(uuid));

redis中,執行lua腳本的命令一般是這樣:

eval 腳本 key的數量n key1 key2 ... key_n ARGV的數量(這個沒有規定多少,可以不跟key的數量保持一致,只要知道key結束后面的都是argv)

那么在上面代碼的腳本放在redis中就變成下面這樣:

  eval "if redis.call('get',KEYS[1])==ARGV[l] then return redis.call('del',KEYS[1]) else return 0 end" 1 'key' 'uuid'

到這里,基本就沒什么問題了,最終的代碼如下

  try {
        String luascript = "if redis.call('get',KEYS[1])==ARGV[l] then return redis.call('del',KEYS[1]) else return 0 end";
        String uuid = UUID.randomUUID().toString();
      
        while (!"OK".equals(redisClient.set(key,uuid,"NX","EX",100))) {
            // 沒獲取到鎖的處理,可以睡眠一段時間再請求,也可以直接返回請求告訴用戶有其他人在操作(后者是最好的,可以減少線程資源的浪費)
        }
      
        // 獲取到鎖之后的事情
        doBizThings();
    } finally {
        redisClient.eval(luaScript, Collections.singletonList(key), Collections.singletonList(uuid));
    }

以上就是redis實現分布式鎖的內容了,另外還可以使用zookeeper實現分布式鎖,大致原理就是在一個鎖下面創建"臨時順序節點",如果是第一個節點的話,獲取鎖,執行完操作后刪除,這個刪除操作會通知下個節點(第二個節點),告訴它鎖已經釋放了,它現在是第一個節點了可以獲取鎖了。大致就是這樣的一個過程,相比redis的好處是多了一個通知的機制,有興趣的話可以自己去了解下。

如果本文有幫到你,希望右下角關注沒事。

本文平台為博客園,點此跳轉


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM