java實現分散式鎖
1.前言
大多數網際網路系統是分散式部署的,分散式部署解決了高併發高可用的問題,但是由此帶來了資料一致性問題。
當某個資源在多系統之間,被共享操作的時候,為了保證這個資源資料是一致的,那麼就必須要求在同一時刻只能被一個客戶端操作,不能併發的執行,否者就會出現同一時刻有客戶端寫,別的客戶端在讀,兩者訪問到的資料就不一致了。
2.我們為什麼需要分散式鎖
在單機時代,雖然不需要分散式鎖,但也面臨過類似的問題,只不過在單機的情況下,如果有多個執行緒要同時訪問某個共享資源的時候,我們可以採用執行緒間加鎖的機制,即當某個執行緒獲取到這個資源後,就立即對這個資源進行加鎖,當使用完資源之後,再解鎖,其它執行緒就可以接著使用了。例如,在JAVA中,甚至專門提供了一些處理鎖機制的一些API(synchronize/Lock等)。
但是到了分散式系統的時代,這種執行緒之間的鎖機制,就沒作用了,應用程式會有多份,並且部署在不同的機器上,這些資源已經不是在同一程序的不同執行緒間共享,而是屬於多程序之間共享的資源。
因此,為了解決這個問題,我們就必須引入「分散式鎖」。
分散式鎖,是指在分散式的部署環境下,通過鎖機制來讓多客戶端互斥的對共享資源進行訪問。
分散式鎖要滿足哪些要求呢?
排他性:在同一時間只會有一個客戶端能獲取到鎖,其它客戶端無法獲取
避免死鎖:這把鎖在一段有限的時間之後,一定會被釋放(正常釋放或異常釋放)
高可用:獲取或釋放鎖的機制必須高可用且效能佳
而且最好是可重入鎖。
3.分散式鎖的實現方式有哪些
目前主流的有三種,從實現的複雜度上來看,從上往下難度依次增加:
基於資料庫實現
基於Redis實現
基於ZooKeeper實現
無論哪種方式,其實都不完美,依舊要根據咱們業務的實際場景來選擇。
方案1 基於資料庫實現
基於資料庫來做分散式鎖的話,通常有兩種做法:
基於資料庫的樂觀鎖
基於資料庫的悲觀鎖
我們先來看一下如何基於「樂觀鎖」來實現:
樂觀鎖機制其實就是在資料庫表中引入一個版本號(version)欄位來實現的。
當我們要從資料庫中讀取資料的時候,同時把這個version欄位也讀出來,如果要對讀出來的資料進行更新後寫回資料庫,則需要將version加1,同時將新的資料與新的version更新到資料表中,且必須在更新的時候同時檢查目前資料庫裡version值是不是之前的那個version,如果是,則正常更新。如果不是,則更新失敗,說明在這個過程中有其它的程序去更新過資料了。
樂觀鎖通常實現基於資料版本(version)的記錄機制實現的,比如有一張紅包表(t_bonus),有一個欄位(left_count)記錄禮物的剩餘個數,使用者每領取一個獎品,對應的left_count減1,在併發的情況下如何要保證left_count不為負數,樂觀鎖的實現方式為在紅包表上新增一個版本號欄位(version),預設為0。
異常實現流程
-- 可能會發生的異常情況 -- 執行緒1查詢,當前left_count為1,則有記錄 select * from t_bonus where id = 10001 and left_count > 0 -- 執行緒2查詢,當前left_count為1,也有記錄 select * from t_bonus where id = 10001 and left_count > 0 -- 執行緒1完成領取記錄,修改left_count為0, update t_bonus set left_count = left_count - 1 where id = 10001 -- 執行緒2完成領取記錄,修改left_count為-1,產生髒資料 update t_bonus set left_count = left_count - 1 where id = 10001
通過樂觀鎖實現
-- 新增版本號控制欄位 ALTER TABLE table ADD COLUMN version INT DEFAULT '0' NOT NULL AFTER t_bonus; -- 執行緒1查詢,當前left_count為1,則有記錄,當前版本號為1234 select left_count, version from t_bonus where id = 10001 and left_count > 0 -- 執行緒2查詢,當前left_count為1,有記錄,當前版本號為1234 select left_count, version from t_bonus where id = 10001 and left_count > 0 -- 執行緒1,更新完成後當前的version為1235,update狀態為1,更新成功 update t_bonus set version = 1235, left_count = left_count-1 where id = 10001 and version = 1234 -- 執行緒2,更新由於當前的version為1235,udpate狀態為0,更新失敗,再針對相關業務做異常處理 update t_bonus set version = 1235, left_count = left_count-1 where id = 10001 and version = 1234
「悲觀鎖」的實現:
悲觀鎖利用資料庫的行鎖來進行鎖定指定行,
通常用"SELECT * FROM TABLE_NAME WHERE id=id_value FOR UPDATE" 來獲取資料。
如果能獲取到資料,則加鎖成功, 如果獲取失敗,說明鎖已經被別的程式佔用了,自己則獲取鎖失敗。
/** * 消費以後更新銀行餘額 * @param bankId 銀行卡號 * @param cost 消費金額 * @return */ public boolean consume(Long bankId, Integer cost){ //先鎖定銀行賬戶 BankAccount product = query("SELECT * FROM bank_account WHERE bank_id=#{bankId} FOR UPDATE", bankId); if (product.getNumber() > 0) { int updateCnt = update("UPDATE tb_product_stock SET number=#{cost} WHERE product_id=#{productId}", cost, bankId); if(updateCnt > 0){ //更新庫存成功 return true; } } return false; }
方案二:基於Redis的分散式鎖
用到的部分redis指令
SETNX命令(SET if Not eXists) 語法:SETNX key value 功能:原子性操作,當且僅當 key 不存在,將 key 的值設為 value ,並返回1;若給定的 key 已經存在,則 SETNX 不做任何動作,並返回0。 Expire命令 語法:expire(key, expireTime) 功能:key設定過期時間 GETSET命令 語法:GETSET key value 功能:將給定 key 的值設為 value ,並返回 key 的舊值 (old value),當 key 存在但不是字串型別時,返回一個錯誤,當key不存在時,返回nil。 GET命令 語法:GET key 功能:返回 key 所關聯的字串值,如果 key 不存在那麼返回特殊值 nil 。 DEL命令 語法:DEL key [KEY …] 功能:刪除給定的一個或多個 key ,不存在的 key 會被忽略。
第一種:使用redis的setnx()、expire()方法,用於分散式鎖
- setnx(lockkey, 1) 如果返回0,則說明佔位失敗;如果返回1,則說明佔位成功
- expire()命令對lockkey設定超時時間,為的是避免死鎖問題。
- 執行完業務程式碼後,可以通過delete命令刪除key。
這個方案其實是可以解決日常工作中的需求的,但從技術方案的探討上來說,可能還有一些可以完善的地方。比如,如果在第一步setnx執行成功後,
在expire()命令執行成功前,發生了宕機的現象,那麼就依然會出現死鎖的問題
第二種:使用redis的setnx()、get()、getset()方法,用於分散式鎖,解決死鎖問題
- setnx(lockkey, 當前時間+過期超時時間) ,如果返回1,則獲取鎖成功;如果返回0則沒有獲取到鎖,轉向2。
- get(lockkey)獲取值oldExpireTime ,並將這個value值與當前的系統時間進行比較,如果小於當前系統時間,則認為這個鎖已經超時,可以允許別的請求重新獲取,轉向3。
- 計算newExpireTime=當前時間+過期超時時間,然後getset(lockkey, newExpireTime) 會返回當前lockkey的值currentExpireTime。
- 判斷currentExpireTime與oldExpireTime 是否相等,如果相等,說明當前getset設定成功,獲取到了鎖。如果不相等,說明這個鎖又被別的請求獲取走了,那麼當前請求可以直接返回失敗,或者繼續重試。
- 在獲取到鎖之後,當前執行緒可以開始自己的業務處理,當處理完畢後,比較自己的處理時間和對於鎖設定的超時時間,如果小於鎖設定的超時時間,則直接執行delete釋放鎖;如果大於鎖設定的超時時間,則不需要再鎖進行處理。
複製程式碼 import cn.com.tpig.cache.redis.RedisService; import cn.com.tpig.utils.SpringUtils; /** * Created by IDEA * User: shma1664 * Date: 2016-08-16 14:01 * Desc: redis分散式鎖 */ public final class RedisLockUtil { private static final int defaultExpire = 60; private RedisLockUtil() { // } /** * 加鎖 * @param key redis key * @param expire 過期時間,單位秒 * @return true:加鎖成功,false,加鎖失敗 */ public static boolean lock(String key, int expire) { RedisService redisService = SpringUtils.getBean(RedisService.class); long status = redisService.setnx(key, "1"); if(status == 1) { redisService.expire(key, expire); return true; } return false; } public static boolean lock(String key) { return lock2(key, defaultExpire); } /** * 加鎖 * @param key redis key * @param expire 過期時間,單位秒 * @return true:加鎖成功,false,加鎖失敗 */ public static boolean lock2(String key, int expire) { RedisService redisService = SpringUtils.getBean(RedisService.class); long value = System.currentTimeMillis() + expire; long status = redisService.setnx(key, String.valueOf(value)); if(status == 1) { return true; } long oldExpireTime = Long.parseLong(redisService.get(key, "0")); if(oldExpireTime < System.currentTimeMillis()) { //超時 long newExpireTime = System.currentTimeMillis() + expire; long currentExpireTime = Long.parseLong(redisService.getSet(key, String.valueOf(newExpireTime))); if(currentExpireTime == oldExpireTime) { return true; } } return false; } public static void unLock1(String key) { RedisService redisService = SpringUtils.getBean(RedisService.class); redisService.del(key); } public static void unLock2(String key) { RedisService redisService = SpringUtils.getBean(RedisService.class); long oldExpireTime = Long.parseLong(redisService.get(key, "0")); if(oldExpireTime > System.currentTimeMillis()) { redisService.del(key); } } } public void drawRedPacket(long userId) { String key = "draw.redpacket.userid:" + userId; boolean lock = RedisLockUtil.lock2(key, 60); if(lock) { try { //領取操作 } finally { //釋放鎖 RedisLockUtil.unLock(key); } } else { new RuntimeException("重複領取獎勵"); } }
方案三 :基於Zookeeper的分散式鎖
利用節點名稱的唯一性來實現獨佔鎖
ZooKeeper機制規定同一個目錄下只能有一個唯一的檔名,zookeeper上的一個znode看作是一把鎖,通過createznode的方式來實現。所有客戶端都去建立/lock/${lock_name}_lock節點,最終成功建立的那個客戶端也即擁有了這把鎖,建立失敗的可以選擇監聽繼續等待,還是放棄丟擲異常實現獨佔鎖。
ZK具體實現分散式鎖,可以看
https://www.cnblogs.com/lijiasnong/p/9952494.html