分散式鎖定一般有三種實作方式:
1、資料庫樂觀鎖定;
2、基於Redis的分散式鎖定;
3、基於ZooKeeper的分散式鎖定。
本文將介紹第二種方式,基於Redis實作分散式鎖定。雖然網路上已經有各種介紹Redis分散式鎖實現的博客,然而他們的實現卻有著各種各樣的問題,為了避免誤人子弟,本篇博客將詳細介紹如何正確地實現Redis分散式鎖。
可靠性
首先,為了確保分散式鎖定可用,我們至少要確保鎖定的實作同時滿足以下四個條件:
1、互斥性。在任意時刻,只有一個客戶端能持有鎖。
2、不會發生死鎖。即使有一個客戶端在持有鎖的期間崩潰而沒有主動解鎖,也能保證後續其他客戶端能加鎖。
3、具有容錯性。只要大部分的Redis節點正常運行,客戶端就可以加鎖解鎖。
4、解鈴還須繫鈴人。加鎖和解鎖必須是同一個客戶端,客戶端自己不能把別人加的鎖給解了。
程式碼實作
元件依賴
首先我們要透過Maven引入Jedis開源元件,在pom.xml檔加入下面的程式碼:
<dependency> <groupId>redis.clients</groupId> <artifactId>jedis</artifactId> <version>2.9.0</version> </dependency>
加鎖程式碼
正確姿勢
Talk is cheap, show me the code。先展示程式碼,再帶大家慢慢解釋為什麼這樣實作:
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,我們就知道這把鎖是哪個請求加的了,在解鎖的時候就可以有依據。 requestId可以使用UUID.randomUUID().toString()方法產生。
第三個為nxxx,這個參數我們填的是NX,意思是SET IF NOT EXIST,即當key不存在時,我們進行set操作;若key已經存在,則不做任何操作;
第四個為expx,這個參數我們傳的是PX,意思是我們要給這個key加一個過期的設置,具體時間由第五個參數決定。
第五個為time,與第四個參數相呼應,代表key的過期時間。
總的來說,執行上面的set()方法就只會導致兩種結果:1. 目前沒有鎖(key不存在),那麼就進行加鎖操作,並對鎖設定個有效期,同時value表示加鎖的客戶端。 2. 已有鎖存在,不做任何操作。
心細的童鞋就會發現了,我們的加鎖程式碼滿足我們可靠性裡描述的三個條件:
1、首先,set()加入了NX參數,可以保證如果已有key存在,則函數不會呼叫成功,也就是只有一個客戶端能持有鎖,滿足互斥性。
2、其次,由於我們對鎖設定了過期時間,即使鎖的持有者後續發生崩潰而沒有解鎖,鎖也會因為到了過期時間而自動解鎖(即key被刪除),不會發生死鎖。
3、最後,因為我們將value賦值為requestId,代表加鎖的客戶端請求標識,那麼在客戶端在解鎖的時候就可以進行校驗是否是同一個客戶端。由於我們只考慮Redis單機部署的場景,所以容錯性我們暫不考慮。
錯誤範例1
比較常見的錯誤範例就是使用jedis.setnx()和jedis.expire()組合實作加鎖,程式碼如下:
public static void wrongGetLock1(Jedis jedis, String lockKey, String requestId, int expireTime) { Long result = jedis.setnx(lockKey, requestId); if (result == 1) { // 若在这里程序突然崩溃,则无法设置过期时间,将发生死锁 jedis.expire(lockKey, expireTime); } }
setnx()方法功能就是SET IF NOT EXIST,expire()方法就是在鎖定加上過期時間。乍看之下好像和前面的set()方法結果一樣,然而由於這是兩個Redis指令,不具有原子性,如果程式在執行完setnx()之後突然崩潰,導致鎖沒有設定過期時間。那麼將會發生死鎖。網路上之所以有人這樣實現,是因為低版本的jedis並不支援多參數的set()方法。
錯誤範例2
public static boolean wrongGetLock2(Jedis jedis, String lockKey, int expireTime) { long expires = System.currentTimeMillis() + expireTime; String expiresStr = String.valueOf(expires); // 如果当前锁不存在,返回加锁成功 if (jedis.setnx(lockKey, expiresStr) == 1) { return true; } // 如果锁存在,获取锁的过期时间 String currentValueStr = jedis.get(lockKey); if (currentValueStr != null && Long.parseLong(currentValueStr) < System.currentTimeMillis()) { // 锁已过期,获取上一个锁的过期时间,并设置现在锁的过期时间 String oldValueStr = jedis.getSet(lockKey, expiresStr); if (oldValueStr != null && oldValueStr.equals(currentValueStr)) { // 考虑多线程并发的情况,只有一个线程的设置值和当前值相同,它才有权利加锁 return true; } } // 其他情况,一律返回加锁失败 return false; }
這一種錯誤範例就比較難以發現問題,實作也比較複雜。實作想法:使用jedis.setnx()指令實作加鎖,其中key是鎖,value是鎖的過期時間。
執行程序:
1、透過setnx()方法嘗試加鎖,如果目前鎖不存在,回傳加鎖成功。
2、如果鎖已經存在則取得鎖的過期時間,和當前時間比較,如果鎖已經過期,則設定新的過期時間,返回加鎖成功。程式碼如下:
那麼這段程式碼問題在哪裡?
1、由于是客户端自己生成过期时间,所以需要强制要求分布式下每个客户端的时间必须同步。
2、当锁过期的时候,如果多个客户端同时执行jedis.getSet()方法,那么虽然最终只有一个客户端可以加锁,但是这个客户端的锁的过期时间可能被其他客户端覆盖。
3、锁不具备拥有者标识,即任何客户端都可以解锁。
解锁代码
正确姿势
还是先展示代码,再带大家慢慢解释为什么这样实现:
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脚本代码,上一次见到这个编程语言还是在《黑客与画家》里,没想到这次居然用上了。第二行代码,我们将Lua代码传到jedis.eval()方法里,并使参数KEYS[1]赋值为lockKey,ARGV[1]赋值为requestId。eval()方法是将Lua代码交给Redis服务端执行。
那么这段Lua代码的功能是什么呢?其实很简单,首先获取锁对应的value值,检查是否与requestId相等,如果相等则删除锁(解锁)。那么为什么要使用Lua语言来实现呢?因为要确保上述操作是原子性的。关于非原子性会带来什么问题,可以阅读【解锁代码-错误示例2】 。
那么为什么执行eval()方法可以确保原子性,源于Redis的特性,下面是官网对eval命令的部分解释:
简单来说,就是在eval命令执行Lua代码的时候,Lua代码将被当成一个命令去执行,并且直到eval命令执行完成,Redis才会执行其他命令。
错误示例1
最常见的解锁代码就是直接使用jedis.del()方法删除锁,这种不先判断锁的拥有者而直接解锁的方式,会导致任何客户端都可以随时进行解锁,即使这把锁不是它的。
public static void wrongReleaseLock1(Jedis jedis, String lockKey) { jedis.del(lockKey); }
错误示例2
这种解锁代码乍一看也是没问题,甚至我之前也差点这样实现,与正确姿势差不多,唯一区别的是分成两条命令去执行,代码如下:
public static void wrongReleaseLock2(Jedis jedis, String lockKey, String requestId) { // 判断加锁与解锁是不是同一个客户端 if (requestId.equals(jedis.get(lockKey))) { // 若在此时,这把锁突然不是这个客户端的,则会误解锁 jedis.del(lockKey); } }
如代码注释,问题在于如果调用jedis.del()方法的时候,这把锁已经不属于当前客户端的时候会解除他人加的锁。那么是否真的有这种场景?答案是肯定的,比如客户端A加锁,一段时间之后客户端A解锁,在执行jedis.del()之前,锁突然过期了,此时客户端B尝试加锁成功,然后客户端A再执行del()方法,则将客户端B的锁给解除了。
更多redis知识请关注redis数据库教程栏目。
以上是Redis分散式鎖的正確實作方式介紹的詳細內容。更多資訊請關注PHP中文網其他相關文章!