用Redis構建一把高性能的鎖
背景:
筆者所在的公司,上周末經歷了一場大促活動后,系統暴露出這樣一個問題:分布式鎖使用的zk鎖,由于當天大促用戶量比較多,系統瘋狂的加鎖釋放鎖,***zk承受不住這么大的壓力宕機。由于馬上就要618,為了避免再次發生這樣的事情,公司決定把所有系統的zk鎖都替換為高性能的Redis鎖。
在這里簡單的提一下,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腳本。
正確的解鎖姿勢:
- 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。這樣就保證了釋放鎖操作時原子的,并且當前客戶端只會釋放當前客戶端的鎖。