利用Redis實現分散式鎖 使用mysql樂觀鎖解決併發問題
寫在最前面
我在之前總結冪等性的時候,寫過一種分散式鎖的實現,可惜當時沒有真正應用過,著實的心虛啊。正好這段時間對這部分實踐了一下,也算是對之前填坑了。
分散式鎖按照網上的結論,大致分為三種:1、資料庫樂觀鎖; 2、基於Redis的分散式鎖;3.、基於ZooKeeper的分散式鎖;
關於樂觀鎖的實現其實在之前已經講的很清楚了,有興趣的移步:使用mysql樂觀鎖解決併發問題 。今天先簡單總結下redis的實現方法,後面詳細研究過ZooKeeper的實現原理後再具體說說ZooKeeper的實現。
為什麼需要分散式鎖?
在傳統單體應用單機部署的情況下,可以使用Java併發相關的鎖,如ReentrantLcok或synchronized進行互斥控制。但是,隨著業務發展的需要,原單體單機部署的系統,漸漸的被部署在多機器多JVM上同時提供服務,這使得原單機部署情況下的併發控制鎖策略失效了,為了解決這個問題就需要一種跨JVM的互斥機制來控制共享資源的訪問,這就是分散式鎖要解決的問題。
分散式鎖的實現條件
1、互斥性,和單體應用一樣,要保證任意時刻,只能有一個客戶端持有鎖
2、可靠性,要保證系統的穩定性,不能產生死鎖
3、一致性,要保證鎖只能由加鎖人解鎖,不能產生A的加鎖被B使用者解鎖的情況
Redis分散式鎖的實現
Redis實現分散式鎖不同的人可能有不同的實現邏輯,但是核心就是下面三個方法。
SETNX
SETNX key val
當且僅當key不存在時,set一個key為val的字串,返回1;若key存在,則什麼都不做,返回0。
Expire
expire key timeout
為key設定一個超時時間,單位為second,超過這個時間鎖會自動釋放,避免死鎖。
Delete
delete key
刪除key
獲取鎖
首先講一個目前網上應用最多的一種實現
實現思路:
1.獲取鎖的時候,使用setnx加鎖,並使用expire命令為鎖新增一個超時時間,超過該時間則自動釋放鎖以免產生死鎖,鎖的value值為一個隨機生成的UUID,通過此在釋放鎖的時候進行判斷。
2.獲取鎖的時候還設定一個獲取的超時時間,若超過這個時間則放棄獲取鎖。
3.釋放鎖的時候,通過UUID判斷是不是該鎖,若是該鎖,則執行delete進行鎖釋放。
public String getRedisLock(Jedis jedis, String lockKey, Long acquireTimeout, Long timeOut) {try { // 定義 redis 對應key 的value值(uuid) 作用 釋放鎖 隨機生成value,根據專案情況修改 String identifierValue = UUID.randomUUID().toString(); // 定義在獲取鎖之後的超時時間 int expireLock = (int) (timeOut / 1000);// 以秒為單位 // 定義在獲取鎖之前的超時時間 //使用迴圈機制 如果沒有獲取到鎖,要在規定acquireTimeout時間 保證重複進行嘗試獲取鎖 // 使用迴圈方式重試的獲取鎖 Long endTime = System.currentTimeMillis() + acquireTimeout; while (System.currentTimeMillis() < endTime) { // 獲取鎖 // 使用setnx命令插入對應的redislockKey ,如果返回為1 成功獲取鎖 if (jedis.setnx(lockKey, identifierValue) == 1) { // 設定對應key的有效期 jedis.expire(lockKey, expireLock); return identifierValue; } } } catch (Exception e) { e.printStackTrace(); } return null; }
這種實現方法也是目前應用最多的實現,我一直以為這確實是正確的。然而由於這是兩條Redis命令,不具有原子性,如果程式在執行完setnx()之後突然崩潰,導致鎖沒有設定過期時間。那麼還是會發生死鎖的情況。網上之所以有人這樣實現,是因為低版本的jedis並不支援多引數的set()方法。
當然這種情況Jedis的設計者也顯然想到了,新版的Jedis可以同時set多個引數,具體實現如下:
實現思路:
基本上和原來的邏輯類似,只是將setnx和expire的操作合併為一步,改為使用新的set多參的方法。
set(final String key, final String value, final String nxxx, final String expx,final long time)
key和value自然不用多說。nxxx引數只可以傳String 型別的NX(僅在不存在的情況下設定)和XX(和普通的set操作一樣會做更新操作)兩種。
expx是指到期時間單位,可傳引數為EX (秒)和 PX (毫秒),time就是具體的過期時間了,單位為前面expx所指定的。
然後我們對上面的程式碼進行改造如下:
/** * @param acquireTimeout * 在獲取鎖之前的超時時間 * @param timeOut * 在獲取鎖之後的超時時間 */ public String getRedisLock(Jedis jedis, String lockKey, Long acquireTimeout, Long timeOut) { try { // 定義 redis 對應key 的value值(uuid) 作用 釋放鎖 隨機生成value,根據專案情況修改 String identifierValue = UUID.randomUUID().toString(); // 定義在獲取鎖之前的超時時間 //使用迴圈機制 如果沒有獲取到鎖,要在規定acquireTimeout時間 保證重複進行嘗試獲取鎖 // 使用迴圈方式重試的獲取鎖 Long endTime = System.currentTimeMillis() + acquireTimeout; while (System.currentTimeMillis() < endTime) { // 獲取鎖 // set使用NX引數的方式就等同於 setnx()方法,成功返回OK.PX以毫秒為單位 if ("OK".equals(jedis.set(lockKey, lockKey, "NX", "PX", timeOut))) { return identifierValue; } } } catch (Exception e) { e.printStackTrace(); } return null; }
好了,獲取鎖的操作基本上就上面這些,有同學可能要問,為什麼不直接返回一個Boolean型的true或false呢?
正如我前面所說的,要保證解鎖的一致性,所以就需要通過value值來保證解鎖人就是加鎖人,而不能直接返回true或false了。
下面在說下解鎖的過程。
釋放鎖
還是先舉一個錯誤的例子:
實現思路:
釋放鎖的時候,通過傳入key和加鎖時返回的value值,判斷傳入的value是否和key從redis中取出的相等。相等則證明解鎖人就是加鎖人,執行delete釋放鎖的操作。
// 釋放redis鎖 public void unRedisLock(Jedis jedis, String lockKey, String identifierValue) { try { // 如果該鎖的id 等於identifierValue 是同一把鎖情況才可以刪除 if (jedis.get(lockKey).equals(identifierValue)) { jedis.del(lockKey); } } catch (Exception e){ e.printStackTrace(); } }
看著好像沒啥問題哈。然而仔細想想又總感覺哪裡不對。
如果在執行jedis.del(lockKey)操作之前,剛好鎖的過期時間到了,而這個時候又有別的客戶端取到了鎖,我們在此時執行刪除操作,不是又不符合一致性的要求了嗎。
然後我們修改為下述方案:
修改後的程式碼為:
public void unRedisLock(Jedis jedis, String lockKey, String identifierValue) { try { String script = "if redis.call('get', KEYS[1]) == ARGV[1] then return redis.call('del', KEYS[1]) else return 0 end"; Long result = (Long) jedis.eval(script, Collections.singletonList(lockKey), Collections.singletonList(identifierValue)); //0釋放鎖失敗。1釋放成功 if (1 == result) { //如果你想返回刪除成功還是失敗,可以在這裡返回 System.out.println(result+"釋放鎖成功"); } if (0 == result){ System.out.println(result+"釋放鎖失敗"); } } catch (Exception e){ e.printStackTrace(); } }
實現思路:
我們將Lua程式碼傳到jedis.eval()方法裡,並使引數KEYS[1]賦值為lockKey,ARGV[1]賦值為identifierValue。eval()方法是將Lua程式碼交給Redis服務端執行。
那麼這段Lua程式碼的功能是什麼呢?其實很簡單,首先獲取鎖對應的value值,檢查是否與identifierValue相等,如果相等則刪除鎖(解鎖)。那麼為什麼要使用Lua語言來實現呢?因為要確保上述操作是原子性的。
那麼為什麼執行eval()方法可以確保原子性?源於Redis的特性,因為Redis是單執行緒,在eval命令執行Lua程式碼的時候,Lua程式碼將被當成一個命令去執行,並且直到eval命令執行完成,Redis才會執行其他命令。
總結
本文對Redis實現分散式鎖做了比較詳細的總結。我個人也對上述程式碼做了實踐檢驗。其實我在使用時,一直用的錯誤的案例。直到看到園友Ruthless的一篇文章才曉得稀疏平常的寫法竟然漏洞百出,感謝部落格園,感謝Ruthless。下一篇準備再研究研究ZooKeeper的實現。
最後附上Ruthless的原文連結:https://www.cnblogs.com/linjiqin/p/8003838.html