淺談Java中的分布式鎖
帶入到場景討論分布式鎖的意義
上圖可以看到,變量A存在JVM1、JVM2、JVM3三個JVM內存中(這個變量A主要體現(xiàn)是在一個類中的一個成員變量,是一個有狀態(tài)的對象
例如:UserController控制器中的一個整形類型的成員變量),如果不加任何控制的話,變量A同時都會在JVM分配一塊內存,三個請求發(fā)過來同時對這個變量操作,顯然結果是不對的!
即使不是同時發(fā)過來,三個請求分別操作三個不同JVM內存區(qū)域的數(shù)據(jù),變量A之間不存在共享,也不具有可見性,處理的結果也是不對的!
如果我們業(yè)務中確實存在這個場景的話,我們就需要一種方法解決這個問題!
為了保證一個方法或屬性在高并發(fā)情況下的同一時間只能被同一個線程執(zhí)行,在傳統(tǒng)單體應用單機部署的情況下,可以使用Java并發(fā)處理相關的API(如ReentrantLock或Synchronized)進行互斥控制。
在單機環(huán)境中,Java中提供了很多并發(fā)處理相關的API。
但是,隨著業(yè)務發(fā)展的需要,原單體單機部署的系統(tǒng)被演化成分布式集群系統(tǒng)后,由于分布式系統(tǒng)多線程、多進程并且分布在不同機器上,這將使原單機部署情況下的并發(fā)控制鎖策略失效,單純的Java API并不能提供分布式鎖的能力。
為了解決這個問題就需要一種跨JVM的互斥機制來控制共享資源的訪問,這就是分布式鎖要解決的問題!
分布式鎖的實現(xiàn)討論
分布式鎖一般有三種實現(xiàn)方式:
- 數(shù)據(jù)庫樂觀鎖;
- 基于ZooKeeper的分布式鎖;
- 基于Redis的分布式鎖;
Redis實現(xiàn)分布式鎖
基于Redis命令:
SET key value NX EX max-lock-time
這里補充下: 從2.6.12版本后, 就可以使用set來獲取鎖, Lua 腳本來釋放鎖。
setnx
是老黃歷了,set命令nx,xx等參數(shù), 是為了實現(xiàn) setnx 的功能。
1.加鎖
public class RedisTool { private static final String LOCK_SUCCESS = "OK"; private static final String SET_IF_NOT_EXIST = "NX"; private static final String SET_WITH_EXPIRE_TIME = "PX"; /** * 嘗試獲取分布式鎖 * @param jedis Redis客戶端 * @param lockKey 鎖 * @param requestId 請求標識 * @param expireTime 超期時間 * @return 是否獲取成功 */ public static boolean tryGetDistributedLock(Jedis jedis, String lockKey, String requestId, int expireTime) { String result = jedis.set(lockKey, requestId, SET_IF_NOT_EXIST, SET_WITH_EXPIRE_TIME, expireTime); if (LOCK_SUCCESS.equals(result)) { return true; } return false; } } jedis.set(String key, String value, String nxxx, String expx, int time)
這個set()方法一共有五個形參:
- 第一個為key,我們使用key來當鎖,因為key是唯一的。
- 第二個為value,我們傳的是requestId,很多童鞋可能不明白,有key作為鎖不就夠了嗎,為什么還要用到value?原因就是我們在上面講到可靠性時,分布式鎖要滿足第四個條件解鈴還須系鈴人,通過給value賦值為requestId,我們就知道這把鎖是哪個請求加的了,在解鎖的時候就可以有依據(jù)。requestId可以使用UUID.randomUUID().toString()方法生成。
- 第三個為nxxx,這個參數(shù)我們填的是NX,意思是SET IF NOT EXIST,即當key不存在時,我們進行set操作;若key已經(jīng)存在,則不做任何操作;
- 第四個為expx,這個參數(shù)我們傳的是PX,意思是我們要給這個key加一個過期的設置,具體時間由第五個參數(shù)決定。
- 第五個為time,與第四個參數(shù)相呼應,代表key的過期時間。
總的來說,執(zhí)行上面的set()方法就只會導致兩種結果:
- 當前沒有鎖(key不存在),那么就進行加鎖操作,并對鎖設置個有效期,同時value表示加鎖的客戶端。
- 已有鎖存在,不做任何操作。
2.解鎖
public class RedisTool { private static final Long RELEASE_SUCCESS = 1L; /** * 釋放分布式鎖 * @param jedis Redis客戶端 * @param lockKey 鎖 * @param requestId 請求標識 * @return 是否釋放成功 */ public static boolean releaseDistributedLock(Jedis jedis, String lockKey, String requestId) { String script = "if redis.call('get', KEYS[1]) == ARGV[1] then return redis.call('del', KEYS[1]) else return 0 end"; Object result = jedis.eval(script,Collections.singletonList(lockKey),Collections.singletonList(requestId)); if (RELEASE_SUCCESS.equals(result)) { return true; } return false; } }
那么這段Lua代碼的功能是什么呢?其實很簡單,首先獲取鎖對應的value值,檢查是否與requestId相等,如果相等則刪除鎖(解鎖)。為什么要寫這段lua腳本呢?
因為判斷key存不存在和刪除key的操作必須是源自的,否則就有可能發(fā)生我下一個請求本應該能獲取到鎖,結果因為還沒有被刪除而獲取不到,因為redis是單線程更新緩存的,如果不適用lua腳本,獲取和刪除操作就不是一個連續(xù)的事務操作。仔細觀察上面代碼實現(xiàn)還有什么問題呢?
直接解答好了,一般情況下我們的key都會設置過期防止死鎖,假如我們程序執(zhí)行階段占用時間過長,就會導致key過期了但是程序還沒執(zhí)行完。假如這個時候下一個請求進來就有可能獲取到鎖,這個時候執(zhí)行這個方法的線程就不是安全的了。為了解決這個問題redission引入了redlock。
關于這個問題,目前常見的解決方法有兩種:
1、守護線程“續(xù)命”:額外起一個線程,定期檢查線程是否還持有鎖,如果有則延長過期時間。Redisson 里面就實現(xiàn)了這個方案,使用“看門狗”定期檢查(每1/3的鎖時間檢查1次),如果線程還持有鎖,則刷新過期時間。
2、超時回滾:當我們解鎖時發(fā)現(xiàn)鎖已經(jīng)被其他線程獲取了,說明此時我們執(zhí)行的操作已經(jīng)是“不安全”的了,此時需要進行回滾,并返回失敗。
同時,需要進行告警,人為介入驗證數(shù)據(jù)的正確性,然后找出超時原因,是否需要對超時時間進行優(yōu)化等等。
守護線程續(xù)命的方案有什么問題嗎
Redisson 使用看門狗(守護線程)“續(xù)命”的方案在大多數(shù)場景下是挺不錯的,也被廣泛應用于生產環(huán)境,但是在極端情況下還是會存在問題。
問題例子如下: 1、線程1首先獲取鎖成功,將鍵值對寫入 redis 的 master 節(jié)點 2、在 redis 將該鍵值對同步到 slave 節(jié)點之前,master 發(fā)生了故障 3、redis 觸發(fā)故障轉移,其中一個 slave 升級為新的 master 4、此時新的 master 并不包含線程1寫入的鍵值對,因此線程2嘗試獲取鎖也可以成功拿到鎖 5、此時相當于有兩個線程獲取到了鎖,可能會導致各種預期之外的情況發(fā)生,例如最常見的臟數(shù)據(jù)
解決方法:上述問題的根本原因主要是由于 redis 異步復制帶來的數(shù)據(jù)不一致問題導致的,因此解決的方向就是保證數(shù)據(jù)的一致。 當前比較主流的解法和思路有兩種:
1)Redis 作者提出的 RedLock; 2)Zookeeper 實現(xiàn)的分布式鎖。
接下來介紹下這兩種方案。
RedLock
首先,該方案也是基于文章開頭的那個方案(set加鎖、lua腳本解鎖)進行改良的,所以 antirez 只描述了差異的地方,大致方案如下。
假設我們有 N 個 Redis 主節(jié)點,例如 N = 5,這些節(jié)點是完全獨立的,我們不使用復制或任何其他隱式協(xié)調系統(tǒng),為了取到鎖,客戶端應該執(zhí)行以下操作:
1、獲取當前時間,以毫秒為單位。
2、依次嘗試從5個實例,使用相同的 key 和隨機值(例如UUID)獲取鎖。當向Redis 請求獲取鎖時,客戶端應該設置一個超時時間,這個超時時間應該小于鎖的失效時間。例如你的鎖自動失效時間為10秒,則超時時間應該在 5-50 毫秒之間。這樣可以防止客戶端在試圖與一個宕機的 Redis 節(jié)點對話時長時間處于阻塞狀態(tài)。如果一個實例不可用,客戶端應該盡快嘗試去另外一個Redis實例請求獲取鎖。
3、客戶端通過當前時間減去步驟1記錄的時間來計算獲取鎖使用的時間。當且僅當從大多數(shù)(N/2+1,這里是3個節(jié)點)的Redis節(jié)點都取到鎖,并且獲取鎖使用的時間小于鎖失效時間時,鎖才算獲取成功。
4、如果取到了鎖,其有效時間等于有效時間減去獲取鎖所使用的時間(步驟3計算的結果)。
5、如果由于某些原因未能獲得鎖(無法在至少N/2+1個Redis實例獲取鎖、或獲取鎖的時間超過了有效時間),客戶端應該在所有的Redis實例上進行解鎖(即便某些Redis實例根本就沒有加鎖成功,防止某些節(jié)點獲取到鎖但是客戶端沒有得到響應而導致接下來的一段時間不能被重新獲取鎖)。
可以看出,該方案為了解決數(shù)據(jù)不一致的問題,直接舍棄了異步復制,只使用 master 節(jié)點,同時由于舍棄了 slave,為了保證可用性,引入了 N 個節(jié)點,官方建議是 5。
該方案看著挺美好的,但是實際上我所了解到的在實際生產上應用的不多,主要有兩個原因:
1)該方案的成本似乎有點高,需要使用5個實例;
2)該方案一樣存在問題。
該方案主要存以下問題:
1)嚴重依賴系統(tǒng)時鐘。如果線程1從3個實例獲取到了鎖,但是這3個實例中的某個實例的系統(tǒng)時間走的稍微快一點,則它持有的鎖會提前過期被釋放,當他釋放后,此時又有3個實例是空閑的,則線程2也可以獲取到鎖,則可能出現(xiàn)兩個線程同時持有鎖了。
2)如果線程1從3個實例獲取到了鎖,但是萬一其中有1臺重啟了,則此時又有3個實例是空閑的,則線程2也可以獲取到鎖,此時又出現(xiàn)兩個線程同時持有鎖了。
針對以上問題其實后續(xù)也有人給出一些相應的解法,但是整體上來看還是不夠完美,所以目前實際應用得不是那么多。
數(shù)據(jù)庫樂觀鎖和悲觀鎖
樂觀鎖
基本原理為:樂觀鎖一般通過 version 來實現(xiàn),也就是在數(shù)據(jù)庫表創(chuàng)建一個 version 字段,每次更新成功,則 version+1,讀取數(shù)據(jù)時,我們將 version 字段一并讀出,每次更新時將會對版本號進行比較,如果一致則執(zhí)行此操作,否則更新失敗!
樂觀鎖的簡單場景描述: 訂單服務有A、B兩臺服務器,使用Nginx輪詢訪問A、B。兩臺服務的數(shù)據(jù)庫數(shù)據(jù)庫都是D. 假如同時有兩個用戶U1和U2對一個商品下單,同時進入到下單方法需要扣減庫存,那么如何保證同一時間只能有一個用戶可以下單扣減庫存成功呢?
提交訂單的時候會帶上當前的樂觀鎖版本號,在進入到下單的方法中的時候,比對傳過來的版本號和數(shù)據(jù)庫中的版本號是否一致,如果一致則調用扣減庫存服務。否則購買失敗,用戶需要重新下單。如果購買成功的話需要庫存-1的同時樂觀鎖的版本號也需要+1,這樣同一時間只能有一個用戶可以下單成功扣減庫存。
數(shù)據(jù)庫更新版本號的SQL必須這樣寫
update set NAME='XXX' ... , version = version +1 where version ='傳參:期望值,就是你一開始查數(shù)據(jù)的時候查出的version ' and ...
缺點: 同時只能有一個用戶下單成功,失敗了之后用戶就需要重新進入訂單獲取新的版本號。假如說是有1000個人同時下單,那么可能這個時間只有1個人能成功,其他人都會失敗,失敗之后需要自己去重試或者用戶重新點擊。
悲觀鎖
//SELECT * FROM xxxx WHERE id=31212221321123 FOR UPDATE; begin;/begin work;/start transaction; (三者選一就可以) //1.查詢出商品信息 select goods_status from goods where id=1 forupdate; //2.根據(jù)商品信息生成訂單 insert into orders (goods_id,goods_count) values (3,5); //3.修改商品status為2 update goods set status=2; //4.提交事務 commit; //commit work;
悲觀鎖是可以實現(xiàn)分布式鎖的,借助上述場景,我們在提交訂單的時候,先根據(jù)商品鎖住商品對應的庫存記錄,在扣減完庫存之后提交事務釋放鎖。這個時候其實其他請求是會被阻塞的,等到上一個用戶購買成功之后釋放鎖,下個請求線程競爭就會拿到鎖,進行商品庫存的扣減。
xxljob就是使用悲觀鎖來做分布式鎖,來控制任務的觸發(fā)。
缺點: 1.多服務必須同一個數(shù)據(jù)庫,加鎖的記錄必須在同一張表中,假如說有分表那就不能用了。
2.性能低。 3.注意扣減庫存的SQL要在同一個數(shù)據(jù)庫連接中。
Zookeeper
Zookeeper 的分布式鎖實現(xiàn)方案如下: 1、創(chuàng)建一個鎖目錄 /locks,該節(jié)點為持久節(jié)點
2、想要獲取鎖的線程都在鎖目錄下創(chuàng)建一個臨時順序節(jié)點
3、獲取鎖目錄下所有子節(jié)點,對子節(jié)點按節(jié)點自增序號從小到大排序
4、判斷本節(jié)點是不是第一個子節(jié)點,如果是,則成功獲取鎖,開始執(zhí)行業(yè)務邏輯操作;如果不是,則監(jiān)聽自己的上一個節(jié)點的刪除事件
5、持有鎖的線程釋放鎖,只需刪除當前節(jié)點即可。
6、當自己監(jiān)聽的節(jié)點被刪除時,監(jiān)聽事件觸發(fā),則回到第3步重新進行判斷,直到獲取到鎖。
由于 Zookeeper 保證了數(shù)據(jù)的強一致性,因此不會存在之前 Redis 方案中的問題,整體上來看還是比較不錯的。
Zookeeper 方案的主要問題在于性能不如 Redis 那么好,當申請鎖和釋放鎖的頻率較高時,會對集群造成壓力,此時集群的穩(wěn)定性可用性能可能又會遭受挑戰(zhàn)。
總結
通過以上的實例可以得出以下結論: 通過數(shù)據(jù)庫實現(xiàn)分布式鎖是最不可靠的一種方式,對數(shù)據(jù)庫依賴較大,性能較低,不利于處理高并發(fā)的場景。
通過 Redis 的 Redlock 和 ZooKeeper 來加鎖,性能有了比較大的提升。
針對 Redlock,曾經(jīng)有位大神對其實現(xiàn)的分布式鎖提出了質疑,但是 Redis 官方卻不認可其說法,所謂公說公有理婆說婆有理,對于分布式鎖的解決方案,沒有最好,只有最適合的,根據(jù)不同的項目采取不同方案才是最合理的。
下面是從各個方面進行三種實現(xiàn)方式的對比
從理解的難易程度角度(從低到高)
數(shù)據(jù)庫 > 緩存 > Zookeeper
從實現(xiàn)的復雜性角度(從低到高)
Zookeeper >= 緩存 > 數(shù)據(jù)庫
從性能角度(從高到低)
緩存 > Zookeeper >= 數(shù)據(jù)庫
從可靠性角度(從高到低)
Zookeeper > 緩存 > 數(shù)據(jù)庫
到此這篇關于淺談Java中的分布式鎖的文章就介紹到這了,更多相關Java分布式鎖內容請搜索腳本之家以前的文章或繼續(xù)瀏覽下面的相關文章希望大家以后多多支持腳本之家!
相關文章
Java創(chuàng)建可執(zhí)行JAR文件的多種方式
本文主要介紹了Java創(chuàng)建可執(zhí)行JAR文件的多種方式,使用JDK的jar工具、IDE、Maven和Gradle來創(chuàng)建和配置可執(zhí)行JAR文件,具有一定的參考價值,感興趣的可以了解一下2024-07-07k8s部署的java服務添加idea調試參數(shù)的方法
文章介紹了如何在K8S容器中的Java服務上進行遠程調試,包括配置Deployment、Service以及本地IDEA的調試設置,感興趣的朋友跟隨小編一起看看吧2025-02-02Springboot結合rabbitmq實現(xiàn)的死信隊列
為了保證訂單業(yè)務的消息數(shù)據(jù)不丟失,需要使用到RabbitMQ的死信隊列機制,本文主要介紹了Springboot結合rabbitmq實現(xiàn)的死信隊列,具有一定的參考價值,感興趣的可以了解一下2023-09-09Java 垃圾回收機制詳解(動力節(jié)點Java學院整理)
在系統(tǒng)運行過程中,會產生一些無用的對象,這些對象占據(jù)著一定的內存,如果不對這些對象清理回收無用對象的內存,可能會導致內存的耗盡,所以垃圾回收機制回收的是內存。下面通過本文給大家詳細介紹java垃圾回收機制,一起學習吧2017-02-02