[Redis]Redis實現分散式鎖
分散式鎖
為了防止分散式系統中的多個執行緒之間相互干擾,需要一種分散式協調技術來對這些程序進行排程,這個技術的核心就是分散式鎖。比如在如下場景中,就需要用到分散式鎖,現有某個服務有ABC三個例項,部署在三臺伺服器上,成員變數var在三個例項中都存在,此時三個請求經過nginx同時對var操作,顯然結果不是對的,而倘若不同時對A進行操作,而A是不共享的,也不具有可見性,所以處理的結果也是不對的。這時候就需要分散式鎖了。
分散式鎖的條件
- 分散式環境下,一個方法同一時間只能有一個機器的執行緒執行。
- 高可用地獲取和釋放
- 高效能地獲取和釋放
- 可重入
- 具有鎖失效機制,防止死鎖
- 具有非阻塞鎖特性,即沒有獲取到鎖將直接返回獲取鎖失敗。
用Redis實現分散式鎖
加鎖
加鎖過程簡單拆分為兩步,第一步是檢查key是否存在,也就是檢查目前有沒有別的客戶端已經上鎖了,如果有人上鎖了,那就直接返回失敗。第二步就是如果沒有上鎖,也就是key不存在,那麼就設定key,那麼就設定一個過期時間,之所以設定過期時間,是因為萬一客戶端發生意外沒有來解鎖,redis也可以自己來解,程式碼比較簡單。
/** * 嘗試獲取分散式鎖 * @param lockKey 鍵 * @param reqId 值,此處設定為requestID可以在解鎖的時候有依據知道是哪個請求加的鎖 * @param expire 過期時間 * */ public static boolean tryGetLock(Jedis jedis,String lockKey,String reqId,int expire){ //SET_IF_NOT_EXIST 不存在的時候設定,存在的時候不操作 //SET_WITH_EXPIRE_TIME 設定過期時間 String result = jedis.set(lockKey, reqId, SET_IF_NOT_EXIST, SET_WITH_EXPIRE_TIME, expire); if(LOCK_SUCCESS.equals(result)){ return true; } return false; }
那麼問題來了,如果將這兩個步驟分開可以不呢,例如像下面這樣:
public static boolean tryGetLock(Jedis jedis,String lockKey,String reqId,int expire){
long result = jedis.setnx(lockKey,reqId);
if(result==1){
jedis.expire(lockKey,expire);
}
}
自然是不行的,因為這段程式碼不能保證原子性,倘若某一個客戶端執行完setnx之後就因為某些原因沒有繼續往下面執行了,那麼這個key就一直設定在這裡而不能自動過期,這個時候就沒有別的客戶端能夠獲取到這個鎖了。
第二個問題是我在網上看到的,我初一看還沒想明白為什麼是錯誤的加鎖程式碼:
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;
}
這段問題在於,1,使用System.currentTimeMillis()這個系統函式,那麼就可能存在多個客戶端上時間並不一致的問題。2,多執行緒的情況下,設定過期時間還是不執行緒安全。3,俗話說,解鈴還須繫鈴人,這段程式碼缺少一個能表示客戶端的值來用作key的值。所以無論是哪個客戶端都能來解鎖。
釋放鎖(lua指令碼)
看過不少網上部落格的會發現在使用Redis實現分散式鎖的時候使用了lua指令碼,那麼為什麼需要lua指令碼呢,難道就不能手動通過Redis的原生API實現麼,如果用了會有什麼問題呢,首先看lua指令碼是怎麼寫的。
if redis.call('get', KEYS[1]) == ARGV[1]
then return redis.call('del', KEYS[1])
else return 0 end
這段程式碼作用挺好理解,就是獲取鎖對應的值,如果和傳來的ARGV[1]即RequestID相等,那麼就刪除,這是解鎖中用到的,現在拋開這個lua指令碼不談,用jedis的api來解鎖。
第一種:
public static void releaseLock(Jedis jedis,String key){
jedis.del(key);
}
第二種:
public static void releaseLock(Jedis jedis,String key,String reqId){
if(jedis.get(key).equals(reqId)){
jedis.del(key);
}
}
第一種的問題在於,當一個執行緒到達解鎖方法的時候,沒有判別是否這個執行緒就是給這個key上鎖的執行緒,也就是鎖不認主人了,誰都能解開。當然在某些場景下是允許這樣的,但是在分散式鎖中這樣自然不行。而第二個問題看似滴水不漏實際上這是兩條命令,而兩個命令無法保證原子性,也就是說,判斷if之後執行del之前,中間的是有可能其他執行緒再上鎖的,但是這個時候被當前執行緒解鎖了。說到這裡,lua指令碼的作用自然就出來了,請出官方對於jedis,eval()的解釋:
然後中間有一段這個:
Atomicity of scripts
Redis uses the same Lua interpreter to run all the commands. Also Redis guarantees that a script is executed in an atomic way: no other script or Redis command will be executed while a script is being executed. This semantic is similar to the one of MULTI / EXEC. From the point of view of all the other clients the effects of a script are either still not visible or already completed.
......
Atomicity,也就是說,使用lua指令碼,能夠保證指令碼的執行具有原子性,不會因為多個程序競爭而被細分為更小的過程。
實測
寫一個簡單的Controller,然後用JMeter來模擬多個客戶端進行請求的情景。
@Slf4j
@RestController
public class RedisController {
@Autowired
private JedisPool jedisPool;
@GetMapping("/lock")
public String lock(){
String reqId = UUID.randomUUID().toString();
boolean locked = false;
Jedis jedis = jedisPool.getResource();
try {
locked = RedisUtil.tryGetLock(jedis,"lock1",reqId,2000);
if (locked){
log.info(reqId+"獲取成功\n");
}else{
log.info(reqId+"獲取失敗\n");
}
Thread.sleep(1000);
}catch (Exception e){
e.printStackTrace();
//回收jedis例項,不回收jedis例項會pool中的jedis資源越來越少,從而導致獲取不到可以用的jedis例項,報異常。
if(jedis != null ) {
jedisPool.returnResource(jedis);
}
}finally {
if(locked) {
boolean released = RedisUtil.releaseLock(jedis, "lock1",reqId);
if(released){
log.info(reqId+"釋放成功\n");
}else{
log.info(reqId+"釋放失敗\n");
}
}
if(jedis != null ) {
jedisPool.returnResource(jedis);
}
}
return "ok";
}
}
邏輯很簡單,設定key,這裡設定過期時間為兩秒,實際上這裡也可以通過請求傳入過期時間,這樣每個客戶端都可以設定自己的時間,然後用sleep(1000)來模擬業務處理。最後釋放鎖。
- JMeter測試
用JMeter開啟一個執行緒組,設定五個執行緒,迴圈五次,然後用http請求去訪問這個介面。
- 測試結果
上面五次中,每一個都應該是一個執行緒獲取成功,其他四個獲取失敗。下面檢視一下控制檯,擷取兩次的日誌進行檢視,可以發現符合預期。
2020-10-25 13:32:39.274 INFO 13012 --- [nio-9001-exec-2] c.i.r.controller.RedisController : a230e2cb-c87c-4abf-a17e-19ea8d3affc5獲取成功
2020-10-25 13:32:39.274 INFO 13012 --- [nio-9001-exec-1] c.i.r.controller.RedisController : 77ce1001-9621-42bb-bd2a-15bce0f83e25獲取失敗
2020-10-25 13:32:39.684 INFO 13012 --- [nio-9001-exec-3] c.i.r.controller.RedisController : d9a3d30b-8aa1-4481-b82e-d1a3b347267e獲取失敗
2020-10-25 13:32:39.684 INFO 13012 --- [nio-9001-exec-4] c.i.r.controller.RedisController : 48709ff6-5b84-42ca-8b00-83ee64ef756a獲取失敗
2020-10-25 13:32:39.783 INFO 13012 --- [nio-9001-exec-5] c.i.r.controller.RedisController : e54a38a1-3c3f-4e3b-99a9-6766b04bafa5獲取失敗
2020-10-25 13:32:40.296 INFO 13012 --- [nio-9001-exec-2] c.i.r.controller.RedisController : a230e2cb-c87c-4abf-a17e-19ea8d3affc5釋放成功
2020-10-25 13:32:40.393 INFO 13012 --- [nio-9001-exec-2] c.i.r.controller.RedisController : 740adf9e-bb28-445f-82e4-cf7e8f1bda99獲取成功
2020-10-25 13:32:40.394 INFO 13012 --- [nio-9001-exec-1] c.i.r.controller.RedisController : f5bbe368-e016-4fed-ba05-32700e908404獲取失敗
2020-10-25 13:32:40.698 INFO 13012 --- [nio-9001-exec-4] c.i.r.controller.RedisController : cf393325-868f-4137-806e-c52f6d5e2968獲取失敗
2020-10-25 13:32:40.698 INFO 13012 --- [nio-9001-exec-3] c.i.r.controller.RedisController : d914d620-2ce6-46df-a002-c92b546978c1獲取失敗
2020-10-25 13:32:40.792 INFO 13012 --- [nio-9001-exec-6] c.i.r.controller.RedisController : 7356a83c-30a8-492f-b7a0-179d4ecd27a2獲取失敗
2020-10-25 13:32:41.396 INFO 13012 --- [nio-9001-exec-2] c.i.r.controller.RedisController : 740adf9e-bb28-445f-82e4-cf7e8f1bda99釋放成功