說到Redis,咱們第一想到的功能就是能夠緩存數據,除此以外,Redis由於單進程、性能高的特色,它還常常被用於作分佈式鎖。redis
鎖咱們都知道,在程序中的做用就是同步工具,保證共享資源在同一時刻只能被一個線程訪問,Java中的鎖咱們都很熟悉了,像synchronized 、Lock都是咱們常用的,可是Java的鎖只能保證單機的時候有效,分佈式集羣環境就無能爲力了,這個時候咱們就須要用到分佈式鎖。算法
分佈式鎖,顧名思義,就是分佈式項目開發中用到的鎖,能夠用來控制分佈式系統之間同步訪問共享資源,通常來講,分佈式鎖須要知足的特性有這麼幾點:緩存
一、互斥性:在任什麼時候刻,對於同一條數據,只有一臺應用能夠獲取到分佈式鎖;安全
二、高可用性:在分佈式場景下,一小部分服務器宕機不影響正常使用,這種狀況就須要將提供分佈式鎖的服務以集羣的方式部署;服務器
三、防止鎖超時:若是客戶端沒有主動釋放鎖,服務器會在一段時間以後自動釋放鎖,防止客戶端宕機或者網絡不可達時產生死鎖;網絡
四、獨佔性:加鎖解鎖必須由同一臺服務器進行,也就是鎖的持有者才能夠釋放鎖,不能出現你加的鎖,別人給你解鎖了;併發
業界裏能夠實現分佈式鎖效果的工具不少,但操做無非這麼幾個:加鎖、解鎖、防止鎖超時。異步
既然本文說的是Redis分佈式鎖,那咱們理所固然就以Redis的知識點來延伸。分佈式
先介紹下Redis的幾個命令,ide
一、SETNX,用法是SETNX key value
SETNX是『 SET if Not eXists』(若是不存在,則 SET)的簡寫,設置成功就返回1,不然返回0。
能夠看出,當把key爲lock的值設置爲"Java"後,再設置成別的值就會失敗,看上去很簡單,也好像獨佔了鎖,但有個致命的問題,就是key沒有過時時間,這樣一來,除非手動刪除key或者獲取鎖後設置過時時間,否則其餘線程永遠拿不到鎖。
既然這樣,咱們給key加個過時時間總能夠吧,直接讓線程獲取鎖的時候執行兩步操做:
SETNX Key 1 EXPIRE Key Seconds
這個方案也有問題,由於獲取鎖和設置過時時間分紅兩步了,不是原子性操做,有可能獲取鎖成功但設置時間失敗,那樣不就白乾了嗎。
不過也不用急,這種事情Redis官方早爲咱們考慮到了,因此就引出了下面這個命令
二、SETEX,用法SETEX key seconds value
將值 value
關聯到 key
,並將 key
的生存時間設爲 seconds
(以秒爲單位)。若是 key
已經存在,SETEX 命令將覆寫舊值。
這個命令相似於如下兩個命令:
SET key value EXPIRE key seconds # 設置生存時間
這兩步動做是原子性的,會在同一時間完成。
三、PSETEX ,用法PSETEX key milliseconds value
這個命令和SETEX命令類似,但它以毫秒爲單位設置 key
的生存時間,而不是像SETEX命令那樣,以秒爲單位。
不過,從Redis 2.6.12 版本開始,SET命令能夠經過參數來實現和SETNX、SETEX、PSETEX 三個命令相同的效果。
就好比這條命令
SET key value NX EX seconds
加上NX、EX參數後,效果就至關於SETEX,這也是Redis獲取鎖寫法裏面最多見的。
釋放鎖的命令就簡單了,直接刪除key就行,但咱們前面說了,由於分佈式鎖必須由鎖的持有者本身釋放,因此咱們必須先確保當前釋放鎖的線程是持有者,沒問題了再刪除,這樣一來,就變成兩個步驟了,彷佛又違背了原子性了,怎麼辦呢?
不慌,咱們能夠用lua腳本把兩步操做作拼裝,就好像這樣:
if redis.call("get",KEYS[1]) == ARGV[1] then return redis.call("del",KEYS[1]) else return 0 end
KEYS[1]是當前key的名稱,ARGV[1]能夠是當前線程的ID(或者其餘不固定的值,能識別所屬線程便可),這樣就能夠防止持有過時鎖的線程,或者其餘線程誤刪現有鎖的狀況出現。
知道了原理後,咱們就能夠手寫代碼來實現Redis分佈式鎖的功能了,由於本文的目的主要是爲了講解原理,不是爲了教你們怎麼寫分佈式鎖,因此我就用僞代碼實現了。
首先是redis鎖的工具類,包含了加鎖和解鎖的基礎方法:
public class RedisLockUtil { private String LOCK_KEY = "redis_lock"; // key的持有時間,5ms private long EXPIRE_TIME = 5; // 等待超時時間,1s private long TIME_OUT = 1000; // redis命令參數,至關於nx和px的命令合集 private SetParams params = SetParams.setParams().nx().px(EXPIRE_TIME); // redis鏈接池,連的是本地的redis客戶端 JedisPool jedisPool = new JedisPool("127.0.0.1", 6379); /** * 加鎖 * * @param id * 線程的id,或者其餘可識別當前線程且不重複的字段 * @return */ public boolean lock(String id) { Long start = System.currentTimeMillis(); Jedis jedis = jedisPool.getResource(); try { for (;;) { // SET命令返回OK ,則證實獲取鎖成功 String lock = jedis.set(LOCK_KEY, id, params); if ("OK".equals(lock)) { return true; } // 不然循環等待,在TIME_OUT時間內仍未獲取到鎖,則獲取失敗 long l = System.currentTimeMillis() - start; if (l >= TIME_OUT) { return false; } try { // 休眠一會,否則反覆執行循環會一直失敗 Thread.sleep(100); } catch (InterruptedException e) { e.printStackTrace(); } } } finally { jedis.close(); } } /** * 解鎖 * * @param id * 線程的id,或者其餘可識別當前線程且不重複的字段 * @return */ public boolean unlock(String id) { Jedis jedis = jedisPool.getResource(); // 刪除key的lua腳本 String script = "if redis.call('get',KEYS[1]) == ARGV[1] then" + " return redis.call('del',KEYS[1]) " + "else" + " return 0 " + "end"; try { String result = jedis.eval(script, Collections.singletonList(LOCK_KEY), Collections.singletonList(id)).toString(); return "1".equals(result); } finally { jedis.close(); } } }
具體的代碼做用註釋已經寫得很清楚了,而後咱們就能夠寫一個demo類來測試一下效果:
public class RedisLockTest { private static RedisLockUtil demo = new RedisLockUtil(); private static Integer NUM = 101; public static void main(String[] args) { for (int i = 0; i < 100; i++) { new Thread(() -> { String id = Thread.currentThread().getId() + ""; boolean isLock = demo.lock(id); try { // 拿到鎖的話,就對共享參數減一 if (isLock) { NUM--; System.out.println(NUM); } } finally { // 釋放鎖必定要注意放在finally demo.unlock(id); } }).start(); } } }
咱們建立100個線程來模擬併發的狀況,執行後的結果是這樣的:
能夠看出,鎖的效果達到了,線程安全是能夠保證的。
固然,上面的代碼只是簡單的實現了效果,功能確定是不完整的,一個健全的分佈式鎖要考慮的方面還有不少,實際設計起來不是那麼容易的。
咱們的目的只是爲了學習和了解原理,手寫一個工業級的分佈式鎖工具不現實,也不必,相似的開源工具一大堆(Redisson),原理都差很少,並且早已通過業界同行的檢驗,直接拿來用就行。
雖然功能是實現了,但其實從設計上來講,這樣的分佈式鎖存在着很大的缺陷,這也是本篇文章想重點探討的內容。
1、客戶端長時間阻塞致使鎖失效問題
客戶端1獲得了鎖,由於網絡問題或者GC等緣由致使長時間阻塞,而後業務程序還沒執行完鎖就過時了,這時候客戶端2也能正常拿到鎖,可能會致使線程安全的問題。
那麼該如何防止這樣的異常呢?咱們先不說解決方案,介紹完其餘的缺陷後再來討論。
2、redis服務器時鐘漂移問題
若是redis服務器的機器時鐘發生了向前跳躍,就會致使這個key過早超時失效,好比說客戶端1拿到鎖後,key的過時時間是12:02分,但redis服務器自己的時鐘比客戶端快了2分鐘,致使key在12:00的時候就失效了,這時候,若是客戶端1尚未釋放鎖的話,就可能致使多個客戶端同時持有同一把鎖的問題。
3、單點實例安全問題
若是redis是單master模式的,當這臺機宕機的時候,那麼全部的客戶端都獲取不到鎖了,爲了提升可用性,可能就會給這個master加一個slave,可是由於redis的主從同步是異步進行的,可能會出現客戶端1設置完鎖後,master掛掉,slave提高爲master,由於異步複製的特性,客戶端1設置的鎖丟失了,這時候客戶端2設置鎖也可以成功,致使客戶端1和客戶端2同時擁有鎖。
爲了解決Redis單點問題,redis的做者提出了RedLock算法。
該算法的實現前提在於Redis必須是多節點部署的,能夠有效防止單點故障,具體的實現思路是這樣的:
一、獲取當前時間戳(ms);
二、先設定key的有效時長(TTL),超出這個時間就會自動釋放,而後client(客戶端)嘗試使用相同的key和value對全部redis實例進行設置,每次連接redis實例時設置一個比TTL短不少的超時時間,這是爲了避免要過長時間等待已經關閉的redis服務。而且試着獲取下一個redis實例。
好比:TTL(也就是過時時間)爲5s,那獲取鎖的超時時間就能夠設置成50ms,因此若是50ms內沒法獲取鎖,就放棄獲取這個鎖,從而嘗試獲取下個鎖;
三、client經過獲取全部能獲取的鎖後的時間減去第一步的時間,還有redis服務器的時鐘漂移偏差,而後這個時間差要小於TTL時間而且成功設置鎖的實例數>= N/2 + 1(N爲Redis實例的數量),那麼加鎖成功
好比TTL是5s,鏈接redis獲取全部鎖用了2s,而後再減去時鐘漂移(假設偏差是1s左右),那麼鎖的真正有效時長就只有2s了;
四、若是客戶端因爲某些緣由獲取鎖失敗,便會開始解鎖全部redis實例。
根據這樣的算法,咱們假設有5個Redis實例的話,那麼client只要獲取其中3臺以上的鎖就算是成功了,用流程圖演示大概就像這樣:
好了,算法也介紹完了,從設計上看,毫無疑問,RedLock算法的思想主要是爲了有效防止Redis單點故障的問題,並且在設計TTL的時候也考慮到了服務器時鐘漂移的偏差,讓分佈式鎖的安全性提升了很多。
但事實真的是這樣嗎?反正我我的的話感受效果通常般,
首先第一點,咱們能夠看到,在RedLock算法中,鎖的有效時間會減去鏈接Redis實例的時長,若是這個過程由於網絡問題致使耗時太長的話,那麼最終留給鎖的有效時長就會大大減小,客戶端訪問共享資源的時間很短,極可能程序處理的過程當中鎖就到期了。並且,鎖的有效時間還須要減去服務器的時鐘漂移,可是應該減多少合適呢,要是這個值設置很差,很容易出現問題。
而後第二點,這樣的算法雖然考慮到用多節點來防止Redis單點故障的問題,但但若是有節點發生崩潰重啓的話,仍是有可能出現多個客戶端同時獲取鎖的狀況。
假設一共有5個Redis節點:A、B、C、D、E,客戶端1和2分別加鎖
這樣,客戶端1和客戶端2就同時拿到了鎖,程序安全的隱患依然存在。除此以外,若是這些節點裏面某個節點發生了時間漂移的話,也有可能致使鎖的安全問題。
因此說,雖然經過多實例的部署提升了可用性和可靠性,但RedLock並無徹底解決Redis單點故障存在的隱患,也沒有解決時鐘漂移以及客戶端長時間阻塞而致使的鎖超時失效存在的問題,鎖的安全性隱患依然存在。
有人可能要進一步問了,那該怎麼作才能保證鎖的絕對安全呢?
對此我只能說,魚和熊掌不可兼得,咱們之因此用Redis做爲分佈式鎖的工具,很大程度上是由於Redis自己效率高且單進程的特色,即便在高併發的狀況下也能很好的保證性能,但不少時候,性能和安全不能徹底兼顧,若是你必定要保證鎖的安全性的話,能夠用其餘的中間件如db、zookeeper來作控制,這些工具能很好的保證鎖的安全,但性能方面只能說是差強人意,不然你們早就用上了。
通常來講,用Redis控制共享資源而且還要求數據安全要求較高的話,最終的保底方案是對業務數據作冪等控制,這樣一來,即便出現多個客戶端得到鎖的狀況也不會影響數據的一致性。固然,也不是全部的場景都適合這麼作,具體怎麼取捨就須要各位看官本身處理啦,畢竟,沒有完美的技術,只有適合的纔是最好的。
歡迎你們關注個人公衆號【風平浪靜如碼】,海量Java相關文章,學習資料都會在裏面更新,整理的資料也會放在裏面。
以爲寫的還不錯的就點個贊,加個關注唄!點關注,不迷路,持續更新!!!