/** 用於鎖的對象*/ 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只能作用於當前的JVM,所以對於其他JVM就鎖不住了,這樣對於資源的訪問也就亂套了(當然不同JVM上方的source也是只在當前JVM生效,source只是一種資源的象征,實際可能是DB中某條數據的值)。如下圖所示:
所以為了解決這個問題,分布式鎖就這樣誕生了。分布式鎖這名字聽起來很大氣,但是仔細想想我們現在的問題只是不同機器不能訪問同一個鎖,那么如果我們將這個鎖放到第三方(如redis)中,所有機器在訪問的時候去這個第三方拿,由於第三方的鎖只有一個,這樣又能保證鎖住了。
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機器鎖(當前獲得鎖的機器)的情況。
這個問題這樣解決:
我們可以在相同的機器上開一個守護線程(如上面例子就在A機器再開一個守護線程),這個線程主要作用是在key快過期的時候進行續命操作,保證代碼執行完畢。
關於誤刪,我們可以把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的好處是多了一個通知的機制,有興趣的話可以自己去了解下。
如果本文有幫到你,希望右下角關注沒事。