一文教你如何用Redis構建高效能鎖

JAVA一方發表於2020-02-18

前言

在這裡粗略的說一下,zk鎖效能比redis低的原因:zk中的角色分為leader,flower,每次寫請求只能請求leader,leader會把寫請求廣播到所有flower,如果flower都成功才會提交給leader,其實這裡相當於一個2PC的過程。在加鎖的時候是一個寫請求,當寫請求很多時,zk會有很大的壓力,最後導致伺服器響應很慢。

正題:

什麼情況下需要加鎖?

當多個執行緒、使用者同時競爭同一個資源時,需要加鎖。比如,下訂單減庫存,搶票,選課,搶紅包等。如果在此處沒有鎖的控制,會導致很嚴重的問題,下訂單減庫存的時候不加鎖,會導致商品超賣;搶票的時候不加鎖,會導致兩個人搶到同一個位置;選課的時候沒有鎖的控制,導致選課成功的人數大於教室的座位數;搶紅包時沒有鎖的控制,搶到紅包的金額大於紅包的實際金額。

什麼是分散式鎖?

學過JAVA多執行緒的朋友都知道,為了防止多個執行緒同時執行同一段程式碼,可以用synchronized關鍵字或JAVA API中ReentrantLock類來控制。

但是目前幾乎任何一個系統都往往部署多臺機器的,單機部署的應用很少,synchronized和ReentrantLock發揮不出任何作用,此時就需要一把全域性的鎖,來代替JAVA中的synchronized和ReentrantLock。

當Thread1執行緒獲取到鎖,執行鎖中的程式碼,其他執行緒或其他機器再次請求該鎖,發現鎖被Thread1佔用,加鎖失敗。當Thread1釋放鎖,其他執行緒則可以獲取到鎖並執行相應的操作。

我們可以用Jedis中是setnx命令來構建這把鎖,首先,我列舉一些錯誤的構建鎖的方式:

錯誤例子1

Long lock= jedis.setnx(key,value); 
if(lock>0){ 
//執行業務邏輯 
} 
複製程式碼

通過setnx命令建立一個key、value,如果key不存在,則加鎖成功。這樣做有什麼問題呢?如果執行加鎖操作成功,在釋放鎖的時候,系統當機,導致這個key永遠不會被del掉,也就是說其他執行緒一直獲取不到鎖,

導致死鎖發生。為了避免這種情況,請看下面的程式碼

錯誤例子2

Long lock= jedis.setnx(key,value); 
if(lock>0){ 
 jedis.expire(key,expireTime); 
} 
複製程式碼

和上面的例子類似,唯一不同的是這裡多了一步設定key過期時間的操作。如果在del的時候系統當機,等過期時間一到,Redis會刪除這個key。

其他執行緒可以再次獲取鎖。這樣就可以萬無一失了嗎?這裡有一個問題,如果在第一步setnx成功後,突然網路閃斷,expire命令執行失敗,同樣也有死鎖的風險。這兩步並不具備原子性,不保證全部成功或全部失敗。

正確的構建方式

public static boolean getDistributedLock(Jedis jedis, String lockKey, String requestId, int expireTime) { 
 String result = jedis.set(lockKey, requestId, "NX", "EX", expireTime); 
 if (LOCK_SUCCESS.equals(result)) { 
return true; 
 } 
return false; 
} 
複製程式碼

引數解釋:

key:鍵

value:值

nx:如果當前key存在,則set失敗,否則成功

ex:設定key的過期時間

expireTime:key的過期時間,時間到了,Redis會自動刪除key和value。

這個命令,將上面的錯誤例子2中的兩個操作合為一個原子操作,保證了同時成功或同時失敗。

解鎖方式:

錯誤例子1:

jedis.del(key); 
複製程式碼

執行這個操作的執行緒,不去判斷鎖的擁有者就刪除鎖。

還記的set命令可以設定value嗎?在獲取鎖的操作時,主要是判斷key是否存在,那麼value有什麼用呢??如果在刪除鎖的時候,不去判斷當前鎖的擁有者,任何執行緒都可以釋放鎖。這個時候,value值就起到作用了。

錯誤例子2:

if(value==jedis.get(key)){ 
 jedis.del(key); 
} 
複製程式碼

我們在加鎖的時候,可以將value設定成唯一標識當前執行緒的一個值,這個值可以是一個UUID,當釋放鎖的時間,判斷value是否和set時的值相同,如果相同,則說明加鎖和釋放鎖是同一個執行緒,允許釋放。否則釋放鎖失敗。

這樣就可以絕對安全了嗎??答案當然是否定的。這步操作,同樣不具備原子性。如果ThreadA在執行value==jedis.get(key)返回true後的瞬間,del命令還沒來的及執行,key過期了,而此時ThreadB獲取到鎖,之後ThreadA執行del命令,把ThreadB的鎖釋放掉了。

所以要保證兩部操作的原子性,我們不得不利用簡單的Lua指令碼。

學習的過程當中有遇見學習,行業方面的問題,或者說缺乏系統的學習路線和學習視訊可以點選https://shimo.im/docs/VqQR6tPrpR3C3tjq/

正確的解鎖姿勢:

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; 
} 
複製程式碼

Redis在2.6後內部內嵌Lua指令碼直譯器,所以我們可以通過簡單的Lua指令碼來保證上述操作的原子性。程式碼中的Lua指令碼的的意思是:我們把LockKey賦值給KEYS[1],把RequestId賦值給ARGV[1],如果key中的值等於RequestId,返回true否則返回false。這樣就保證了釋放鎖操作時原子的,並且當前客戶端只會釋放當前客戶端的鎖。


相關文章