1. 程式人生 > >【高併發】高併發分散式鎖架構解密,不是所有的鎖都是分散式鎖!!

【高併發】高併發分散式鎖架構解密,不是所有的鎖都是分散式鎖!!

## 寫在前面 > 最近,很多小夥伴留言說,在學習高併發程式設計時,不太明白分散式鎖是用來解決什麼問題的,還有不少小夥伴甚至連分散式鎖是什麼都不太明白。明明在生產環境上使用了自己開發的分散式鎖,為什麼還會出現問題呢?同樣的程式,加上分散式鎖後,效能差了幾個數量級!這又是為什麼呢?今天,我們就來說說如何在高併發環境下實現分散式鎖,不是所有的鎖都是高併發的。 > > 萬字長文,帶你深入解密高併發環境下的分散式鎖架構,不是所有的鎖都是分散式鎖!!! 究竟什麼樣的鎖才能更好的支援高併發場景呢?今天,我們就一起解密高併發環境下典型的分散式鎖架構,結合【高併發】專題下的其他文章,學以致用。 ## 鎖用來解決什麼問題呢? 在我們編寫的應用程式或者高併發程式中,不知道大家有沒有想過一個問題,就是我們為什麼需要引入鎖?鎖為我們解決了什麼問題呢? 在很多業務場景下,我們編寫的應用程式中會存在很多的 **資源競爭** 的問題。而我們在高併發程式中,引入鎖,就是為了解決這些資源競爭的問題。 ## 電商超賣問題 這裡,我們可以列舉一個簡單的業務場景。比如,在電子商務(商城)的業務場景中,提交訂單購買商品時,首先需要查詢相應商品的庫存是否足夠,只有在商品庫存數量足夠的前提下,才能讓使用者成功的下單。下單時,我們需要在庫存數量中減去使用者下單的商品數量,並將庫存操作的結果資料更新到資料庫中。整個流程我們可以簡化成下圖所示。 ![](https://img2020.cnblogs.com/blog/1729473/202004/1729473-20200426130421613-1663990610.jpg) 很多小夥伴也留言說,讓我給出程式碼,這樣能夠更好的學習和掌握相關的知識。好吧,這裡,我也給出相應的程式碼片段吧。我們可以使用下面的程式碼片段來表示使用者的下單操作,我這裡將商品的庫存資訊儲存在了Redis中。 ```java @RequestMapping("/submitOrder") public String submitOrder(){ int stock = Integer.parseInt(stringRedisTemplate.opsForValue().get("stock")); if(stock > 0){ stock -= 1; stringRedisTemplate.opsForValue().set("stock", String.valueOf(stock)); logger.debug("庫存扣減成功,當前庫存為:{}", stock); }else{ logger.debug("庫存不足,扣減庫存失敗"); throw new OrderException("庫存不足,扣減庫存失敗"); } return "success"; } ``` **注意:上述程式碼片段比較簡單,只是為了方便大家理解,真正專案中的程式碼就不能這麼寫了。** 上述的程式碼看似是沒啥問題的,但是我們不能只從程式碼表面上來觀察程式碼的執行順序。這是因為在JVM中程式碼的執行順序未必是按照我們書寫程式碼的順序執行的。即使在JVM中程式碼是按照我們書寫的順序執行,那我們對外提供的介面一旦暴露出去,就會有成千上萬的客戶端來訪問我們的介面。所以說,我們暴露出去的介面是會被併發訪問的。 **試問,上面的程式碼在高併發環境下是執行緒安全的嗎?答案肯定不是執行緒安全的,因為上述扣減庫存的操作會出現並行執行的情況。** 我們可以使用Apache JMeter來對上述介面進行測試,這裡,我使用Apache JMeter對上述介面進行測試。 ![](https://img2020.cnblogs.com/blog/1729473/202004/1729473-20200426130443450-523513685.jpg) 在Jmeter中,我將執行緒的併發度設定為3,接下來的配置如下所示。 ![](https://img2020.cnblogs.com/blog/1729473/202004/1729473-20200426130456591-206282800.jpg) 以HTTP GET請求的方式來併發訪問提交訂單的介面。此時,執行JMeter來訪問介面,命令列會打印出下面的日誌資訊。 ```bash 庫存扣減成功,當前庫存為:49 庫存扣減成功,當前庫存為:49 庫存扣減成功,當前庫存為:49 ``` 這裡,我們明明請求了3次,也就是說,提交了3筆訂單,為什麼扣減後的庫存都是一樣的呢?這種現象在電商領域有一個專業的名詞叫做 **“超賣”** 。 如果一個大型的高併發電商系統,比如淘寶、天貓、京東等,出現了超賣現象,那損失就無法估量了!架構設計和開發電商系統的人員估計就要通通下崗了。所以,**作為技術人員,我們一定要嚴謹的對待技術,嚴格做好系統的每一個技術環節。** ## JVM中提供的鎖 JVM中提供的synchronized和Lock鎖,相信大家並不陌生了,很多小夥伴都會使用這些鎖,也能使用這些鎖來實現一些簡單的執行緒互斥功能。那麼,作為立志要成為架構師的你,是否瞭解過JVM鎖的底層原理呢? ### JVM鎖原理 說到JVM鎖的原理,我們就不得不限說說Java中的物件頭了。 **Java中的物件頭** > 每個Java物件都有物件頭。如果是⾮陣列型別,則⽤2個字寬來儲存物件頭,如果是陣列,則會⽤3個字寬來儲存物件頭。在32位處理器中,⼀個字寬是32位;在64位虛擬機器中,⼀個字寬是64位。 物件頭的內容如下表 。 | 長度 | 內容 | 說明 | | -------- | --------------------- | ---------------------------- | | 32/64bit | Mark Word | 儲存物件的hashCode或鎖資訊等 | | 32/64bit | Class Metadata Access | 儲存到物件型別資料的指標 | | 32/64bit | Array length | 陣列的長度(如果是陣列) | Mark Work的格式如下所示。 | 鎖狀態 | 29bit或61bit | 1bit是否是偏向鎖? | 2bit鎖標誌位 | | -------- | ---------------------------- | -------------------------- | ------------ | | 無鎖 | | 0 | 01 | | 偏向鎖 | 執行緒ID | 1 | 01 | | 輕量級鎖 | 指向棧中鎖記錄的指標 | 此時這一位不用於標識偏向鎖 | 00 | | 重量級鎖 | 指向互斥量(重量級鎖)的指標 | 此時這一位不用於標識偏向鎖 | 10 | | GC標記 | | 此時這一位不用於標識偏向鎖 | 11 | 可以看到,當物件狀態為偏向鎖時, Mark Word 儲存的是偏向的執行緒ID;當狀態為輕量級鎖時, Mark Word 儲存的是指向執行緒棧中 Lock Record 的指標;當狀態為重量級鎖時, Mark Word 為指向堆中的monitor物件的指標 。 > 有關Java物件頭的知識,參考《深入淺出Java多執行緒》。 **JVM鎖原理** 簡單點來說,JVM中鎖的原理如下。 在Java物件的物件頭上,有一個鎖的標記,比如,第一個執行緒執行程式時,檢查Java物件頭中的鎖標記,發現Java物件頭中的鎖標記為未加鎖狀態,於是為Java物件進行了加鎖操作,將物件頭中的鎖標記設定為鎖定狀態。第二個執行緒執行同樣的程式時,也會檢查Java物件頭中的鎖標記,此時會發現Java物件頭中的鎖標記的狀態為鎖定狀態。於是,第二個執行緒會進入相應的阻塞佇列中進行等待。 **這裡有一個關鍵點就是Java物件頭中的鎖標記如何實現。** ### JVM鎖的短板 JVM中提供的synchronized和Lock鎖都是JVM級別的,大家都知道,當執行一個Java程式時,會啟動一個JVM程序來執行我們的應用程式。synchronized和Lock在JVM級別有效,也就是說,synchronized和Lock在同一Java程序內有效。如果我們開發的應用程式是分散式的,那麼只是使用synchronized和Lock來解決分散式場景下的高併發問題,就會顯得有點力不從心了。 **synchronized和Lock支援JVM同一程序內部的執行緒互斥** synchronized和Lock在JVM級別能夠保證高併發程式的互斥,我們可以使用下圖來表示。 ![](https://img2020.cnblogs.com/blog/1729473/202004/1729473-20200426130517784-631375840.jpg) 但是,當我們將應用程式部署成分散式架構,或者將應用程式在不同的JVM程序中執行時,synchronized和Lock就不能保證分散式架構和多JVM程序下應用程式的互斥性了。 **synchronized和Lock不能實現多JVM程序之間的執行緒互斥** 分散式架構和多JVM程序的本質都是將應用程式部署在不同的JVM例項中,也就是說,其本質還是多JVM程序。 ![](https://img2020.cnblogs.com/blog/1729473/202004/1729473-20200426130532951-971577515.jpg) ## 分散式鎖 我們在實現分散式鎖時,可以參照JVM鎖實現的思想,JVM鎖在為物件加鎖時,通過改變Java物件的物件頭中的鎖的標誌位來實現,也就是說,所有的執行緒都會訪問這個Java物件的物件頭中的鎖標誌位。 ![](https://img2020.cnblogs.com/blog/1729473/202004/1729473-20200426130548502-902715275.jpg) 我們同樣以這種思想來實現分散式鎖,當我們將應用程式進行拆分並部署成分散式架構時,所有應用程式中的執行緒訪問共享變數時,都到同一個地方去檢查當前程式的臨界區是否進行了加鎖操作,而是否進行了加鎖操作,我們在統一的地方使用相應的狀態來進行標記。 ![](https://img2020.cnblogs.com/blog/1729473/202004/1729473-20200426130602086-1758625275.jpg) 可以看到,在分散式鎖的實現思想上,與JVM鎖相差不大。而在實現分散式鎖中,**儲存加鎖狀態的服務可以使用MySQL、Redis和Zookeeper實現。** 但是,在網際網路高併發環境中, **使用Redis實現分散式鎖的方案是使用的最多的。** 接下來,我們就使用Redis來深入解密分散式鎖的架構設計。 ## Redis如何實現分散式鎖 ### Redis命令 在Redis中,有一個不常使用的命令如下所示。 ```bash SETNX key value ``` 這條命令的含義就是“SET if Not Exists”,即不存在的時候才會設定值。 只有在key不存在的情況下,將鍵key的值設定為value。如果key已經存在,則SETNX命令不做任何操作。 這個命令的返回值如下。 * 命令在設定成功時返回1。 * 命令在設定失敗時返回0。 所以,我們在分散式高併發環境下,可以使用Redis的SETNX命令來實現分散式鎖。假設此時有執行緒A和執行緒B同時訪問臨界區程式碼,假設執行緒A首先執行了SETNX命令,並返回結果1,繼續向下執行。而此時執行緒B再次執行SETNX命令時,返回的結果為0,則執行緒B不能繼續向下執行。只有當執行緒A執行DELETE命令將設定的鎖狀態刪除時,執行緒B才會成功執行SETNX命令設定加鎖狀態後繼續向下執行。 ### 引入分散式鎖 瞭解瞭如何使用Redis中的命令實現分散式鎖後,我們就可以對下單介面進行改造了,加入分散式鎖,如下所示。 ```java /** * 為了演示方便,我這裡就簡單定義了一個常量作為商品的id * 實際工作中,這個商品id是前端進行下單操作傳遞過來的引數 */ public static final String PRODUCT_ID = "100001"; @RequestMapping("/submitOrder") public String submitOrder(){ //通過stringRedisTemplate來呼叫Redis的SETNX命令,key為商品的id,value為字串“binghe” //實際上,value可以為任意的字元換 Boolean isLocked = stringRedisTemplate.opsForValue().setIfAbsent(PRODUCT_ID, "binghe"); //沒有拿到鎖,返回下單失敗 if(!isLock){ return "failure"; } int stock = Integer.parseInt(stringRedisTemplate.opsForValue().get("stock")); if(stock > 0){ stock -= 1; stringRedisTemplate.opsForValue().set("stock", String.valueOf(stock)); logger.debug("庫存扣減成功,當前庫存為:{}", stock); }else{ logger.debug("庫存不足,扣減庫存失敗"); throw new OrderException("庫存不足,扣減庫存失敗"); } //業務執行完成,刪除PRODUCT_ID key stringRedisTemplate.delete(PRODUCT_ID); return "success"; } ``` 那麼,在上述程式碼中,我們加入了分散式鎖的操作,那上述程式碼是否能夠在高併發場景下保證業務的原子性呢?答案是可以保證業務的原子性。但是,**在實際場景中,上面實現分散式鎖的程式碼是不可用的!!** 假設當執行緒A首先執行stringRedisTemplate.opsForValue()的setIfAbsent()方法返回true,繼續向下執行,正在執行業務程式碼時,丟擲了異常,執行緒A直接退出了JVM。此時,stringRedisTemplate.delete(PRODUCT_ID);程式碼還沒來得及執行,之後所有的執行緒進入提交訂單的方法時,呼叫stringRedisTemplate.opsForValue()的setIfAbsent()方法都會返回false。導致後續的所有下單操作都會失敗。**這就是分散式場景下的死鎖問題。** **所以,上述程式碼中實現分散式鎖的方式在實際場景下是不可取的!!** ### 引入try-finally程式碼塊 說到這,相信小夥伴們都能夠想到,使用try-finall程式碼塊啊,接下來,我們為下單介面的方法加上try-finally程式碼塊。 ```java /** * 為了演示方便,我這裡就簡單定義了一個常量作為商品的id * 實際工作中,這個商品id是前端進行下單操作傳遞過來的引數 */ public static final String PRODUCT_ID = "100001"; @RequestMapping("/submitOrder") public String submitOrder(){ //通過stringRedisTemplate來呼叫Redis的SETNX命令,key為商品的id,value為字串“binghe” //實際上,value可以為任意的字元換 Boolean isLocked = stringRedisTemplate.opsForValue().setIfAbsent(PRODUCT_ID, "binghe"); //沒有拿到鎖,返回下單失敗 if(!isLock){ return "failure"; } try{ int stock = Integer.parseInt(stringRedisTemplate.opsForValue().get("stock")); if(stock > 0){ stock -= 1; stringRedisTemplate.opsForValue().set("stock", String.valueOf(stock)); logger.debug("庫存扣減成功,當前庫存為:{}", stock); }else{ logger.debug("庫存不足,扣減庫存失敗"); throw new OrderException("庫存不足,扣減庫存失敗"); } }finally{ //業務執行完成,刪除PRODUCT_ID key stringRedisTemplate.delete(PRODUCT_ID); } return "success"; } ``` 那麼,上述程式碼是否真正解決了死鎖的問題呢?我們在寫程式碼時,不能只盯著程式碼本身,覺得上述程式碼沒啥問題了。實際上,生產環境是非常複雜的。如果執行緒在成功加鎖之後,執行業務程式碼時,還沒來得及執行刪除鎖標誌的程式碼,此時,伺服器宕機了,程式並沒有優雅的退出JVM。也會使得後續的執行緒進入提交訂單的方法時,因無法成功的設定鎖標誌位而下單失敗。所以說,上述的程式碼仍然存在問題。 ### 引入Redis超時機制 在Redis中可以設定快取的自動過期時間,我們可以將其引入到分散式鎖的實現中,如下程式碼所示。 ```java /** * 為了演示方便,我這裡就簡單定義了一個常量作為商品的id * 實際工作中,這個商品id是前端進行下單操作傳遞過來的引數 */ public static final String PRODUCT_ID = "100001"; @RequestMapping("/submitOrder") public String submitOrder(){ //通過stringRedisTemplate來呼叫Redis的SETNX命令,key為商品的id,value為字串“binghe” //實際上,value可以為任意的字元換 Boolean isLocked = stringRedisTemplate.opsForValue().setIfAbsent(PRODUCT_ID, "binghe"); //沒有拿到鎖,返回下單失敗 if(!isLock){ return "failure"; } try{ stringRedisTemplate.expire(PRODUCT_ID, 30, TimeUnit.SECONDS); int stock = Integer.parseInt(stringRedisTemplate.opsForValue().get("stock")); if(stock > 0){ stock -= 1; stringRedisTemplate.opsForValue().set("stock", String.valueOf(stock)); logger.debug("庫存扣減成功,當前庫存為:{}", stock); }else{ logger.debug("庫存不足,扣減庫存失敗"); throw new OrderException("庫存不足,扣減庫存失敗"); } }finally{ //業務執行完成,刪除PRODUCT_ID key stringRedisTemplate.delete(PRODUCT_ID); } return "success"; } ``` 在上述程式碼中,我們加入瞭如下一行程式碼來為Redis中的鎖標誌設定過期時間。 ```java stringRedisTemplate.expire(PRODUCT_ID, 30, TimeUnit.SECONDS); ``` 此時,我們設定的過期時間為30秒。 那麼問題來了,這樣是否就真正的解決了問題呢?上述程式就真的沒有坑了嗎?**答案是還是有坑的!!** ### “坑位”分析 我們在下單操作的方法中為分散式鎖引入了超時機制,此時的程式碼還是無法真正避免死鎖的問題,那“坑位”到底在哪裡呢?試想,當程式執行完stringRedisTemplate.opsForValue().setIfAbsent()方法後,正要執行stringRedisTemplate.expire(PRODUCT_ID, 30, TimeUnit.SECONDS)程式碼時,伺服器宕機了,你還別說,生產壞境的情況非常複雜,就是這麼巧,伺服器就宕機了。此時,後續請求進入提交訂單的方法時,都會因為無法成功設定鎖標誌而導致後續下單流程無法正常執行。 既然我們找到了上述程式碼的“坑位”,那我們如何將這個”坑“填上?如何解決這個問題呢?別急,Redis已經提供了這樣的功能。我們可以在向Redis中儲存資料的時候,可以同時指定資料的超時時間。所以,我們可以將程式碼改造成如下所示。 ```java /** * 為了演示方便,我這裡就簡單定義了一個常量作為商品的id * 實際工作中,這個商品id是前端進行下單操作傳遞過來的引數 */ public static final String PRODUCT_ID = "100001"; @RequestMapping("/submitOrder") public String submitOrder(){ //通過stringRedisTemplate來呼叫Redis的SETNX命令,key為商品的id,value為字串“binghe” //實際上,value可以為任意的字元換 Boolean isLocked = stringRedisTemplate.opsForValue().setIfAbsent(PRODUCT_ID, "binghe", 30, TimeUnit.SECONDS); //沒有拿到鎖,返回下單失敗 if(!isLock){ return "failure"; } try{ int stock = Integer.parseInt(stringRedisTemplate.opsForValue().get("stock")); if(stock > 0){ stock -= 1; stringRedisTemplate.opsForValue().set("stock", String.valueOf(stock)); logger.debug("庫存扣減成功,當前庫存為:{}", stock); }else{ logger.debug("庫存不足,扣減庫存失敗"); throw new OrderException("庫存不足,扣減庫存失敗"); } }finally{ //業務執行完成,刪除PRODUCT_ID key stringRedisTemplate.delete(PRODUCT_ID); } return "success"; } ``` 在上述程式碼中,我們在向Redis中設定鎖標誌位的時候就設定了超時時間。此時,只要向Redis中成功設定了資料,則即使我們的業務系統宕機,Redis中的資料過期後,也會自動刪除。後續的執行緒進入提交訂單的方法後,就會成功的設定鎖標誌位,並向下執行正常的下單流程。 到此,上述的程式碼基本上在功能角度解決了程式的死鎖問題,那麼,上述程式真的就完美了嗎?哈哈,很多小夥伴肯定會說不完美!確實,上面的程式碼還不是完美的,那大家知道哪裡不完美嗎?接下來,我們繼續分析。 ### 在開發整合角度分析程式碼 在我們開發公共的系統元件時,比如我們這裡說的分散式鎖,我們肯定會抽取一些公共的類來完成相應的功能來供系統使用。 這裡,假設我們定義了一個RedisLock介面,如下所示。 ```java public interface RedisLock{ //加鎖操作 boolean tryLock(String key, long timeout, TimeUnit unit); //解鎖操作 void releaseLock(String key); } ``` 接下來,使用RedisLockImpl類實現RedisLock介面,提供具體的加鎖和解鎖實現,如下所示。 ```java public class RedisLockImpl implements RedisLock{ @Autowired private StringRedisTemplate stringRedisTemplate; @Override public boolean tryLock(String key, long timeout, TimeUnit unit){ return stringRedisTemplate.opsForValue().setIfAbsent(key, "binghe", timeout, unit); } @Override public void releaseLock(String key){ stringRedisTemplate.delete(key); } } ``` 在開發整合的角度來說,當一個執行緒從上到下執行時,首先對程式進行加鎖操作,然後執行業務程式碼,執行完成後,再進行釋放鎖的操作。理論上,加鎖和釋放鎖時,操作的Redis Key都是一樣的。但是,如果其他開發人員在編寫程式碼時,並沒有呼叫tryLock()方法,而是直接呼叫了releaseLock()方法,並且他呼叫releaseLock()方法傳遞的key與你呼叫tryLock()方法傳遞的key是一樣的。那此時就會出現問題了,他在編寫程式碼時,硬生生的將你加的鎖釋放了!!! 所以,上述程式碼是不安全的,別人能夠隨隨便便的將你加的鎖刪除,這就是鎖的誤刪操作,這是非常危險的,所以,上述的程式存在很嚴重的問題!! **那如何實現只有加鎖的執行緒才能進行相應的解鎖操作呢?** 繼續向下看。 ### 如何實現加鎖和解鎖的歸一化? 什麼是加鎖和解鎖的歸一化呢?簡單點來說,就是一個執行緒執行了加鎖操作後,後續必須由這個執行緒執行解鎖操作,加鎖和解鎖操作由同一個執行緒來完成。 為了解決只有加鎖的執行緒才能進行相應的解鎖操作的問題,那麼,我們就需要將加鎖和解鎖操作繫結到同一個執行緒中,那麼,如何將加鎖操作和解鎖操作繫結到同一個執行緒呢?其實很簡單,相信很多小夥伴都想到了—— **使用ThreadLocal實現** 。沒錯,使用ThreadLocal類確實能夠解決這個問題。 此時,我們將RedisLockImpl類的程式碼修改成如下所示。 ```java public class RedisLockImpl implements RedisLock{ @Autowired private StringRedisTemplate stringRedisTemplate; private ThreadLocal