1.組件依賴
通過Maven引入Jedis開源組件,在pom.xml文件加入下面的代碼:
<dependency>
<groupId>redis.clients</groupId>
<artifactId>jedis</artifactId>
<version>2.9.0</version>
</dependency>
2.加鎖的實現
2.1常見的兩種錯誤方式
1.第一種
public static void wrongGetLock(Jedis jedis,String key,String value,int expTime){
Long result = jedis.setnx(key, value);
if(result == 1){
//若在這里程序突然崩潰,則無法設置過期時間,將發生死鎖
jedis.expire(key, expTime);
}
}
2.第二種
public static boolean wrongGetLock2(Jedis jedis,String key,String value,int expTime){
long expires = System.currentTimeMillis() + expTime;
String expiresStr = String.valueOf(expires);
// 如果當前鎖不存在,返回加鎖成功
if (jedis.setnx(key, expiresStr) == 1) {
return true;
}
// 如果鎖存在,獲取鎖的過期時間
String currentValueStr = jedis.get(key);
if(currentValueStr != null && Long.valueOf(currentValueStr)<System.currentTimeMillis()){
// 鎖已過期,獲取上一個鎖的過期時間,并設置現在鎖的過期時間
String oldString = jedis.getSet(key, expiresStr);
if(oldString != null && oldString.equals(currentValueStr)){
return true;
}
}
return false;
}
問題所在:
- 由于是客戶端自己生成過期時間,所以需要強制要求分布式下每個客戶端的時間必須同步。
- 當鎖過期的時候,如果多個客戶端同時執行jedis.getSet()方法,那么雖然最終只有一個客戶端可以加鎖,但是這個客戶端的鎖的過期時間可能被其他客戶端覆蓋。
- 鎖不具備擁有者標識,即任何客戶端都可以解鎖。
2.2 正確的加鎖方式
public class RedisTool {
private static final Logger logger = LoggerFactory.getLogger(RedisTool.class);
//加鎖成功標志
private static final String LOCK_SUCCESS = "OK";
//解鎖成功標志
private static final String RELEASE_SUCCESS = "1";
//加鎖標志
private static final String SET_IF_NOT_EXIST = "NX";
//給鎖設置過期時間的標志
private static final String SET_WITH_EXPIRE_TIME = "PX";
/**
* 嘗試獲得鎖
* @param jedis redis實例
* @param key 具體的鎖
* @param value 鎖的值,解鎖的時候用
* @param expTime 鎖的失效時間
* @return 加鎖成功標志
*/
public static boolean getDistributedLock(Jedis jedis,String key,String value,int expTime){
boolean isLocked = false;
try {
String result = jedis.set(key, value, SET_IF_NOT_EXIST, SET_WITH_EXPIRE_TIME, expTime);
if (LOCK_SUCCESS.equals(result)) {
isLocked = true;
}
} catch (Exception e) {
e.printStackTrace();
logger.error("redis加鎖失敗:"+e.getMessage());
}
return isLocked;
}
}
可以看到,我們加鎖就一行代碼:jedis.set(String key, String value, String nxxx, String expx, int time),這個set()方法一共有五個形參:
- 第一個為key,我們使用key來當鎖,因為key是唯一的。
- 第二個為value,在解鎖的時候就可以有依據。value可以使用UUID.randomUUID().toString()方法生成。
- 第三個為nxxx,這個參數我們填的是NX,意思是SET IF NOT EXIST,即當key不存在時,我們進行set操作;若key已經存在,則不做任何操作;
- 第四個為expx,這個參數我們傳的是PX,意思是我們要給這個key加一個過期的設置,具體時間由第五個參數決定。
- 第五個為time,與第四個參數相呼應,代表key的過期時間。
首先,set()加入了NX參數,可以保證如果已有key存在,則函數不會調用成功,也就是只有一個客戶端能持有鎖,滿足互斥性。其次,由于我們對鎖設置了過期時間,即使鎖的持有者后續發生崩潰而沒有解鎖,鎖也會因為到了過期時間而自動解鎖(即key被刪除),不會發生死鎖。最后,因為我們將value賦值為requestId,代表加鎖的客戶端請求標識,那么在客戶端在解鎖的時候就可以進行校驗是否是同一個客戶端。由于我們只考慮Redis單機部署的場景,所以容錯性我們暫不考慮。
3.解鎖
3.1 解鎖的常見兩種錯誤方式
1.第一種
public static void wrongReleaseLock1(Jedis jedis, String lockKey) {
jedis.del(lockKey);
}
最常見的解鎖代碼就是直接使用jedis.del()方法刪除鎖,這種不先判斷鎖的擁有者而直接解鎖的方式,會導致任何客戶端都可以隨時進行解鎖,即使這把鎖不是它的。
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的鎖給解除了。
3.2 正確的解鎖方式
/**
* 嘗試解鎖(刪除key)
* @param jedis redis實例
* @param key 具體的鎖
* @param value
* @return
*/
public static boolean releaseDistributedLock(Jedis jedis, String key, String value){
boolean isUnlocked = false;
try {
//獲取鎖對應的value值,檢查是否與requestId相等,如果相等則刪除鎖(解鎖);eval()方法執行Lua腳本是原子性的
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(key), Collections.singletonList(value));
if(!CommonUtils.check(result) && RELEASE_SUCCESS.equals(result.toString())){
isUnlocked = true;
}
} catch (Exception e) {
e.printStackTrace();
logger.error("redis解鎖異常:"+e.getMessage());
}
return isUnlocked;
}
那么這段Lua代碼的功能是什么呢?其實很簡單,首先獲取鎖對應的value值,檢查是否與requestId相等,如果相等則刪除鎖(解鎖)。那么為什么要使用Lua語言來實現呢?因為要確保上述操作是原子性的。
簡單來說,就是在eval命令執行Lua代碼的時候,Lua代碼將被當成一個命令去執行,并且直到eval命令執行完成,Redis才會執行其他命令。