高并发分布式场景下的应用---分布式锁
一. 單體架構(gòu)和垂直應(yīng)用架構(gòu)
很久以前,還在我還年輕的時候大部分包括剛開始的淘寶,還不是亞馬遜中國的卓越等網(wǎng)站,那時候還沒有多少知道上網(wǎng),大部分年輕人還停留在cs局域網(wǎng)對戰(zhàn)的時候,大多國人由于不太清楚上網(wǎng)這個概念時候那時候國內(nèi)興起了一系列的互聯(lián)網(wǎng)公司(活下來的大部分成了行業(yè)巨頭)那時候流量很小,只需要一個基本應(yīng)用,將所有的功能都部署在一起(前端和后端都放在一起),用來減少部署節(jié)點和成本。那時候,主要關(guān)注點就是對業(yè)務(wù)的增刪改查工作
特點:
- 所有的業(yè)務(wù)代碼都放在一起。
- 通過部署應(yīng)用集群和數(shù)據(jù)庫集群來提高系統(tǒng)的性能。
優(yōu)點:
- 項目架構(gòu)簡單,開發(fā)成本低,周期短一人一馬搞定。
缺點:
- 全部代碼放在一個項目中耦合嚴(yán)重,對于往后發(fā)展不適合。
- 修改代碼需要人力,還要去學(xué)習(xí)前任的業(yè)務(wù)邏輯實現(xiàn),成本高。
- 容錯率低。
垂直架構(gòu)
當(dāng)訪問量逐漸增大,功能逐漸復(fù)雜起來,單一應(yīng)用架構(gòu)就顯得有些捉襟見肘,由于所有的功能都寫在同一個工程中,整個工程會越來越龐大越來越臃腫,所以將應(yīng)用拆成互不相干的幾個應(yīng)用,以提升效率。
特點:
- 將項目以單一應(yīng)用架構(gòu)的方式,將一個大應(yīng)用拆分成為幾個互不干擾的應(yīng)用。
- 應(yīng)用于應(yīng)用之間互不相干,功能和數(shù)據(jù)都會存在冗余。
優(yōu)點:
- 通過垂直拆分,防止單體項目無限擴大。
- 系統(tǒng)間相互獨立。
缺點:
- 項目拆分之后,項目與項目之間存在數(shù)據(jù)冗余,耦合性較大。
- 提高系統(tǒng)性能只能通過擴展集群,成本高,并且存在瓶頸。
上述其實就是多應(yīng)用相結(jié)合,但是彼此之間應(yīng)用無關(guān)。
二. 微服務(wù)的出現(xiàn)
當(dāng)垂直應(yīng)用越來越多,應(yīng)用與應(yīng)用之間的交互不可避免,這時需要將核心業(yè)務(wù)抽取出來,作為獨立的服務(wù),逐漸形成穩(wěn)定的服務(wù),使前端應(yīng)用能更快速的響應(yīng)多變的市場需求。
特點:
- 系統(tǒng)服務(wù)層完全獨立,并且抽取成一個一個的獨立服務(wù)。
- 微服務(wù)遵循單一原則。
- 使用服務(wù)中心進行服務(wù)注冊與發(fā)現(xiàn)。
- 每個服務(wù)有自己的獨立數(shù)據(jù)源。
- 微服務(wù)之間采用 RESTful 等輕量協(xié)議傳輸。
優(yōu)點:
- 通過分解巨大單體式應(yīng)用為多個服務(wù)方法解決了復(fù)雜性問題。
- 可以更加精準(zhǔn)的制定每個服務(wù)的優(yōu)化方案,提高系統(tǒng)可維護性。
- 微服務(wù)架構(gòu)模式是每個微服務(wù)獨立的部署,可以使每個服務(wù)獨立擴展。
- 微服務(wù)架構(gòu)采用去中心化思想,服務(wù)之間采用 RESTful 等輕量協(xié)議通信。
缺點:
- 微服務(wù)過多,服務(wù)治理成本高,對系統(tǒng)運維團隊挑戰(zhàn)大。
- 分布式系統(tǒng)開發(fā)的技術(shù)成本高(容錯、分布式事務(wù)等),對團隊挑戰(zhàn)大。
三. 滿足人們?nèi)找嬖鲩L的物質(zhì)文化需要
說完微服務(wù),并不是一蹴而就為用而用,如果只是一個非常小的系統(tǒng),或者只是一個簡單的應(yīng)用程序,后續(xù)不會再進行過多的提升或者是版本是直接替換性質(zhì)的(比如曾經(jīng)的某駕游,1.0-2.0直接就是替換了所有內(nèi)容),這個一般都是適用于剛開始創(chuàng)業(yè)的小微公司,不會開始就直接上大型分布式架構(gòu)系統(tǒng),一方面考慮快速上架,后續(xù)迭代開發(fā);另外一方面也是最重要的就是沒那么多錢!—體會
那成熟的軟件公司尤其是大中型企業(yè)他們本身已經(jīng)經(jīng)歷過洗禮,比如淘寶從開始的PHP—現(xiàn)在的自研等。所以已經(jīng)有了穩(wěn)固的和強大的(資本)流量和目標(biāo)。為此這類型公司早就在行業(yè)內(nèi)抓住了前瞻技術(shù),尤其是業(yè)務(wù)復(fù)雜度和內(nèi)容多樣性的出現(xiàn),使得他們對待系統(tǒng)穩(wěn)定/安全/數(shù)據(jù)一致性等方面都有著很強悍的經(jīng)驗。同樣隨著系統(tǒng)的不斷擴展和內(nèi)容,流量的不斷飆升(甚至可以達到億級),比如雙11,雙12,年中大促,各類所謂的節(jié)等等,如果只是單體或者簡單的微服務(wù)架構(gòu)是肯定不行的,還需要各類所謂的機制保證和相關(guān)服務(wù)的支撐–人力,硬件,運維等等。
總結(jié)一下,微服務(wù)雖然可以通過ddd(領(lǐng)域設(shè)計)來實現(xiàn)一套完整的系統(tǒng),但是還需要其他配合,比如人力/相關(guān)服務(wù)等配合完成。
本次內(nèi)容不談那么大而深的概念和實現(xiàn),而是我們討論一下在這個大型分布式環(huán)境下的一個肯定會用到的內(nèi)容—分布式鎖的應(yīng)用和實現(xiàn)。
四. 分布式鎖
什么是分布式鎖?
如果在一個分布式系統(tǒng)中,我們從數(shù)據(jù)庫中讀取一個數(shù)據(jù),然后修改保存,這種情況很容易遇到并發(fā)問題。因為讀取和更新保存不是一個原子操作,在并發(fā)時就會導(dǎo)致數(shù)據(jù)的不正確。說白一點就是一次操作有讀又有寫,且讀和寫必須保證是當(dāng)前這一次操作的,不能被其他情況給挾持。
這種場景其實并不少見,比如電商秒殺活動,庫存數(shù)量的更新就會遇到。如果是單機應(yīng)用,線程鎖就可以避免。如果是分布式應(yīng)用,不同服務(wù)器不同容器對應(yīng)的應(yīng)用都是不同的jvm,所以無法通過線程來實現(xiàn),這時就需要引入分布式鎖來解決。—提個話題,大部分情況下想要保證分布式系統(tǒng)的cap,基本都依賴于中間件。
由此可見分布式鎖的目的其實很簡單,就是為了保證多臺服務(wù)器在執(zhí)行某一段代碼時保證只有一臺服務(wù)器執(zhí)行。
為了保證分布式鎖的可用性,至少要確保鎖的實現(xiàn)要同時滿足以下幾點:
- 互斥性。在任何時刻,保證只有一個客戶端持有鎖。
- 不能出現(xiàn)死鎖。如果在一個客戶端持有鎖的期間,這個客戶端崩潰了,也要保證后續(xù)的其他客戶端可以上鎖。
- 保證上鎖和解鎖都是同一個客戶端。
一般來說,實現(xiàn)分布式鎖的方式有以下幾種:
- 使用MySQL,基于唯一索引。
- 使用Redis,基于setnx命令。
- 使用ZooKeeper,基于臨時有序節(jié)點。
五. 實現(xiàn)方案
第一個數(shù)據(jù)庫操作
可以針對一列做唯一索引,或者做一張表,去做對應(yīng)更新記錄;或者做樂觀鎖加version.
但是這種方案是否合適?!!!!!
問題很多,首先最明顯的問題就是io問題,其次就是連接耗時問題。
可是為了解決響應(yīng)時間和分布式環(huán)境下的數(shù)據(jù)一致性問題。
redis實現(xiàn)
Redis實現(xiàn)分布式鎖主要利用Redis的setnx命令和Redis的單線程特性(由來以久的到底多還是單的問題,其實這個問題只是這對于處理能力進行細化,比如在6.x之前所有的操作都是由一個單工作線程來實現(xiàn),對于一個數(shù)據(jù)的操作包括讀/計算/寫入等都由該單工作線程實現(xiàn);而6.x之后分出一個io的子線程,把運算放入了工作線程操作,二讀和寫入兩個操作放入了獨立的io子線程操作;那如果多個數(shù)據(jù)操作,實際上就會出現(xiàn)一個單工作線程,多個io子線程,構(gòu)成io復(fù)用—性能更高)。setnx是SET if not exists(如果不存在,則 SET)的簡寫。
上述是加了過期時間的,如果不加過期時間直接用setnx key:value。
為什么要使用nx這種模式,實際為了解決一個問題,就是我們?nèi)绻x了一個值,在其沒有被手動刪除或者時間戳自動失效前都不能被其他線程給操作和修改。明白了嗎?是不是這樣就可以保證在針對于這一個操作的時候可以保證數(shù)據(jù)的原子性?!
為了保證實現(xiàn)的最基本要求,本案例啟動了2個服務(wù),分別是8081,8082,通過用Nginx做了網(wǎng)關(guān),負(fù)載使用了輪詢。模擬訪問地址是Nginx網(wǎng)關(guān)地址。庫存模擬放入redis中 stock:1000
并發(fā)測試工具采用postman自帶測試(效果也就玩玩)
以及Jmeters5.3.
數(shù)據(jù)模擬采用了redis或者數(shù)據(jù)庫。
方案1:
public String updateStock01(String pId) throws Exception {String clientId = UUID.randomUUID().toString();try{Boolean result = stringRedisTemplate.opsForValue().setIfAbsent(pId,clientId,30, TimeUnit.SECONDS);if(result) {int stock = Integer.parseInt(stringRedisTemplate.opsForValue().get("stock"));if (stock > 0) {stock = stock - 1;stringRedisTemplate.opsForValue().set("stock", stock + "");log.info("扣除成功,目前庫存還有" + stock + "件");} else {log.info("庫存不足");}}}catch (Exception e){log.info(e.getMessage());}finally {if(clientId.equals(stringRedisTemplate.opsForValue().get(pId))) {stringRedisTemplate.delete(pId);}}return "success"; }上述代碼可以看到我們通過生成一個30秒ttl的Key -> pId value->clientId的列子。在這個30秒的時間里面,任何一個同樣的Key都不可以進來修改。然后通過操縱對于庫存結(jié)果的扣件后,在最終finally里面保證刪除該key留給其他線程。看起來沒問題,但是真沒有問題嗎?
如果流量不大,其實出現(xiàn)問題的情況基本為0.但是如果說在壓測時候使用j meters通過大量線程同時進入,并保持不停壓測。會發(fā)現(xiàn)—涼涼!
我這邊操作2個相同服務(wù)去實現(xiàn),如下圖:
發(fā)現(xiàn)結(jié)果有問題了!這個問題究其原因是怎么造成的呢?
首先我們這個是2個服務(wù),都是依賴于獨立的jvm,所以有不同的Jmm,就算你做了線程加了重量級鎖結(jié)果還是一樣,畢竟是非原子性的操作。
其次雖然采用了set key value [EX seconds] [PX milliseconds] [NX|XX]的方式,但是如果一個線程a執(zhí)行的時間較長沒有來得及釋放,鎖就過期了,此時l另外一個線程B是可以獲取到鎖的。當(dāng)線程A執(zhí)行完成之后,釋放鎖,實際上就把線程B的鎖釋放掉了。這個時候,再來一個線程C又是可以獲取到鎖的,而此時如果線程B執(zhí)行完釋放鎖實際上就是釋放的線程C設(shè)置的鎖。
這邊只是本地機器,如果是云端/線上環(huán)境等,服務(wù)和服務(wù)之間的調(diào)度延遲,網(wǎng)絡(luò)訪問延遲等等都會造成問題。
方案1不可行!!!
那上述問題,我們?nèi)绾我?guī)避呢?
可以這樣想,既然代碼層面直接實現(xiàn)不可以,但是可以通過交給redis這個所謂的單線程去處理不就行了!?
方案2:
set、del是一一映射的,不會出現(xiàn)把其他現(xiàn)成的鎖del的情況。從實際情況的角度來看,即使能做到set、del一一映射,也無法保障業(yè)務(wù)的絕對安全。因為鎖的過期時間始終是有界的,除非不設(shè)置過期時間或者把過期時間設(shè)置的很長,但這樣做也會帶來其他問題。故沒有意義。要想實現(xiàn)相對安全的分布式鎖,必須依賴key的value值。在釋放鎖的時候,通過value值的唯一性來保證不會勿刪。
通過lua腳本來實現(xiàn);
通過redission實現(xiàn):
public String updateStock02(String pId) throws Exception {//增加分布式鎖RLock rLock =redisson.getLock(pId);try{rLock.lock(5000,TimeUnit.MILLISECONDS);int stock = Integer.parseInt(stringRedisTemplate.opsForValue().get("stock"));if(stock>0){stock = stock - 1;stringRedisTemplate.opsForValue().set("stock",stock+"");log.info("扣除成功,目前庫存還有"+stock+"件");}else{log.info("庫存不足");}}catch (Exception e){log.info(e.getMessage());}finally {rLock.unlock();}return "success"; }按照原來還有982個,現(xiàn)在做1000個線程,最快執(zhí)行完畢,連續(xù)循環(huán)2次,結(jié)果到0的時候,顯示庫存不足。
看代碼:
RLock rLock =redisson.getLock(pId);rLock.lock(5000,TimeUnit.MILLISECONDS);rLock.unlock();眼熟嗎?是否和Lock很像?!
其實這邊和Lock相似的地方采用類似自旋鎖的方式,做了do while循環(huán)判斷是否獲得鎖,然后根據(jù)hash算法分配到不同的redis 哨兵主從環(huán)境(關(guān)于16384的槽以及丟失某個節(jié)點后數(shù)據(jù)問題后續(xù)可以討論),當(dāng)然單機也沒有問題就是。并執(zhí)行Lua腳本。
"if (redis.call('exists', KEYS[1]) == 0) then " + "redis.call('hset', KEYS[1], ARGV[2], 1); " + "redis.call('pexpire', KEYS[1], ARGV[1]); " + "return nil; " + "end; " + "if (redis.call('hexists', KEYS[1], ARGV[2]) == 1) then " + "redis.call('hincrby', KEYS[1], ARGV[2], 1); " + "redis.call('pexpire', KEYS[1], ARGV[1]); " + "return nil; " + "end; " + "return redis.call('pttl', KEYS[1]);"其中hash保證通過lua腳本保證了原子性。
具體內(nèi)容用了一段別人介紹的說法(不去深究,可以結(jié)合線程鎖的源碼來閱讀):
原引:csdn 段子猿 給大家解釋一下,第一段if判斷語句,就是用“exists myLock”命令判斷一下,如果要加鎖的那個鎖key不存在的話,就進行加鎖。 如何加鎖呢?很簡單,用下面的命令:hset myLock 3d7b5418-a86d-48c5-ae15-7fe13ef0034c:1接著會執(zhí)行“pexpire myLock 30000”命令,設(shè)置myLock這個鎖key的生存時間是30秒。這樣加鎖完成了。幾個參數(shù)所代表的意思:KEYS[1]代表的是你加鎖的那個key,比如說 RLock lock = redisson.getLock("myLock"),這里你自己設(shè)置了加鎖的那個鎖key就是“myLock”。ARGV[1]代表的就是鎖key的默認(rèn)生存時間,默認(rèn)30秒。ARGV[2]代表的是加鎖的客戶端的ID,類似于這樣: 3d7b5418-a86d-48c5-ae15-7fe13ef0034c:1 2.鎖互斥機制在這個時候,如果客戶端2來嘗試加鎖,執(zhí)行了同樣的一段lua腳本,會咋樣呢?按照腳本流程,第一個if判斷會執(zhí)行“exists myLock”,發(fā)現(xiàn)myLock這個鎖key已經(jīng)存在了。接著第二個if判斷,判斷一下,myLock鎖key的hash數(shù)據(jù)結(jié)構(gòu)中,是否包含客戶端2的ID,但是明顯客戶端id不一致。所以,客戶端2會獲取到pttl myLock返回的一個數(shù)字,這個數(shù)字代表了myLock這個鎖key的剩余生存時間。比如還剩15000毫秒的生存時間, 此時客戶端2會進入一個while循環(huán),不停的嘗試加鎖。3.watch dog自動延期機制客戶端加鎖的key默認(rèn)生存時間是30秒,如果超過了30秒,客戶端還想一直持有這把鎖,怎么辦呢?只要客戶端一旦加鎖成功,就會啟動一個watch dog看門狗,他是一個后臺線程,每到過期時間1/3(默認(rèn)生存時間是30s的話,就是10s)就去重新刷一次,如果客戶端還持有鎖key,那么就會不斷的延長鎖key的生存時間,如果key不存在則停止刷新。4.可重入加鎖機制那如果客戶端已經(jīng)持有了這把鎖,結(jié)果可重入的加鎖會怎么樣呢?比如下面這種代碼:RLock lock = redisson.getLock("myLock"); lock.lock(); //do something lock.lock(); //do something lock.unlock(); lock.unlock(); 這時我們來分析一下上面那段lua腳本。第一個if判斷肯定不成立,“exists myLock”會顯示鎖key已經(jīng)存在了。第二個if判斷會成立,因為myLock的hash數(shù)據(jù)結(jié)構(gòu)中包含的那個ID,就是客戶端那個ID,也就是“3d7b5418-a86d-48c5-ae15-7fe13ef0034c:1” ,此時就會執(zhí)行可重入加鎖的邏輯,他會用: incrby myLock 3d7b5418-a86d-48c5-ae15-7fe13ef0034c:1 1通過這個命令,對客戶端1的加鎖次數(shù),累加1。此時myLock數(shù)據(jù)結(jié)構(gòu)變?yōu)橄旅孢@樣: 大家看到了吧,那個myLock的hash數(shù)據(jù)結(jié)構(gòu)中的那個客戶端ID,就對應(yīng)著加鎖的次數(shù)5.釋放鎖機制如果執(zhí)行l(wèi)ock.unlock(),就可以釋放分布式鎖,此時的業(yè)務(wù)邏輯其實很簡單。就是每次都對myLock數(shù)據(jù)結(jié)構(gòu)中的那個加鎖次數(shù)減1。如果發(fā)現(xiàn)加鎖次數(shù)是0了,說明這個客戶端已經(jīng)不再持有鎖了,此時就會用: “del myLock”命令,從redis里刪除這個key。然后呢,另外的客戶端就可以嘗試完成加鎖了。這就是所謂的分布式鎖的開源Redisson框架的實現(xiàn)機制。方案3: Spring Integration
Spring Integration不需要你去關(guān)注它到底是基于什么存儲技術(shù)實現(xiàn)的,它是面向接口編程,低耦合讓你不需要關(guān)注底層實現(xiàn)。你要做的僅僅是做簡單的選擇,然后用相同的一套api即可完成分布式鎖的操作。
@Override public String updateStock03(String pId) throws Exception {//增加分布式鎖Lock lock = redisLockRegistry.obtain(pId);boolean flag = false;try{flag = lock.tryLock(10,TimeUnit.SECONDS);int stock = Integer.parseInt(stringRedisTemplate.opsForValue().get("stock"));if(stock>0){stock = stock - 1;stringRedisTemplate.opsForValue().set("stock",stock+"");log.info("購買成功,庫存還有"+stock+"件");}else{log.info("庫存不足");}}catch (Exception e){log.info(e.getMessage());}finally {lock.unlock();}return "success"; }通過測試
共計100個。
測試成功。
zookeeper實現(xiàn)
通過zk來實現(xiàn),其中zk通過定義EPHEMERAL_SEQUENTIAL臨時順序節(jié)點.比如創(chuàng)建一個/lock/臨時有序;
創(chuàng)建節(jié)點成功后,獲取/lock目錄下的所有臨時節(jié)點,再判斷當(dāng)前線程創(chuàng)建的節(jié)點是否是所有的節(jié)點的序號最小的節(jié)點
如果當(dāng)前線程創(chuàng)建的節(jié)點是所有節(jié)點序號最小的節(jié)點,則認(rèn)為獲取鎖成功。
如果當(dāng)前線程創(chuàng)建的節(jié)點不是所有節(jié)點序號最小的節(jié)點,則對節(jié)點序號的前一個節(jié)點添加一個事件監(jiān)聽。
比如當(dāng)前線程獲取到的節(jié)點序號為/lock/003,然后所有的節(jié)點列表為[/lock/001,/lock/002,/lock/003],則對/lock/002這個節(jié)點添加一個事件監(jiān)聽器。
如果鎖被釋放,會喚醒下一個序號的節(jié)點,然后重新執(zhí)行第3步,判斷是否自己的節(jié)點序號是最小。比如/lock/001釋放了,/lock/002監(jiān)聽到時間,此時節(jié)點集合為[/lock/002,/lock/003],則/lock/002為最小序號節(jié)點,獲取到鎖。
偷懶的寫法—上面寫法實話太麻煩了:
Curator是一個zookeeper的開源客戶端,也提供了分布式鎖的實現(xiàn):
六. 總結(jié)
上述內(nèi)容就是目前實現(xiàn)分布式鎖的幾種流行方式,那還有沒有其他的?有!比如consul也可以實現(xiàn),這邊也不延展。這邊要說的是如果業(yè)務(wù)如果并沒有想象的那么夸張的時候,沒有必要不要考慮使用第三方實現(xiàn)。
另外Redission實際上就是將并行的請求,轉(zhuǎn)化為串行請求。這樣就降低了并發(fā)的響應(yīng)速度.
可以通過鎖分段來實現(xiàn),比如ps5昨天發(fā)布國行價格,商品只有2000臺貨品,暫時不考慮地區(qū)庫存計算;一下黃牛們紛涌而至,這個時候2000臺貨如果按照串行走的話,比如每個50ms,暫時不考慮分布式事務(wù)鎖或者是最終一致性的死信隊列情況,2000個大概需要2000x50ms=100000ms,大概也要100秒左右時間,那如果并行呢?比如我把2000個ps5分成20份,那就是每份里面存100個,按照庫存取余數(shù)(或者Hash或者就100,100的放等等)分別放置。這樣每次可以定義多個鎖進行加鎖即可。這邊注意一個問題就是因為涉及到20個分段,所以注意如果某個分段已經(jīng)沒有庫存的時候,需要解鎖進入下一個分段繼續(xù)。
也可以直接在redis預(yù)設(shè)好(個人比較推崇),我可以給ps5的2000臺分成不同的stock:product_ps5_xxx:01: 100;stock:product_ps5_xxx:02: 100…
最后無論你身處一個什么樣的公司,最開始的工作可能都需要從最簡單的做起。所以能根據(jù)自己公司業(yè)務(wù)場景,選擇適合自己項目的方案。
總結(jié)
以上是生活随笔為你收集整理的高并发分布式场景下的应用---分布式锁的全部內(nèi)容,希望文章能夠幫你解決所遇到的問題。
- 上一篇: 电脑不允许被PING的解决办法
- 下一篇: 怎样打印计算机桌面,敬业签电脑桌面便签软