網絡上很多關於分佈式鎖的文章,有些標題起得誇張得要死, 內容卻很是通常, 固然也有一些文章總結得至關不錯, 讓人受益不淺.html
本文比較務實,並無不少高大上的理論, 單純地想從分佈式鎖的實現的推演過程當中,探討一下分佈式鎖實現,和使用中應該注意哪些問題java
這個主要是利用到了數據庫的主鍵的惟一性, 例如惟一性來實現分佈式鎖的排他性.git
具體案例的話, 據我所知的, 就是quartz的集羣模式中就利用到了innodb來作分佈式鎖,來避免同一個任務被多個節點重複執行.github
例如數據庫主鍵作分佈式鎖的主要問題是不夠靈活,例如可重入等等特性實現起來比較麻煩, 適合比較簡單的場景下使用redis
基於zk的分佈式鎖通常是用到了其臨時順序節點的特性, id最小的臨時節點視爲獲取到鎖, 會話結束時臨時節點會被自動刪掉,下一個最小id的臨時節點獲取到鎖算法
zk分佈式鎖存在的問題是,zk的寫性能其實很差,畢竟都是寫在硬盤上的文件中的, 因此不大適合在高併發環境中數據庫
這個主要是利用到了redis是每個命令單個命令都是原子性的特性來實現分佈式鎖.緩存
簡單的來講就是,須要加鎖的時候就調用set, 須要釋放鎖的時候就調用del, 固然實際上沒有那麼簡單.網絡
redis實現分佈式鎖的最大優勢就是性能好.併發
其實每一種分佈式鎖的實現都有它的優點, 例如說數據庫的理解簡單, zk的實現可靠性高, redis的實現性能高. 主要仍是要根據具體的業務場景選擇合適的實現方式.
因爲實際應用中, 仍是redis實現的比較多(印象流), 所以本文選擇redis實現來進行分析
首先定義一個最簡單的分佈式鎖的接口,它只有兩個方法:
package com.north.lat.dislocklat; /** * @author lhh */ public interface DisLock { /** * 加鎖 * @param lockName 鎖的名稱 * @param lockValue 鎖的redis值 * @param expire 鎖的超時時間 * @return 加鎖成功則返回true, 不然返回false */ boolean lock(String lockName,String lockValue,int expire); /** * 釋放鎖 * @param lockName * @param lockValue * @return 釋放成功則返回true */ boolean unlock(String lockName,String lockValue); }
redis官方已經爲咱們提供了一個命令
SET key value [EX seconds] [PX milliseconds] [NX|XX]
這個命令能夠在一個key不存在的時候,設置這個KEY的值, 並指定這個key的過時時間, 而且這個命令是原子性的, 因此能夠完美地被咱們用來做爲加鎖的操做
利用這個命令, 咱們能夠先實現第一個版本的分佈式鎖:
package com.north.lat.dislocklat.redisimpl; import com.north.lat.dislocklat.DisLock; import redis.clients.jedis.Jedis; /** * @author lhh */ public class DisLockV1 implements DisLock { public static final String OK = "OK"; private Jedis jedis = new Jedis ("localhost",6379); @Override public boolean lock(String lockName, String lockValue, int expire) { String ret = jedis.set(lockName, lockValue, "NX", "EX", expire); return OK.equalsIgnoreCase(ret); } @Override public boolean unlock(String lockName,String lockValue) { Long c = jedis.del(lockName); return c > 0; } }
測試代碼以下:
package com.north.lat.dislocklat; import com.north.lat.dislocklat.redisimpl.DisLockV1; public class DisLockTest { public static void main(String[] args) { String lockName = "test_lock"; String lockValue = "test_value"; DisLock disLock = new DisLockV1(); boolean success = disLock.lock(lockName, lockValue, 10); if(success){ try { doSomeThingImportant(); }finally { disLock.unlock(lockName, lockValue); } } } public static void doSomeThingImportant(){ } }
這是一個最簡單版本的分佈式鎖
這個分佈鎖理論上在簡單的場景下是沒有問題的,然而在doSomeThingImportant()業務比較複雜, 處理時間過長的狀況下, 就會出現問題了. 咱們來模擬一下
時刻 | 線程1 | 線程2 | 線程3 | 線程4 |
---|---|---|---|---|
第1秒 | 加鎖 | 加鎖 | 未開始執行 | 未開始執行 |
第2秒 | 獲取到鎖 | 沒獲取到鎖 | 未開始執行 | 未開始執行 |
第10秒 | 執行業務邏輯 | 已返回 | 未開始執行 | 未開始執行 |
第11秒 | 執行業務邏輯(鎖已超時失效) | - | 加鎖 | 未開始執行 |
第12秒 | 釋放鎖, 這時把線程2的鎖也釋放了 | - | 執行業務邏輯 | 未開始執行 |
第13秒 | 返回 | - | 執行業務邏輯 | 加鎖(獲取鎖成功) |
第14秒 | - | - | 執行業務邏輯 | 執行業務邏輯 |
第n秒 | - | - | ... | ... |
對照上面的時刻表, 前面的10秒都沒有問題, 若是10秒內線程能處理完業務邏輯的話,也不會有問題.
然而, 第11秒的時候線程1尚未處理完它本身的業務邏輯, 恰好線程2又過來加鎖, 這時候問題就出現了:
線程1尚未釋放鎖的時候, 線程2加鎖成功了.
問題並不止一個, 到了第12秒的時候,線程1終於處理完本身的業務邏輯,而後就屁顛屁顛地去把鎖給釋放了.這一釋放不單把本身的鎖給釋放了, 還把線程3的鎖也給釋放了.
到了第13秒的時候, 線程4過來加鎖,有線程1和線程3的鎖都被釋放了, 所以線程4加鎖成功
整個過程當中, 線程1和線程3同時執行過臨界區代碼, 線程3和線程4也同時執行過臨界區代碼.分佈鎖跟本沒起一點做用
綜上所述, 這個絕對不是一個可用的分佈式鎖代碼. 那麼它的問題是什麼呢, 主要是下面兩點:
1. 超時時間設置不合理, 由於redis key過時致使鎖失效 2. 釋放鎖的問題, 釋放鎖的時候把其餘線程加的鎖也給釋放了
怎麼解決呢? 咱們來看第二個版本的分佈式鎖實現
對於分佈式鎖的過時時間的值,實際上是一個比較難肯定的東西. 由於咱們永遠不知道臨界區的業務邏輯到底要執行多長時間, 若是設置過短, 就會出現上面的那種狀況, 若是說設置得長點, 那多長算是長呢?
一個簡單的辦法就是在鎖快要失效的時候,若是代碼沒有執行完,那麼就給這個鎖的過時時間延長一些.
這個算法思想大概以下:
1. 加鎖, 過時時間爲N秒 2. 若是加鎖成功, 則開啓一個定時器 3. 定時器一直在執行, 每過了X(X < N, 通常可配置)秒, 就給這個鎖延長Y (Y > X, 通常可配置)秒 4. 釋放鎖的時候, 把定時器刪掉
在上面算法中, 只要臨界區的代碼沒有執行完, 定時器會一直給分佈式鎖"續命", 直到這個分佈式鎖被應用程序釋放掉.
乍一看,若是業務代碼一直沒有處理完, 那這裏豈不是跟沒有設置超時時間同樣同樣的?
但其實仍是有區別:
1. 沒有設置超時時間, redis的key是不會失效的. 2. "續命"的這種方式, 只有在應用程序(的臨界代碼)一直在運行的狀況下, redis的key的過時時間會不斷地被延長 區別就在於, 鎖的失效與否仍是在鎖的使用方手上, 而不是在於鎖自己
另外定時器(具體實現中多是一個守護線程)都是在臨界區內生成和銷燬的, 也就是每一個時刻最多隻會有一個定時器存在, 因此也沒必要擔憂性能問題
只是要保證加鎖釋放鎖和定時器的生成銷燬的事務性, 即加鎖成功必需要生成定時器, 釋放鎖必需要銷燬定時器
鎖釋放的時候,誤把其餘線程加的鎖也釋放了. 這個問題其實很容易解決, 就是釋放鎖的時候, 判斷一下這個鎖是不是本身加的,是的話才釋放鎖. 僞代碼實現以下:
public boolean unlock(String lockName,String lockValue) { String val = jedis.get(lockName); // (1) if(lockValue.equalsIgnoreCase(val)){ jedis.del(lockName); // (2) } return true; }
可是上面這段代碼明顯(1)和(2)不是原子性的, 極可能會帶來一些未知的問題.因此真正的實現並非這樣的,而是使用lua腳本,把兩個命令放在一塊兒,原子性地執行, 代碼以下:
public boolean unlock(String lockName,String lockValue) { String script = "if redis.call('get', KEYS[1]) == ARGV[1] then return redis.call('del', KEYS[1]) else return 0 end"; // public Object eval(String script, List<String> keys, List<String> args) // 第一個參數是腳本, 第二個參數是腳本中涉及到的key的列表, 這裏只涉及到lockName一個key, 第三個參數是涉及到的參數的列表, 這裏只有一個lockValue參數 // 因此這裏實際執行的腳本是: if redis.call('get', lockName) == lockValue then return redis.call('del', lockName) else return 0 end Object o = jedis.eval(script, Collections.singletonList(lockName), Collections.singletonList(lockValue)); return "1".equalsIgnoreCase(o.toString()); }
在實現咱們的第二個版本的redis分佈鎖以前, 咱們先來總結一些,針對初版,有哪些優化
1. 每一個線程加鎖的時候, redis key的值必須不同,並且惟一.釋放鎖的時候要傳上這個惟一值 2. 加鎖的時候,要新建一個定時器, 不斷地延長這key的過時時間,直到鎖釋放 3. 釋放鎖的時候, 要判斷當前鎖的redis value是不是當前線程set進入的, 若是不是則不能釋放 4. 釋放鎖的時候要把定時器銷燬
代碼簡單實現以下:
package com.north.lat.dislocklat.redisimpl; import com.north.lat.dislocklat.DisLock; import redis.clients.jedis.Jedis; import java.util.Collections; /** * @author lhh */ public class DisLockV2 implements DisLock { public static final String OK = "OK"; private Jedis jedis = new Jedis ("localhost",6379); @Override public boolean lock(String lockName, String lockValue, int expire) { String ret = jedis.set(lockName, lockValue, "NX", "EX", expire); createTimer(lockName, jedis, expire); return OK.equalsIgnoreCase(ret); } @Override public boolean unlock(String lockName,String lockValue) { deleteTimer(); String script = "if redis.call('get', KEYS[1]) == ARGV[1] then return redis.call('del', KEYS[1]) else return 0 end"; // public Object eval(String script, List<String> keys, List<String> args) // 第一個參數是腳本, 第二個參數是腳本中涉及到的key的列表, 這裏只涉及到lockName一個key, 第三個參數是涉及到的參數的列表, 這裏只有一個lockValue參數 // 因此這裏實際執行的腳本是: if redis.call('get', lockName) == lockValue then return redis.call('del', lockName) else return 0 end Object o = jedis.eval(script, Collections.singletonList(lockName), Collections.singletonList(lockValue)); return "1".equalsIgnoreCase(o.toString()); } /** * 建立定時器, 這裏暫時省略實現 * @param lockName * @param jedis * @param expire */ void createTimer(String lockName,Jedis jedis, int expire){ //每過了X(X < expire, 通常可配置)秒,jedis.expire 就給lockName這個鎖延長Y (Y > X, 通常可配置)秒 } /** *銷燬定時器, 這裏暫時省略實現 */ void deleteTimer(){ } }
測試main方法, 惟一變更的是lockValue:
package com.north.lat.dislocklat; import com.north.lat.dislocklat.redisimpl.DisLockV1; import java.util.UUID; public class DisLockTest { public static void main(String[] args) { String lockName = "test_lock"; // 用uuid 保持惟一 String lockValue = UUID.randomUUID().toString(); DisLock disLock = new DisLockV1(); boolean success = disLock.lock(lockName, lockValue, 10); if(success){ try { doSomeThingImportant(); }finally { disLock.unlock(lockName, lockValue); } } } public static void doSomeThingImportant(){ } }
上面這個定時器的思路,實際上是redission 分佈式鎖裏面的實現細想. 固然redission還實現了可重入,異步等等特性,咱們的跟它的是沒法比的這裏只是體現一下思想而已.
那麼這樣實現的分佈式鎖是否還有問題? 答案是確定的. 讓咱們再來推演一下兩種異常狀況.
你們都知道, 爲了提升可用性, 生產環境中的redis通常都不會是單點.解決單點有不少種方案, 可用是客戶端分片, 哨兵模式,集羣模式等等, 無論是哪一種方式 redis通常都會有一主一從. 正常狀況是master提供服務, slave節點保持數據同步,
當master掛了的話, slave節點變成新的master, 來繼續提供服務.
在redis只做爲緩存服務的時候, 這個模式是比較可靠的. 可是在做爲分佈鎖的狀況下, 有時就不可用了.考慮如下的一種場景:
時刻 | 線程1 | 線程2 | redis1 | redis2 | 備註 |
---|---|---|---|---|---|
第1秒 | 獲取鎖 | - | 做爲master | 做爲slave | redis1有lock的key, redis2尚未 |
第2秒 | 獲取到鎖,執行業務邏輯 | 獲取鎖 | 掛了 | 成爲master | 假設因爲網絡延遲,redis1的lock的key尚未同步到redis2 |
第3秒 | 執行業務邏輯 | 獲取到鎖,執行業務邏輯 | 掛了 | 做爲master | 同時有兩個線程在執行臨界區代碼,分佈式鎖不起做用 |
第4秒 | 執行業務邏輯 | 執行業務邏輯 | 掛了 | 做爲master | |
第n秒 | ... | .. | ... | ... |
從上面第2秒能夠看到,因爲主從切換的時候, slave節點上面是不必定有master節點的全部的數據的, 這個時候若是有另一個線程來獲取鎖, 就會出現多個線程同時獲取到鎖的狀況
若是redis是單實例的話, 上面的分佈式鎖已是可用的了, 只是又必需要面臨單redis實例掛掉的風險.
爲了解決redis主從切換帶來的問題,reddsion的設計者實現一個新的分佈式鎖, 就是大名鼎鼎的REDLOCK
REDLOCK的設計思想仍是很符合咱們實事求是,具體問題具體分析的方法論的:
1. 主從切換會致使分佈式鎖失效? ok, 那就用單實例的redis 2. 單實例存在單點故障? ok, 那咱們用多個相互獨立的單實例redis
總的來講, REDLOCK的實現思路就是放棄redis的主從結構, 使用N(通常是5)個redis實例來保證可用性
N個redis實例互相獨立,分佈式鎖只有在大多數的實例上成功獲取到鎖, 纔到算獲取到鎖成功. 爲了不多個實例同時掛掉,
通常來講每一個實例都在不一樣的機器上面.
當客戶端嘗試去獲取分佈式鎖的時候, 須要通過如下幾個步驟
1. 計算當前時間戳CUR_T 2. 客戶端逐一貫N個redis獲取鎖.也就是把同一個KEY和VALUE分佈寫到每一個redis實例中,過時時間爲EX_T. 獲取鎖的時候還須要指一個時間: 此次set命令的響應超時時間RESP_T. 其中RESP_T < EX_T. RESP_T的存在是爲了不某個redis實例已經掛了的時候,還在苦等它響應返回. 3. 對於第2步中的任何一個redis實例, 若是RESP_T時間內沒有返回, 或者set命令返回false, 則表明獲取鎖失敗, 不然就是獲取鎖成功. 無論在當前實例獲取鎖成功仍是失敗, 都立馬向下一個實例獲取鎖. 4. N個redis都請求完後,計算總耗時(用加鎖完成時間戳-CUR_T) ,知足至少有(N/2+1)個實例能獲取到鎖,並且總耗時小於鎖的失效時間纔算獲取鎖成功. 5. 若是獲取鎖失敗,要算全部的實例unlock釋放鎖.
上面的這個思路, 在這篇譯文中描述得很是清楚, 文中REDLOCK的做者大概的論證了這個算法的正確性,並不是常自信地認爲該分佈鎖算法是無懈可擊的
可是另一位大神Martin Kleppmann在他的文章內舉了很多的例子, 來證實REDLOCK是脆弱的,不可靠的. 其中這裏是一篇簡單的譯文
我試着理解了一下他的其中一個舉證
在java應用裏面, 當full gc發生的時候, 整個jvm會發生stop the world的停頓, 當停頓發生時, 分佈鎖的正確性就可能會被打破
來考慮一下下面的一種場景:
時刻 | 進程1 | 進程2 | 進程3 |
---|---|---|---|
第1秒 | 加鎖 | 加鎖 | 未開始執行 |
第2秒 | 獲取到鎖 | 沒獲取到鎖 | 未開始執行 |
第3秒 | 執行業務邏輯,發生FULL GC | 已返回 | 未開始執行 |
第4秒 | 執行業務邏輯,FULL GC, STOP THE WORLD中 | 已返回 | 未開始執行 |
第11秒 | FULL GC結束,執行業務邏輯(鎖已超時失效) | - | 加鎖 |
第12秒 | 執行業務邏輯 | - | 執行業務邏輯 |
第n秒 | .. | ... | ... |
當JVM在stop the world時, 無論是業務邏輯代碼, 仍是上面的"續命"定時器代碼, 都會中止運行.
當FULL GC的停頓時間過長時, redis中分佈式鎖的key有可能已通過期了. 倘若FULL GC結束的瞬間有另一個進程過來獲取鎖的話, 就會發生同時兩個進程獲取到鎖,同時執行臨界區代碼的狀況.
Martin Kleppmann也給出這個狀況的解決方案(詳細見這篇譯文), 並指出redlock處理不了這種狀況, 因此redlock是不可靠的.
有趣的是, redlock的做者在另一篇文章迴應了Martin Kleppmann的質疑. 內容就沒有仔細看了, 質疑的論文和反質疑的論文都是兩三年前的了, 在技術突飛猛進的這個時代, 文中的一些觀點可能早就過期或者是解決掉了.