分佈式鎖通常有3中實現方式:redis
如下將詳細介紹如何正確地實現Redis分佈式鎖。數據庫
首先,爲了確保分佈式鎖的可用,咱們至少要確保鎖的實現的同時,要知足如下四個條件:多線程
首先咱們經過 Maven 引入 Jedis 開源組件,在 pom.xml 文件加入如下代碼:併發
1 <dependency> 2 <groupId>redis.clientsgroupId</groupId> 3 <artifactId>jedisartifactId</artifactId> 4 <version>2.9.0version</version> 5 </dependency>
先放代碼,後面再解釋爲何這樣實現:dom
1 public class RedisTool { 2 3 private static final String LOCK_SUCCESS = "OK"; 4 private static final String SET_IF_NOT_EXIST = "NX"; 5 private static final String SET_WITH_EXPIRE_TIME = "PX"; 6 /** 7 * 嘗試獲取分佈式鎖 8 * @param jedis Redis客戶端 9 * @param lockKey 鎖 10 * @param requestId 請求標識 11 * @param expireTime 超期時間 12 * @return 是否獲取成功 13 */ 14 public static boolean tryGetDistributedLock(Jedis jedis, 15 String lockKey,String requestId, int expireTime) { 16 17 String result = jedis.set(lockKey, requestId, SET_IF_NOT_EXIST, 18 SET_WITH_EXPIRE_TIME, expireTime); 19 20 if (LOCK_SUCCESS.equals(result)) { 21 return true; 22 } 23 return false; 24 } 25 }
能夠看到,加鎖就一行代碼:分佈式
1 jedis.set(String key, String value, String nxxx, String expx, int time);
這個 set( ) 方法一共5個形參:函數
第一個是 key,咱們使用 key來當鎖,由於 key 是惟一的。spa
第二個是value,入參是 requestId,不少人不明白,有 key 做爲鎖不就夠了嗎,爲什麼還要用到value?線程
由於咱們上面講到的可靠性裏,分佈式鎖要知足第4個條件:解鈴還須繫鈴人,經過給 value 賦值爲 requestId,咱們就知道這把鎖是哪一個請求加的,在解鎖的時候能夠有依據。code
requestId 可使用UUID.random().toString() 方法生成。
第三個事nxxx,這個參數 咱們填的是NX,意識 set if not exist,即當 key 不存在時,咱們進行 set 操做;若 key 已經存在,則不作任何操做;
第四個是expx,這個參數咱們傳的是PX,意思是咱們要給這個 key 加一個過時的設置,具體時間由第五個參數決定;
第五個是time,於第四個參數相呼應,表明 key 的過時時間。
總的來講,執行上面的 set() 方法就只有兩種結果:
細心查看就會發現,咱們加鎖的代碼知足可靠性裏面描述的三個條件。
首先,set() 加入了 NX 參數,能夠保證若是已有 key 存在,則函數不會調用成功,也就是隻有一個客戶端能持有鎖,知足互斥性。
其次,因爲咱們對鎖設置了過時時間,即便鎖的持有者後續發生崩潰而沒有解鎖,鎖也會由於到了過時時間而自動解鎖(即key被刪除),不會發生死鎖。
最後,由於咱們將 value 賦值 requestId,表明加鎖的客戶標識,那麼在客戶端解鎖時就能夠進行校驗是不是同一個客戶端,即解鈴還須繫鈴人。
因爲咱們只考慮 Redis 單機部署的場景,因此容錯性暫不考慮。
比較常見的錯誤示例就是使用 jedis.setnx() 和 jedis.expire() 組合實現加鎖。代碼以下:
1 public static void wrongGetLock1(Jedis jedis, String lockKey, String requestId, int expireTime) { 2 3 Long result = jedis.setnx(lockKey, requestId); 4 if (result == 1) { 5 // 若在這裏程序忽然崩潰,則沒法設置過時時間,將發生死鎖 6 jedis.expire(lockKey, expireTime); 7 } 8 }
setnx() 方法的做用就是 set if not exist,expire() 方法就是給鎖加一個過時時間。
咋一看好像和前面的 set() 方法結果同樣,然而因爲這是兩條 Redis 命令,不具備原子性,若是程序在執行完 setnx()以後忽然崩潰,致使沒有設置過時時間,那麼將會發生死鎖。
網上之因此有人這麼實現,是由於低版本的 Jedis 並不支持多參數的 set() 方法。
這種錯誤示例就是比較難發現問題,並且實現也比較複雜。實現思路:使用 jedis.setnx( key, value) 命令實現加鎖,其中 key 是鎖,value 是鎖的過時時間。
執行過程:
代碼以下:
1 public static boolean wrongGetLock2(Jedis jedis, String lockKey, int expireTime) { 2 3 long expires = System.currentTimeMillis() + expireTime; 4 String expiresStr = String.valueOf(expires); 5 6 // 若是當前鎖不存在,返回加鎖成功 7 if (jedis.setnx(lockKey, expiresStr) == 1) { 8 return true; 9 } 10 // 若是鎖存在,獲取鎖的過時時間 11 String currentValueStr = jedis.get(lockKey); 12 if (currentValueStr != null && Long.parseLong(currentValueStr) < System.currentTimeMillis()) { 13 // 鎖已過時,獲取上一個鎖的過時時間,並設置如今鎖的過時時間 14 String oldValueStr = jedis.getSet(lockKey, expiresStr); 15 if (oldValueStr != null && oldValueStr.equals(currentValueStr)) { 16 // 考慮多線程併發的狀況,只有一個線程的設置值和當前值相同,它纔有權利加鎖 17 return true; 18 } 19 } 20 // 其餘狀況,一概返回加鎖失敗 21 return false; 22 }
這段代碼的問題在哪裏?
先展現代碼,再解釋爲何這樣實現:
1 public class RedisTool { 2 private static final Long RELEASE_SUCCESS = 1L; 3 /** 4 * 釋放分佈式鎖 5 * @param jedis Redis客戶端 6 * @param lockKey 鎖 7 * @param requestId 請求標識 8 * @return 是否釋放成功 9 */ 10 public static boolean releaseDistributedLock(Jedis jedis, String lockKey, String requestId) { 11 String script = "if redis.call('get', KEYS[1]) == ARGV[1] then return redis.call('del', KEYS[1]) else return 0 end"; 12 Object result = jedis.eval(script, Collections.singletonList(lockKey), Collections.singletonList(requestId)); 13 14 if (RELEASE_SUCCESS.equals(result)) { 15 return true; 16 } 17 return false; 18 } 19 }
能夠看到,解鎖只須要兩行代碼就能夠搞定了。
第一行,寫的是一個簡單的 Lua 腳本代碼;
第二行,咱們將 Lua 代碼傳到 jedis.eval( ) 方法裏,並使參數 KEYS[1] 賦值爲lockKey,ARGV[1] 賦值爲 requestId,eval() 方法是將 Lua代碼交給 Redis 服務端執行。
那麼這段代碼的功能是什麼呢?
很簡單,首先獲取鎖對應的 value 值,檢查是否與 requestId 相等,若是相等則刪除鎖(解鎖)。
那麼爲何要使用Lua語言來講實現呢?
由於要確保上述操做是原子性的。關於非原子性會帶來的問題,能夠閱讀【解鎖代碼 - 錯誤示例二】。
那麼爲何執行 eval() 方法能夠確保原子性?
源於 Redis 的特性,下面是官網對 eval 命令的部分解釋:簡單來講,就是在 eval 命令執行Lua代碼的時候,Lua代碼將被當成一個命令去執行,而且直到 eval 命令執行完成,Redis 纔會執行其餘命令。
最多見的解鎖代碼就是直接使用 jedis.del() 方法刪除鎖,這種不先判斷鎖的擁有者就直接解鎖的方法,會致使任何客戶端均可以隨時進行解鎖,即便這把鎖不是它的。
1 public static void wrongReleaseLock1(Jedis jedis, String lockKey) { 2 jedis.del(lockKey); 3 }
這種解鎖代碼咋一看也是沒有問題的,與正確姿式差很少,惟一區別的是分紅兩條命令去執行。代碼以下:
1 public static void wrongReleaseLock2(Jedis jedis, String lockKey, String requestId) { 2 // 判斷加鎖與解鎖是否是同一個客戶端 3 if (requestId.equals(jedis.get(lockKey))) { 4 // 若在此時,這把鎖忽然不是這個客戶端的,則會誤解鎖 5 jedis.del(lockKey); 6 } 7 }
如代碼的註釋,問題在於若是調用 jedis.del() 方法的時候,這把鎖已經不屬於當前客戶端的時候,會解鎖他人加的鎖。
是否有這種場景?
固然有的。如客戶端A加鎖,一段時間後客戶端A解鎖,在執行 jedis.del() 方法以前,鎖忽然過時了,此時客戶端B嘗試加鎖成功,而後客戶端A再執行 jedis.del() 方法,則會將客戶端B的鎖給解除。
想要經過 Redis實現分佈式鎖並不難,只要保證能知足可靠性裏的四個條件。
若是項目中Redis是多機部署,那麼能夠嘗試使用 Redisson 實現分佈式鎖。