拓展閱讀:Redis閒談(1):構建知識圖譜html
近來,分佈式的問題被普遍說起,好比分佈式事務、分佈式框架、ZooKeeper、SpringCloud等等。本文先回顧鎖的概念,再介紹分佈式鎖,以及如何用Redis來實現分佈式鎖。數據庫
首先,回顧一下咱們工做學習中的鎖的概念。緩存
爲何要先講鎖再講分佈式鎖呢?安全
咱們都清楚,鎖的做用是要解決多線程對共享資源的訪問而產生的線程安全問題,而在平時生活中用到鎖的狀況其實並很少,可能有些朋友對鎖的概念和一些基本的使用不是很清楚,因此咱們先看鎖,再深刻介紹分佈式鎖。性能優化
經過一個賣票的小案例來看,好比你們去搶dota2 ti9門票,若是不加鎖的話會出現什麼問題?此時代碼以下:數據結構
package Thread; import java.util.concurrent.TimeUnit; public class Ticket { /** * 初始庫存量 * */ Integer ticketNum = 8; public void reduce(int num){ //判斷庫存是否夠用 if((ticketNum - num) >= 0){ try { TimeUnit.MILLISECONDS.sleep(200); }catch (InterruptedException e){ e.printStackTrace(); } ticketNum -= num; System.out.println(Thread.currentThread().getName() + "成功賣出" + num + "張,剩餘" + ticketNum + "張票"); }else { System.err.println(Thread.currentThread().getName() + "沒有賣出" + num + "張,剩餘" + ticketNum + "張票"); } } public static void main(String[] args) throws InterruptedException{ Ticket ticket = new Ticket(); //開啓10個線程進行搶票,按理說應該有兩我的搶不到票 for(int i=0;i<10;i++){ new Thread(() -> ticket.reduce(1),"用戶" + (i + 1)).start(); } Thread.sleep(1000L); } }
代碼分析:這裏有8張ti9門票,設置了10個線程(也就是模擬10我的)去併發搶票,若是搶成功了顯示成功,搶失敗的話顯示失敗。按理說應該有8我的搶成功了,2我的搶失敗,下面來看運行結果:多線程
咱們發現運行結果和預期的狀況不一致,竟然10我的都買到了票,也就是說出現了線程安全的問題,那麼是什麼緣由致使的呢?併發
緣由就是多個線程之間產生了時間差。框架
如圖所示,只剩一張票了,可是兩個線程都讀到的票餘量是1,也就是說線程B尚未等到線程A改庫存就已經搶票成功了。
怎麼解決呢?想必你們都知道,加個synchronized關鍵字就能夠了,在一個線程進行reduce方法的時候,其餘線程則阻塞在等待隊列中,這樣就不會發生多個線程對共享變量的競爭問題。
舉個例子
好比咱們去健身房健身,若是好多人同時用一臺機器,同時在一臺跑步機上跑步,就會發生很大的問題,你們會打得不可開交。若是咱們加一把鎖在健身房門口,只有拿到鎖的鑰匙的人才能夠進去鍛鍊,其餘人在門外等候,這樣就能夠避免你們對健身器材的競爭。代碼以下:
public synchronized void reduce(int num){ //判斷庫存是否夠用 if((ticketNum - num) >= 0){ try { TimeUnit.MILLISECONDS.sleep(200); }catch (InterruptedException e){ e.printStackTrace(); } ticketNum -= num; System.out.println(Thread.currentThread().getName() + "成功賣出" + num + "張,剩餘" + ticketNum + "張票"); }else { System.err.println(Thread.currentThread().getName() + "沒有賣出" + num + "張,剩餘" + ticketNum + "張票"); } }
運行結果:
果不其然,結果有兩我的沒有成功搶到票,看來咱們的目地達成了。
事實上,按照咱們對平常生活的理解,不可能整個健身房只有一我的在運動。因此咱們只須要對某一臺機器加鎖就能夠了,好比一我的在跑步,另外一我的能夠去作其餘的運動。
對於票務系統來講,咱們只須要對庫存的修改操做的代碼加鎖就能夠了,別的代碼仍是能夠並行進行,這樣會大大減小鎖的持有時間,代碼修改以下:
public void reduceByLock(int num){ boolean flag = false; synchronized (ticketNum){ if((ticketNum - num) >= 0){ ticketNum -= num; flag = true; } } if(flag){ System.out.println(Thread.currentThread().getName() + "成功賣出" + num + "張,剩餘" + ticketNum + "張票"); } else { System.err.println(Thread.currentThread().getName() + "沒有賣出" + num + "張,剩餘" + ticketNum + "張票"); } if(ticketNum == 0){ System.out.println("耗時" + (System.currentTimeMillis() - startTime) + "毫秒"); } }
這樣作的目的是充分利用cpu的資源,提升代碼的執行效率。
這裏咱們對兩種方式的時間作個打印:
public synchronized void reduce(int num){ //判斷庫存是否夠用 if((ticketNum - num) >= 0){ try { TimeUnit.MILLISECONDS.sleep(200); }catch (InterruptedException e){ e.printStackTrace(); } ticketNum -= num; if(ticketNum == 0){ System.out.println("耗時" + (System.currentTimeMillis() - startTime) + "毫秒"); } System.out.println(Thread.currentThread().getName() + "成功賣出" + num + "張,剩餘" + ticketNum + "張票"); }else { System.err.println(Thread.currentThread().getName() + "沒有賣出" + num + "張,剩餘" + ticketNum + "張票"); } }
果真,只對部分代碼加鎖會大大提供代碼的執行效率。
因此,在解決了線程安全的問題後,咱們還要考慮到加鎖以後的代碼執行效率問題。
舉個例子,有兩場電影,分別是最近剛上映的魔童哪吒和蜘蛛俠,咱們模擬一個支付購買的過程,讓方法等待,加了一個CountDownLatch的await方法,運行結果以下:
package Thread; import java.util.concurrent.CountDownLatch; public class Movie { private final CountDownLatch latch = new CountDownLatch(1); //魔童哪吒 private Integer babyTickets = 20; //蜘蛛俠 private Integer spiderTickets = 100; public synchronized void showBabyTickets() throws InterruptedException{ System.out.println("魔童哪吒的剩餘票數爲:" + babyTickets); //購買 latch.await(); } public synchronized void showSpiderTickets() throws InterruptedException{ System.out.println("蜘蛛俠的剩餘票數爲:" + spiderTickets); //購買 } public static void main(String[] args) { Movie movie = new Movie(); new Thread(() -> { try { movie.showBabyTickets(); }catch (InterruptedException e){ e.printStackTrace(); } },"用戶A").start(); new Thread(() -> { try { movie.showSpiderTickets(); }catch (InterruptedException e){ e.printStackTrace(); } },"用戶B").start(); } }
執行結果:
魔童哪吒的剩餘票數爲:20
咱們發現買哪吒票的時候阻塞會影響蜘蛛俠票的購買,而實際上,這兩場電影之間是相互獨立的,因此咱們須要減小鎖的粒度,將movie整個對象的鎖變爲兩個全局變量的鎖,修改代碼以下:
public void showBabyTickets() throws InterruptedException{ synchronized (babyTickets) { System.out.println("魔童哪吒的剩餘票數爲:" + babyTickets); //購買 latch.await(); } } public void showSpiderTickets() throws InterruptedException{ synchronized (spiderTickets) { System.out.println("蜘蛛俠的剩餘票數爲:" + spiderTickets); //購買 } }
執行結果:
魔童哪吒的剩餘票數爲:20 蜘蛛俠的剩餘票數爲:100
如今兩場電影的購票不會互相影響了,這就是第二個優化鎖的方式:減小鎖的粒度。順便提一句,Java併發包裏的ConcurrentHashMap就是把一把大鎖變成了16把小鎖,經過分段鎖的方式達到高效的併發安全。
鎖分離就是常說的讀寫分離,咱們把鎖分紅讀鎖和寫鎖,讀的鎖不須要阻塞,而寫的鎖要考慮併發問題。
這裏就不一一講述每一種鎖的概念了,你們能夠本身學習,鎖還能夠按照偏向鎖、輕量級鎖、重量級鎖來分類。
瞭解了鎖的基本概念和鎖的優化後,重點介紹分佈式鎖的概念。
上圖所示是咱們搭建的分佈式環境,有三個購票項目,對應一個庫存,每個系統會有多個線程,和上文同樣,對庫存的修改操做加上鎖,能不能保證這6個線程的線程安全呢?
固然是不能的,由於每個購票系統都有各自的JVM進程,互相獨立,因此加synchronized只能保證一個系統的線程安全,並不能保證分佈式的線程安全。
因此須要對於三個系統都是公共的一箇中間件來解決這個問題。
這裏咱們選擇Redis來做爲分佈式鎖,多個系統在Redis中set同一個key,只有key不存在的時候,才能設置成功,而且該key會對應其中一個系統的惟一標識,當該系統訪問資源結束後,將key刪除,則達到了釋放鎖的目的。
在任意時刻只有一個客戶端能夠獲取鎖。
這個很容易理解,全部的系統中只能有一個系統持有鎖。
假如一個客戶端在持有鎖的時候崩潰了,沒有釋放鎖,那麼別的客戶端沒法得到鎖,則會形成死鎖,因此要保證客戶端必定會釋放鎖。
Redis中咱們能夠設置鎖的過時時間來保證不會發生死鎖。
解鈴還須繫鈴人,加鎖和解鎖必須是同一個客戶端,客戶端A的線程加的鎖必須是客戶端A的線程來解鎖,客戶端不能解開別的客戶端的鎖。
當一個客戶端獲取對象鎖以後,這個客戶端能夠再次獲取這個對象上的鎖。
Redis分佈式鎖的具體流程:
1)首先利用Redis緩存的性質在Redis中設置一個key-value形式的鍵值對,key就是鎖的名稱,而後客戶端的多個線程去競爭鎖,競爭成功的話將value設爲客戶端的惟一標識。
2)競爭到鎖的客戶端要作兩件事:
須要根據業務須要,不斷的壓力測試來決定有效期的長短。
因此這裏的value就設置成惟一標識(好比uuid)。
3)訪問共享資源
4)釋放鎖,釋放鎖有兩種方式,第一種是有效期結束後自動釋放鎖,第二種是先根據惟一標識判斷本身是否有釋放鎖的權限,若是標識正確則釋放鎖。
1)setnx命令加鎖
set if not exists 咱們會用到Redis的命令setnx,setnx的含義就是隻有鎖不存在的狀況下才會設置成功。
2)設置鎖的有效時間,防止死鎖 expire
加鎖須要兩步操做,思考一下會有什麼問題嗎?
假如咱們加鎖完以後客戶端忽然掛了呢?那麼這個鎖就會成爲一個沒有有效期的鎖,接着就可能發生死鎖。雖然這種狀況發生的機率很小,可是一旦出現問題會很嚴重,因此咱們也要把這兩步合爲一步。
幸運的是,Redis3.0已經把這兩個指令合在一塊兒成爲一個新的指令。
來看jedis的官方文檔中的源碼:
public String set(String key, String value, String nxxx, String expx, long time) { this.checkIsInMultiOrPipeline(); this.client.set(key, value, nxxx, expx, time); return this.client.getStatusCodeReply(); }
這就是咱們想要的!
解鎖也是兩步,一樣也要保證解鎖的原子性,把兩步合爲一步。
這就沒法藉助於Redis了,只能依靠Lua腳本來實現。
if Redis.call("get",key==argv[1])then return Redis.call("del",key) else return 0 end
這就是一段判斷是否本身持有鎖並釋放鎖的Lua腳本。
爲何Lua腳本是原子性呢?由於Lua腳本是jedis用eval()函數執行的,若是執行則會所有執行完成。
public class RedisDistributedLock implements Lock { //上下文,保存當前鎖的持有人id private ThreadLocal<String> lockContext = new ThreadLocal<String>(); //默認鎖的超時時間 private long time = 100; //可重入性 private Thread ownerThread; public RedisDistributedLock() { } public void lock() { while (!tryLock()){ try { Thread.sleep(100); }catch (InterruptedException e){ e.printStackTrace(); } } } public boolean tryLock() { return tryLock(time,TimeUnit.MILLISECONDS); } public boolean tryLock(long time, TimeUnit unit){ String id = UUID.randomUUID().toString(); //每個鎖的持有人都分配一個惟一的id Thread t = Thread.currentThread(); Jedis jedis = new Jedis("127.0.0.1",6379); //只有鎖不存在的時候加鎖並設置鎖的有效時間 if("OK".equals(jedis.set("lock",id, "NX", "PX", unit.toMillis(time)))){ //持有鎖的人的id lockContext.set(id); ① //記錄當前的線程 setOwnerThread(t); ② return true; }else if(ownerThread == t){ //由於鎖是可重入的,因此須要判斷當前線程已經持有鎖的狀況 return true; }else { return false; } } private void setOwnerThread(Thread t){ this.ownerThread = t; } public void unlock() { String script = null; try{ Jedis jedis = new Jedis("127.0.0.1",6379); script = inputStream2String(getClass().getResourceAsStream("/Redis.Lua")); if(lockContext.get()==null){ //沒有人持有鎖 return; } //刪除鎖 ③ jedis.eval(script, Arrays.asList("lock"), Arrays.asList(lockContext.get())); lockContext.remove(); }catch (Exception e){ e.printStackTrace(); } } /** * 將InputStream轉化成String * @param is * @return * @throws IOException */ public String inputStream2String(InputStream is) throws IOException { ByteArrayOutputStream baos = new ByteArrayOutputStream(); int i = -1; while ((i = is.read()) != -1) { baos.write(i); } return baos.toString(); } public void lockInterruptibly() throws InterruptedException { } public Condition newCondition() { return null; } }
1)實現方式
獲取鎖的時候插入一條數據,解鎖時刪除數據。
2)缺點
1)實現方式
加鎖時在指定節點的目錄下建立一個新節點,釋放鎖的時候刪除這個臨時節點。由於有心跳檢測的存在,因此不會發生死鎖,更加安全。
2)缺點
性能通常,沒有Redis高效。
因此:
本文從鎖的基本概念出發,提出多線程訪問共享資源會出現的線程安全問題,而後經過加鎖的方式去解決線程安全的問題,這個方法會性能會降低,須要經過:縮短鎖的持有時間、減少鎖的粒度、鎖分離三種方式去優化鎖。
以後介紹了分佈式鎖的4個特色:
而後用Redis實現了分佈式鎖,加鎖的時候用到了Redis的命令去加鎖,解鎖的時候則藉助了Lua腳原本保證原子性。
最後對比了三種分佈式鎖的優缺點和使用場景。
但願你們對分佈式鎖有新的理解,也但願你們在考慮解決問題的同時要多想一想性能的問題。
做者:楊亨
來源:宜信技術學院